d3ro-voice/apps/api-server/Services/AuthService.cs
2026-08-29 18:33:45 +09:00

166 lines
5.7 KiB
C#

using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Dtos;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens;
namespace D3ROVoice.Api.Services;
public interface IAuthService
{
Task<AuthResponseDto> RegisterAsync(RegisterDto dto);
Task<AuthResponseDto> LoginAsync(LoginDto dto);
Task<UserInfoDto?> GetUserByEmailAsync(string email);
}
public class AuthService : IAuthService
{
private static readonly SemaphoreSlim BootstrapLock = new(1, 1);
private readonly AppDbContext _db;
private readonly IConfiguration _config;
private readonly PasswordHasher<User> _passwordHasher = new();
private readonly string _dummyPasswordHash;
public AuthService(AppDbContext db, IConfiguration config)
{
_db = db;
_config = config;
_dummyPasswordHash = _passwordHasher.HashPassword(new User(), "dummy-password-never-used");
}
public async Task<AuthResponseDto> RegisterAsync(RegisterDto dto)
{
if (dto.Password.Length < 12 || dto.Password.Length > 256)
{
throw new InvalidOperationException("비밀번호는 12자 이상 256자 이하여야 합니다.");
}
await BootstrapLock.WaitAsync();
try
{
var normalizedEmail = dto.Email.Trim().ToLowerInvariant();
var existing = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == normalizedEmail);
if (existing != null)
{
throw new InvalidOperationException("이미 등록된 이메일 주소입니다.");
}
if (await _db.Users.AnyAsync(u => u.IsActive))
{
throw new InvalidOperationException("관리자 초기 등록이 이미 완료되었습니다.");
}
var user = new User
{
Email = normalizedEmail,
Role = "SuperAdmin",
CreatedAt = DateTime.UtcNow,
IsActive = true
};
user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password);
_db.Users.Add(user);
await _db.SaveChangesAsync();
return GenerateToken(user);
}
finally
{
BootstrapLock.Release();
}
}
public async Task<AuthResponseDto> LoginAsync(LoginDto dto)
{
var normalizedEmail = dto.Email.Trim().ToLowerInvariant();
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == normalizedEmail);
if (user == null)
{
_passwordHasher.VerifyHashedPassword(new User(), _dummyPasswordHash, dto.Password);
throw new UnauthorizedAccessException("이메일 또는 비밀번호가 올바르지 않습니다.");
}
PasswordVerificationResult verification;
try
{
verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, dto.Password);
}
catch (FormatException)
{
verification = PasswordVerificationResult.Failed;
}
if (verification == PasswordVerificationResult.Failed)
{
throw new UnauthorizedAccessException("이메일 또는 비밀번호가 올바르지 않습니다.");
}
if (!user.IsActive)
{
throw new UnauthorizedAccessException("비활성화된 계정입니다. 관리자에게 문의하세요.");
}
user.LastLoginAt = DateTime.UtcNow;
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
{
user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password);
}
await _db.SaveChangesAsync();
return GenerateToken(user);
}
public async Task<UserInfoDto?> GetUserByEmailAsync(string email)
{
var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Email.ToLower() == email.ToLower());
if (user == null) return null;
return new UserInfoDto(user.Id, user.Email, user.Role, user.CreatedAt, user.LastLoginAt, user.IsActive);
}
private AuthResponseDto GenerateToken(User user)
{
var secretKey = _config["JWT_SECRET"];
if (string.IsNullOrWhiteSpace(secretKey) || Encoding.UTF8.GetByteCount(secretKey) < 32)
{
throw new InvalidOperationException("JWT_SECRET must contain at least 32 non-whitespace bytes.");
}
var issuer = _config["JWT_ISSUER"];
if (string.IsNullOrWhiteSpace(issuer))
{
throw new InvalidOperationException("JWT_ISSUER is required.");
}
var audience = _config["JWT_AUDIENCE"];
if (string.IsNullOrWhiteSpace(audience))
{
throw new InvalidOperationException("JWT_AUDIENCE is required.");
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Role, user.Role)
};
var expiresAt = DateTime.UtcNow.AddHours(8);
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: expiresAt,
signingCredentials: creds
);
var tokenHandler = new JwtSecurityTokenHandler();
return new AuthResponseDto(tokenHandler.WriteToken(token), user.Email, user.Role, expiresAt);
}
}