JwtAuthService.cs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. using System.IdentityModel.Tokens.Jwt;
  2. using System.Security.Claims;
  3. using System.Text;
  4. using System.Security.Cryptography;
  5. using Microsoft.IdentityModel.Tokens;
  6. using Microsoft.EntityFrameworkCore;
  7. using Microsoft.Extensions.Caching.Memory;
  8. using bitforum.Models.Account;
  9. using bitforum.Models;
  10. using bitforum.Repository;
  11. namespace bitforum.Services
  12. {
  13. public interface IJwtAuthService
  14. {
  15. // Access Token 생성
  16. string GenerateAccessToken(Member member);
  17. // Refresh Token 생성
  18. string GenerateRefreshToken();
  19. // Access Token 검증
  20. bool ValidateAccessToken(string accessToken);
  21. // Refresh Token 검증
  22. Task<bool> ValidateRefreshToken(string refreshToken);
  23. // Refresh Token DB 저장
  24. Task SaveRefreshToken(int memberID, string refreshToken);
  25. // AccessToken에서 회원 ID 추출
  26. Member? GetMemberFromAccessToken(string accessToken);
  27. // RefreshToken에서 회원 ID 추출
  28. Task<Member?> GetMemberFromRefreshToken(string refreshToken);
  29. // AccessToken 만료 기간 조회
  30. DateTime GetAccessTokenExpiration();
  31. // RefreshToken 만료 기간 조회
  32. DateTime GetRefreshTokenExpiration();
  33. // AccessToken 설정 및 쿠키 설정
  34. string SetAccessTokenAndCookieAsync(Member member);
  35. // RefreshToken 설정 및 쿠키 설정
  36. Task<string> SetRefreshTokenAndCookieAsync(Member member);
  37. }
  38. public class JwtAuthService : IJwtAuthService
  39. {
  40. private readonly DefaultDbContext _db;
  41. private readonly IHttpContextAccessor _httpContextAccessor;
  42. private readonly IMemoryCache _cache;
  43. private readonly IRedisRepository _redisRepository;
  44. private readonly string _secretKey;
  45. private readonly string _issuer;
  46. private readonly string _audience;
  47. private readonly int _accessTokenExpiration;
  48. private readonly int _refreshTokenExpiration;
  49. public JwtAuthService(DefaultDbContext db, IHttpContextAccessor httpContextAccessor, IMemoryCache cache, IRedisRepository redisRepository)
  50. {
  51. _db = db;
  52. _httpContextAccessor = httpContextAccessor;
  53. _cache = cache;
  54. _redisRepository = redisRepository;
  55. _secretKey = Setting.Jwt.SecretKey;
  56. _issuer = Setting.Jwt.Issuer;
  57. _audience = Setting.Jwt.Audience;
  58. _accessTokenExpiration = Setting.Jwt.AccessTokenExpiration;
  59. _refreshTokenExpiration = Setting.Jwt.RefreshTokenExpiration;
  60. }
  61. public string GenerateAccessToken(Member member)
  62. {
  63. var claims = new[]
  64. {
  65. new Claim(ClaimTypes.NameIdentifier, member.ID.ToString()),
  66. new Claim(ClaimTypes.Email, member.Email),
  67. new Claim(ClaimTypes.Name, member.Name ?? "")
  68. };
  69. var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secretKey));
  70. var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
  71. var token = new JwtSecurityToken(
  72. issuer: _issuer,
  73. audience: _audience,
  74. claims: claims,
  75. expires: GetAccessTokenExpiration(),
  76. signingCredentials: creds
  77. );
  78. return new JwtSecurityTokenHandler().WriteToken(token);
  79. }
  80. public string GenerateRefreshToken()
  81. {
  82. return Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
  83. }
  84. public bool ValidateAccessToken(string accessToken)
  85. {
  86. // 캐싱된 검증 결과 사용 (속도 향상)
  87. if (_cache.TryGetValue($"valid_token_{accessToken}", out bool isValid))
  88. {
  89. return isValid;
  90. }
  91. try
  92. {
  93. var tokenHandler = new JwtSecurityTokenHandler();
  94. var key = Encoding.UTF8.GetBytes(_secretKey);
  95. var principal = tokenHandler.ValidateToken(accessToken, new TokenValidationParameters
  96. {
  97. ValidateIssuerSigningKey = true,
  98. IssuerSigningKey = new SymmetricSecurityKey(key),
  99. ValidateIssuer = true,
  100. ValidateAudience = true,
  101. ValidIssuer = _issuer,
  102. ValidAudience = _audience,
  103. ValidateLifetime = true,
  104. ClockSkew = TimeSpan.Zero
  105. }, out SecurityToken validatedToken);
  106. // 검증된 토큰을 캐싱하여 속도 향상 (만료 시간의 절반으로 유지)
  107. _cache.Set($"valid_token_{accessToken}", true, TimeSpan.FromMinutes(_accessTokenExpiration / 2));
  108. return true;
  109. }
  110. catch (SecurityTokenExpiredException)
  111. {
  112. return false;
  113. }
  114. catch (Exception)
  115. {
  116. return false;
  117. }
  118. }
  119. public async Task<bool> ValidateRefreshToken(string refreshToken)
  120. {
  121. // 속도를 위해 Redis에서 RefreshToken 검증
  122. return await _redisRepository.ExistsAsync($"refresh_token_{refreshToken}");
  123. // DB에서 RefreshToken 검증
  124. // return await _db.RefreshToken.AnyAsync(c => c.Token == refreshToken && c.Expiration > DateTime.UtcNow);
  125. }
  126. public async Task SaveRefreshToken(int memberID, string refreshToken)
  127. {
  128. await _redisRepository.SetStringAsync($"refresh_token_{refreshToken}", memberID.ToString(), _refreshTokenExpiration);
  129. var expiration = DateTime.UtcNow.AddDays(_refreshTokenExpiration);
  130. var existingToken = await _db.RefreshToken.FirstOrDefaultAsync(c => c.MemberID == memberID);
  131. if (existingToken != null)
  132. {
  133. if (existingToken.Token == refreshToken)
  134. {
  135. return;
  136. }
  137. existingToken.Token = refreshToken;
  138. existingToken.Expiration = expiration;
  139. }
  140. else
  141. {
  142. _db.RefreshToken.Add(new RefreshToken
  143. {
  144. MemberID = memberID,
  145. Token = refreshToken,
  146. Expiration = expiration
  147. });
  148. }
  149. // 만료된 RefreshToken 정리
  150. _db.RefreshToken.RemoveRange(_db.RefreshToken.Where(c => c.Expiration < DateTime.UtcNow));
  151. await _db.SaveChangesAsync();
  152. }
  153. public Member? GetMemberFromAccessToken(string accessToken)
  154. {
  155. var jwtToken = new JwtSecurityTokenHandler().ReadToken(accessToken) as JwtSecurityToken;
  156. if (jwtToken == null)
  157. {
  158. return null;
  159. }
  160. var memberIDClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier);
  161. var emailClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email);
  162. if (memberIDClaim == null || emailClaim == null)
  163. {
  164. return null;
  165. }
  166. return new Member
  167. {
  168. ID = int.Parse(memberIDClaim.Value),
  169. Email = emailClaim.Value
  170. };
  171. }
  172. public async Task<Member?> GetMemberFromRefreshToken(string refreshToken)
  173. {
  174. var memberID = await _db.RefreshToken.Where(rt => rt.Token == refreshToken && rt.Expiration > DateTime.UtcNow).Select(c => c.MemberID).FirstOrDefaultAsync();
  175. if (memberID == 0)
  176. {
  177. return null;
  178. }
  179. return await _db.Member.FindAsync(memberID);
  180. }
  181. public DateTime GetAccessTokenExpiration()
  182. {
  183. return DateTime.UtcNow.AddMinutes(_accessTokenExpiration);
  184. }
  185. public DateTime GetRefreshTokenExpiration()
  186. {
  187. return DateTime.UtcNow.AddDays(_refreshTokenExpiration);
  188. }
  189. public string SetAccessTokenAndCookieAsync(Member member)
  190. {
  191. var accessToken = GenerateAccessToken(member);
  192. var cookies = _httpContextAccessor.HttpContext?.Response.Cookies;
  193. var headers = _httpContextAccessor.HttpContext?.Response.Headers;
  194. // AccessToken을 HttpOnly 쿠키로 설정
  195. if (cookies != null)
  196. {
  197. cookies.Append("accessToken", accessToken, new CookieOptions
  198. {
  199. HttpOnly = true,
  200. Secure = true,
  201. SameSite = SameSiteMode.None,
  202. Expires = GetAccessTokenExpiration()
  203. });
  204. }
  205. if (headers != null)
  206. {
  207. headers["Authorization"] = $"Bearer {accessToken}";
  208. }
  209. return accessToken;
  210. }
  211. public async Task<string> SetRefreshTokenAndCookieAsync(Member member)
  212. {
  213. var refreshToken = GenerateRefreshToken();
  214. await SaveRefreshToken(member.ID, refreshToken);
  215. var cookies = _httpContextAccessor.HttpContext?.Response.Cookies;
  216. // RefreshToken을 HttpOnly 쿠키로 설정
  217. if (cookies != null)
  218. {
  219. cookies.Append("refreshToken", refreshToken, new CookieOptions
  220. {
  221. HttpOnly = true,
  222. Secure = true,
  223. SameSite = SameSiteMode.None,
  224. Expires = GetRefreshTokenExpiration()
  225. });
  226. }
  227. return refreshToken;
  228. }
  229. }
  230. }