diff options
Diffstat (limited to 'Timeline/Services')
-rw-r--r-- | Timeline/Services/JwtService.cs | 53 | ||||
-rw-r--r-- | Timeline/Services/PasswordService.cs | 205 | ||||
-rw-r--r-- | Timeline/Services/UserService.cs | 115 |
3 files changed, 324 insertions, 49 deletions
diff --git a/Timeline/Services/JwtService.cs b/Timeline/Services/JwtService.cs index abdde908..91e7f879 100644 --- a/Timeline/Services/JwtService.cs +++ b/Timeline/Services/JwtService.cs @@ -7,34 +7,25 @@ using System.Linq; using System.Security.Claims; using System.Text; using Timeline.Configs; -using Timeline.Entities; namespace Timeline.Services { public interface IJwtService { /// <summary> - /// Create a JWT token for a given user. - /// Return null if <paramref name="user"/> is null. + /// Create a JWT token for a given user id. /// </summary> - /// <param name="user">The user to generate token.</param> - /// <returns>The generated token or null if <paramref name="user"/> is null.</returns> - string GenerateJwtToken(User user); + /// <param name="userId">The user id used to generate token.</param> + /// <returns>Return the generated token.</returns> + string GenerateJwtToken(long userId, string[] roles); /// <summary> - /// Validate a JWT token. + /// Verify a JWT token. /// Return null is <paramref name="token"/> is null. - /// If token is invalid, return a <see cref="TokenValidationResponse"/> with - /// <see cref="TokenValidationResponse.IsValid"/> set to false and - /// <see cref="TokenValidationResponse.UserInfo"/> set to null. - /// If token is valid, return a <see cref="TokenValidationResponse"/> with - /// <see cref="TokenValidationResponse.IsValid"/> set to true and - /// <see cref="TokenValidationResponse.UserInfo"/> filled with the user info - /// in the token. /// </summary> - /// <param name="token">The token string to validate.</param> - /// <returns>Null if <paramref name="token"/> is null. Or the result.</returns> - TokenValidationResponse ValidateJwtToken(string token); + /// <param name="token">The token string to verify.</param> + /// <returns>Return null if <paramref name="token"/> is null or token is invalid. Return the saved user id otherwise.</returns> + long? VerifyJwtToken(string token); } @@ -50,17 +41,13 @@ namespace Timeline.Services _logger = logger; } - public string GenerateJwtToken(User user) + public string GenerateJwtToken(long id, string[] roles) { - if (user == null) - return null; - var jwtConfig = _jwtConfig.CurrentValue; var identity = new ClaimsIdentity(); - identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); - identity.AddClaim(new Claim(identity.NameClaimType, user.Username)); - identity.AddClaims(user.Roles.Select(role => new Claim(identity.RoleClaimType, role))); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, id.ToString())); + identity.AddClaims(roles.Select(role => new Claim(identity.RoleClaimType, role))); var tokenDescriptor = new SecurityTokenDescriptor() { @@ -80,7 +67,7 @@ namespace Timeline.Services } - public TokenValidationResponse ValidateJwtToken(string token) + public long? VerifyJwtToken(string token) { if (token == null) return null; @@ -100,24 +87,12 @@ namespace Timeline.Services IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)) }, out SecurityToken validatedToken); - var identity = principal.Identity as ClaimsIdentity; - - var userInfo = new UserInfo - { - Username = identity.FindAll(identity.NameClaimType).Select(claim => claim.Value).Single(), - Roles = identity.FindAll(identity.RoleClaimType).Select(claim => claim.Value).ToArray() - }; - - return new TokenValidationResponse - { - IsValid = true, - UserInfo = userInfo - }; + return long.Parse(principal.FindAll(ClaimTypes.NameIdentifier).Single().Value); } catch (Exception e) { _logger.LogInformation(e, "Token validation failed! Token is {} .", token); - return new TokenValidationResponse { IsValid = false }; + return null; } } } diff --git a/Timeline/Services/PasswordService.cs b/Timeline/Services/PasswordService.cs new file mode 100644 index 00000000..8eab526e --- /dev/null +++ b/Timeline/Services/PasswordService.cs @@ -0,0 +1,205 @@ +using Microsoft.AspNetCore.Cryptography.KeyDerivation; +using Microsoft.Extensions.Logging; +using System; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; + +namespace Timeline.Services +{ + public interface IPasswordService + { + /// <summary> + /// Returns a hashed representation of the supplied <paramref name="password"/>. + /// </summary> + /// <param name="password">The password to hash.</param> + /// <returns>A hashed representation of the supplied <paramref name="password"/>.</returns> + string HashPassword(string password); + + /// <summary> + /// Returns a boolean indicating the result of a password hash comparison. + /// </summary> + /// <param name="hashedPassword">The hash value for a user's stored password.</param> + /// <param name="providedPassword">The password supplied for comparison.</param> + /// <returns>True indicating success. Otherwise false.</returns> + 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. + /// Add log for wrong format. + /// </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 static EventId BadFormatEventId { get; } = new EventId(4000, "BadFormatPassword"); + + private readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); + private readonly ILogger<PasswordService> _logger; + + public PasswordService(ILogger<PasswordService> logger) + { + _logger = logger; + } + + + // 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 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; + } + + private void LogBadFormatError(string hashedPassword, string message, Exception exception = null) + { + if (_logger == null) + return; + + if (exception != null) + _logger.LogError(BadFormatEventId, exception, $"{message} Hashed password is {hashedPassword} ."); + else + _logger.LogError(BadFormatEventId, $"{message} Hashed password is {hashedPassword} ."); + } + + public virtual bool VerifyPassword(string hashedPassword, string providedPassword) + { + if (hashedPassword == null) + throw new ArgumentNullException(nameof(hashedPassword)); + if (providedPassword == null) + throw new ArgumentNullException(nameof(providedPassword)); + + byte[] decodedHashedPassword = Convert.FromBase64String(hashedPassword); + + // read the format marker from the hashed password + if (decodedHashedPassword.Length == 0) + { + LogBadFormatError(hashedPassword, "Decoded hashed password is of length 0."); + return false; + } + switch (decodedHashedPassword[0]) + { + case 0x01: + return VerifyHashedPasswordV3(decodedHashedPassword, providedPassword, hashedPassword); + + default: + LogBadFormatError(hashedPassword, "Unknown format marker."); + return false; // unknown format marker + } + } + + private 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) + { + LogBadFormatError(hashedPasswordString, "Salt length < 128 bits."); + return false; + } + 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) + { + LogBadFormatError(hashedPasswordString, "Subkey length < 128 bits."); + return false; + } + 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. + LogBadFormatError(hashedPasswordString, "See exception.", e); + return false; + } + } + + 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/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index 1da6922d..ad36c37b 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -1,31 +1,126 @@ -using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using System.Linq; +using System.Threading.Tasks; using Timeline.Entities; +using Timeline.Models; namespace Timeline.Services { + public class CreateTokenResult + { + public string Token { get; set; } + public UserInfo UserInfo { get; set; } + } + + public enum CreateUserResult + { + Success, + AlreadyExists + } + public interface IUserService { /// <summary> /// Try to anthenticate with the given username and password. + /// If success, create a token and return the user info. /// </summary> /// <param name="username">The username of the user to be anthenticated.</param> /// <param name="password">The password of the user to be anthenticated.</param> - /// <returns><c>null</c> if anthentication failed. - /// An instance of <see cref="User"/> if anthentication succeeded.</returns> - User Authenticate(string username, string password); + /// <returns>Return null if anthentication failed. An <see cref="CreateTokenResult"/> containing the created token and user info if anthentication succeeded.</returns> + Task<CreateTokenResult> CreateToken(string username, string password); + + /// <summary> + /// Verify the given token. + /// If success, return the user info. + /// </summary> + /// <param name="token">The token to verify.</param> + /// <returns>Return null if verification failed. The user info if verification succeeded.</returns> + Task<UserInfo> VerifyToken(string token); + + Task<CreateUserResult> CreateUser(string username, string password, string[] roles); } public class UserService : IUserService { - private readonly IList<User> _users = new List<User>{ - new User { Id = 0, Username = "admin", Password = "admin", Roles = new string[] { "User", "Admin" } }, - new User { Id = 1, Username = "user", Password = "user", Roles = new string[] { "User"} } - }; + private readonly ILogger<UserService> _logger; + private readonly DatabaseContext _databaseContext; + private readonly IJwtService _jwtService; + private readonly IPasswordService _passwordService; + + public UserService(ILogger<UserService> logger, DatabaseContext databaseContext, IJwtService jwtService, IPasswordService passwordService) + { + _logger = logger; + _databaseContext = databaseContext; + _jwtService = jwtService; + _passwordService = passwordService; + } - public User Authenticate(string username, string password) + public async Task<CreateTokenResult> CreateToken(string username, string password) { - return _users.FirstOrDefault(user => user.Username == username && user.Password == password); + var users = _databaseContext.Users.ToList(); + + var user = await _databaseContext.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); + + if (user == null) + { + _logger.LogInformation($"Create token failed with invalid username. Username = {username} Password = {password} ."); + return null; + } + + var verifyResult = _passwordService.VerifyPassword(user.EncryptedPassword, password); + + if (verifyResult) + { + var userInfo = new UserInfo(user); + + return new CreateTokenResult + { + Token = _jwtService.GenerateJwtToken(user.Id, userInfo.Roles), + UserInfo = userInfo + }; + } + else + { + _logger.LogInformation($"Create token failed with invalid password. Username = {username} Password = {password} ."); + return null; + } + } + + public async Task<UserInfo> VerifyToken(string token) + { + var userId = _jwtService.VerifyJwtToken(token); + + if (userId == null) + { + _logger.LogInformation($"Verify token falied. Reason: invalid token. Token: {token} ."); + return null; + } + + var user = await _databaseContext.Users.Where(u => u.Id == userId.Value).SingleOrDefaultAsync(); + + if (user == null) + { + _logger.LogInformation($"Verify token falied. Reason: invalid user id. UserId: {userId} Token: {token} ."); + return null; + } + + return new UserInfo(user); + } + + public async Task<CreateUserResult> CreateUser(string username, string password, string[] roles) + { + var exists = (await _databaseContext.Users.Where(u => u.Name == username).ToListAsync()).Count != 0; + + if (exists) + { + return CreateUserResult.AlreadyExists; + } + + await _databaseContext.Users.AddAsync(new User { Name = username, EncryptedPassword = _passwordService.HashPassword(password), RoleString = string.Join(',', roles) }); + await _databaseContext.SaveChangesAsync(); + + return CreateUserResult.Success; } } } |