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