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 string _jwtSecretKey; private readonly string? _passwordResetToken; private readonly int _tokenExpirationMinutes = 480; // 8시간 public AuthService(IAdminUserRepository adminUserRepository, ILogger logger, IConfiguration configuration) { _adminUserRepository = adminUserRepository; _logger = logger; _jwtSecretKey = configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing 'Jwt:SecretKey' configuration."); _passwordResetToken = configuration["Admin:PasswordResetToken"]; } public async Task AuthenticateAndGenerateTokenAsync(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); return null; } _logger.LogInformation("로그인 성공: {Username}", username); await _adminUserRepository.UpdateLastLoginAtAsync(user.Id); return GenerateJwtToken(user); } 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 string GenerateJwtToken(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 token = new JwtSecurityToken( issuer: "taxbaik-admin", audience: "taxbaik-admin-client", claims: claims, expires: DateTime.UtcNow.AddMinutes(_tokenExpirationMinutes), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } 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); } }