[d23bf72] | 1 | using System;
|
---|
| 2 | using System.Collections.Concurrent;
|
---|
| 3 | using System.Collections.Immutable;
|
---|
| 4 | using System.IdentityModel.Tokens.Jwt;
|
---|
| 5 | using System.Linq;
|
---|
| 6 | using System.Security.Claims;
|
---|
| 7 | using System.Security.Cryptography;
|
---|
| 8 | using System.Text;
|
---|
| 9 | using System.Text.Json.Serialization;
|
---|
| 10 | using FarmatikoServices.Infrastructure;
|
---|
| 11 | using Microsoft.IdentityModel.Tokens;
|
---|
| 12 |
|
---|
| 13 | namespace FarmatikoServices.Infrastructure
|
---|
| 14 | {
|
---|
| 15 |
|
---|
| 16 |
|
---|
| 17 | public class JwtAuthManager : IJwtAuthManager
|
---|
| 18 | {
|
---|
| 19 | public IImmutableDictionary<string, RefreshToken> UsersRefreshTokensReadOnlyDictionary => _usersRefreshTokens.ToImmutableDictionary();
|
---|
| 20 | private readonly ConcurrentDictionary<string, RefreshToken> _usersRefreshTokens; // can store in a database or a distributed cache
|
---|
| 21 | private readonly JwtTokenConfig _jwtTokenConfig;
|
---|
| 22 | private readonly byte[] _secret;
|
---|
| 23 |
|
---|
| 24 | public JwtAuthManager(JwtTokenConfig jwtTokenConfig)
|
---|
| 25 | {
|
---|
| 26 | _jwtTokenConfig = jwtTokenConfig;
|
---|
| 27 | _usersRefreshTokens = new ConcurrentDictionary<string, RefreshToken>();
|
---|
| 28 | _secret = Encoding.ASCII.GetBytes(jwtTokenConfig.Secret);
|
---|
| 29 | }
|
---|
| 30 |
|
---|
| 31 | // optional: clean up expired refresh tokens
|
---|
| 32 | public void RemoveExpiredRefreshTokens(DateTime now)
|
---|
| 33 | {
|
---|
| 34 | var expiredTokens = _usersRefreshTokens.Where(x => x.Value.ExpireAt < now).ToList();
|
---|
| 35 | foreach (var expiredToken in expiredTokens)
|
---|
| 36 | {
|
---|
| 37 | _usersRefreshTokens.TryRemove(expiredToken.Key, out _);
|
---|
| 38 | }
|
---|
| 39 | }
|
---|
| 40 |
|
---|
| 41 | // can be more specific to ip, user agent, device name, etc.
|
---|
| 42 | public void RemoveRefreshTokenByUserName(string userName)
|
---|
| 43 | {
|
---|
| 44 | var refreshTokens = _usersRefreshTokens.Where(x => x.Value.UserName == userName).ToList();
|
---|
| 45 | foreach (var refreshToken in refreshTokens)
|
---|
| 46 | {
|
---|
| 47 | _usersRefreshTokens.TryRemove(refreshToken.Key, out _);
|
---|
| 48 | }
|
---|
| 49 | }
|
---|
| 50 |
|
---|
| 51 | public JwtAuthResult GenerateTokens(string username, Claim[] claims, DateTime now)
|
---|
| 52 | {
|
---|
| 53 | var shouldAddAudienceClaim = string.IsNullOrWhiteSpace(claims?.FirstOrDefault(x => x.Type == JwtRegisteredClaimNames.Aud)?.Value);
|
---|
| 54 | var jwtToken = new JwtSecurityToken(
|
---|
| 55 | _jwtTokenConfig.Issuer,
|
---|
| 56 | shouldAddAudienceClaim ? _jwtTokenConfig.Audience : string.Empty,
|
---|
| 57 | claims,
|
---|
| 58 | expires: now.AddMinutes(_jwtTokenConfig.AccessTokenExpiration),
|
---|
| 59 | signingCredentials: new SigningCredentials(new SymmetricSecurityKey(_secret), SecurityAlgorithms.HmacSha256Signature));
|
---|
| 60 | var accessToken = new JwtSecurityTokenHandler().WriteToken(jwtToken);
|
---|
| 61 |
|
---|
| 62 | var refreshToken = new RefreshToken
|
---|
| 63 | {
|
---|
| 64 | UserName = username,
|
---|
| 65 | TokenString = GenerateRefreshTokenString(),
|
---|
| 66 | ExpireAt = now.AddMinutes(_jwtTokenConfig.RefreshTokenExpiration)
|
---|
| 67 | };
|
---|
| 68 | _usersRefreshTokens.AddOrUpdate(refreshToken.TokenString, refreshToken, (s, t) => refreshToken);
|
---|
| 69 |
|
---|
| 70 | return new JwtAuthResult
|
---|
| 71 | {
|
---|
| 72 | AccessToken = accessToken,
|
---|
| 73 | RefreshToken = refreshToken
|
---|
| 74 | };
|
---|
| 75 | }
|
---|
| 76 |
|
---|
| 77 | public JwtAuthResult Refresh(string refreshToken, string accessToken, DateTime now)
|
---|
| 78 | {
|
---|
| 79 | var (principal, jwtToken) = DecodeJwtToken(accessToken);
|
---|
| 80 | if (jwtToken == null || !jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256Signature))
|
---|
| 81 | {
|
---|
| 82 | throw new SecurityTokenException("Invalid token");
|
---|
| 83 | }
|
---|
| 84 |
|
---|
| 85 | var userName = principal.Identity.Name;
|
---|
| 86 | if (!_usersRefreshTokens.TryGetValue(refreshToken, out var existingRefreshToken))
|
---|
| 87 | {
|
---|
| 88 | throw new SecurityTokenException("Invalid token");
|
---|
| 89 | }
|
---|
| 90 | if (existingRefreshToken.UserName != userName || existingRefreshToken.ExpireAt < now)
|
---|
| 91 | {
|
---|
| 92 | throw new SecurityTokenException("Invalid token");
|
---|
| 93 | }
|
---|
| 94 |
|
---|
| 95 | return GenerateTokens(userName, principal.Claims.ToArray(), now); // need to recover the original claims
|
---|
| 96 | }
|
---|
| 97 |
|
---|
| 98 | public (ClaimsPrincipal, JwtSecurityToken) DecodeJwtToken(string token)
|
---|
| 99 | {
|
---|
| 100 | if (string.IsNullOrWhiteSpace(token))
|
---|
| 101 | {
|
---|
| 102 | throw new SecurityTokenException("Invalid token");
|
---|
| 103 | }
|
---|
| 104 | var principal = new JwtSecurityTokenHandler()
|
---|
| 105 | .ValidateToken(token,
|
---|
| 106 | new TokenValidationParameters
|
---|
| 107 | {
|
---|
| 108 | ValidateIssuer = true,
|
---|
| 109 | ValidIssuer = _jwtTokenConfig.Issuer,
|
---|
| 110 | ValidateIssuerSigningKey = true,
|
---|
| 111 | IssuerSigningKey = new SymmetricSecurityKey(_secret),
|
---|
| 112 | ValidAudience = _jwtTokenConfig.Audience,
|
---|
| 113 | ValidateAudience = true,
|
---|
| 114 | ValidateLifetime = true,
|
---|
| 115 | ClockSkew = TimeSpan.FromMinutes(1)
|
---|
| 116 | },
|
---|
| 117 | out var validatedToken);
|
---|
| 118 | return (principal, validatedToken as JwtSecurityToken);
|
---|
| 119 | }
|
---|
| 120 |
|
---|
| 121 | private static string GenerateRefreshTokenString()
|
---|
| 122 | {
|
---|
| 123 | var randomNumber = new byte[32];
|
---|
| 124 | using var randomNumberGenerator = RandomNumberGenerator.Create();
|
---|
| 125 | randomNumberGenerator.GetBytes(randomNumber);
|
---|
| 126 | return Convert.ToBase64String(randomNumber);
|
---|
| 127 | }
|
---|
| 128 | }
|
---|
| 129 |
|
---|
| 130 | public class JwtAuthResult
|
---|
| 131 | {
|
---|
| 132 | [JsonPropertyName("accessToken")]
|
---|
| 133 | public string AccessToken { get; set; }
|
---|
| 134 |
|
---|
| 135 | [JsonPropertyName("refreshToken")]
|
---|
| 136 | public RefreshToken RefreshToken { get; set; }
|
---|
| 137 | }
|
---|
| 138 |
|
---|
| 139 | public class RefreshToken
|
---|
| 140 | {
|
---|
| 141 | [JsonPropertyName("username")]
|
---|
| 142 | public string UserName { get; set; } // can be used for usage tracking
|
---|
| 143 | // can optionally include other metadata, such as user agent, ip address, device name, and so on
|
---|
| 144 |
|
---|
| 145 | [JsonPropertyName("tokenString")]
|
---|
| 146 | public string TokenString { get; set; }
|
---|
| 147 |
|
---|
| 148 | [JsonPropertyName("expireAt")]
|
---|
| 149 | public DateTime ExpireAt { get; set; }
|
---|
| 150 | }
|
---|
| 151 | }
|
---|