From 7c37a5885437aaf97b9986b7cc2941b5e4316003 Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 24 Apr 2021 22:01:27 +0800 Subject: refactor: Move token services. --- .../Token/JwtUserTokenBadFormatException.cs | 49 +++++++ .../Token/TokenServiceColletionExtensions.cs | 18 +++ .../Timeline/Services/Token/UserTokenException.cs | 83 ++++++++++++ .../Timeline/Services/Token/UserTokenHandler.cs | 146 +++++++++++++++++++++ .../Timeline/Services/Token/UserTokenManager.cs | 115 ++++++++++++++++ 5 files changed, 411 insertions(+) create mode 100644 BackEnd/Timeline/Services/Token/JwtUserTokenBadFormatException.cs create mode 100644 BackEnd/Timeline/Services/Token/TokenServiceColletionExtensions.cs create mode 100644 BackEnd/Timeline/Services/Token/UserTokenException.cs create mode 100644 BackEnd/Timeline/Services/Token/UserTokenHandler.cs create mode 100644 BackEnd/Timeline/Services/Token/UserTokenManager.cs (limited to 'BackEnd/Timeline/Services/Token') diff --git a/BackEnd/Timeline/Services/Token/JwtUserTokenBadFormatException.cs b/BackEnd/Timeline/Services/Token/JwtUserTokenBadFormatException.cs new file mode 100644 index 00000000..4d7300a1 --- /dev/null +++ b/BackEnd/Timeline/Services/Token/JwtUserTokenBadFormatException.cs @@ -0,0 +1,49 @@ +using System; +using System.Globalization; +using static Timeline.Resources.Services.Exception; + +namespace Timeline.Services.Token +{ + [Serializable] + public class JwtUserTokenBadFormatException : UserTokenBadFormatException + { + public enum ErrorKind + { + NoIdClaim, + IdClaimBadFormat, + NoVersionClaim, + VersionClaimBadFormat, + NoExp, + Other + } + + public JwtUserTokenBadFormatException() : this("", ErrorKind.Other) { } + public JwtUserTokenBadFormatException(string message) : base(message) { } + public JwtUserTokenBadFormatException(string message, Exception inner) : base(message, inner) { } + + public JwtUserTokenBadFormatException(string token, ErrorKind type) : base(token, GetErrorMessage(type)) { ErrorType = type; } + public JwtUserTokenBadFormatException(string token, ErrorKind type, Exception inner) : base(token, GetErrorMessage(type), inner) { ErrorType = type; } + public JwtUserTokenBadFormatException(string token, ErrorKind type, string message, Exception inner) : base(token, message, inner) { ErrorType = type; } + protected JwtUserTokenBadFormatException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public ErrorKind ErrorType { get; set; } + + private static string GetErrorMessage(ErrorKind type) + { + var reason = type switch + { + ErrorKind.NoIdClaim => JwtUserTokenBadFormatExceptionIdMissing, + ErrorKind.IdClaimBadFormat => JwtUserTokenBadFormatExceptionIdBadFormat, + ErrorKind.NoVersionClaim => JwtUserTokenBadFormatExceptionVersionMissing, + ErrorKind.VersionClaimBadFormat => JwtUserTokenBadFormatExceptionVersionBadFormat, + ErrorKind.Other => JwtUserTokenBadFormatExceptionOthers, + _ => JwtUserTokenBadFormatExceptionUnknown + }; + + return string.Format(CultureInfo.CurrentCulture, + Resources.Services.Exception.JwtUserTokenBadFormatException, reason); + } + } +} diff --git a/BackEnd/Timeline/Services/Token/TokenServiceColletionExtensions.cs b/BackEnd/Timeline/Services/Token/TokenServiceColletionExtensions.cs new file mode 100644 index 00000000..d3219ec4 --- /dev/null +++ b/BackEnd/Timeline/Services/Token/TokenServiceColletionExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Timeline.Configs; + +namespace Timeline.Services.Token +{ + public static class TokenServiceColletionExtensions + { + public static IServiceCollection AddTokenService(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection("Token")); + services.Configure(configuration.GetSection("Jwt")); + services.AddScoped(); + services.AddScoped(); + return services; + } + } +} diff --git a/BackEnd/Timeline/Services/Token/UserTokenException.cs b/BackEnd/Timeline/Services/Token/UserTokenException.cs new file mode 100644 index 00000000..d666ba10 --- /dev/null +++ b/BackEnd/Timeline/Services/Token/UserTokenException.cs @@ -0,0 +1,83 @@ +using System; + +namespace Timeline.Services.Token +{ + + [Serializable] + public class UserTokenException : Exception + { + public UserTokenException() { } + public UserTokenException(string message) : base(message) { } + public UserTokenException(string message, Exception inner) : base(message, inner) { } + public UserTokenException(string token, string message) : base(message) { Token = token; } + public UserTokenException(string token, string message, Exception inner) : base(message, inner) { Token = token; } + protected UserTokenException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public string Token { get; private set; } = ""; + } + + + [Serializable] + public class UserTokenTimeExpiredException : UserTokenException + { + public UserTokenTimeExpiredException() : base(Resources.Services.Exception.UserTokenTimeExpireException) { } + public UserTokenTimeExpiredException(string message) : base(message) { } + public UserTokenTimeExpiredException(string message, Exception inner) : base(message, inner) { } + public UserTokenTimeExpiredException(string token, DateTime expireTime, DateTime verifyTime) : base(token, Resources.Services.Exception.UserTokenTimeExpireException) { ExpireTime = expireTime; VerifyTime = verifyTime; } + public UserTokenTimeExpiredException(string token, DateTime expireTime, DateTime verifyTime, Exception inner) : base(token, Resources.Services.Exception.UserTokenTimeExpireException, inner) { ExpireTime = expireTime; VerifyTime = verifyTime; } + protected UserTokenTimeExpiredException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public DateTime ExpireTime { get; private set; } + + public DateTime VerifyTime { get; private set; } + } + + [Serializable] + public class UserTokenVersionExpiredException : UserTokenException + { + public UserTokenVersionExpiredException() : base(Resources.Services.Exception.UserTokenBadVersionException) { } + public UserTokenVersionExpiredException(string message) : base(message) { } + public UserTokenVersionExpiredException(string message, Exception inner) : base(message, inner) { } + public UserTokenVersionExpiredException(string token, long tokenVersion, long requiredVersion) : base(token, Resources.Services.Exception.UserTokenBadVersionException) { TokenVersion = tokenVersion; RequiredVersion = requiredVersion; } + public UserTokenVersionExpiredException(string token, long tokenVersion, long requiredVersion, Exception inner) : base(token, Resources.Services.Exception.UserTokenBadVersionException, inner) { TokenVersion = tokenVersion; RequiredVersion = requiredVersion; } + protected UserTokenVersionExpiredException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public long TokenVersion { get; set; } + + public long RequiredVersion { get; set; } + } + + + [Serializable] + public class UserTokenUserNotExistException : UserTokenException + { + const string message = "The owner of the token does not exist."; + + public UserTokenUserNotExistException() : base(message) { } + public UserTokenUserNotExistException(string token) : base(token, message) { } + public UserTokenUserNotExistException(string token, Exception inner) : base(token, message, inner) { } + + protected UserTokenUserNotExistException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } + + [Serializable] + public class UserTokenBadFormatException : UserTokenException + { + public UserTokenBadFormatException() : base(Resources.Services.Exception.UserTokenBadFormatException) { } + public UserTokenBadFormatException(string token) : base(token, Resources.Services.Exception.UserTokenBadFormatException) { } + public UserTokenBadFormatException(string token, string message) : base(token, message) { } + public UserTokenBadFormatException(string token, Exception inner) : base(token, Resources.Services.Exception.UserTokenBadFormatException, inner) { } + public UserTokenBadFormatException(string token, string message, Exception inner) : base(token, message, inner) { } + protected UserTokenBadFormatException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/BackEnd/Timeline/Services/Token/UserTokenHandler.cs b/BackEnd/Timeline/Services/Token/UserTokenHandler.cs new file mode 100644 index 00000000..2eaea57e --- /dev/null +++ b/BackEnd/Timeline/Services/Token/UserTokenHandler.cs @@ -0,0 +1,146 @@ +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using Timeline.Configs; +using Timeline.Entities; + +namespace Timeline.Services.Token +{ + public class UserTokenInfo + { + public long Id { get; set; } + public long Version { get; set; } + public DateTime ExpireAt { get; set; } + } + + public interface IUserTokenHandler + { + /// + /// Create a token for a given token info. + /// + /// The info to generate token. + /// Return the generated token. + /// Thrown when is null. + string GenerateToken(UserTokenInfo tokenInfo); + + /// + /// Verify a token and get the saved info. Do not validate lifetime!!! + /// + /// The token to verify. + /// The saved info in token. + /// Thrown when is null. + /// Thrown when the token is of bad format. + /// + /// If this method throw , it usually means the token is not created by this service. + /// + UserTokenInfo VerifyToken(string token); + } + + public class JwtUserTokenHandler : IUserTokenHandler + { + private const string VersionClaimType = "timeline_version"; + + private readonly IOptionsMonitor _jwtConfig; + private readonly IClock _clock; + + private readonly JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler(); + private SymmetricSecurityKey _tokenSecurityKey; + + public JwtUserTokenHandler(IOptionsMonitor jwtConfig, IClock clock, DatabaseContext database) + { + _jwtConfig = jwtConfig; + _clock = clock; + + var key = database.JwtToken.Select(t => t.Key).SingleOrDefault(); + + if (key == null) + { + throw new InvalidOperationException(Resources.Services.UserTokenService.JwtKeyNotExist); + } + + _tokenSecurityKey = new SymmetricSecurityKey(key); + } + + public string GenerateToken(UserTokenInfo tokenInfo) + { + if (tokenInfo == null) + throw new ArgumentNullException(nameof(tokenInfo)); + + var config = _jwtConfig.CurrentValue; + + var identity = new ClaimsIdentity(); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, tokenInfo.Id.ToString(CultureInfo.InvariantCulture.NumberFormat), ClaimValueTypes.Integer64)); + identity.AddClaim(new Claim(VersionClaimType, tokenInfo.Version.ToString(CultureInfo.InvariantCulture.NumberFormat), ClaimValueTypes.Integer64)); + + var tokenDescriptor = new SecurityTokenDescriptor() + { + Subject = identity, + Issuer = config.Issuer, + Audience = config.Audience, + SigningCredentials = new SigningCredentials(_tokenSecurityKey, SecurityAlgorithms.HmacSha384), + IssuedAt = _clock.GetCurrentTime(), + Expires = tokenInfo.ExpireAt, + NotBefore = _clock.GetCurrentTime() // I must explicitly set this or it will use the current time by default and mock is not work in which case test will not pass. + }; + + var token = _tokenHandler.CreateToken(tokenDescriptor); + var tokenString = _tokenHandler.WriteToken(token); + + return tokenString; + } + + + public UserTokenInfo VerifyToken(string token) + { + if (token == null) + throw new ArgumentNullException(nameof(token)); + + var config = _jwtConfig.CurrentValue; + try + { + var principal = _tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = false, + ValidIssuer = config.Issuer, + ValidAudience = config.Audience, + IssuerSigningKey = _tokenSecurityKey + }, out var t); + + var idClaim = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (idClaim == null) + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.NoIdClaim); + if (!long.TryParse(idClaim, out var id)) + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.IdClaimBadFormat); + + var versionClaim = principal.FindFirstValue(VersionClaimType); + if (versionClaim == null) + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.NoVersionClaim); + if (!long.TryParse(versionClaim, out var version)) + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.VersionClaimBadFormat); + + var decodedToken = (JwtSecurityToken)t; + var exp = decodedToken.Payload.Exp; + if (exp is null) + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.NoExp); + + return new UserTokenInfo + { + Id = id, + Version = version, + ExpireAt = EpochTime.DateTime(exp.Value) + }; + } + catch (Exception e) when (e is SecurityTokenException || e is ArgumentException) + { + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.Other, e); + } + } + } +} diff --git a/BackEnd/Timeline/Services/Token/UserTokenManager.cs b/BackEnd/Timeline/Services/Token/UserTokenManager.cs new file mode 100644 index 00000000..00bc2cf7 --- /dev/null +++ b/BackEnd/Timeline/Services/Token/UserTokenManager.cs @@ -0,0 +1,115 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Threading.Tasks; +using Timeline.Configs; +using Timeline.Entities; +using Timeline.Helpers; +using Timeline.Services.Exceptions; + +namespace Timeline.Services.Token +{ + public class UserTokenCreateResult + { + public string Token { get; set; } = default!; + public UserEntity User { get; set; } = default!; + } + + public interface IUserTokenManager + { + /// + /// Try to create a token for given username and password. + /// + /// The username. + /// The password. + /// The expire time of the token. + /// The created token and the user info. + /// Thrown when or is null. + /// Thrown when is of bad format. + /// Thrown when the user with does not exist. + /// Thrown when is wrong. + public Task CreateToken(string username, string password, DateTime? expireAt = null); + + /// + /// Verify a token and get the saved user info. This also check the database for existence of the user. + /// + /// The token. + /// The user stored in token. + /// Thrown when is null. + /// Thrown when the token is expired. + /// Thrown when the token is of bad version. + /// Thrown when the token is of bad format. + /// Thrown when the user specified by the token does not exist. Usually the user had been deleted after the token was issued. + public Task VerifyToken(string token); + } + + public class UserTokenManager : IUserTokenManager + { + private readonly ILogger _logger; + private readonly IOptionsMonitor _tokenOptionsMonitor; + private readonly IUserService _userService; + private readonly IUserCredentialService _userCredentialService; + private readonly IUserTokenHandler _userTokenService; + private readonly IClock _clock; + + public UserTokenManager(ILogger logger, IOptionsMonitor tokenOptionsMonitor, IUserService userService, IUserCredentialService userCredentialService, IUserTokenHandler userTokenService, IClock clock) + { + _logger = logger; + _tokenOptionsMonitor = tokenOptionsMonitor; + _userService = userService; + _userCredentialService = userCredentialService; + _userTokenService = userTokenService; + _clock = clock; + } + + public async Task CreateToken(string username, string password, DateTime? expireAt = null) + { + expireAt = expireAt?.MyToUtc(); + + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (password == null) + throw new ArgumentNullException(nameof(password)); + + var userId = await _userCredentialService.VerifyCredential(username, password); + var user = await _userService.GetUser(userId); + + var token = _userTokenService.GenerateToken(new UserTokenInfo + { + Id = user.Id, + Version = user.Version, + ExpireAt = expireAt ?? _clock.GetCurrentTime() + TimeSpan.FromSeconds(_tokenOptionsMonitor.CurrentValue.DefaultExpireSeconds) + }); + + return new UserTokenCreateResult { Token = token, User = user }; + } + + + public async Task VerifyToken(string token) + { + if (token == null) + throw new ArgumentNullException(nameof(token)); + + var tokenInfo = _userTokenService.VerifyToken(token); + + var currentTime = _clock.GetCurrentTime(); + if (tokenInfo.ExpireAt < currentTime) + throw new UserTokenTimeExpiredException(token, tokenInfo.ExpireAt, currentTime); + + try + { + var user = await _userService.GetUser(tokenInfo.Id); + + if (tokenInfo.Version < user.Version) + throw new UserTokenVersionExpiredException(token, tokenInfo.Version, user.Version); + + return user; + + } + catch (UserNotExistException e) + { + throw new UserTokenUserNotExistException(token, e); + } + } + } +} -- cgit v1.2.3