improvement: 임시 비밀번호 발급 및 강제변경 플로우 구현 (#207)
All checks were successful
SPMS_API/pipeline/head This commit looks good
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:
commit
09831ebcbf
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
SPMS.Application/DTOs/Account/TempPasswordRequestDto.cs
Normal file
12
SPMS.Application/DTOs/Account/TempPasswordRequestDto.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1117
SPMS.Infrastructure/Migrations/20260225014730_AddTempPasswordFieldsToAdmin.Designer.cs
generated
Normal file
1117
SPMS.Infrastructure/Migrations/20260225014730_AddTempPasswordFieldsToAdmin.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user