improvement: 비밀번호 변경 보안 정책 적용 (#251)

- 비밀번호 정책 서버 검증 강화 (영대/소문자, 숫자, 특수문자 조합, 8~64자)
- 동일 비밀번호 재사용 금지 검증 추가
- 비밀번호 변경 후 세션 무효화 (Refresh Token 삭제)
- ChangePasswordResponseDto 신규 (re_login_required 힌트)
- 에러코드 추가 (PasswordPolicyViolation, PasswordReuseForbidden)
- AuthController Swagger 문서 보강

Closes #251
This commit is contained in:
SEAN 2026-02-26 10:07:12 +09:00
parent 335676a282
commit f31964c92e
6 changed files with 44 additions and 10 deletions

View File

@ -132,9 +132,10 @@ public class AuthController : ControllerBase
[Authorize] [Authorize]
[SwaggerOperation( [SwaggerOperation(
Summary = "비밀번호 변경", Summary = "비밀번호 변경",
Description = "현재 로그인된 관리자의 비밀번호를 변경합니다.")] Description = "현재 로그인된 관리자의 비밀번호를 변경합니다. 변경 성공 시 모든 세션이 무효화되며 재로그인이 필요합니다. " +
[SwaggerResponse(200, "비밀번호 변경 성공")] "비밀번호 정책: 8자 이상, 영문 대/소문자·숫자·특수문자 각 1자 이상, 현재 비밀번호와 동일 불가.")]
[SwaggerResponse(400, "현재 비밀번호 불일치")] [SwaggerResponse(200, "비밀번호 변경 성공", typeof(ApiResponse<ChangePasswordResponseDto>))]
[SwaggerResponse(400, "현재 비밀번호 불일치 / 정책 위반 / 동일 비밀번호 재사용")]
[SwaggerResponse(401, "인증되지 않은 요청")] [SwaggerResponse(401, "인증되지 않은 요청")]
public async Task<IActionResult> ChangePasswordAsync([FromBody] ChangePasswordRequestDto request) public async Task<IActionResult> ChangePasswordAsync([FromBody] ChangePasswordRequestDto request)
{ {
@ -142,7 +143,7 @@ public class AuthController : ControllerBase
if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId)) if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId))
throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다."); throw SpmsException.Unauthorized("인증 정보가 유효하지 않습니다.");
await _authService.ChangePasswordAsync(adminId, request); var result = await _authService.ChangePasswordAsync(adminId, request);
return Ok(ApiResponse.Success()); return Ok(ApiResponse<ChangePasswordResponseDto>.Success(result));
} }
} }

View File

@ -9,5 +9,9 @@ public class ChangePasswordRequestDto
[Required(ErrorMessage = "새 비밀번호를 입력해주세요.")] [Required(ErrorMessage = "새 비밀번호를 입력해주세요.")]
[MinLength(8, ErrorMessage = "비밀번호는 8자 이상이어야 합니다.")] [MinLength(8, ErrorMessage = "비밀번호는 8자 이상이어야 합니다.")]
[MaxLength(64, ErrorMessage = "비밀번호는 64자 이하여야 합니다.")]
[RegularExpression(
@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':""\\|,.<>\/?]).{8,}$",
ErrorMessage = "비밀번호는 영문 대/소문자, 숫자, 특수문자를 각각 1자 이상 포함해야 합니다.")]
public string NewPassword { get; set; } = string.Empty; public string NewPassword { get; set; } = string.Empty;
} }

View File

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Auth;
public class ChangePasswordResponseDto
{
[JsonPropertyName("re_login_required")]
public bool ReLoginRequired { get; set; }
}

View File

@ -9,7 +9,7 @@ public interface IAuthService
Task<LoginResponseDto> LoginAsync(LoginRequestDto request); Task<LoginResponseDto> LoginAsync(LoginRequestDto request);
Task<TokenRefreshResponseDto> RefreshTokenAsync(TokenRefreshRequestDto request); Task<TokenRefreshResponseDto> RefreshTokenAsync(TokenRefreshRequestDto request);
Task LogoutAsync(long adminId, string accessToken); Task LogoutAsync(long adminId, string accessToken);
Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request); Task<ChangePasswordResponseDto> ChangePasswordAsync(long adminId, ChangePasswordRequestDto request);
Task<EmailCheckResponseDto> CheckEmailAsync(EmailCheckRequestDto request); Task<EmailCheckResponseDto> CheckEmailAsync(EmailCheckRequestDto request);
Task<EmailVerifyResponseDto> VerifyEmailAsync(EmailVerifyRequestDto request); Task<EmailVerifyResponseDto> VerifyEmailAsync(EmailVerifyRequestDto request);
Task<EmailResendResponseDto> ResendVerificationAsync(EmailResendRequestDto request); Task<EmailResendResponseDto> ResendVerificationAsync(EmailResendRequestDto request);

View File

@ -315,7 +315,7 @@ public class AuthService : IAuthService
} }
} }
public async Task ChangePasswordAsync(long adminId, ChangePasswordRequestDto request) public async Task<ChangePasswordResponseDto> ChangePasswordAsync(long adminId, ChangePasswordRequestDto request)
{ {
// 1. 관리자 조회 // 1. 관리자 조회
var admin = await _adminRepository.GetByIdAsync(adminId); var admin = await _adminRepository.GetByIdAsync(adminId);
@ -336,19 +336,37 @@ public class AuthService : IAuthService
400); 400);
} }
// 3. 새 비밀번호 해싱 및 저장 // 3. 동일 비밀번호 재사용 금지
if (BCrypt.Net.BCrypt.Verify(request.NewPassword, admin.Password))
{
throw new SpmsException(
ErrorCodes.PasswordReuseForbidden,
"현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다.",
400);
}
// 4. 새 비밀번호 해싱 및 저장
admin.Password = BCrypt.Net.BCrypt.HashPassword(request.NewPassword); admin.Password = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
// 4. 강제변경 플래그 해제 // 5. 강제변경 플래그 해제
if (admin.MustChangePassword) if (admin.MustChangePassword)
{ {
admin.MustChangePassword = false; admin.MustChangePassword = false;
admin.TempPasswordIssuedAt = null; admin.TempPasswordIssuedAt = null;
} }
// 6. 세션 무효화 — Refresh Token 삭제
admin.RefreshToken = null;
admin.RefreshTokenExpiresAt = null;
_adminRepository.Update(admin); _adminRepository.Update(admin);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("[AUTH] 비밀번호 변경 — AdminId: {AdminId}", adminId); _logger.LogInformation("[AUTH] 비밀번호 변경 + 세션 무효화 — AdminId: {AdminId}", adminId);
return new ChangePasswordResponseDto
{
ReLoginRequired = true
};
} }
public async Task<EmailCheckResponseDto> CheckEmailAsync(EmailCheckRequestDto request) public async Task<EmailCheckResponseDto> CheckEmailAsync(EmailCheckRequestDto request)

View File

@ -31,6 +31,8 @@ public static class ErrorCodes
// === Account (2) === // === Account (2) ===
public const string PasswordValidationFailed = "121"; public const string PasswordValidationFailed = "121";
public const string ResetTokenError = "122"; public const string ResetTokenError = "122";
public const string PasswordPolicyViolation = "123";
public const string PasswordReuseForbidden = "124";
// === Service (3) === // === Service (3) ===
public const string DecryptionFailed = "131"; public const string DecryptionFailed = "131";