feat: 관리자 로그인 API 구현 (#36)
All checks were successful
SPMS_API/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/37
This commit is contained in:
김선규 2026-02-09 13:53:43 +00:00
commit f037977102
9 changed files with 214 additions and 1 deletions

View File

@ -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<LoginResponseDto>))]
[SwaggerResponse(401, "로그인 실패")]
public async Task<IActionResult> LoginAsync([FromBody] LoginRequestDto request)
{
var result = await _authService.LoginAsync(request);
return Ok(ApiResponse<LoginResponseDto>.Success(result));
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<IAuthService, AuthService>();
services.AddScoped<IAuthService, AuthService>();
return services;
}

View File

@ -0,0 +1,8 @@
using SPMS.Application.DTOs.Auth;
namespace SPMS.Application.Interfaces;
public interface IAuthService
{
Task<LoginResponseDto> LoginAsync(LoginRequestDto request);
}

View File

@ -11,7 +11,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.2" />
</ItemGroup>
</Project>

View File

@ -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> jwtSettings)
{
_adminRepository = adminRepository;
_unitOfWork = unitOfWork;
_jwtService = jwtService;
_jwtSettings = jwtSettings.Value;
}
public async Task<LoginResponseDto> 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()
}
};
}
}

View File

@ -25,6 +25,7 @@ public static class DependencyInjection
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
services.AddScoped<IServiceRepository, ServiceRepository>();
services.AddScoped<IAdminRepository, AdminRepository>();
// External Services
services.AddScoped<IJwtService, JwtService>();

View File

@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore;
using SPMS.Domain.Entities;
using SPMS.Domain.Interfaces;
namespace SPMS.Infrastructure.Persistence.Repositories;
public class AdminRepository : Repository<Admin>, IAdminRepository
{
public AdminRepository(AppDbContext context) : base(context)
{
}
public async Task<Admin?> GetByEmailAsync(string email)
{
return await _dbSet
.FirstOrDefaultAsync(a => a.Email == email && !a.IsDeleted);
}
public async Task<Admin?> GetByAdminCodeAsync(string adminCode)
{
return await _dbSet
.FirstOrDefaultAsync(a => a.AdminCode == adminCode && !a.IsDeleted);
}
public async Task<bool> 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();
}
}