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 | }
|
---|