aboutsummaryrefslogtreecommitdiff
path: root/BackEnd/Timeline/Services/User
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2021-04-25 21:20:04 +0800
committercrupest <crupest@outlook.com>2021-04-25 21:20:04 +0800
commit657fb589137099794e58fbd35beb7d942b376965 (patch)
tree7ab03d970f4c556b0a005f94da2e0752e9d7ce99 /BackEnd/Timeline/Services/User
parentb64806226723df9a9deb64e80defc93860896f50 (diff)
downloadtimeline-657fb589137099794e58fbd35beb7d942b376965.tar.gz
timeline-657fb589137099794e58fbd35beb7d942b376965.tar.bz2
timeline-657fb589137099794e58fbd35beb7d942b376965.zip
...
Diffstat (limited to 'BackEnd/Timeline/Services/User')
-rw-r--r--BackEnd/Timeline/Services/User/BadPasswordException.cs25
-rw-r--r--BackEnd/Timeline/Services/User/BasicUserService.cs94
-rw-r--r--BackEnd/Timeline/Services/User/InvalidOperationOnRootUserException.cs16
-rw-r--r--BackEnd/Timeline/Services/User/PasswordBadFormatException.cs26
-rw-r--r--BackEnd/Timeline/Services/User/PasswordService.cs224
-rw-r--r--BackEnd/Timeline/Services/User/Resource.Designer.cs117
-rw-r--r--BackEnd/Timeline/Services/User/Resource.resx138
-rw-r--r--BackEnd/Timeline/Services/User/UserAlreadyExistException.cs24
-rw-r--r--BackEnd/Timeline/Services/User/UserAvatarService.cs266
-rw-r--r--BackEnd/Timeline/Services/User/UserCredentialService.cs101
-rw-r--r--BackEnd/Timeline/Services/User/UserDeleteService.cs70
-rw-r--r--BackEnd/Timeline/Services/User/UserNotExistException.cs37
-rw-r--r--BackEnd/Timeline/Services/User/UserPermissionService.cs240
-rw-r--r--BackEnd/Timeline/Services/User/UserService.cs214
14 files changed, 1592 insertions, 0 deletions
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) { }
+
+ /// <summary>
+ /// The wrong password.
+ /// </summary>
+ 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
+{
+ /// <summary>
+ /// This service provide some basic user features, which should be used internally for other services.
+ /// </summary>
+ public interface IBasicUserService
+ {
+ /// <summary>
+ /// Check if a user exists.
+ /// </summary>
+ /// <param name="id">The id of the user.</param>
+ /// <returns>True if exists. Otherwise false.</returns>
+ Task<bool> CheckUserExistence(long id);
+
+ /// <summary>
+ /// Get the user id of given username.
+ /// </summary>
+ /// <param name="username">Username of the user.</param>
+ /// <returns>The id of the user.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="username"/> is null.</exception>
+ /// <exception cref="ArgumentException">Thrown when <paramref name="username"/> is of bad format.</exception>
+ /// <exception cref="UserNotExistException">Thrown when the user with given username does not exist.</exception>
+ Task<long> GetUserIdByUsername(string username);
+
+ /// <summary>
+ /// Get the username modified time of a user.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <returns>The time.</returns>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ Task<DateTime> 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<bool> CheckUserExistence(long id)
+ {
+ return await _database.Users.AnyAsync(u => u.Id == id);
+ }
+
+ public async Task<long> 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<DateTime> 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
+{
+ /// <summary>
+ /// Hashed password is of bad format.
+ /// </summary>
+ /// <seealso cref="IPasswordService.VerifyPassword(string, string)"/>
+ [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
+ {
+ /// <summary>
+ /// Hash a password.
+ /// </summary>
+ /// <param name="password">The password to hash.</param>
+ /// <returns>A hashed representation of the supplied <paramref name="password"/>.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="password"/> is null.</exception>
+ string HashPassword(string password);
+
+ /// <summary>
+ /// Verify whether the password fits into the hashed one.
+ ///
+ /// Usually you only need to check the returned bool value.
+ /// Catching <see cref="HashedPasswordBadFromatException"/> usually is not necessary.
+ /// Because if your program logic is right and always call <see cref="HashPassword(string)"/>
+ /// and <see cref="VerifyPassword(string, string)"/> in pair, this exception will never be thrown.
+ /// A thrown one usually means the data you saved is corupted, which is a critical problem.
+ /// </summary>
+ /// <param name="hashedPassword">The hashed password.</param>
+ /// <param name="providedPassword">The password supplied for comparison.</param>
+ /// <returns>True indicating password is right. Otherwise false.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="hashedPassword"/> or <paramref name="providedPassword"/> is null.</exception>
+ /// <exception cref="HashedPasswordBadFromatException">Thrown when the hashed password is of bad format.</exception>
+ bool VerifyPassword(string hashedPassword, string providedPassword);
+ }
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ 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 @@
+//------------------------------------------------------------------------------
+// <auto-generated>
+// 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.
+// </auto-generated>
+//------------------------------------------------------------------------------
+
+namespace Timeline.Services.User {
+ using System;
+
+
+ /// <summary>
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ /// </summary>
+ // 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() {
+ }
+
+ /// <summary>
+ /// Returns the cached ResourceManager instance used by this class.
+ /// </summary>
+ [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;
+ }
+ }
+
+ /// <summary>
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ /// </summary>
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to Password is wrong..
+ /// </summary>
+ internal static string ExceptionBadPassword {
+ get {
+ return ResourceManager.GetString("ExceptionBadPassword", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to Nickname is of bad format. {0}.
+ /// </summary>
+ internal static string ExceptionNicknameBadFormat {
+ get {
+ return ResourceManager.GetString("ExceptionNicknameBadFormat", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to Password can&apos;t be empty..
+ /// </summary>
+ internal static string ExceptionPasswordEmpty {
+ get {
+ return ResourceManager.GetString("ExceptionPasswordEmpty", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to User with given constraints already exists..
+ /// </summary>
+ internal static string ExceptionUserAlreadyExist {
+ get {
+ return ResourceManager.GetString("ExceptionUserAlreadyExist", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to Username is of bad format. {0}.
+ /// </summary>
+ internal static string ExceptionUsernameBadFormat {
+ get {
+ return ResourceManager.GetString("ExceptionUsernameBadFormat", resourceCulture);
+ }
+ }
+
+ /// <summary>
+ /// Looks up a localized string similar to Requested user does not exist..
+ /// </summary>
+ 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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<root>
+ <!--
+ Microsoft ResX Schema
+
+ Version 2.0
+
+ The primary goals of this format is to allow a simple XML format
+ that is mostly human readable. The generation and parsing of the
+ various data types are done through the TypeConverter classes
+ associated with the data types.
+
+ Example:
+
+ ... ado.net/XML headers & schema ...
+ <resheader name="resmimetype">text/microsoft-resx</resheader>
+ <resheader name="version">2.0</resheader>
+ <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
+ <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
+ <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
+ <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
+ <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
+ <value>[base64 mime encoded serialized .NET Framework object]</value>
+ </data>
+ <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
+ <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
+ <comment>This is a comment</comment>
+ </data>
+
+ There are any number of "resheader" rows that contain simple
+ name/value pairs.
+
+ Each data row contains a name, and value. The row also contains a
+ type or mimetype. Type corresponds to a .NET class that support
+ text/value conversion through the TypeConverter architecture.
+ Classes that don't support this are serialized and stored with the
+ mimetype set.
+
+ The mimetype is used for serialized objects, and tells the
+ ResXResourceReader how to depersist the object. This is currently not
+ extensible. For a given mimetype the value must be set accordingly:
+
+ Note - application/x-microsoft.net.object.binary.base64 is the format
+ that the ResXResourceWriter will generate, however the reader can
+ read any of the formats listed below.
+
+ mimetype: application/x-microsoft.net.object.binary.base64
+ value : The object must be serialized with
+ : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
+ : and then encoded with base64 encoding.
+
+ mimetype: application/x-microsoft.net.object.soap.base64
+ value : The object must be serialized with
+ : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
+ : and then encoded with base64 encoding.
+
+ mimetype: application/x-microsoft.net.object.bytearray.base64
+ value : The object must be serialized into a byte array
+ : using a System.ComponentModel.TypeConverter
+ : and then encoded with base64 encoding.
+ -->
+ <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+ <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
+ <xsd:element name="root" msdata:IsDataSet="true">
+ <xsd:complexType>
+ <xsd:choice maxOccurs="unbounded">
+ <xsd:element name="metadata">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" />
+ </xsd:sequence>
+ <xsd:attribute name="name" use="required" type="xsd:string" />
+ <xsd:attribute name="type" type="xsd:string" />
+ <xsd:attribute name="mimetype" type="xsd:string" />
+ <xsd:attribute ref="xml:space" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="assembly">
+ <xsd:complexType>
+ <xsd:attribute name="alias" type="xsd:string" />
+ <xsd:attribute name="name" type="xsd:string" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="data">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+ <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
+ </xsd:sequence>
+ <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
+ <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
+ <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
+ <xsd:attribute ref="xml:space" />
+ </xsd:complexType>
+ </xsd:element>
+ <xsd:element name="resheader">
+ <xsd:complexType>
+ <xsd:sequence>
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
+ </xsd:sequence>
+ <xsd:attribute name="name" type="xsd:string" use="required" />
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:choice>
+ </xsd:complexType>
+ </xsd:element>
+ </xsd:schema>
+ <resheader name="resmimetype">
+ <value>text/microsoft-resx</value>
+ </resheader>
+ <resheader name="version">
+ <value>2.0</value>
+ </resheader>
+ <resheader name="reader">
+ <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+ </resheader>
+ <resheader name="writer">
+ <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+ </resheader>
+ <data name="ExceptionBadPassword" xml:space="preserve">
+ <value>Password is wrong.</value>
+ </data>
+ <data name="ExceptionNicknameBadFormat" xml:space="preserve">
+ <value>Nickname is of bad format. {0}</value>
+ </data>
+ <data name="ExceptionPasswordEmpty" xml:space="preserve">
+ <value>Password can't be empty.</value>
+ </data>
+ <data name="ExceptionUserAlreadyExist" xml:space="preserve">
+ <value>User with given constraints already exists.</value>
+ </data>
+ <data name="ExceptionUsernameBadFormat" xml:space="preserve">
+ <value>Username is of bad format. {0}</value>
+ </data>
+ <data name="ExceptionUserNotExist" xml:space="preserve">
+ <value>Requested user does not exist.</value>
+ </data>
+</root> \ 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
+{
+ /// <summary>
+ /// The user requested does not exist.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Provider for default user avatar.
+ /// </summary>
+ /// <remarks>
+ /// Mainly for unit tests.
+ /// </remarks>
+ public interface IDefaultUserAvatarProvider
+ {
+ /// <summary>
+ /// Get the digest of default avatar.
+ /// </summary>
+ /// <returns>The digest.</returns>
+ Task<ICacheableDataDigest> GetDefaultAvatarDigest();
+
+ /// <summary>
+ /// Get the default avatar.
+ /// </summary>
+ /// <returns>The avatar.</returns>
+ Task<ByteData> GetDefaultAvatar();
+ }
+
+ public interface IUserAvatarService
+ {
+ /// <summary>
+ /// Get avatar digest of a user.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <returns>The avatar digest.</returns>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ Task<ICacheableDataDigest> GetAvatarDigest(long userId);
+
+ /// <summary>
+ /// Get avatar of a user. If the user has no avatar set, a default one is returned.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <returns>The avatar.</returns>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ Task<ByteData> GetAvatar(long userId);
+
+ /// <summary>
+ /// Set avatar for a user.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="avatar">The new avatar data.</param>
+ /// <returns>The digest of the avatar.</returns>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="avatar"/> is null.</exception>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ /// <exception cref="ImageException">Thrown if avatar is of bad format.</exception>
+ Task<ICacheableDataDigest> SetAvatar(long userId, ByteData avatar);
+
+ /// <summary>
+ /// Remove avatar of a user.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ 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<ICacheableDataDigest> GetDefaultAvatarDigest()
+ {
+ await CheckAndInit();
+ return _cacheDigest!;
+ }
+
+ public async Task<ByteData> GetDefaultAvatar()
+ {
+ await CheckAndInit();
+ return _cacheData!;
+ }
+ }
+
+ public class UserAvatarService : IUserAvatarService
+ {
+ private readonly ILogger<UserAvatarService> _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<UserAvatarService> 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<ICacheableDataDigest> 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<ByteData> 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<ICacheableDataDigest> 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<IUserAvatarService, UserAvatarService>();
+ services.AddScoped<IDefaultUserAvatarProvider, DefaultUserAvatarProvider>();
+ }
+ }
+}
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
+ {
+ /// <summary>
+ /// Try to verify the given username and password.
+ /// </summary>
+ /// <param name="username">The username of the user to verify.</param>
+ /// <param name="password">The password of the user to verify.</param>
+ /// <returns>User id.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="username"/> or <paramref name="password"/> is null.</exception>
+ /// <exception cref="ArgumentException">Thrown when <paramref name="username"/> is of bad format or <paramref name="password"/> is empty.</exception>
+ /// <exception cref="UserNotExistException">Thrown when the user with given username does not exist.</exception>
+ /// <exception cref="BadPasswordException">Thrown when password is wrong.</exception>
+ Task<long> VerifyCredential(string username, string password);
+
+ /// <summary>
+ /// Try to change a user's password with old password.
+ /// </summary>
+ /// <param name="id">The id of user to change password of.</param>
+ /// <param name="oldPassword">Old password.</param>
+ /// <param name="newPassword">New password.</param>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="oldPassword"/> or <paramref name="newPassword"/> is null.</exception>
+ /// <exception cref="ArgumentException">Thrown if <paramref name="oldPassword"/> or <paramref name="newPassword"/> is empty.</exception>
+ /// <exception cref="UserNotExistException">Thrown if the user with given username does not exist.</exception>
+ /// <exception cref="BadPasswordException">Thrown if the old password is wrong.</exception>
+ Task ChangePassword(long id, string oldPassword, string newPassword);
+ }
+
+ public class UserCredentialService : IUserCredentialService
+ {
+ private readonly ILogger<UserCredentialService> _logger;
+ private readonly DatabaseContext _database;
+ private readonly IPasswordService _passwordService;
+
+ private readonly UsernameValidator _usernameValidator = new UsernameValidator();
+
+ public UserCredentialService(ILogger<UserCredentialService> logger, DatabaseContext database, IPasswordService passwordService)
+ {
+ _logger = logger;
+ _database = database;
+ _passwordService = passwordService;
+ }
+
+ public async Task<long> 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
+ {
+ /// <summary>
+ /// Delete a user of given username.
+ /// </summary>
+ /// <param name="username">Username of the user to delete. Can't be null.</param>
+ /// <returns>True if user is deleted, false if user not exist.</returns>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="username"/> is null.</exception>
+ /// <exception cref="ArgumentException">Thrown when <paramref name="username"/> is of bad format.</exception>
+ /// <exception cref="InvalidOperationOnRootUserException">Thrown when deleting root user.</exception>
+ Task<bool> DeleteUser(string username);
+ }
+
+ public class UserDeleteService : IUserDeleteService
+ {
+ private readonly ILogger<UserDeleteService> _logger;
+
+ private readonly DatabaseContext _databaseContext;
+
+ private readonly ITimelinePostService _timelinePostService;
+
+ private readonly UsernameValidator _usernameValidator = new UsernameValidator();
+
+ public UserDeleteService(ILogger<UserDeleteService> logger, DatabaseContext databaseContext, ITimelinePostService timelinePostService)
+ {
+ _logger = logger;
+ _databaseContext = databaseContext;
+ _timelinePostService = timelinePostService;
+ }
+
+ public async Task<bool> 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
+{
+ /// <summary>
+ /// The user requested does not exist.
+ /// </summary>
+ [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) { }
+
+ /// <summary>
+ /// The username of the user that does not exist.
+ /// </summary>
+ public string? Username { get; set; }
+
+ /// <summary>
+ /// The id of the user that does not exist.
+ /// </summary>
+ 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
+ {
+ /// <summary>
+ /// This permission allows to manage user (creating, deleting or modifying).
+ /// </summary>
+ UserManagement,
+ /// <summary>
+ /// This permission allows to view and modify all timelines.
+ /// </summary>
+ AllTimelineManagement,
+ /// <summary>
+ /// This permission allow to add or remove highlight timelines.
+ /// </summary>
+ HighlightTimelineManagement
+ }
+
+ /// <summary>
+ /// Represents a user's permissions.
+ /// </summary>
+ public class UserPermissions : IEnumerable<UserPermission>, IEquatable<UserPermissions>
+ {
+ public static UserPermissions AllPermissions { get; } = new UserPermissions(Enum.GetValues<UserPermission>());
+
+ /// <summary>
+ /// Create an instance containing given permissions.
+ /// </summary>
+ /// <param name="permissions">Permission list.</param>
+ public UserPermissions(params UserPermission[] permissions) : this(permissions as IEnumerable<UserPermission>)
+ {
+
+ }
+
+ /// <summary>
+ /// Create an instance containing given permissions.
+ /// </summary>
+ /// <param name="permissions">Permission list.</param>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="permissions"/> is null.</exception>
+ public UserPermissions(IEnumerable<UserPermission> permissions)
+ {
+ if (permissions == null) throw new ArgumentNullException(nameof(permissions));
+ _permissions = new SortedSet<UserPermission>(permissions);
+ }
+
+ private readonly SortedSet<UserPermission> _permissions = new();
+
+ /// <summary>
+ /// Check if a permission is contained in the list.
+ /// </summary>
+ /// <param name="permission">The permission to check.</param>
+ /// <returns>True if contains. Otherwise false.</returns>
+ public bool Contains(UserPermission permission)
+ {
+ return _permissions.Contains(permission);
+ }
+
+ /// <summary>
+ /// To a serializable string list.
+ /// </summary>
+ /// <returns>A string list.</returns>
+ public List<string> ToStringList()
+ {
+ return _permissions.Select(p => p.ToString()).ToList();
+ }
+
+ /// <summary>
+ /// Convert a string list to user permissions.
+ /// </summary>
+ /// <param name="list">The string list.</param>
+ /// <returns>An instance.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="list"/> is null.</exception>
+ /// <exception cref="ArgumentException">Thrown when there is unknown permission name.</exception>
+ public static UserPermissions FromStringList(IEnumerable<string> list)
+ {
+ List<UserPermission> permissions = new();
+
+ foreach (var value in list)
+ {
+ if (Enum.TryParse<UserPermission>(value, false, out var result))
+ {
+ permissions.Add(result);
+ }
+ else
+ {
+ throw new ArgumentException("Unknown permission name.", nameof(list));
+ }
+ }
+
+ return new UserPermissions(permissions);
+ }
+
+ public IEnumerator<UserPermission> 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<UserPermission>())
+ {
+ if (_permissions.Contains(permission))
+ {
+ result += 1;
+ }
+ result <<= 1;
+ }
+ return result;
+ }
+ }
+
+ public interface IUserPermissionService
+ {
+ /// <summary>
+ /// Get permissions of a user.
+ /// </summary>
+ /// <param name="userId">The id of the user.</param>
+ /// <param name="checkUserExistence">Whether check the user's existence.</param>
+ /// <returns>The permission list.</returns>
+ /// <exception cref="UserNotExistException">Thrown when <paramref name="checkUserExistence"/> is true and user does not exist.</exception>
+ Task<UserPermissions> GetPermissionsOfUserAsync(long userId, bool checkUserExistence = true);
+
+ /// <summary>
+ /// Add a permission to user.
+ /// </summary>
+ /// <param name="userId">The id of the user.</param>
+ /// <param name="permission">The new permission.</param>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ /// <exception cref="InvalidOperationOnRootUserException">Thrown when change root user's permission.</exception>
+ Task AddPermissionToUserAsync(long userId, UserPermission permission);
+
+ /// <summary>
+ /// Remove a permission from user.
+ /// </summary>
+ /// <param name="userId">The id of the user.</param>
+ /// <param name="permission">The permission.</param>
+ /// <param name="checkUserExistence">Whether check the user's existence.</param>
+ /// <exception cref="UserNotExistException">Thrown when <paramref name="checkUserExistence"/> is true and user does not exist.</exception>
+ /// <exception cref="InvalidOperationOnRootUserException">Thrown when change root user's permission.</exception>
+ 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<UserPermissions> 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
+{
+ /// <summary>
+ /// Null means not change.
+ /// </summary>
+ public class ModifyUserParams
+ {
+ public string? Username { get; set; }
+ public string? Password { get; set; }
+ public string? Nickname { get; set; }
+ }
+
+ public interface IUserService : IBasicUserService
+ {
+ /// <summary>
+ /// Try to get a user by id.
+ /// </summary>
+ /// <param name="id">The id of the user.</param>
+ /// <returns>The user info.</returns>
+ /// <exception cref="UserNotExistException">Thrown when the user with given id does not exist.</exception>
+ Task<UserEntity> GetUser(long id);
+
+ /// <summary>
+ /// List all users.
+ /// </summary>
+ /// <returns>The user info of users.</returns>
+ Task<List<UserEntity>> GetUsers();
+
+ /// <summary>
+ /// Create a user with given info.
+ /// </summary>
+ /// <param name="username">The username of new user.</param>
+ /// <param name="password">The password of new user.</param>
+ /// <returns>The the new user.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="username"/> or <paramref name="password"/> is null.</exception>
+ /// <exception cref="ArgumentException">Thrown when <paramref name="username"/> or <paramref name="password"/> is of bad format.</exception>
+ /// <exception cref="EntityAlreadyExistException">Thrown when a user with given username already exists.</exception>
+ Task<UserEntity> CreateUser(string username, string password);
+
+ /// <summary>
+ /// Modify a user.
+ /// </summary>
+ /// <param name="id">The id of the user.</param>
+ /// <param name="param">The new information.</param>
+ /// <returns>The new user info.</returns>
+ /// <exception cref="ArgumentException">Thrown when some fields in <paramref name="param"/> is bad.</exception>
+ /// <exception cref="UserNotExistException">Thrown when user with given id does not exist.</exception>
+ /// <remarks>
+ /// Version will increase if password is changed.
+ /// </remarks>
+ Task<UserEntity> ModifyUser(long id, ModifyUserParams? param);
+ }
+
+ public class UserService : BasicUserService, IUserService
+ {
+ private readonly ILogger<UserService> _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<UserService> 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<UserEntity> 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<List<UserEntity>> GetUsers()
+ {
+ return await _databaseContext.Users.ToListAsync();
+ }
+
+ public async Task<UserEntity> 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<UserEntity> 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;
+ }
+ }
+}