using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using System.Security.Cryptography; using Microsoft.IdentityModel.Tokens; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using bitforum.Models.Account; using bitforum.Models; using bitforum.Repository; namespace bitforum.Services { public interface IJwtAuthService { // Access Token 생성 string GenerateAccessToken(Member member); // Refresh Token 생성 string GenerateRefreshToken(); // Access Token 검증 bool ValidateAccessToken(string accessToken); // Refresh Token 검증 Task ValidateRefreshToken(string refreshToken); // Refresh Token DB 저장 Task SaveRefreshToken(int memberID, string refreshToken); // AccessToken에서 회원 ID 추출 Member? GetMemberFromAccessToken(string accessToken); // RefreshToken에서 회원 ID 추출 Task GetMemberFromRefreshToken(string refreshToken); // AccessToken 만료 기간 조회 DateTime GetAccessTokenExpiration(); // RefreshToken 만료 기간 조회 DateTime GetRefreshTokenExpiration(); // AccessToken 설정 및 쿠키 설정 string SetAccessTokenAndCookieAsync(Member member); // RefreshToken 설정 및 쿠키 설정 Task SetRefreshTokenAndCookieAsync(Member member); } public class JwtAuthService : IJwtAuthService { private readonly DefaultDbContext _db; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IMemoryCache _cache; private readonly IRedisRepository _redisRepository; private readonly string _secretKey; private readonly string _issuer; private readonly string _audience; private readonly int _accessTokenExpiration; private readonly int _refreshTokenExpiration; public JwtAuthService(DefaultDbContext db, IHttpContextAccessor httpContextAccessor, IMemoryCache cache, IRedisRepository redisRepository) { _db = db; _httpContextAccessor = httpContextAccessor; _cache = cache; _redisRepository = redisRepository; _secretKey = Setting.Jwt.SecretKey; _issuer = Setting.Jwt.Issuer; _audience = Setting.Jwt.Audience; _accessTokenExpiration = Setting.Jwt.AccessTokenExpiration; _refreshTokenExpiration = Setting.Jwt.RefreshTokenExpiration; } public string GenerateAccessToken(Member member) { var claims = new[] { new Claim(ClaimTypes.NameIdentifier, member.ID.ToString()), new Claim(ClaimTypes.Email, member.Email), new Claim(ClaimTypes.Name, member.Name ?? "") }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secretKey)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: _issuer, audience: _audience, claims: claims, expires: GetAccessTokenExpiration(), signingCredentials: creds ); return new JwtSecurityTokenHandler().WriteToken(token); } public string GenerateRefreshToken() { return Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); } public bool ValidateAccessToken(string accessToken) { // 캐싱된 검증 결과 사용 (속도 향상) if (_cache.TryGetValue($"valid_token_{accessToken}", out bool isValid)) { return isValid; } try { var tokenHandler = new JwtSecurityTokenHandler(); var key = Encoding.UTF8.GetBytes(_secretKey); var principal = tokenHandler.ValidateToken(accessToken, new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(key), ValidateIssuer = true, ValidateAudience = true, ValidIssuer = _issuer, ValidAudience = _audience, ValidateLifetime = true, ClockSkew = TimeSpan.Zero }, out SecurityToken validatedToken); // 검증된 토큰을 캐싱하여 속도 향상 (만료 시간의 절반으로 유지) _cache.Set($"valid_token_{accessToken}", true, TimeSpan.FromMinutes(_accessTokenExpiration / 2)); return true; } catch (SecurityTokenExpiredException) { return false; } catch (Exception) { return false; } } public async Task ValidateRefreshToken(string refreshToken) { // 속도를 위해 Redis에서 RefreshToken 검증 return await _redisRepository.ExistsAsync($"refresh_token_{refreshToken}"); // DB에서 RefreshToken 검증 // return await _db.RefreshToken.AnyAsync(c => c.Token == refreshToken && c.Expiration > DateTime.UtcNow); } public async Task SaveRefreshToken(int memberID, string refreshToken) { await _redisRepository.SetStringAsync($"refresh_token_{refreshToken}", memberID.ToString(), _refreshTokenExpiration); var expiration = DateTime.UtcNow.AddDays(_refreshTokenExpiration); var existingToken = await _db.RefreshToken.FirstOrDefaultAsync(c => c.MemberID == memberID); if (existingToken != null) { if (existingToken.Token == refreshToken) { return; } existingToken.Token = refreshToken; existingToken.Expiration = expiration; } else { _db.RefreshToken.Add(new RefreshToken { MemberID = memberID, Token = refreshToken, Expiration = expiration }); } // 만료된 RefreshToken 정리 _db.RefreshToken.RemoveRange(_db.RefreshToken.Where(c => c.Expiration < DateTime.UtcNow)); await _db.SaveChangesAsync(); } public Member? GetMemberFromAccessToken(string accessToken) { var jwtToken = new JwtSecurityTokenHandler().ReadToken(accessToken) as JwtSecurityToken; if (jwtToken == null) { return null; } var memberIDClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier); var emailClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email); if (memberIDClaim == null || emailClaim == null) { return null; } return new Member { ID = int.Parse(memberIDClaim.Value), Email = emailClaim.Value }; } public async Task GetMemberFromRefreshToken(string refreshToken) { var memberID = await _db.RefreshToken.Where(rt => rt.Token == refreshToken && rt.Expiration > DateTime.UtcNow).Select(c => c.MemberID).FirstOrDefaultAsync(); if (memberID == 0) { return null; } return await _db.Member.FindAsync(memberID); } public DateTime GetAccessTokenExpiration() { return DateTime.UtcNow.AddMinutes(_accessTokenExpiration); } public DateTime GetRefreshTokenExpiration() { return DateTime.UtcNow.AddDays(_refreshTokenExpiration); } public string SetAccessTokenAndCookieAsync(Member member) { var accessToken = GenerateAccessToken(member); var cookies = _httpContextAccessor.HttpContext?.Response.Cookies; var headers = _httpContextAccessor.HttpContext?.Response.Headers; // AccessToken을 HttpOnly 쿠키로 설정 if (cookies != null) { cookies.Append("accessToken", accessToken, new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.None, Expires = GetAccessTokenExpiration() }); } if (headers != null) { headers["Authorization"] = $"Bearer {accessToken}"; } return accessToken; } public async Task SetRefreshTokenAndCookieAsync(Member member) { var refreshToken = GenerateRefreshToken(); await SaveRefreshToken(member.ID, refreshToken); var cookies = _httpContextAccessor.HttpContext?.Response.Cookies; // RefreshToken을 HttpOnly 쿠키로 설정 if (cookies != null) { cookies.Append("refreshToken", refreshToken, new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.None, Expires = GetRefreshTokenExpiration() }); } return refreshToken; } } }