From 657fb589137099794e58fbd35beb7d942b376965 Mon Sep 17 00:00:00 2001 From: crupest Date: Sun, 25 Apr 2021 21:20:04 +0800 Subject: ... --- .../Timeline/Services/User/BadPasswordException.cs | 25 ++ BackEnd/Timeline/Services/User/BasicUserService.cs | 94 ++++++++ .../User/InvalidOperationOnRootUserException.cs | 16 ++ .../Services/User/PasswordBadFormatException.cs | 26 ++ BackEnd/Timeline/Services/User/PasswordService.cs | 224 +++++++++++++++++ .../Timeline/Services/User/Resource.Designer.cs | 117 +++++++++ BackEnd/Timeline/Services/User/Resource.resx | 138 +++++++++++ .../Services/User/UserAlreadyExistException.cs | 24 ++ .../Timeline/Services/User/UserAvatarService.cs | 266 +++++++++++++++++++++ .../Services/User/UserCredentialService.cs | 101 ++++++++ .../Timeline/Services/User/UserDeleteService.cs | 70 ++++++ .../Services/User/UserNotExistException.cs | 37 +++ .../Services/User/UserPermissionService.cs | 240 +++++++++++++++++++ BackEnd/Timeline/Services/User/UserService.cs | 214 +++++++++++++++++ 14 files changed, 1592 insertions(+) create mode 100644 BackEnd/Timeline/Services/User/BadPasswordException.cs create mode 100644 BackEnd/Timeline/Services/User/BasicUserService.cs create mode 100644 BackEnd/Timeline/Services/User/InvalidOperationOnRootUserException.cs create mode 100644 BackEnd/Timeline/Services/User/PasswordBadFormatException.cs create mode 100644 BackEnd/Timeline/Services/User/PasswordService.cs create mode 100644 BackEnd/Timeline/Services/User/Resource.Designer.cs create mode 100644 BackEnd/Timeline/Services/User/Resource.resx create mode 100644 BackEnd/Timeline/Services/User/UserAlreadyExistException.cs create mode 100644 BackEnd/Timeline/Services/User/UserAvatarService.cs create mode 100644 BackEnd/Timeline/Services/User/UserCredentialService.cs create mode 100644 BackEnd/Timeline/Services/User/UserDeleteService.cs create mode 100644 BackEnd/Timeline/Services/User/UserNotExistException.cs create mode 100644 BackEnd/Timeline/Services/User/UserPermissionService.cs create mode 100644 BackEnd/Timeline/Services/User/UserService.cs (limited to 'BackEnd/Timeline/Services/User') diff --git a/BackEnd/Timeline/Services/User/BadPasswordException.cs b/BackEnd/Timeline/Services/User/BadPasswordException.cs new file mode 100644 index 00000000..7302fbca --- /dev/null +++ b/BackEnd/Timeline/Services/User/BadPasswordException.cs @@ -0,0 +1,25 @@ +using System; + +namespace Timeline.Services.User +{ + [Serializable] + public class BadPasswordException : Exception + { + public BadPasswordException() : this(null, null, null) { } + public BadPasswordException(string? badPassword) : this(badPassword, null, null) { } + public BadPasswordException(string? badPassword, Exception? inner) : this(badPassword, null, inner) { } + public BadPasswordException(string? badPassword, string? message, Exception? inner) : base(message ?? Resource.ExceptionBadPassword, inner) + { + Password = badPassword; + } + + protected BadPasswordException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + /// + /// The wrong password. + /// + public string? Password { get; set; } + } +} diff --git a/BackEnd/Timeline/Services/User/BasicUserService.cs b/BackEnd/Timeline/Services/User/BasicUserService.cs new file mode 100644 index 00000000..a3763ef6 --- /dev/null +++ b/BackEnd/Timeline/Services/User/BasicUserService.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Models.Validation; + +namespace Timeline.Services.User +{ + /// + /// This service provide some basic user features, which should be used internally for other services. + /// + public interface IBasicUserService + { + /// + /// Check if a user exists. + /// + /// The id of the user. + /// True if exists. Otherwise false. + Task CheckUserExistence(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); + + /// + /// Get the username modified time of a user. + /// + /// User id. + /// The time. + /// Thrown when user does not exist. + Task GetUsernameLastModifiedTime(long userId); + } + + public class BasicUserService : IBasicUserService + { + private readonly DatabaseContext _database; + + private readonly UsernameValidator _usernameValidator = new UsernameValidator(); + + public BasicUserService(DatabaseContext database) + { + _database = database; + } + + public async Task CheckUserExistence(long id) + { + return await _database.Users.AnyAsync(u => u.Id == id); + } + + public async Task GetUserIdByUsername(string username) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + + if (!_usernameValidator.Validate(username, out var message)) + throw new ArgumentException(message); + + var entity = await _database.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 GetUsernameLastModifiedTime(long userId) + { + var entity = await _database.Users.Where(u => u.Id == userId).Select(u => new { u.UsernameChangeTime }).SingleOrDefaultAsync(); + + if (entity is null) + throw new UserNotExistException(userId); + + return entity.UsernameChangeTime; + } + } + + public static class BasicUserServiceExtensions + { + public static async Task ThrowIfUserNotExist(this IBasicUserService service, long userId) + { + if (!await service.CheckUserExistence(userId)) + { + throw new UserNotExistException(userId); + } + } + } +} diff --git a/BackEnd/Timeline/Services/User/InvalidOperationOnRootUserException.cs b/BackEnd/Timeline/Services/User/InvalidOperationOnRootUserException.cs new file mode 100644 index 00000000..c432febd --- /dev/null +++ b/BackEnd/Timeline/Services/User/InvalidOperationOnRootUserException.cs @@ -0,0 +1,16 @@ +using System; + +namespace Timeline.Services.User +{ + + [Serializable] + public class InvalidOperationOnRootUserException : InvalidOperationException + { + public InvalidOperationOnRootUserException() { } + public InvalidOperationOnRootUserException(string message) : base(message) { } + public InvalidOperationOnRootUserException(string message, Exception inner) : base(message, inner) { } + protected InvalidOperationOnRootUserException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/BackEnd/Timeline/Services/User/PasswordBadFormatException.cs b/BackEnd/Timeline/Services/User/PasswordBadFormatException.cs new file mode 100644 index 00000000..b9d76017 --- /dev/null +++ b/BackEnd/Timeline/Services/User/PasswordBadFormatException.cs @@ -0,0 +1,26 @@ +using System; + +namespace Timeline.Services.User +{ + [Serializable] + public class PasswordBadFormatException : Exception + { + public PasswordBadFormatException() : base(Resources.Services.Exception.PasswordBadFormatException) { } + public PasswordBadFormatException(string message) : base(message) { } + public PasswordBadFormatException(string message, Exception inner) : base(message, inner) { } + + public PasswordBadFormatException(string password, string validationMessage) : this() + { + Password = password; + ValidationMessage = validationMessage; + } + + protected PasswordBadFormatException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public string Password { get; set; } = ""; + + public string ValidationMessage { get; set; } = ""; + } +} diff --git a/BackEnd/Timeline/Services/User/PasswordService.cs b/BackEnd/Timeline/Services/User/PasswordService.cs new file mode 100644 index 00000000..580471e1 --- /dev/null +++ b/BackEnd/Timeline/Services/User/PasswordService.cs @@ -0,0 +1,224 @@ +using Microsoft.AspNetCore.Cryptography.KeyDerivation; +using System; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; + +namespace Timeline.Services.User +{ + /// + /// Hashed password is of bad format. + /// + /// + [Serializable] + public class HashedPasswordBadFromatException : Exception + { + private static string MakeMessage(string reason) + { + return Resources.Services.Exception.HashedPasswordBadFromatException + " Reason: " + reason; + } + + public HashedPasswordBadFromatException() : base(Resources.Services.Exception.HashedPasswordBadFromatException) { } + + public HashedPasswordBadFromatException(string message) : base(message) { } + public HashedPasswordBadFromatException(string message, Exception inner) : base(message, inner) { } + + public HashedPasswordBadFromatException(string hashedPassword, string reason) : base(MakeMessage(reason)) { HashedPassword = hashedPassword; } + public HashedPasswordBadFromatException(string hashedPassword, string reason, Exception inner) : base(MakeMessage(reason), inner) { HashedPassword = hashedPassword; } + protected HashedPasswordBadFromatException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public string? HashedPassword { get; set; } + } + + public interface IPasswordService + { + /// + /// Hash a password. + /// + /// The password to hash. + /// A hashed representation of the supplied . + /// Thrown when is null. + string HashPassword(string password); + + /// + /// Verify whether the password fits into the hashed one. + /// + /// Usually you only need to check the returned bool value. + /// Catching usually is not necessary. + /// Because if your program logic is right and always call + /// and in pair, this exception will never be thrown. + /// A thrown one usually means the data you saved is corupted, which is a critical problem. + /// + /// The hashed password. + /// The password supplied for comparison. + /// True indicating password is right. Otherwise false. + /// Thrown when or is null. + /// Thrown when the hashed password is of bad format. + bool VerifyPassword(string hashedPassword, string providedPassword); + } + + /// + /// Copied from https://github.com/aspnet/AspNetCore/blob/master/src/Identity/Extensions.Core/src/PasswordHasher.cs + /// Remove V2 format and unnecessary format version check. + /// Remove configuration options. + /// Remove user related parts. + /// Change the exceptions. + /// + public class PasswordService : IPasswordService + { + /* ======================= + * HASHED PASSWORD FORMATS + * ======================= + * + * Version 3: + * PBKDF2 with HMAC-SHA256, 128-bit salt, 256-bit subkey, 10000 iterations. + * Format: { 0x01, prf (UInt32), iter count (UInt32), salt length (UInt32), salt, subkey } + * (All UInt32s are stored big-endian.) + */ + + private readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); + + public PasswordService() + { + } + + // Compares two byte arrays for equality. The method is specifically written so that the loop is not optimized. + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + private static bool ByteArraysEqual(byte[] a, byte[] b) + { + if (a == null && b == null) + { + return true; + } + if (a == null || b == null || a.Length != b.Length) + { + return false; + } + var areSame = true; + for (var i = 0; i < a.Length; i++) + { + areSame &= (a[i] == b[i]); + } + return areSame; + } + + public string HashPassword(string password) + { + if (password == null) + throw new ArgumentNullException(nameof(password)); + return Convert.ToBase64String(HashPasswordV3(password, _rng)); + } + + private static byte[] HashPasswordV3(string password, RandomNumberGenerator rng) + { + return HashPasswordV3(password, rng, + prf: KeyDerivationPrf.HMACSHA256, + iterCount: 10000, + saltSize: 128 / 8, + numBytesRequested: 256 / 8); + } + + private static byte[] HashPasswordV3(string password, RandomNumberGenerator rng, KeyDerivationPrf prf, int iterCount, int saltSize, int numBytesRequested) + { + // Produce a version 3 (see comment above) text hash. + byte[] salt = new byte[saltSize]; + rng.GetBytes(salt); + byte[] subkey = KeyDerivation.Pbkdf2(password, salt, prf, iterCount, numBytesRequested); + + var outputBytes = new byte[13 + salt.Length + subkey.Length]; + outputBytes[0] = 0x01; // format marker + WriteNetworkByteOrder(outputBytes, 1, (uint)prf); + WriteNetworkByteOrder(outputBytes, 5, (uint)iterCount); + WriteNetworkByteOrder(outputBytes, 9, (uint)saltSize); + Buffer.BlockCopy(salt, 0, outputBytes, 13, salt.Length); + Buffer.BlockCopy(subkey, 0, outputBytes, 13 + saltSize, subkey.Length); + return outputBytes; + } + + public bool VerifyPassword(string hashedPassword, string providedPassword) + { + if (hashedPassword == null) + throw new ArgumentNullException(nameof(hashedPassword)); + if (providedPassword == null) + throw new ArgumentNullException(nameof(providedPassword)); + + byte[] decodedHashedPassword; + try + { + decodedHashedPassword = Convert.FromBase64String(hashedPassword); + } + catch (FormatException e) + { + throw new HashedPasswordBadFromatException(hashedPassword, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotBase64, e); + } + + // read the format marker from the hashed password + if (decodedHashedPassword.Length == 0) + { + throw new HashedPasswordBadFromatException(hashedPassword, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotLength0); + } + + return (decodedHashedPassword[0]) switch + { + 0x01 => VerifyHashedPasswordV3(decodedHashedPassword, providedPassword, hashedPassword), + _ => throw new HashedPasswordBadFromatException(hashedPassword, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotUnknownMarker), + }; + } + + private static bool VerifyHashedPasswordV3(byte[] hashedPassword, string password, string hashedPasswordString) + { + try + { + // Read header information + KeyDerivationPrf prf = (KeyDerivationPrf)ReadNetworkByteOrder(hashedPassword, 1); + int iterCount = (int)ReadNetworkByteOrder(hashedPassword, 5); + int saltLength = (int)ReadNetworkByteOrder(hashedPassword, 9); + + // Read the salt: must be >= 128 bits + if (saltLength < 128 / 8) + { + throw new HashedPasswordBadFromatException(hashedPasswordString, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotSaltTooShort); + } + byte[] salt = new byte[saltLength]; + Buffer.BlockCopy(hashedPassword, 13, salt, 0, salt.Length); + + // Read the subkey (the rest of the payload): must be >= 128 bits + int subkeyLength = hashedPassword.Length - 13 - salt.Length; + if (subkeyLength < 128 / 8) + { + throw new HashedPasswordBadFromatException(hashedPasswordString, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotSubkeyTooShort); + } + byte[] expectedSubkey = new byte[subkeyLength]; + Buffer.BlockCopy(hashedPassword, 13 + salt.Length, expectedSubkey, 0, expectedSubkey.Length); + + // Hash the incoming password and verify it + byte[] actualSubkey = KeyDerivation.Pbkdf2(password, salt, prf, iterCount, subkeyLength); + return ByteArraysEqual(actualSubkey, expectedSubkey); + } + catch (Exception e) + { + // This should never occur except in the case of a malformed payload, where + // we might go off the end of the array. Regardless, a malformed payload + // implies verification failed. + throw new HashedPasswordBadFromatException(hashedPasswordString, Resources.Services.Exception.HashedPasswordBadFromatExceptionNotOthers, e); + } + } + + private static uint ReadNetworkByteOrder(byte[] buffer, int offset) + { + return ((uint)(buffer[offset + 0]) << 24) + | ((uint)(buffer[offset + 1]) << 16) + | ((uint)(buffer[offset + 2]) << 8) + | ((uint)(buffer[offset + 3])); + } + + private static void WriteNetworkByteOrder(byte[] buffer, int offset, uint value) + { + buffer[offset + 0] = (byte)(value >> 24); + buffer[offset + 1] = (byte)(value >> 16); + buffer[offset + 2] = (byte)(value >> 8); + buffer[offset + 3] = (byte)(value >> 0); + } + } +} diff --git a/BackEnd/Timeline/Services/User/Resource.Designer.cs b/BackEnd/Timeline/Services/User/Resource.Designer.cs new file mode 100644 index 00000000..d64a7aab --- /dev/null +++ b/BackEnd/Timeline/Services/User/Resource.Designer.cs @@ -0,0 +1,117 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Timeline.Services.User { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resource { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resource() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Timeline.Services.User.Resource", typeof(Resource).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Password is wrong.. + /// + internal static string ExceptionBadPassword { + get { + return ResourceManager.GetString("ExceptionBadPassword", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Nickname is of bad format. {0}. + /// + internal static string ExceptionNicknameBadFormat { + get { + return ResourceManager.GetString("ExceptionNicknameBadFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Password can't be empty.. + /// + internal static string ExceptionPasswordEmpty { + get { + return ResourceManager.GetString("ExceptionPasswordEmpty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to User with given constraints already exists.. + /// + internal static string ExceptionUserAlreadyExist { + get { + return ResourceManager.GetString("ExceptionUserAlreadyExist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Username is of bad format. {0}. + /// + internal static string ExceptionUsernameBadFormat { + get { + return ResourceManager.GetString("ExceptionUsernameBadFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Requested user does not exist.. + /// + internal static string ExceptionUserNotExist { + get { + return ResourceManager.GetString("ExceptionUserNotExist", resourceCulture); + } + } + } +} diff --git a/BackEnd/Timeline/Services/User/Resource.resx b/BackEnd/Timeline/Services/User/Resource.resx new file mode 100644 index 00000000..732cfefd --- /dev/null +++ b/BackEnd/Timeline/Services/User/Resource.resx @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Password is wrong. + + + Nickname is of bad format. {0} + + + Password can't be empty. + + + User with given constraints already exists. + + + Username is of bad format. {0} + + + Requested user does not exist. + + \ No newline at end of file diff --git a/BackEnd/Timeline/Services/User/UserAlreadyExistException.cs b/BackEnd/Timeline/Services/User/UserAlreadyExistException.cs new file mode 100644 index 00000000..e257af74 --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserAlreadyExistException.cs @@ -0,0 +1,24 @@ +using System; + +namespace Timeline.Services.User +{ + /// + /// The user requested does not exist. + /// + [Serializable] + public class UserAlreadyExistException : EntityAlreadyExistException + { + public UserAlreadyExistException() : this(null, null, null) { } + public UserAlreadyExistException(object? entity) : this(entity, null, null) { } + public UserAlreadyExistException(object? entity, Exception? inner) : this(entity, null, inner) { } + public UserAlreadyExistException(object? entity, string? message, Exception? inner) + : base(EntityNames.User, entity, message ?? Resource.ExceptionUserAlreadyExist, inner) + { + + } + + protected UserAlreadyExistException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/BackEnd/Timeline/Services/User/UserAvatarService.cs b/BackEnd/Timeline/Services/User/UserAvatarService.cs new file mode 100644 index 00000000..0a4b7438 --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserAvatarService.cs @@ -0,0 +1,266 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Helpers.Cache; +using Timeline.Models; +using Timeline.Services.Data; +using Timeline.Services.Imaging; + +namespace Timeline.Services.User +{ + /// + /// Provider for default user avatar. + /// + /// + /// Mainly for unit tests. + /// + public interface IDefaultUserAvatarProvider + { + /// + /// Get the digest of default avatar. + /// + /// The digest. + Task GetDefaultAvatarDigest(); + + /// + /// Get the default avatar. + /// + /// The avatar. + Task GetDefaultAvatar(); + } + + public interface IUserAvatarService + { + /// + /// Get avatar digest of a user. + /// + /// User id. + /// The avatar digest. + /// Thrown when user does not exist. + Task GetAvatarDigest(long userId); + + /// + /// Get avatar of a user. If the user has no avatar set, a default one is returned. + /// + /// User id. + /// The avatar. + /// Thrown when user does not exist. + Task GetAvatar(long userId); + + /// + /// Set avatar for a user. + /// + /// User id. + /// The new avatar data. + /// The digest of the avatar. + /// Thrown if is null. + /// Thrown when user does not exist. + /// Thrown if avatar is of bad format. + Task SetAvatar(long userId, ByteData avatar); + + /// + /// Remove avatar of a user. + /// + /// User id. + /// Thrown when user does not exist. + Task DeleteAvatar(long userId); + } + + // TODO! : Make this configurable. + public class DefaultUserAvatarProvider : IDefaultUserAvatarProvider + { + private readonly IETagGenerator _eTagGenerator; + + private readonly string _avatarPath; + + private CacheableDataDigest? _cacheDigest; + private ByteData? _cacheData; + + public DefaultUserAvatarProvider(IWebHostEnvironment environment, IETagGenerator eTagGenerator) + { + _avatarPath = Path.Combine(environment.ContentRootPath, "default-avatar.png"); + _eTagGenerator = eTagGenerator; + } + + private async Task CheckAndInit() + { + var path = _avatarPath; + if (_cacheData == null || File.GetLastWriteTime(path) > _cacheDigest!.LastModified) + { + var data = await File.ReadAllBytesAsync(path); + _cacheDigest = new CacheableDataDigest(await _eTagGenerator.Generate(data), File.GetLastWriteTime(path)); + Image.Identify(data, out var format); + _cacheData = new ByteData(data, format.DefaultMimeType); + } + } + + public async Task GetDefaultAvatarDigest() + { + await CheckAndInit(); + return _cacheDigest!; + } + + public async Task GetDefaultAvatar() + { + await CheckAndInit(); + return _cacheData!; + } + } + + public class UserAvatarService : IUserAvatarService + { + private readonly ILogger _logger; + private readonly DatabaseContext _database; + private readonly IBasicUserService _basicUserService; + private readonly IDefaultUserAvatarProvider _defaultUserAvatarProvider; + private readonly IImageValidator _imageValidator; + private readonly IDataManager _dataManager; + private readonly IClock _clock; + + public UserAvatarService( + ILogger logger, + DatabaseContext database, + IBasicUserService basicUserService, + IDefaultUserAvatarProvider defaultUserAvatarProvider, + IImageValidator imageValidator, + IDataManager dataManager, + IClock clock) + { + _logger = logger; + _database = database; + _basicUserService = basicUserService; + _defaultUserAvatarProvider = defaultUserAvatarProvider; + _imageValidator = imageValidator; + _dataManager = dataManager; + _clock = clock; + } + + public async Task GetAvatarDigest(long userId) + { + var usernameChangeTime = await _basicUserService.GetUsernameLastModifiedTime(userId); + + var entity = await _database.UserAvatars.Where(a => a.UserId == userId).Select(a => new { a.DataTag, a.LastModified }).SingleOrDefaultAsync(); + + if (entity is null) + { + var defaultAvatarDigest = await _defaultUserAvatarProvider.GetDefaultAvatarDigest(); + return new CacheableDataDigest(defaultAvatarDigest.ETag, new DateTime[] { usernameChangeTime, defaultAvatarDigest.LastModified }.Max()); + } + else if (entity.DataTag is null) + { + var defaultAvatarDigest = await _defaultUserAvatarProvider.GetDefaultAvatarDigest(); + return new CacheableDataDigest(defaultAvatarDigest.ETag, new DateTime[] { usernameChangeTime, defaultAvatarDigest.LastModified, entity.LastModified }.Max()); + } + else + { + return new CacheableDataDigest(entity.DataTag, new DateTime[] { usernameChangeTime, entity.LastModified }.Max()); + } + } + + public async Task GetAvatar(long userId) + { + await _basicUserService.ThrowIfUserNotExist(userId); + + var entity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync(); + + if (entity is null || entity.DataTag is null) + { + return await _defaultUserAvatarProvider.GetDefaultAvatar(); + } + + var data = await _dataManager.GetEntryAndCheck(entity.DataTag, $"This is required by avatar of {userId}."); + + if (entity.Type is null) + { + Image.Identify(data, out var format); + entity.Type = format.DefaultMimeType; + await _database.SaveChangesAsync(); + } + + return new ByteData(data, entity.Type); + } + + public async Task SetAvatar(long userId, ByteData avatar) + { + if (avatar is null) + throw new ArgumentNullException(nameof(avatar)); + + await _imageValidator.Validate(avatar.Data, avatar.ContentType, true); + + await _basicUserService.ThrowIfUserNotExist(userId); + + var entity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync(); + + await using var transaction = await _database.Database.BeginTransactionAsync(); + + var tag = await _dataManager.RetainEntry(avatar.Data); + + var now = _clock.GetCurrentTime(); + + if (entity is null) + { + var newEntity = new UserAvatarEntity + { + DataTag = tag, + Type = avatar.ContentType, + LastModified = now, + UserId = userId + }; + _database.Add(newEntity); + } + else + { + if (entity.DataTag is not null) + await _dataManager.FreeEntry(entity.DataTag); + + entity.DataTag = tag; + entity.Type = avatar.ContentType; + entity.LastModified = now; + } + + await _database.SaveChangesAsync(); + + await transaction.CommitAsync(); + + return new CacheableDataDigest(tag, now); + } + + public async Task DeleteAvatar(long userId) + { + await _basicUserService.ThrowIfUserNotExist(userId); + + var entity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleOrDefaultAsync(); + + if (entity is null || entity.DataTag is null) + return; + + await using var transaction = await _database.Database.BeginTransactionAsync(); + + await _dataManager.FreeEntry(entity.DataTag); + + entity.DataTag = null; + entity.Type = null; + entity.LastModified = _clock.GetCurrentTime(); + + await _database.SaveChangesAsync(); + + await transaction.CommitAsync(); + } + } + + public static class UserAvatarServiceCollectionExtensions + { + public static void AddUserAvatarService(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } + } +} diff --git a/BackEnd/Timeline/Services/User/UserCredentialService.cs b/BackEnd/Timeline/Services/User/UserCredentialService.cs new file mode 100644 index 00000000..6becc469 --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserCredentialService.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Helpers; +using Timeline.Models.Validation; + +namespace Timeline.Services.User +{ + public interface IUserCredentialService + { + /// + /// Try to verify the given username and password. + /// + /// The username of the user to verify. + /// The password of the user to verify. + /// User id. + /// 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 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 UserCredentialService : IUserCredentialService + { + private readonly ILogger _logger; + private readonly DatabaseContext _database; + private readonly IPasswordService _passwordService; + + private readonly UsernameValidator _usernameValidator = new UsernameValidator(); + + public UserCredentialService(ILogger logger, DatabaseContext database, IPasswordService passwordService) + { + _logger = logger; + _database = database; + _passwordService = passwordService; + } + + public async Task VerifyCredential(string username, string password) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (password == null) + throw new ArgumentNullException(nameof(password)); + if (!_usernameValidator.Validate(username, out var message)) + throw new ArgumentException(message); + if (password.Length == 0) + throw new ArgumentException("Password can't be empty."); + + var entity = await _database.Users.Where(u => u.Username == username).Select(u => new { u.Id, u.Password }).SingleOrDefaultAsync(); + + if (entity == null) + throw new UserNotExistException(username); + + if (!_passwordService.VerifyPassword(entity.Password, password)) + throw new BadPasswordException(password); + + return entity.Id; + } + + 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)); + if (oldPassword.Length == 0) + throw new ArgumentException("Old password can't be empty."); + if (newPassword.Length == 0) + throw new ArgumentException("New password can't be empty."); + + var entity = await _database.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 _database.SaveChangesAsync(); + _logger.LogInformation(Log.Format(Resources.Services.UserService.LogDatabaseUpdate, ("Id", id), ("Operation", "Change password"))); + } + } +} diff --git a/BackEnd/Timeline/Services/User/UserDeleteService.cs b/BackEnd/Timeline/Services/User/UserDeleteService.cs new file mode 100644 index 00000000..8da4678a --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserDeleteService.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; +using Timeline.Models.Validation; +using Timeline.Services.Timeline; + +namespace Timeline.Services.User +{ + public interface IUserDeleteService + { + /// + /// Delete a user of given username. + /// + /// Username of the user to delete. Can't be null. + /// True if user is deleted, false if user not exist. + /// Thrown if is null. + /// Thrown when is of bad format. + /// Thrown when deleting root user. + Task DeleteUser(string username); + } + + public class UserDeleteService : IUserDeleteService + { + private readonly ILogger _logger; + + private readonly DatabaseContext _databaseContext; + + private readonly ITimelinePostService _timelinePostService; + + private readonly UsernameValidator _usernameValidator = new UsernameValidator(); + + public UserDeleteService(ILogger logger, DatabaseContext databaseContext, ITimelinePostService timelinePostService) + { + _logger = logger; + _databaseContext = databaseContext; + _timelinePostService = timelinePostService; + } + + public async Task DeleteUser(string username) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + + if (!_usernameValidator.Validate(username, out var message)) + { + throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resource.ExceptionUsernameBadFormat, message), nameof(username)); + } + + var user = await _databaseContext.Users.Where(u => u.Username == username).SingleOrDefaultAsync(); + if (user == null) + return false; + + if (user.Id == 1) + throw new InvalidOperationOnRootUserException("Can't delete root user."); + + await _timelinePostService.DeleteAllPostsOfUser(user.Id); + + _databaseContext.Users.Remove(user); + + await _databaseContext.SaveChangesAsync(); + + return true; + } + + } +} diff --git a/BackEnd/Timeline/Services/User/UserNotExistException.cs b/BackEnd/Timeline/Services/User/UserNotExistException.cs new file mode 100644 index 00000000..bc5d8d9e --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserNotExistException.cs @@ -0,0 +1,37 @@ +using System; + +namespace Timeline.Services.User +{ + /// + /// The user requested does not exist. + /// + [Serializable] + public class UserNotExistException : EntityNotExistException + { + public UserNotExistException() : this(null, null, null, null) { } + public UserNotExistException(string? username) : this(username, null, null, null) { } + public UserNotExistException(string? username, Exception? inner) : this(username, null, null, inner) { } + public UserNotExistException(long id) : this(null, id, null, null) { } + public UserNotExistException(long id, Exception? inner) : this(null, id, null, inner) { } + public UserNotExistException(string? username, long? id, string? message, Exception? inner) + : base(EntityNames.User, message ?? Resource.ExceptionUserNotExist, inner) + { + Username = username; + Id = id; + } + + protected UserNotExistException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + /// + /// The username of the user that does not exist. + /// + public string? Username { get; set; } + + /// + /// The id of the user that does not exist. + /// + public long? Id { get; set; } + } +} diff --git a/BackEnd/Timeline/Services/User/UserPermissionService.cs b/BackEnd/Timeline/Services/User/UserPermissionService.cs new file mode 100644 index 00000000..f292142d --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserPermissionService.cs @@ -0,0 +1,240 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Timeline.Entities; + +namespace Timeline.Services.User +{ + public enum UserPermission + { + /// + /// This permission allows to manage user (creating, deleting or modifying). + /// + UserManagement, + /// + /// This permission allows to view and modify all timelines. + /// + AllTimelineManagement, + /// + /// This permission allow to add or remove highlight timelines. + /// + HighlightTimelineManagement + } + + /// + /// Represents a user's permissions. + /// + public class UserPermissions : IEnumerable, IEquatable + { + public static UserPermissions AllPermissions { get; } = new UserPermissions(Enum.GetValues()); + + /// + /// Create an instance containing given permissions. + /// + /// Permission list. + public UserPermissions(params UserPermission[] permissions) : this(permissions as IEnumerable) + { + + } + + /// + /// Create an instance containing given permissions. + /// + /// Permission list. + /// Thrown when is null. + public UserPermissions(IEnumerable permissions) + { + if (permissions == null) throw new ArgumentNullException(nameof(permissions)); + _permissions = new SortedSet(permissions); + } + + private readonly SortedSet _permissions = new(); + + /// + /// Check if a permission is contained in the list. + /// + /// The permission to check. + /// True if contains. Otherwise false. + public bool Contains(UserPermission permission) + { + return _permissions.Contains(permission); + } + + /// + /// To a serializable string list. + /// + /// A string list. + public List ToStringList() + { + return _permissions.Select(p => p.ToString()).ToList(); + } + + /// + /// Convert a string list to user permissions. + /// + /// The string list. + /// An instance. + /// Thrown when is null. + /// Thrown when there is unknown permission name. + public static UserPermissions FromStringList(IEnumerable list) + { + List permissions = new(); + + foreach (var value in list) + { + if (Enum.TryParse(value, false, out var result)) + { + permissions.Add(result); + } + else + { + throw new ArgumentException("Unknown permission name.", nameof(list)); + } + } + + return new UserPermissions(permissions); + } + + public IEnumerator GetEnumerator() + { + return _permissions.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)_permissions).GetEnumerator(); + } + + public bool Equals(UserPermissions? other) + { + if (other == null) + return false; + + return _permissions.SequenceEqual(other._permissions); + } + + public override bool Equals(object? obj) + { + return Equals(obj as UserPermissions); + } + + public override int GetHashCode() + { + int result = 0; + foreach (var permission in Enum.GetValues()) + { + if (_permissions.Contains(permission)) + { + result += 1; + } + result <<= 1; + } + return result; + } + } + + public interface IUserPermissionService + { + /// + /// Get permissions of a user. + /// + /// The id of the user. + /// Whether check the user's existence. + /// The permission list. + /// Thrown when is true and user does not exist. + Task GetPermissionsOfUserAsync(long userId, bool checkUserExistence = true); + + /// + /// Add a permission to user. + /// + /// The id of the user. + /// The new permission. + /// Thrown when user does not exist. + /// Thrown when change root user's permission. + Task AddPermissionToUserAsync(long userId, UserPermission permission); + + /// + /// Remove a permission from user. + /// + /// The id of the user. + /// The permission. + /// Whether check the user's existence. + /// Thrown when is true and user does not exist. + /// Thrown when change root user's permission. + Task RemovePermissionFromUserAsync(long userId, UserPermission permission, bool checkUserExistence = true); + } + + public class UserPermissionService : IUserPermissionService + { + private readonly DatabaseContext _database; + + public UserPermissionService(DatabaseContext database) + { + _database = database; + } + + private async Task CheckUserExistence(long userId, bool checkUserExistence) + { + if (checkUserExistence) + { + var existence = await _database.Users.AnyAsync(u => u.Id == userId); + if (!existence) + { + throw new UserNotExistException(userId); + } + } + } + + public async Task GetPermissionsOfUserAsync(long userId, bool checkUserExistence = true) + { + if (userId == 1) // The init administrator account. + { + return UserPermissions.AllPermissions; + } + + await CheckUserExistence(userId, checkUserExistence); + + var permissionNameList = await _database.UserPermission.Where(e => e.UserId == userId).Select(e => e.Permission).ToListAsync(); + + return UserPermissions.FromStringList(permissionNameList); + } + + public async Task AddPermissionToUserAsync(long userId, UserPermission permission) + { + if (userId == 1) + throw new InvalidOperationOnRootUserException("Can't change root user's permission."); + + await CheckUserExistence(userId, true); + + var alreadyHas = await _database.UserPermission + .AnyAsync(e => e.UserId == userId && e.Permission == permission.ToString()); + + if (alreadyHas) return; + + _database.UserPermission.Add(new UserPermissionEntity { UserId = userId, Permission = permission.ToString() }); + + await _database.SaveChangesAsync(); + } + + public async Task RemovePermissionFromUserAsync(long userId, UserPermission permission, bool checkUserExistence = true) + { + if (userId == 1) + throw new InvalidOperationOnRootUserException("Can't change root user's permission."); + + await CheckUserExistence(userId, checkUserExistence); + + var entity = await _database.UserPermission + .Where(e => e.UserId == userId && e.Permission == permission.ToString()) + .SingleOrDefaultAsync(); + + if (entity == null) return; + + _database.UserPermission.Remove(entity); + + await _database.SaveChangesAsync(); + } + } +} diff --git a/BackEnd/Timeline/Services/User/UserService.cs b/BackEnd/Timeline/Services/User/UserService.cs new file mode 100644 index 00000000..bbbe15b0 --- /dev/null +++ b/BackEnd/Timeline/Services/User/UserService.cs @@ -0,0 +1,214 @@ +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.Models.Validation; + +namespace Timeline.Services.User +{ + /// + /// Null means not change. + /// + public class ModifyUserParams + { + public string? Username { get; set; } + public string? Password { get; set; } + public string? Nickname { get; set; } + } + + public interface IUserService : IBasicUserService + { + /// + /// 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); + + /// + /// 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); + } + + public class UserService : BasicUserService, IUserService + { + private readonly ILogger _logger; + private readonly IClock _clock; + + private readonly DatabaseContext _databaseContext; + + private readonly IPasswordService _passwordService; + + private readonly UsernameValidator _usernameValidator = new UsernameValidator(); + private readonly NicknameValidator _nicknameValidator = new NicknameValidator(); + + public UserService(ILogger logger, DatabaseContext databaseContext, IPasswordService passwordService, IClock clock) : base(databaseContext) + { + _logger = logger; + _databaseContext = databaseContext; + _passwordService = passwordService; + _clock = clock; + } + + private void CheckUsernameFormat(string username, string? paramName) + { + if (!_usernameValidator.Validate(username, out var message)) + { + throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resource.ExceptionUsernameBadFormat, message), paramName); + } + } + + private static void CheckPasswordFormat(string password, string? paramName) + { + if (password.Length == 0) + { + throw new ArgumentException(Resource.ExceptionPasswordEmpty, paramName); + } + } + + private void CheckNicknameFormat(string nickname, string? paramName) + { + if (!_nicknameValidator.Validate(nickname, out var message)) + { + throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, Resource.ExceptionNicknameBadFormat, message), paramName); + } + } + + private static void ThrowUsernameConflict(object? user) + { + throw new UserAlreadyExistException(user); + } + + 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 user; + } + + public async Task> GetUsers() + { + return await _databaseContext.Users.ToListAsync(); + } + + 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(null); + + var newEntity = new UserEntity + { + Username = username, + Password = _passwordService.HashPassword(password), + Version = 1 + }; + _databaseContext.Users.Add(newEntity); + await _databaseContext.SaveChangesAsync(); + + return 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(null); + + 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(); + } + + return entity; + } + } +} -- cgit v1.2.3