From 747bf829351c30069647a44f98ac19f1a214370f Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 21 Jan 2020 01:11:17 +0800 Subject: ... --- Timeline/Services/DatabaseExtensions.cs | 2 +- Timeline/Services/JwtService.cs | 132 ------------------ .../Services/JwtUserTokenBadFormatException.cs | 48 +++++++ Timeline/Services/JwtVerifyException.cs | 59 --------- Timeline/Services/UserAvatarService.cs | 2 +- Timeline/Services/UserDetailService.cs | 2 +- Timeline/Services/UserService.cs | 68 +++------- Timeline/Services/UserTokenException.cs | 71 ++++++++++ Timeline/Services/UserTokenManager.cs | 93 +++++++++++++ Timeline/Services/UserTokenService.cs | 147 +++++++++++++++++++++ Timeline/Services/UsernameBadFormatException.cs | 2 +- 11 files changed, 385 insertions(+), 241 deletions(-) delete mode 100644 Timeline/Services/JwtService.cs create mode 100644 Timeline/Services/JwtUserTokenBadFormatException.cs delete mode 100644 Timeline/Services/JwtVerifyException.cs create mode 100644 Timeline/Services/UserTokenException.cs create mode 100644 Timeline/Services/UserTokenManager.cs create mode 100644 Timeline/Services/UserTokenService.cs (limited to 'Timeline/Services') diff --git a/Timeline/Services/DatabaseExtensions.cs b/Timeline/Services/DatabaseExtensions.cs index 140c3146..c5c96d8c 100644 --- a/Timeline/Services/DatabaseExtensions.cs +++ b/Timeline/Services/DatabaseExtensions.cs @@ -19,7 +19,7 @@ namespace Timeline.Services /// Thrown if is null. /// Thrown if is of bad format. /// Thrown if user does not exist. - internal static async Task CheckAndGetUser(DbSet userDbSet, string? username) + internal static async Task CheckAndGetUser(DbSet userDbSet, string? username) { if (username == null) throw new ArgumentNullException(nameof(username)); diff --git a/Timeline/Services/JwtService.cs b/Timeline/Services/JwtService.cs deleted file mode 100644 index bf92966a..00000000 --- a/Timeline/Services/JwtService.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using System; -using System.Globalization; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Timeline.Configs; - -namespace Timeline.Services -{ - public class TokenInfo - { - public long Id { get; set; } - public long Version { get; set; } - } - - public interface IJwtService - { - /// - /// Create a JWT token for a given token info. - /// - /// The info to generate token. - /// The expire time. If null then use current time with offset in config. - /// Return the generated token. - /// Thrown when is null. - string GenerateJwtToken(TokenInfo tokenInfo, DateTime? expires = null); - - /// - /// Verify a JWT token. - /// Return null is is null. - /// - /// The token string to verify. - /// Return the saved info in token. - /// Thrown when is null. - /// Thrown when the token is invalid. - TokenInfo VerifyJwtToken(string token); - - } - - public class JwtService : IJwtService - { - private const string VersionClaimType = "timeline_version"; - - private readonly IOptionsMonitor _jwtConfig; - private readonly JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler(); - private readonly IClock _clock; - - public JwtService(IOptionsMonitor jwtConfig, IClock clock) - { - _jwtConfig = jwtConfig; - _clock = clock; - } - - public string GenerateJwtToken(TokenInfo tokenInfo, DateTime? expires = null) - { - 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( - new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)), SecurityAlgorithms.HmacSha384), - IssuedAt = _clock.GetCurrentTime(), - Expires = expires.GetValueOrDefault(_clock.GetCurrentTime().AddSeconds(config.DefaultExpireOffset)), - 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 TokenInfo VerifyJwtToken(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 = true, - ValidIssuer = config.Issuer, - ValidAudience = config.Audience, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)) - }, out _); - - var idClaim = principal.FindFirstValue(ClaimTypes.NameIdentifier); - if (idClaim == null) - throw new JwtVerifyException(JwtVerifyException.ErrorCodes.NoIdClaim); - if (!long.TryParse(idClaim, out var id)) - throw new JwtVerifyException(JwtVerifyException.ErrorCodes.IdClaimBadFormat); - - var versionClaim = principal.FindFirstValue(VersionClaimType); - if (versionClaim == null) - throw new JwtVerifyException(JwtVerifyException.ErrorCodes.NoVersionClaim); - if (!long.TryParse(versionClaim, out var version)) - throw new JwtVerifyException(JwtVerifyException.ErrorCodes.VersionClaimBadFormat); - - return new TokenInfo - { - Id = id, - Version = version - }; - } - catch (SecurityTokenExpiredException e) - { - throw new JwtVerifyException(e, JwtVerifyException.ErrorCodes.Expired); - } - catch (Exception e) - { - throw new JwtVerifyException(e, JwtVerifyException.ErrorCodes.Others); - } - } - } -} diff --git a/Timeline/Services/JwtUserTokenBadFormatException.cs b/Timeline/Services/JwtUserTokenBadFormatException.cs new file mode 100644 index 00000000..c528c3e3 --- /dev/null +++ b/Timeline/Services/JwtUserTokenBadFormatException.cs @@ -0,0 +1,48 @@ +using System; +using System.Globalization; +using static Timeline.Resources.Services.Exception; + +namespace Timeline.Services +{ + [Serializable] + public class JwtUserTokenBadFormatException : UserTokenBadFormatException + { + public enum ErrorKind + { + NoIdClaim, + IdClaimBadFormat, + NoVersionClaim, + VersionClaimBadFormat, + 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/Timeline/Services/JwtVerifyException.cs b/Timeline/Services/JwtVerifyException.cs deleted file mode 100644 index a915b51a..00000000 --- a/Timeline/Services/JwtVerifyException.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Microsoft.IdentityModel.Tokens; -using System; -using System.Globalization; -using static Timeline.Resources.Services.Exception; - -namespace Timeline.Services -{ - [Serializable] - public class JwtVerifyException : Exception - { - public static class ErrorCodes - { - // Codes in -1000 ~ -1999 usually means the user provides a token that is not created by this server. - - public const int Others = -1001; - public const int NoIdClaim = -1002; - public const int IdClaimBadFormat = -1003; - public const int NoVersionClaim = -1004; - public const int VersionClaimBadFormat = -1005; - - /// - /// Corresponds to . - /// - public const int Expired = -2001; - public const int OldVersion = -2002; - } - - public JwtVerifyException() : base(GetErrorMessage(0)) { } - public JwtVerifyException(string message) : base(message) { } - public JwtVerifyException(string message, Exception inner) : base(message, inner) { } - - public JwtVerifyException(int code) : base(GetErrorMessage(code)) { ErrorCode = code; } - public JwtVerifyException(string message, int code) : base(message) { ErrorCode = code; } - public JwtVerifyException(Exception inner, int code) : base(GetErrorMessage(code), inner) { ErrorCode = code; } - public JwtVerifyException(string message, Exception inner, int code) : base(message, inner) { ErrorCode = code; } - protected JwtVerifyException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) { } - - public int ErrorCode { get; set; } - - private static string GetErrorMessage(int errorCode) - { - var reason = errorCode switch - { - ErrorCodes.Others => JwtVerifyExceptionOthers, - ErrorCodes.NoIdClaim => JwtVerifyExceptionNoIdClaim, - ErrorCodes.IdClaimBadFormat => JwtVerifyExceptionIdClaimBadFormat, - ErrorCodes.NoVersionClaim => JwtVerifyExceptionNoVersionClaim, - ErrorCodes.VersionClaimBadFormat => JwtVerifyExceptionVersionClaimBadFormat, - ErrorCodes.Expired => JwtVerifyExceptionExpired, - ErrorCodes.OldVersion => JwtVerifyExceptionOldVersion, - _ => JwtVerifyExceptionUnknown - }; - - return string.Format(CultureInfo.InvariantCulture, Resources.Services.Exception.JwtVerifyException, reason); - } - } -} diff --git a/Timeline/Services/UserAvatarService.cs b/Timeline/Services/UserAvatarService.cs index 01201864..ac7dd857 100644 --- a/Timeline/Services/UserAvatarService.cs +++ b/Timeline/Services/UserAvatarService.cs @@ -275,7 +275,7 @@ namespace Timeline.Services var create = avatarEntity == null; if (create) { - avatarEntity = new UserAvatar(); + avatarEntity = new UserAvatarEntity(); } avatarEntity!.Type = avatar.Type; avatarEntity.Data = avatar.Data; diff --git a/Timeline/Services/UserDetailService.cs b/Timeline/Services/UserDetailService.cs index 0b24e4e2..4f4a7942 100644 --- a/Timeline/Services/UserDetailService.cs +++ b/Timeline/Services/UserDetailService.cs @@ -77,7 +77,7 @@ namespace Timeline.Services var create = userDetail == null; if (create) { - userDetail = new UserDetail + userDetail = new UserDetailEntity { UserId = userId }; diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index 4012539f..db2350a2 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -11,47 +11,37 @@ using Timeline.Models.Validation; namespace Timeline.Services { - public class CreateTokenResult - { - public string Token { get; set; } = default!; - public UserInfo User { get; set; } = default!; - } - public interface IUserService { /// - /// Try to anthenticate with the given username and password. - /// If success, create a token and return the user info. + /// Try to verify the given username and password. /// - /// The username of the user to anthenticate. - /// The password of the user to anthenticate. - /// The expired time point. Null then use default. See for what is default. - /// An containing the created token and user info. + /// The username of the user to verify. + /// The password of the user to verify. + /// The user info. /// Thrown when or is null. /// Thrown when username is of bad format. /// Thrown when the user with given username does not exist. /// Thrown when password is wrong. - Task CreateToken(string username, string password, DateTime? expires = null); + Task VerifyCredential(string username, string password); /// - /// Verify the given token. - /// If success, return the user info. + /// Try to get a user by id. /// - /// The token to verify. - /// The user info specified by the token. - /// Thrown when is null. - /// Thrown when the token is of bad format. Thrown by . - /// Thrown when the user specified by the token does not exist. Usually it has been deleted after the token was issued. - Task VerifyToken(string token); + /// The id of the user. + /// The user info. + /// Thrown when the user with given id does not exist. + Task GetUserById(long id); /// /// Get the user info of given username. /// /// Username of the user. - /// The info of the user. Null if the user of given username does not exists. + /// The info of the user. /// Thrown when is null. /// Thrown when is of bad format. - Task GetUser(string username); + /// Thrown when the user with given username does not exist. + Task GetUserByUsername(string username); /// /// List all users. @@ -120,39 +110,24 @@ namespace Timeline.Services Task ChangeUsername(string oldUsername, string newUsername); } - internal class UserCache - { - public string Username { get; set; } = default!; - public bool Administrator { get; set; } - public long Version { get; set; } - - public UserInfo ToUserInfo() - { - return new UserInfo(Username, Administrator); - } - } - public class UserService : IUserService { private readonly ILogger _logger; - private readonly IMemoryCache _memoryCache; private readonly DatabaseContext _databaseContext; - private readonly IJwtService _jwtService; + private readonly IMemoryCache _memoryCache; + private readonly IPasswordService _passwordService; - private readonly UsernameValidator _usernameValidator; + private readonly UsernameValidator _usernameValidator = new UsernameValidator(); - public UserService(ILogger logger, IMemoryCache memoryCache, DatabaseContext databaseContext, IJwtService jwtService, IPasswordService passwordService) + public UserService(ILogger logger, IMemoryCache memoryCache, DatabaseContext databaseContext, IPasswordService passwordService) { _logger = logger; _memoryCache = memoryCache; _databaseContext = databaseContext; - _jwtService = jwtService; _passwordService = passwordService; - - _usernameValidator = new UsernameValidator(); } private static string GenerateCacheKeyByUserId(long id) => $"user:{id}"; @@ -176,12 +151,13 @@ namespace Timeline.Services } } - public async Task CreateToken(string username, string password, DateTime? expires) + public async Task CheckCredential(string username, string password) { if (username == null) throw new ArgumentNullException(nameof(username)); if (password == null) throw new ArgumentNullException(nameof(password)); + CheckUsernameFormat(username); // We need password info, so always check the database. @@ -231,12 +207,12 @@ namespace Timeline.Services } if (tokenInfo.Version != cache.Version) - throw new JwtVerifyException(new JwtBadVersionException(tokenInfo.Version, cache.Version), JwtVerifyException.ErrorCodes.OldVersion); + throw new JwtUserTokenBadFormatException(new JwtBadVersionException(tokenInfo.Version, cache.Version), JwtUserTokenBadFormatException.ErrorCodes.OldVersion); return cache.ToUserInfo(); } - public async Task GetUser(string username) + public async Task GetUserByUsername(string username) { if (username == null) throw new ArgumentNullException(nameof(username)); @@ -267,7 +243,7 @@ namespace Timeline.Services if (user == null) { - var newUser = new User + var newUser = new UserEntity { Name = username, EncryptedPassword = _passwordService.HashPassword(password), diff --git a/Timeline/Services/UserTokenException.cs b/Timeline/Services/UserTokenException.cs new file mode 100644 index 00000000..e63305b1 --- /dev/null +++ b/Timeline/Services/UserTokenException.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Timeline.Services +{ + + [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 UserTokenTimeExpireException : UserTokenException + { + public UserTokenTimeExpireException() : base(Resources.Services.Exception.UserTokenTimeExpireException) { } + public UserTokenTimeExpireException(string message) : base(message) { } + public UserTokenTimeExpireException(string message, Exception inner) : base(message, inner) { } + public UserTokenTimeExpireException(string token, DateTime expireTime, DateTime verifyTime) : base(token, Resources.Services.Exception.UserTokenTimeExpireException) { ExpireTime = expireTime; VerifyTime = verifyTime; } + public UserTokenTimeExpireException(string token, DateTime expireTime, DateTime verifyTime, Exception inner) : base(token, Resources.Services.Exception.UserTokenTimeExpireException, inner) { ExpireTime = expireTime; VerifyTime = verifyTime; } + protected UserTokenTimeExpireException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + + public DateTime ExpireTime { get; private set; } = default; + + public DateTime VerifyTime { get; private set; } = default; + } + + [Serializable] + public class UserTokenBadVersionException : UserTokenException + { + public UserTokenBadVersionException() : base(Resources.Services.Exception.UserTokenBadVersionException) { } + public UserTokenBadVersionException(string message) : base(message) { } + public UserTokenBadVersionException(string message, Exception inner) : base(message, inner) { } + public UserTokenBadVersionException(string token, long tokenVersion, long requiredVersion) : base(token, Resources.Services.Exception.UserTokenBadVersionException) { TokenVersion = tokenVersion; RequiredVersion = requiredVersion; } + public UserTokenBadVersionException(string token, long tokenVersion, long requiredVersion, Exception inner) : base(token, Resources.Services.Exception.UserTokenBadVersionException, inner) { TokenVersion = tokenVersion; RequiredVersion = requiredVersion; } + protected UserTokenBadVersionException( + 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 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/Timeline/Services/UserTokenManager.cs b/Timeline/Services/UserTokenManager.cs new file mode 100644 index 00000000..c3cb51c9 --- /dev/null +++ b/Timeline/Services/UserTokenManager.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; +using Timeline.Models; + +namespace Timeline.Services +{ + public class UserTokenCreateResult + { + public string Token { get; set; } = default!; + public UserInfo 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 IUserService _userService; + private readonly IUserTokenService _userTokenService; + private readonly IClock _clock; + + public UserTokenManager(ILogger logger, IUserService userService, IUserTokenService userTokenService, IClock clock) + { + _logger = logger; + _userService = userService; + _userTokenService = userTokenService; + _clock = clock; + } + + public async Task CreateToken(string username, string password, DateTime? expireAt = null) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (password == null) + throw new ArgumentNullException(nameof(password)); + + var user = await _userService.VerifyCredential(username, password); + var token = _userTokenService.GenerateToken(new UserTokenInfo { Id = user.Id, Version = user.Version, ExpireAt = expireAt }); + + 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); + + if (tokenInfo.ExpireAt.HasValue) + { + var currentTime = _clock.GetCurrentTime(); + if (tokenInfo.ExpireAt < currentTime) + throw new UserTokenTimeExpireException(token, tokenInfo.ExpireAt.Value, currentTime); + } + + var user = await _userService.GetUserById(tokenInfo.Id); + + if (tokenInfo.Version < user.Version) + throw new UserTokenBadVersionException(token, tokenInfo.Version, user.Version); + + return user; + } + } +} diff --git a/Timeline/Services/UserTokenService.cs b/Timeline/Services/UserTokenService.cs new file mode 100644 index 00000000..c246fdff --- /dev/null +++ b/Timeline/Services/UserTokenService.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Timeline.Configs; + +namespace Timeline.Services +{ + public class UserTokenInfo + { + public long Id { get; set; } + public long Version { get; set; } + public DateTime? ExpireAt { get; set; } + } + + public interface IUserTokenService + { + /// + /// 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. + /// + /// 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 JwtUserTokenService : IUserTokenService + { + private const string VersionClaimType = "timeline_version"; + + private readonly IOptionsMonitor _jwtConfig; + private readonly IClock _clock; + + private readonly JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler(); + private SymmetricSecurityKey _tokenSecurityKey; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1062:Validate arguments of public methods", Justification = "")] + public JwtUserTokenService(IOptionsMonitor jwtConfig, IClock clock) + { + _jwtConfig = jwtConfig; + _clock = clock; + + _tokenSecurityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtConfig.CurrentValue.SigningKey)); + jwtConfig.OnChange(config => + { + _tokenSecurityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)); + }); + } + + 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( + new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config.SigningKey)), SecurityAlgorithms.HmacSha384), + IssuedAt = _clock.GetCurrentTime(), + Expires = tokenInfo.ExpireAt.GetValueOrDefault(_clock.GetCurrentTime().AddSeconds(config.DefaultExpireOffset)), + 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; + DateTime? expireAt = null; + if (exp.HasValue) + { + expireAt = EpochTime.DateTime(exp.Value); + } + + return new UserTokenInfo + { + Id = id, + Version = version, + ExpireAt = expireAt + }; + } + catch (Exception e) when (e is SecurityTokenException || e is ArgumentException) + { + throw new JwtUserTokenBadFormatException(token, JwtUserTokenBadFormatException.ErrorKind.Other, e); + } + } + } +} diff --git a/Timeline/Services/UsernameBadFormatException.cs b/Timeline/Services/UsernameBadFormatException.cs index d82bf962..991be7df 100644 --- a/Timeline/Services/UsernameBadFormatException.cs +++ b/Timeline/Services/UsernameBadFormatException.cs @@ -22,6 +22,6 @@ namespace Timeline.Services /// /// Username of bad format. /// - public string? Username { get; private set; } + public string Username { get; private set; } = ""; } } -- cgit v1.2.3