using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using System; using System.Linq; using System.Threading.Tasks; using Timeline.Entities; using Timeline.Helpers; using Timeline.Models; using Timeline.Models.Validation; namespace Timeline.Services { public class CreateTokenResult { public string Token { get; set; } = default!; public UserInfo User { get; set; } = default!; } public interface IUserService { /// /// Try to anthenticate with the given username and password. /// If success, create a token and return the user info. /// /// The username of the user to anthenticate. /// The password of the user to anthenticate. /// The expired time point. Null then use default. See for what is default. /// An containing the created token and user info. /// Thrown when or is null. /// Thrown when username is of bad format. /// Thrown when the user with given username does not exist. /// Thrown when password is wrong. Task CreateToken(string username, string password, DateTime? expires = null); /// /// Verify the given token. /// If success, return the user info. /// /// The token to verify. /// The user info specified by the token. /// Thrown when is null. /// Thrown when the token is of bad format. Thrown by . /// Thrown when the user specified by the token does not exist. Usually it has been deleted after the token was issued. Task VerifyToken(string token); /// /// Get the user info of given username. /// /// Username of the user. /// The info of the user. Null if the user of given username does not exists. /// Thrown when is null. /// Thrown when is of bad format. Task GetUser(string username); /// /// List all users. /// /// The user info of users. Task ListUsers(); /// /// Create or modify a user with given username. /// Username must be match with [a-zA-z0-9-_]. /// /// Username of user. /// Password of user. /// Whether the user is administrator. /// /// Return if a new user is created. /// Return if a existing user is modified. /// /// Thrown when or is null. /// Thrown when is of bad format. Task PutUser(string username, string password, bool administrator); /// /// Partially modify a user of given username. /// /// Note that whether actually modified or not, Version of the user will always increase. /// /// Username of the user to modify. Can't be null. /// New password. Null if not modify. /// Whether the user is administrator. Null if not modify. /// Thrown if is null. /// Thrown when is of bad format. /// Thrown if the user with given username does not exist. Task PatchUser(string username, string? password, bool? administrator); /// /// Delete a user of given username. /// /// Username of thet user to delete. Can't be null. /// Thrown if is null. /// Thrown when is of bad format. /// Thrown if the user with given username does not exist. Task DeleteUser(string username); /// /// Try to change a user's password with old password. /// /// The name of user to change password of. /// The user's old password. /// The user's new password. /// Thrown if or or is null. /// Thrown when is of bad format. /// Thrown if the user with given username does not exist. /// Thrown if the old password is wrong. Task ChangePassword(string username, string oldPassword, string newPassword); /// /// Change a user's username. /// /// The user's old username. /// The new username. /// Thrown if or is null. /// Thrown if the user with old username does not exist. /// Thrown if the or is of bad format. /// Thrown if user with the new username already exists. Task ChangeUsername(string oldUsername, string newUsername); } internal class UserCache { public string Username { get; set; } = default!; public bool Administrator { get; set; } public long Version { get; set; } public UserInfo ToUserInfo() { return new UserInfo(Username, Administrator); } } public class UserService : IUserService { private readonly ILogger _logger; private readonly IMemoryCache _memoryCache; private readonly DatabaseContext _databaseContext; private readonly IJwtService _jwtService; private readonly IPasswordService _passwordService; private readonly UsernameValidator _usernameValidator; public UserService(ILogger logger, IMemoryCache memoryCache, DatabaseContext databaseContext, IJwtService jwtService, IPasswordService passwordService) { _logger = logger; _memoryCache = memoryCache; _databaseContext = databaseContext; _jwtService = jwtService; _passwordService = passwordService; _usernameValidator = new UsernameValidator(); } private string GenerateCacheKeyByUserId(long id) => $"user:{id}"; private void RemoveCache(long id) { var key = GenerateCacheKeyByUserId(id); _memoryCache.Remove(key); _logger.LogInformation(Log.Format(Resources.Services.UserService.LogCacheRemove, ("Key", key))); } private void CheckUsernameFormat(string username, string? message = null) { var (result, messageGenerator) = _usernameValidator.Validate(username); if (!result) { if (message == null) throw new UsernameBadFormatException(username, messageGenerator(null)); else throw new UsernameBadFormatException(username, message + messageGenerator(null)); } } public async Task CreateToken(string username, string password, DateTime? expires) { if (username == null) throw new ArgumentNullException(nameof(username)); if (password == null) throw new ArgumentNullException(nameof(password)); CheckUsernameFormat(username); // We need password info, so always check the database. var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) throw new UserNotExistException(username); if (!_passwordService.VerifyPassword(user.EncryptedPassword, password)) throw new BadPasswordException(password); var token = _jwtService.GenerateJwtToken(new TokenInfo { Id = user.Id, Version = user.Version }, expires); return new CreateTokenResult { Token = token, User = UserConvert.CreateUserInfo(user) }; } public async Task VerifyToken(string token) { if (token == null) throw new ArgumentNullException(nameof(token)); TokenInfo tokenInfo; tokenInfo = _jwtService.VerifyJwtToken(token); var id = tokenInfo.Id; var key = GenerateCacheKeyByUserId(id); if (!_memoryCache.TryGetValue(key, out var cache)) { // no cache, check the database var user = await _databaseContext.Users.Where(u => u.Id == id).SingleOrDefaultAsync(); if (user == null) throw new UserNotExistException(id); // create cache cache = UserConvert.CreateUserCache(user); _memoryCache.CreateEntry(key).SetValue(cache); _logger.LogInformation(Log.Format(Resources.Services.UserService.LogCacheCreate, ("Key", key))); } if (tokenInfo.Version != cache.Version) throw new JwtVerifyException(new JwtBadVersionException(tokenInfo.Version, cache.Version), JwtVerifyException.ErrorCodes.OldVersion); return cache.ToUserInfo(); } public async Task GetUser(string username) { if (username == null) throw new ArgumentNullException(nameof(username)); CheckUsernameFormat(username); return await _databaseContext.Users .Where(user => user.Name == username) .Select(user => UserConvert.CreateUserInfo(user)) .SingleOrDefaultAsync(); } public async Task ListUsers() { return await _databaseContext.Users .Select(user => UserConvert.CreateUserInfo(user)) .ToArrayAsync(); } public async Task PutUser(string username, string password, bool administrator) { if (username == null) throw new ArgumentNullException(nameof(username)); if (password == null) throw new ArgumentNullException(nameof(password)); CheckUsernameFormat(username); var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) { var newUser = new User { Name = username, EncryptedPassword = _passwordService.HashPassword(password), RoleString = UserRoleConvert.ToString(administrator), Avatar = null }; await _databaseContext.AddAsync(newUser); await _databaseContext.SaveChangesAsync(); _logger.LogInformation(Log.Format(Resources.Services.UserService.LogDatabaseCreate, ("Id", newUser.Id), ("Username", username), ("Administrator", administrator))); return PutResult.Create; } user.EncryptedPassword = _passwordService.HashPassword(password); user.RoleString = UserRoleConvert.ToString(administrator); user.Version += 1; await _databaseContext.SaveChangesAsync(); _logger.LogInformation(Log.Format(Resources.Services.UserService.LogDatabaseUpdate, ("Id", user.Id), ("Username", username), ("Administrator", administrator))); //clear cache RemoveCache(user.Id); return PutResult.Modify; } public async Task PatchUser(string username, string? password, bool? administrator) { if (username == null) throw new ArgumentNullException(nameof(username)); CheckUsernameFormat(username); var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) throw new UserNotExistException(username); if (password != null) { user.EncryptedPassword = _passwordService.HashPassword(password); } if (administrator != null) { user.RoleString = UserRoleConvert.ToString(administrator.Value); } user.Version += 1; await _databaseContext.SaveChangesAsync(); _logger.LogInformation(Resources.Services.UserService.LogDatabaseUpdate, ("Id", user.Id)); //clear cache RemoveCache(user.Id); } public async Task DeleteUser(string username) { if (username == null) throw new ArgumentNullException(nameof(username)); CheckUsernameFormat(username); var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) throw new UserNotExistException(username); _databaseContext.Users.Remove(user); await _databaseContext.SaveChangesAsync(); _logger.LogInformation(Log.Format(Resources.Services.UserService.LogDatabaseRemove, ("Id", user.Id))); //clear cache RemoveCache(user.Id); } public async Task ChangePassword(string username, string oldPassword, string newPassword) { if (username == null) throw new ArgumentNullException(nameof(username)); if (oldPassword == null) throw new ArgumentNullException(nameof(oldPassword)); if (newPassword == null) throw new ArgumentNullException(nameof(newPassword)); CheckUsernameFormat(username); var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); if (user == null) throw new UserNotExistException(username); var verifyResult = _passwordService.VerifyPassword(user.EncryptedPassword, oldPassword); if (!verifyResult) throw new BadPasswordException(oldPassword); user.EncryptedPassword = _passwordService.HashPassword(newPassword); user.Version += 1; await _databaseContext.SaveChangesAsync(); _logger.LogInformation(Log.Format(Resources.Services.UserService.LogDatabaseUpdate, ("Id", user.Id), ("Operation", "Change password"))); //clear cache RemoveCache(user.Id); } public async Task ChangeUsername(string oldUsername, string newUsername) { if (oldUsername == null) throw new ArgumentNullException(nameof(oldUsername)); if (newUsername == null) throw new ArgumentNullException(nameof(newUsername)); CheckUsernameFormat(oldUsername, Resources.Services.UserService.ExceptionOldUsernameBadFormat); CheckUsernameFormat(newUsername, Resources.Services.UserService.ExceptionNewUsernameBadFormat); var user = await _databaseContext.Users.Where(u => u.Name == oldUsername).SingleOrDefaultAsync(); if (user == null) throw new UserNotExistException(oldUsername); var conflictUser = await _databaseContext.Users.Where(u => u.Name == newUsername).SingleOrDefaultAsync(); if (conflictUser != null) throw new UsernameConfictException(newUsername); user.Name = newUsername; user.Version += 1; await _databaseContext.SaveChangesAsync(); _logger.LogInformation(Log.Format(Resources.Services.UserService.LogDatabaseUpdate, ("Id", user.Id), ("Old Username", oldUsername), ("New Username", newUsername))); RemoveCache(user.Id); } } }