using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Timeline.Entities;
using Timeline.Helpers;
using Timeline.Models;
using Timeline.Models.Validation;
using Timeline.Services.Exceptions;
using static Timeline.Resources.Services.UserService;
namespace Timeline.Services
{
///
/// Null means not change.
///
public record ModifyUserParams
{
public string? Username { get; set; }
public string? Password { get; set; }
public string? Nickname { get; set; }
}
public interface IUserService
{
///
/// Try to verify the given username and password.
///
/// The username of the user to verify.
/// The password of the user to verify.
/// The user info and auth info.
/// Thrown when or is null.
/// Thrown when is of bad format or is empty.
/// Thrown when the user with given username does not exist.
/// Thrown when password is wrong.
Task VerifyCredential(string username, string password);
///
/// Try to get a user by id.
///
/// The id of the user.
/// The user info.
/// Thrown when the user with given id does not exist.
Task GetUser(long id);
///
/// Get the user id of given username.
///
/// Username of the user.
/// The id of the user.
/// Thrown when is null.
/// Thrown when is of bad format.
/// Thrown when the user with given username does not exist.
Task GetUserIdByUsername(string username);
///
/// List all users.
///
/// The user info of users.
Task> GetUsers();
///
/// Create a user with given info.
///
/// The username of new user.
/// The password of new user.
/// The the new user.
/// Thrown when or is null.
/// Thrown when or is of bad format.
/// Thrown when a user with given username already exists.
Task CreateUser(string username, string password);
///
/// Modify a user.
///
/// The id of the user.
/// The new information.
/// The new user info.
/// Thrown when some fields in is bad.
/// Thrown when user with given id does not exist.
///
/// Version will increase if password is changed.
///
Task ModifyUser(long id, ModifyUserParams? param);
///
/// Try to change a user's password with old password.
///
/// The id of user to change password of.
/// Old password.
/// New password.
/// Thrown if or is null.
/// Thrown if or is empty.
/// Thrown if the user with given username does not exist.
/// Thrown if the old password is wrong.
Task ChangePassword(long id, string oldPassword, string newPassword);
}
public class UserService : IUserService
{
private readonly ILogger _logger;
private readonly IClock _clock;
private readonly DatabaseContext _databaseContext;
private readonly IPasswordService _passwordService;
private readonly IUserPermissionService _userPermissionService;
private readonly UsernameValidator _usernameValidator = new UsernameValidator();
private readonly NicknameValidator _nicknameValidator = new NicknameValidator();
public UserService(ILogger logger, DatabaseContext databaseContext, IPasswordService passwordService, IClock clock, IUserPermissionService userPermissionService)
{
_logger = logger;
_clock = clock;
_databaseContext = databaseContext;
_passwordService = passwordService;
_userPermissionService = userPermissionService;
}
private void CheckUsernameFormat(string username, string? paramName)
{
if (!_usernameValidator.Validate(username, out var message))
{
throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, ExceptionUsernameBadFormat, message), paramName);
}
}
private static void CheckPasswordFormat(string password, string? paramName)
{
if (password.Length == 0)
{
throw new ArgumentException(ExceptionPasswordEmpty, paramName);
}
}
private void CheckNicknameFormat(string nickname, string? paramName)
{
if (!_nicknameValidator.Validate(nickname, out var message))
{
throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, ExceptionNicknameBadFormat, message), paramName);
}
}
private static void ThrowUsernameConflict()
{
throw new EntityAlreadyExistException(EntityNames.User, ExceptionUsernameConflict);
}
private async Task CreateUserFromEntity(UserEntity entity)
{
var permission = await _userPermissionService.GetPermissionsOfUserAsync(entity.Id);
return new User
{
UniqueId = entity.UniqueId,
Username = entity.Username,
Administrator = permission.Contains(UserPermission.UserManagement),
Permissions = permission,
Nickname = string.IsNullOrEmpty(entity.Nickname) ? entity.Username : entity.Nickname,
Id = entity.Id,
Version = entity.Version,
CreateTime = entity.CreateTime,
UsernameChangeTime = entity.UsernameChangeTime,
LastModified = entity.LastModified
};
}
public async Task VerifyCredential(string username, string password)
{
if (username == null)
throw new ArgumentNullException(nameof(username));
if (password == null)
throw new ArgumentNullException(nameof(password));
CheckUsernameFormat(username, nameof(username));
CheckPasswordFormat(password, nameof(password));
var entity = await _databaseContext.Users.Where(u => u.Username == username).SingleOrDefaultAsync();
if (entity == null)
throw new UserNotExistException(username);
if (!_passwordService.VerifyPassword(entity.Password, password))
throw new BadPasswordException(password);
return await CreateUserFromEntity(entity);
}
public async Task GetUser(long id)
{
var user = await _databaseContext.Users.Where(u => u.Id == id).SingleOrDefaultAsync();
if (user == null)
throw new UserNotExistException(id);
return await CreateUserFromEntity(user);
}
public async Task GetUserIdByUsername(string username)
{
if (username == null)
throw new ArgumentNullException(nameof(username));
CheckUsernameFormat(username, nameof(username));
var entity = await _databaseContext.Users.Where(user => user.Username == username).Select(u => new { u.Id }).SingleOrDefaultAsync();
if (entity == null)
throw new UserNotExistException(username);
return entity.Id;
}
public async Task> GetUsers()
{
List result = new();
foreach (var entity in await _databaseContext.Users.ToArrayAsync())
{
result.Add(await CreateUserFromEntity(entity));
}
return result;
}
public async Task CreateUser(string username, string password)
{
if (username == null)
throw new ArgumentNullException(nameof(username));
if (password == null)
throw new ArgumentNullException(nameof(password));
CheckUsernameFormat(username, nameof(username));
CheckPasswordFormat(password, nameof(password));
var conflict = await _databaseContext.Users.AnyAsync(u => u.Username == username);
if (conflict)
ThrowUsernameConflict();
var newEntity = new UserEntity
{
Username = username,
Password = _passwordService.HashPassword(password),
Version = 1
};
_databaseContext.Users.Add(newEntity);
await _databaseContext.SaveChangesAsync();
_logger.LogInformation(Log.Format(LogDatabaseCreate, ("Id", newEntity.Id), ("Username", username)));
return await CreateUserFromEntity(newEntity);
}
public async Task ModifyUser(long id, ModifyUserParams? param)
{
if (param != null)
{
if (param.Username != null)
CheckUsernameFormat(param.Username, nameof(param));
if (param.Password != null)
CheckPasswordFormat(param.Password, nameof(param));
if (param.Nickname != null)
CheckNicknameFormat(param.Nickname, nameof(param));
}
var entity = await _databaseContext.Users.Where(u => u.Id == id).SingleOrDefaultAsync();
if (entity == null)
throw new UserNotExistException(id);
if (param != null)
{
var now = _clock.GetCurrentTime();
bool updateLastModified = false;
var username = param.Username;
if (username != null && username != entity.Username)
{
var conflict = await _databaseContext.Users.AnyAsync(u => u.Username == username);
if (conflict)
ThrowUsernameConflict();
entity.Username = username;
entity.UsernameChangeTime = now;
updateLastModified = true;
}
var password = param.Password;
if (password != null)
{
entity.Password = _passwordService.HashPassword(password);
entity.Version += 1;
}
var nickname = param.Nickname;
if (nickname != null && nickname != entity.Nickname)
{
entity.Nickname = nickname;
updateLastModified = true;
}
if (updateLastModified)
{
entity.LastModified = now;
}
await _databaseContext.SaveChangesAsync();
_logger.LogInformation(LogDatabaseUpdate, ("Id", id));
}
return await CreateUserFromEntity(entity);
}
public async Task ChangePassword(long id, string oldPassword, string newPassword)
{
if (oldPassword == null)
throw new ArgumentNullException(nameof(oldPassword));
if (newPassword == null)
throw new ArgumentNullException(nameof(newPassword));
CheckPasswordFormat(oldPassword, nameof(oldPassword));
CheckPasswordFormat(newPassword, nameof(newPassword));
var entity = await _databaseContext.Users.Where(u => u.Id == id).SingleOrDefaultAsync();
if (entity == null)
throw new UserNotExistException(id);
if (!_passwordService.VerifyPassword(entity.Password, oldPassword))
throw new BadPasswordException(oldPassword);
entity.Password = _passwordService.HashPassword(newPassword);
entity.Version += 1;
await _databaseContext.SaveChangesAsync();
_logger.LogInformation(Log.Format(LogDatabaseUpdate, ("Id", id), ("Operation", "Change password")));
}
}
}