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"))); } } }