using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using BCrypt.Net; using Microsoft.IdentityModel.Tokens; using TaxBaik.Domain.Entities; using TaxBaik.Domain.Interfaces; namespace TaxBaik.Web.Services; public class AuthService { private readonly IAdminUserRepository _adminUserRepository; private readonly ILogger _logger; private readonly ITelegramNotificationService _telegramService; private readonly string _jwtSecretKey; private readonly string? _passwordResetToken; private readonly int _accessTokenExpirationMinutes = 60; // Access Token: 1시간 (사용성 향상) private readonly int _refreshTokenExpirationMinutes = 10080; // Refresh Token: 7일 public AuthService( IAdminUserRepository adminUserRepository, ILogger logger, IConfiguration configuration, ITelegramNotificationService telegramService) { _adminUserRepository = adminUserRepository; _logger = logger; _telegramService = telegramService; _jwtSecretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration."); _passwordResetToken = configuration["Admin:PasswordResetToken"]; } public async Task AuthenticateAndGenerateTokenPairAsync(string username, string password) { if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) return null; var user = await _adminUserRepository.GetByUsernameAsync(username); if (user == null) { _logger.LogWarning("로그인 시도: 존재하지 않는 사용자 '{Username}'", username); return null; } if (string.IsNullOrWhiteSpace(user.PasswordHash)) { _logger.LogError("로그인 실패: 사용자 '{Username}'의 PasswordHash가 비어 있습니다.", username); return null; } if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash)) { _logger.LogWarning("로그인 시도: 잘못된 비밀번호 '{Username}'", username); await _telegramService.SendErrorAsync( "로그인 실패", $"사용자: {username}\n실패 사유: 잘못된 비밀번호"); return null; } _logger.LogInformation("로그인 성공: {Username}", username); await _telegramService.SendInfoAsync( "관리자 로그인", $"사용자: {username}"); await _adminUserRepository.UpdateLastLoginAtAsync(user.Id); return GenerateTokenPair(user); } public async Task RefreshAccessTokenAsync(string refreshToken) { try { var principal = ValidateRefreshToken(refreshToken); if (principal == null) { _logger.LogWarning("Refresh token 검증 실패"); return null; } var username = principal.FindFirstValue(ClaimTypes.Name); if (string.IsNullOrWhiteSpace(username)) return null; var user = await _adminUserRepository.GetByUsernameAsync(username); if (user == null) return null; return GenerateTokenPair(user); } catch (Exception ex) { _logger.LogError(ex, "Refresh token 처리 중 오류"); return null; } } public async Task ChangePasswordAsync(string username, string currentPassword, string newPassword) { if (!IsValidPassword(newPassword)) throw new ArgumentException("새 비밀번호는 12자 이상이어야 합니다.", nameof(newPassword)); var user = await _adminUserRepository.GetByUsernameAsync(username); if (user == null || string.IsNullOrWhiteSpace(user.PasswordHash)) return false; if (!BCrypt.Net.BCrypt.Verify(currentPassword, user.PasswordHash)) return false; await _adminUserRepository.UpdatePasswordHashAsync(user.Id, BCrypt.Net.BCrypt.HashPassword(newPassword)); _logger.LogInformation("관리자 비밀번호 변경: {Username}", username); return true; } public async Task ResetPasswordAsync(string username, string newPassword, string resetToken) { if (string.IsNullOrWhiteSpace(_passwordResetToken)) throw new InvalidOperationException("Admin:PasswordResetToken is not configured."); if (!TimeConstantEquals(resetToken, _passwordResetToken)) return false; if (!IsValidPassword(newPassword)) throw new ArgumentException("새 비밀번호는 12자 이상이어야 합니다.", nameof(newPassword)); var user = await _adminUserRepository.GetByUsernameAsync(username); if (user == null) return false; await _adminUserRepository.UpdatePasswordHashAsync(user.Id, BCrypt.Net.BCrypt.HashPassword(newPassword)); _logger.LogWarning("관리자 비밀번호 재설정 API 사용: {Username}", username); return true; } private AuthTokenPair GenerateTokenPair(AdminUser user) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims = new[] { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.Username), new Claim("username", user.Username) }; var accessToken = new JwtSecurityToken( issuer: "taxbaik-admin", audience: "taxbaik-admin-client", claims: claims, expires: DateTime.UtcNow.AddMinutes(_accessTokenExpirationMinutes), signingCredentials: creds); var refreshToken = new JwtSecurityToken( issuer: "taxbaik-admin", audience: "taxbaik-admin-client", claims: claims, expires: DateTime.UtcNow.AddMinutes(_refreshTokenExpirationMinutes), signingCredentials: creds); return new AuthTokenPair( new JwtSecurityTokenHandler().WriteToken(accessToken), new JwtSecurityTokenHandler().WriteToken(refreshToken), _accessTokenExpirationMinutes * 60 ); } public ClaimsPrincipal? ValidateRefreshToken(string token) { try { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey)); var handler = new JwtSecurityTokenHandler(); var principal = handler.ValidateToken(token, new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = key, ValidateIssuer = true, ValidIssuer = "taxbaik-admin", ValidateAudience = true, ValidAudience = "taxbaik-admin-client", ValidateLifetime = true, ClockSkew = TimeSpan.Zero }, out SecurityToken validatedToken); return principal; } catch { return null; } } public ClaimsPrincipal? ValidateToken(string token) { try { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSecretKey)); var handler = new JwtSecurityTokenHandler(); var principal = handler.ValidateToken(token, new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = key, ValidateIssuer = true, ValidIssuer = "taxbaik-admin", ValidateAudience = true, ValidAudience = "taxbaik-admin-client", ValidateLifetime = true, ClockSkew = TimeSpan.Zero }, out SecurityToken validatedToken); return principal; } catch { return null; } } private static bool IsValidPassword(string password) => !string.IsNullOrWhiteSpace(password) && password.Length >= 12; private static bool TimeConstantEquals(string value, string expected) { var valueBytes = Encoding.UTF8.GetBytes(value); var expectedBytes = Encoding.UTF8.GetBytes(expected); return valueBytes.Length == expectedBytes.Length && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(valueBytes, expectedBytes); } } public class AuthTokenPair(string accessToken, string refreshToken, int expiresIn) { public string AccessToken { get; } = accessToken; public string RefreshToken { get; } = refreshToken; public int ExpiresIn { get; } = expiresIn; }