improvement: 임시 비밀번호 발급 및 강제변경 플로우 구현 (#207)
All checks were successful
SPMS_API/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/208
This commit is contained in:
김선규 2026-02-25 01:52:51 +00:00
commit 09831ebcbf
11 changed files with 1273 additions and 0 deletions

View File

@ -43,4 +43,16 @@ public class PasswordController : ControllerBase
await _authService.ResetPasswordAsync(request); await _authService.ResetPasswordAsync(request);
return Ok(ApiResponse.Success()); return Ok(ApiResponse.Success());
} }
[HttpPost("temp")]
[SwaggerOperation(
Summary = "임시 비밀번호 발급",
Description = "등록된 이메일로 임시 비밀번호를 발송합니다. 보안을 위해 이메일 존재 여부와 관계없이 동일한 응답을 반환합니다.")]
[SwaggerResponse(200, "임시 비밀번호 발송 완료")]
[SwaggerResponse(400, "잘못된 요청")]
public async Task<IActionResult> IssueTempPasswordAsync([FromBody] TempPasswordRequestDto request)
{
await _authService.IssueTempPasswordAsync(request);
return Ok(ApiResponse.Success());
}
} }

View File

@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Account;
public class TempPasswordRequestDto
{
[Required]
[EmailAddress]
[JsonPropertyName("email")]
public string Email { get; set; } = string.Empty;
}

View File

@ -25,6 +25,9 @@ public class LoginResponseDto
[JsonPropertyName("email_sent")] [JsonPropertyName("email_sent")]
public bool? EmailSent { get; set; } public bool? EmailSent { get; set; }
[JsonPropertyName("must_change_password")]
public bool? MustChangePassword { get; set; }
[JsonPropertyName("admin")] [JsonPropertyName("admin")]
public AdminInfoDto? Admin { get; set; } public AdminInfoDto? Admin { get; set; }
} }

View File

@ -15,6 +15,7 @@ public interface IAuthService
Task<EmailResendResponseDto> ResendVerificationAsync(EmailResendRequestDto request); Task<EmailResendResponseDto> ResendVerificationAsync(EmailResendRequestDto request);
Task ForgotPasswordAsync(PasswordForgotRequestDto request); Task ForgotPasswordAsync(PasswordForgotRequestDto request);
Task ResetPasswordAsync(PasswordResetRequestDto request); Task ResetPasswordAsync(PasswordResetRequestDto request);
Task IssueTempPasswordAsync(TempPasswordRequestDto request);
Task<ProfileResponseDto> GetProfileAsync(long adminId); Task<ProfileResponseDto> GetProfileAsync(long adminId);
Task<ProfileResponseDto> UpdateProfileAsync(long adminId, UpdateProfileRequestDto request); Task<ProfileResponseDto> UpdateProfileAsync(long adminId, UpdateProfileRequestDto request);
} }

View File

@ -5,4 +5,5 @@ public interface IEmailService
Task SendVerificationCodeAsync(string email, string code); Task SendVerificationCodeAsync(string email, string code);
Task SendPasswordResetTokenAsync(string email, string token); Task SendPasswordResetTokenAsync(string email, string token);
Task SendPasswordSetupTokenAsync(string email, string token); Task SendPasswordSetupTokenAsync(string email, string token);
Task SendTempPasswordAsync(string email, string tempPassword);
} }

View File

@ -3,6 +3,7 @@ using SPMS.Application.DTOs.Account;
using SPMS.Application.DTOs.Auth; using SPMS.Application.DTOs.Auth;
using SPMS.Application.Interfaces; using SPMS.Application.Interfaces;
using SPMS.Application.Settings; using SPMS.Application.Settings;
using System.Security.Cryptography;
using SPMS.Domain.Common; using SPMS.Domain.Common;
using SPMS.Domain.Entities; using SPMS.Domain.Entities;
using SPMS.Domain.Enums; using SPMS.Domain.Enums;
@ -159,6 +160,7 @@ public class AuthService : IAuthService
var nextAction = "GO_DASHBOARD"; var nextAction = "GO_DASHBOARD";
string? verifySessionId = null; string? verifySessionId = null;
bool? emailSent = null; bool? emailSent = null;
bool? mustChangePassword = null;
if (!admin.EmailVerified) if (!admin.EmailVerified)
{ {
@ -189,6 +191,11 @@ public class AuthService : IAuthService
emailSent = false; emailSent = false;
} }
} }
else if (admin.MustChangePassword)
{
nextAction = "CHANGE_PASSWORD";
mustChangePassword = true;
}
// 7. 응답 반환 // 7. 응답 반환
return new LoginResponseDto return new LoginResponseDto
@ -200,6 +207,7 @@ public class AuthService : IAuthService
EmailVerified = admin.EmailVerified, EmailVerified = admin.EmailVerified,
VerifySessionId = verifySessionId, VerifySessionId = verifySessionId,
EmailSent = emailSent, EmailSent = emailSent,
MustChangePassword = mustChangePassword,
Admin = new AdminInfoDto Admin = new AdminInfoDto
{ {
AdminCode = admin.AdminCode, AdminCode = admin.AdminCode,
@ -303,6 +311,14 @@ public class AuthService : IAuthService
// 3. 새 비밀번호 해싱 및 저장 // 3. 새 비밀번호 해싱 및 저장
admin.Password = BCrypt.Net.BCrypt.HashPassword(request.NewPassword); admin.Password = BCrypt.Net.BCrypt.HashPassword(request.NewPassword);
// 4. 강제변경 플래그 해제
if (admin.MustChangePassword)
{
admin.MustChangePassword = false;
admin.TempPasswordIssuedAt = null;
}
_adminRepository.Update(admin); _adminRepository.Update(admin);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
} }
@ -461,6 +477,61 @@ public class AuthService : IAuthService
await _tokenStore.RemoveAsync($"password_reset:{request.Email}"); await _tokenStore.RemoveAsync($"password_reset:{request.Email}");
} }
public async Task IssueTempPasswordAsync(TempPasswordRequestDto request)
{
// 1. 이메일로 관리자 조회 (없으면 조용히 반환 — 계정 존재 비노출)
var admin = await _adminRepository.GetByEmailAsync(request.Email);
if (admin is null)
return;
// 2. 삭제된 계정이면 조용히 반환
if (admin.IsDeleted)
return;
// 3. 임시 비밀번호 생성 (12자, 영대소+숫자+특수)
var tempPassword = GenerateTempPassword(12);
// 4. 해시 저장 + 강제변경 플래그 설정
admin.Password = BCrypt.Net.BCrypt.HashPassword(tempPassword);
admin.MustChangePassword = true;
admin.TempPasswordIssuedAt = DateTime.UtcNow;
_adminRepository.Update(admin);
await _unitOfWork.SaveChangesAsync();
// 5. 메일 발송
await _emailService.SendTempPasswordAsync(admin.Email, tempPassword);
}
private static string GenerateTempPassword(int length)
{
const string upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const string lower = "abcdefghijklmnopqrstuvwxyz";
const string digits = "0123456789";
const string special = "!@#$%^&*";
const string all = upper + lower + digits + special;
var password = new char[length];
// 각 카테고리에서 최소 1자씩
password[0] = upper[RandomNumberGenerator.GetInt32(upper.Length)];
password[1] = lower[RandomNumberGenerator.GetInt32(lower.Length)];
password[2] = digits[RandomNumberGenerator.GetInt32(digits.Length)];
password[3] = special[RandomNumberGenerator.GetInt32(special.Length)];
// 나머지 랜덤 채움
for (var i = 4; i < length; i++)
password[i] = all[RandomNumberGenerator.GetInt32(all.Length)];
// 셔플
for (var i = password.Length - 1; i > 0; i--)
{
var j = RandomNumberGenerator.GetInt32(i + 1);
(password[i], password[j]) = (password[j], password[i]);
}
return new string(password);
}
public async Task<ProfileResponseDto> GetProfileAsync(long adminId) public async Task<ProfileResponseDto> GetProfileAsync(long adminId)
{ {
var admin = await _adminRepository.GetByIdAsync(adminId); var admin = await _adminRepository.GetByIdAsync(adminId);

View File

@ -21,4 +21,6 @@ public class Admin : BaseEntity
public bool AgreeTerms { get; set; } public bool AgreeTerms { get; set; }
public bool AgreePrivacy { get; set; } public bool AgreePrivacy { get; set; }
public DateTime AgreedAt { get; set; } public DateTime AgreedAt { get; set; }
public bool MustChangePassword { get; set; }
public DateTime? TempPasswordIssuedAt { get; set; }
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SPMS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTempPasswordFieldsToAdmin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "MustChangePassword",
table: "Admin",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "TempPasswordIssuedAt",
table: "Admin",
type: "datetime(6)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MustChangePassword",
table: "Admin");
migrationBuilder.DropColumn(
name: "TempPasswordIssuedAt",
table: "Admin");
}
}
}

View File

@ -71,6 +71,9 @@ namespace SPMS.Infrastructure.Migrations
b.Property<DateTime?>("LastLoginAt") b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<bool>("MustChangePassword")
.HasColumnType("tinyint(1)");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
@ -96,6 +99,9 @@ namespace SPMS.Infrastructure.Migrations
b.Property<sbyte>("Role") b.Property<sbyte>("Role")
.HasColumnType("tinyint"); .HasColumnType("tinyint");
b.Property<DateTime?>("TempPasswordIssuedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AdminCode") b.HasIndex("AdminCode")

View File

@ -35,4 +35,12 @@ public class ConsoleEmailService : IEmailService
email, token); email, token);
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task SendTempPasswordAsync(string email, string tempPassword)
{
_logger.LogInformation(
"[EMAIL] 임시 비밀번호 발송 → To: {Email}, TempPassword: {TempPassword}",
email, tempPassword);
return Task.CompletedTask;
}
} }