From b11c8dc918677f8456edf37d6f8082f00d5edcd5 Mon Sep 17 00:00:00 2001 From: "seonkyu.kim" Date: Mon, 9 Feb 2026 22:16:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20API=20=EA=B5=AC=ED=98=84=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoginRequestDto, LoginResponseDto 추가 - IAuthService, AuthService 구현 (BCrypt 비밀번호 검증) - AdminRepository 구현 (GetByEmailAsync) - AuthController 추가 (POST /v1/in/auth/login) - DI 등록 (IAuthService, IAdminRepository) Co-Authored-By: Claude Opus 4.5 --- SPMS.API/Controllers/AuthController.cs | 34 ++++++++ SPMS.Application/DTOs/Auth/LoginRequestDto.cs | 13 +++ .../DTOs/Auth/LoginResponseDto.cs | 33 +++++++ SPMS.Application/DependencyInjection.cs | 4 +- SPMS.Application/Interfaces/IAuthService.cs | 8 ++ SPMS.Application/SPMS.Application.csproj | 2 + SPMS.Application/Services/AuthService.cs | 86 +++++++++++++++++++ SPMS.Infrastructure/DependencyInjection.cs | 1 + .../Repositories/AdminRepository.cs | 34 ++++++++ 9 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 SPMS.API/Controllers/AuthController.cs create mode 100644 SPMS.Application/DTOs/Auth/LoginRequestDto.cs create mode 100644 SPMS.Application/DTOs/Auth/LoginResponseDto.cs create mode 100644 SPMS.Application/Interfaces/IAuthService.cs create mode 100644 SPMS.Application/Services/AuthService.cs create mode 100644 SPMS.Infrastructure/Persistence/Repositories/AdminRepository.cs diff --git a/SPMS.API/Controllers/AuthController.cs b/SPMS.API/Controllers/AuthController.cs new file mode 100644 index 0000000..6620fdb --- /dev/null +++ b/SPMS.API/Controllers/AuthController.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using SPMS.Application.DTOs.Auth; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; + +namespace SPMS.API.Controllers; + +[ApiController] +[Route("v1/in/auth")] +[ApiExplorerSettings(GroupName = "auth")] +public class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + [HttpPost("login")] + [AllowAnonymous] + [SwaggerOperation( + Summary = "관리자 로그인", + Description = "이메일과 비밀번호로 로그인하여 JWT 토큰을 발급받습니다.")] + [SwaggerResponse(200, "로그인 성공", typeof(ApiResponse))] + [SwaggerResponse(401, "로그인 실패")] + public async Task LoginAsync([FromBody] LoginRequestDto request) + { + var result = await _authService.LoginAsync(request); + return Ok(ApiResponse.Success(result)); + } +} diff --git a/SPMS.Application/DTOs/Auth/LoginRequestDto.cs b/SPMS.Application/DTOs/Auth/LoginRequestDto.cs new file mode 100644 index 0000000..d84d88d --- /dev/null +++ b/SPMS.Application/DTOs/Auth/LoginRequestDto.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Auth; + +public class LoginRequestDto +{ + [Required(ErrorMessage = "이메일은 필수입니다.")] + [EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")] + public string Email { get; set; } = string.Empty; + + [Required(ErrorMessage = "비밀번호는 필수입니다.")] + public string Password { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/Auth/LoginResponseDto.cs b/SPMS.Application/DTOs/Auth/LoginResponseDto.cs new file mode 100644 index 0000000..c5e8b51 --- /dev/null +++ b/SPMS.Application/DTOs/Auth/LoginResponseDto.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Auth; + +public class LoginResponseDto +{ + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } = string.Empty; + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + [JsonPropertyName("admin")] + public AdminInfoDto? Admin { get; set; } +} + +public class AdminInfoDto +{ + [JsonPropertyName("admin_code")] + public string AdminCode { get; set; } = string.Empty; + + [JsonPropertyName("email")] + public string Email { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("role")] + public string Role { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DependencyInjection.cs b/SPMS.Application/DependencyInjection.cs index 8d6fdb6..639b82e 100644 --- a/SPMS.Application/DependencyInjection.cs +++ b/SPMS.Application/DependencyInjection.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.DependencyInjection; +using SPMS.Application.Interfaces; +using SPMS.Application.Services; namespace SPMS.Application; @@ -7,7 +9,7 @@ public static class DependencyInjection public static IServiceCollection AddApplication(this IServiceCollection services) { // Application Services - // services.AddScoped(); + services.AddScoped(); return services; } diff --git a/SPMS.Application/Interfaces/IAuthService.cs b/SPMS.Application/Interfaces/IAuthService.cs new file mode 100644 index 0000000..e5fe903 --- /dev/null +++ b/SPMS.Application/Interfaces/IAuthService.cs @@ -0,0 +1,8 @@ +using SPMS.Application.DTOs.Auth; + +namespace SPMS.Application.Interfaces; + +public interface IAuthService +{ + Task LoginAsync(LoginRequestDto request); +} diff --git a/SPMS.Application/SPMS.Application.csproj b/SPMS.Application/SPMS.Application.csproj index 4be501f..3c9c3be 100644 --- a/SPMS.Application/SPMS.Application.csproj +++ b/SPMS.Application/SPMS.Application.csproj @@ -11,7 +11,9 @@ + + diff --git a/SPMS.Application/Services/AuthService.cs b/SPMS.Application/Services/AuthService.cs new file mode 100644 index 0000000..7a9dc11 --- /dev/null +++ b/SPMS.Application/Services/AuthService.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Options; +using SPMS.Application.DTOs.Auth; +using SPMS.Application.Interfaces; +using SPMS.Application.Settings; +using SPMS.Domain.Common; +using SPMS.Domain.Exceptions; +using SPMS.Domain.Interfaces; + +namespace SPMS.Application.Services; + +public class AuthService : IAuthService +{ + private readonly IAdminRepository _adminRepository; + private readonly IUnitOfWork _unitOfWork; + private readonly IJwtService _jwtService; + private readonly JwtSettings _jwtSettings; + + public AuthService( + IAdminRepository adminRepository, + IUnitOfWork unitOfWork, + IJwtService jwtService, + IOptions jwtSettings) + { + _adminRepository = adminRepository; + _unitOfWork = unitOfWork; + _jwtService = jwtService; + _jwtSettings = jwtSettings.Value; + } + + public async Task LoginAsync(LoginRequestDto request) + { + // 1. 이메일로 관리자 조회 + var admin = await _adminRepository.GetByEmailAsync(request.Email); + if (admin is null) + { + throw new SpmsException( + ErrorCodes.LoginFailed, + "이메일 또는 비밀번호가 일치하지 않습니다.", + 401); + } + + // 2. 삭제된 계정 확인 + if (admin.IsDeleted) + { + throw new SpmsException( + ErrorCodes.LoginFailed, + "이메일 또는 비밀번호가 일치하지 않습니다.", + 401); + } + + // 3. 비밀번호 검증 (BCrypt) + if (!BCrypt.Net.BCrypt.Verify(request.Password, admin.Password)) + { + throw new SpmsException( + ErrorCodes.LoginFailed, + "이메일 또는 비밀번호가 일치하지 않습니다.", + 401); + } + + // 4. 토큰 생성 + var accessToken = _jwtService.GenerateAccessToken( + admin.Id, + admin.Role.ToString()); + var refreshToken = _jwtService.GenerateRefreshToken(); + + // 5. 최종 로그인 시간 업데이트 + admin.LastLoginAt = DateTime.UtcNow; + _adminRepository.Update(admin); + await _unitOfWork.SaveChangesAsync(); + + // 6. 응답 반환 + return new LoginResponseDto + { + AccessToken = accessToken, + RefreshToken = refreshToken, + ExpiresIn = _jwtSettings.ExpiryMinutes * 60, + Admin = new AdminInfoDto + { + AdminCode = admin.AdminCode, + Email = admin.Email, + Name = admin.Name, + Role = admin.Role.ToString() + } + }; + } +} diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index 41bb640..cda4e95 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -25,6 +25,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); services.AddScoped(); + services.AddScoped(); // External Services services.AddScoped(); diff --git a/SPMS.Infrastructure/Persistence/Repositories/AdminRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/AdminRepository.cs new file mode 100644 index 0000000..d7a349a --- /dev/null +++ b/SPMS.Infrastructure/Persistence/Repositories/AdminRepository.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using SPMS.Domain.Entities; +using SPMS.Domain.Interfaces; + +namespace SPMS.Infrastructure.Persistence.Repositories; + +public class AdminRepository : Repository, IAdminRepository +{ + public AdminRepository(AppDbContext context) : base(context) + { + } + + public async Task GetByEmailAsync(string email) + { + return await _dbSet + .FirstOrDefaultAsync(a => a.Email == email && !a.IsDeleted); + } + + public async Task GetByAdminCodeAsync(string adminCode) + { + return await _dbSet + .FirstOrDefaultAsync(a => a.AdminCode == adminCode && !a.IsDeleted); + } + + public async Task EmailExistsAsync(string email, long? excludeId = null) + { + var query = _dbSet.Where(a => a.Email == email && !a.IsDeleted); + + if (excludeId.HasValue) + query = query.Where(a => a.Id != excludeId.Value); + + return await query.AnyAsync(); + } +}