Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8c6738b
✨ feat: 비밀번호 변경 요청 API의 DTO 구현
CodeVac513 Sep 17, 2025
8e79e74
✨ feat: 비밀번호 변경(업데이트) API의 요청 DTO 구현
CodeVac513 Sep 17, 2025
6247001
✨ feat: 비밀번호 변경 요청 API와 비밀번호 변경 API 핸들러 구현
CodeVac513 Sep 17, 2025
9c5cf3c
✨ feat: 비밀번호 변경 시 사용할 레디스 KEY 상수 추가
CodeVac513 Sep 17, 2025
12e9479
✨ feat: 비밀번호 변경을 위한 메일 양식 추가
CodeVac513 Sep 17, 2025
6465b75
✨ feat: 비밀번호 변경 메일 전송 로직 구현
CodeVac513 Sep 17, 2025
c45c639
✨ feat: 비밀번호 변경 서비스 로직 구현
CodeVac513 Sep 17, 2025
ffbd016
🧼 clean: 디버그용 console.log 정리
CodeVac513 Sep 17, 2025
e96be98
📝 docs: 비밀번호 변경 요청 API 스웨거 작성 및 적용
CodeVac513 Sep 17, 2025
bd14f2b
📝 docs: 비밀번호 변경 API 스웨거 작성 및 적용
CodeVac513 Sep 17, 2025
ca4ba24
✅ test: 비밀번호 변경 API 통합테스트 작성
CodeVac513 Sep 17, 2025
b0df110
✅ test: forgotPasswordRequestDto의 dto 테스트 작성
CodeVac513 Sep 17, 2025
8d1ab4a
✅ test: passwordResetRequestDto의 dto 테스트 작성
CodeVac513 Sep 17, 2025
b5aa99b
Merge branch 'main' into feat/password-reset-api
CodeVac513 Sep 30, 2025
49ed2e8
🧼 clean: forgotPassword로 메서드명을 수정
CodeVac513 Sep 30, 2025
0eeef8c
🧼 clean: passwordResetRequestDto를 resetPasswordRequestDto로 수정
CodeVac513 Sep 30, 2025
4147bfd
✨ feat: user 객체 직렬화에서 userId를 저장하고 변경 시 user 객체를 조회하는 방법으로 수정
CodeVac513 Sep 30, 2025
680d793
🐛 fix: certificateUser 메서드 내 redis del 메서드의 await 적용
CodeVac513 Sep 30, 2025
6a9c07c
✅ test: dto 테스트 리팩토링에 맞게 테스트 코드 수정
CodeVac513 Sep 30, 2025
f7f33bb
✅ test: e2e 테스트 수정
CodeVac513 Sep 30, 2025
43ab014
Merge branch 'main' into feat/password-reset-api
CodeVac513 Sep 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions server/src/common/email/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
import { WinstonLoggerService } from '../logger/logger.service';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import {
createPasswordResetMailContent,
createRssRegistrationContent,
createRssRemoveCertificateContent,
createVerificationMailContent,
Expand Down Expand Up @@ -138,4 +139,27 @@ export class EmailService {
),
};
}

async sendPasswordResetEmail(user: User, uuid: string): Promise<void> {
const mailOptions = this.createPasswordResetEmail(user, uuid);

await this.sendMail(mailOptions);
}

private createPasswordResetEmail(
user: User,
uuid: string,
): nodemailer.SendMailOptions {
const redirectUrl = `${PRODUCT_DOMAIN}/user/password?token=${uuid}`;
return {
from: `Denamu<${this.emailUser}>`,
to: user.email,
subject: `[🎋 Denamu] 비밀번호 재설정`,
html: createPasswordResetMailContent(
user.userName,
redirectUrl,
this.emailUser,
),
};
}
}
39 changes: 39 additions & 0 deletions server/src/common/email/mailContent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Rss } from '../../rss/entity/rss.entity';

export const PRODUCT_DOMAIN = 'https://denamu.site';

export function createRssRegistrationContent(
Expand Down Expand Up @@ -126,3 +127,41 @@ export function createRssRemoveCertificateContent(
</div>
`;
}

export function createPasswordResetMailContent(
userName: string,
passwordResetLink: string,
serviceAddress: string,
) {
return `
<div style="font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', '맑은 고딕', sans-serif; margin: 0; padding: 1px; background-color: #f4f4f4;">
<div style="max-width: 600px; margin: 20px auto; background-color: #ffffff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
<div style="text-align: center; padding: 20px 0; border-bottom: 2px solid #f0f0f0;">
<img src="https://denamu.site/files/Denamu_Logo_KOR.png" alt="Denamu Logo" width="244" height="120">
</div>
<div style="padding: 20px 0;">
<div style="color: #007bff; font-size: 24px; font-weight: bold; margin-bottom: 20px; text-align: center;">비밀번호 재설정</div>
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 4px; margin: 15px 0;">
<p><strong>안녕하세요, ${userName}님!</strong></p>
<p>비밀번호 재설정을 요청하셨습니다.</p>
<p>아래 버튼을 클릭하여 새로운 비밀번호를 설정해 주세요.</p>
</div>
<center>
<a href="${passwordResetLink}" style="display: inline-block; padding: 12px 24px; background-color: #007bff; color: #ffffff; text-decoration: none; border-radius: 4px; margin: 20px 0; font-weight: bold;">비밀번호 재설정하기</a>
</center>
<div style="font-size: 14px; color: #6c757d; margin-top: 20px; text-align: center;">
<p>버튼이 작동하지 않는 경우, 아래 링크를 복사하여 브라우저에 붙여넣기 해주세요:</p>
<p style="word-break: break-all; background-color: #f8f9fa; padding: 10px; border-radius: 4px;">${passwordResetLink}</p>
<p>이 링크는 10분 동안 유효합니다.</p>
<p style="color: #dc3545; font-weight: bold;">만약 비밀번호 재설정을 요청하지 않으셨다면, 이 메일을 무시하셔도 됩니다.</p>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center; border-top: 2px solid #f0f0f0; color: #6c757d; font-size: 14px; height: 100px;">
<p>본 메일은 발신전용입니다.</p>
<p>문의사항이 있으시다면 ${serviceAddress}로 연락주세요.</p>
</div>
</div>
</div>
`;
}
1 change: 1 addition & 0 deletions server/src/common/redis/redis.constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export const REDIS_KEYS = {
ADMIN_AUTH_ALL_KEY: 'auth:*',
RSS_REMOVE_KEY: 'rss:remove',
CHAT_HISTORY_KEY: 'chat:history',
USER_RESET_PASSWORD_KEY: 'user:password_reset',
};
23 changes: 23 additions & 0 deletions server/src/user/api-docs/requestPasswordReset.api-docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { applyDecorators } from '@nestjs/common';
import { ApiOkResponse, ApiOperation } from '@nestjs/swagger';

export function ApiRequestPasswordReset() {
return applyDecorators(
ApiOperation({
summary: '비밀번호 변경 요청 API',
}),
ApiOkResponse({
description: 'Ok',
schema: {
properties: {
message: {
type: 'string',
},
},
},
example: {
message: '비밀번호 재설정 링크를 이메일로 발송했습니다.',
},
}),
);
}
33 changes: 33 additions & 0 deletions server/src/user/api-docs/resetPassword.api-docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
} from '@nestjs/swagger';

export function ApiResetPassword() {
return applyDecorators(
ApiOperation({
summary: '비밀번호 변경 API',
}),
ApiOkResponse({
description: 'Ok',
schema: {
properties: {
message: {
type: 'string',
},
},
},
example: {
message: '비밀번호가 성공적으로 수정되었습니다.',
},
}),
ApiNotFoundResponse({
description: 'Not Found',
example: {
message: '인증에 실패했습니다.',
},
}),
);
}
29 changes: 29 additions & 0 deletions server/src/user/controller/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import { ApiRefreshToken } from '../api-docs/refreshToken.api-docs';
import { ApiLogoutUser } from '../api-docs/logoutUser.api-docs';
import { UpdateUserRequestDto } from '../dto/request/updateUser.dto';
import { ApiUpdateUser } from '../api-docs/updateUser.api-docs';
import { PasswordResetRequestDto } from '../dto/request/passwordReset.dto';
import { ForgotPasswordRequestDto } from '../dto/request/forgotPassword.dto';
import { ApiRequestPasswordReset } from '../api-docs/requestPasswordReset.api-docs';
import { ApiResetPassword } from '../api-docs/resetPassword.api-docs';

@ApiTags('User')
@Controller('user')
Expand Down Expand Up @@ -113,4 +117,29 @@ export class UserController {
'사용자 프로필 정보가 성공적으로 수정되었습니다.',
);
}

@ApiRequestPasswordReset()
@Post('/password-reset')
@HttpCode(HttpStatus.OK)
async requestPasswordReset(
@Body() forgotPasswordDto: ForgotPasswordRequestDto,
) {
await this.userService.requestPasswordReset(forgotPasswordDto.email);
return ApiResponse.responseWithNoContent(
'비밀번호 재설정 링크를 이메일로 발송했습니다.',
);
}

@ApiResetPassword()
@Patch('/password')
@HttpCode(HttpStatus.OK)
async resetPassword(@Body() passwordResetDto: PasswordResetRequestDto) {
await this.userService.resetPassword(
passwordResetDto.uuid,
passwordResetDto.password,
);
return ApiResponse.responseWithNoContent(
'비밀번호가 성공적으로 수정되었습니다.',
);
}
}
23 changes: 23 additions & 0 deletions server/src/user/dto/request/forgotPassword.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class ForgotPasswordRequestDto {
@ApiProperty({
example: '[email protected]',
description: '이메일을 입력해주세요.',
})
@IsEmail(
{},
{
message: '이메일 주소 형식에 맞춰서 작성해주세요.',
},
)
@IsNotEmpty({
message: '이메일이 없습니다.',
})
email: string;

constructor(email: string) {
this.email = email;
}
}
34 changes: 34 additions & 0 deletions server/src/user/dto/request/passwordReset.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, Matches } from 'class-validator';

export class PasswordResetRequestDto {
@ApiProperty({
example: 'd2ba0d98-95ce-4905-87fc-384965ffe7c9',
description: '인증 코드를 입력해주세요.',
})
@IsNotEmpty({
message: '인증 코드를 입력해주세요.',
})
uuid: string;

@ApiProperty({
example: 'example1234!',
description: '비밀번호를 입력해주세요.',
})
@IsNotEmpty({
message: '비밀번호가 없습니다.',
})
@Matches(
/^(?=.{8,32}$)(?:(?=.*[a-z])(?=.*[A-Z])|(?=.*[a-z])(?=.*\d)|(?=.*[a-z])(?=.*[^A-Za-z0-9])|(?=.*[A-Z])(?=.*\d)|(?=.*[A-Z])(?=.*[^A-Za-z0-9])|(?=.*\d)(?=.*[^A-Za-z0-9])).*$/,
{
message:
'비밀번호는 8~32자이며, 영문(대문자/소문자), 숫자, 특수문자 중 2종류 이상을 포함해야 합니다.',
},
)
password: string;

constructor(uuid: string, password: string) {
this.uuid = uuid;
this.password = password;
}
}
36 changes: 36 additions & 0 deletions server/src/user/service/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,4 +192,40 @@ export class UserService {

await this.userRepository.save(user);
}

async requestPasswordReset(email: string) {
const user = await this.userRepository.findOne({
where: { email: email },
});

if (!user) {
return;
}

const uuid = uuidv4();
await this.redisService.set(
`${REDIS_KEYS.USER_RESET_PASSWORD_KEY}:${uuid}`,
JSON.stringify(user),
'EX',
600,
);

this.emailService.sendPasswordResetEmail(user, uuid);
}

async resetPassword(uuid: string, password: string): Promise<void> {
const userData = await this.redisService.get(
`${REDIS_KEYS.USER_RESET_PASSWORD_KEY}:${uuid}`,
);

if (!userData) {
throw new NotFoundException('인증에 실패했습니다.');
}

const user = JSON.parse(userData);
user.password = await this.createHashedPassword(password);

this.redisService.del(`${REDIS_KEYS.USER_RESET_PASSWORD_KEY}:${uuid}`);
await this.userRepository.save(user);
}
}
40 changes: 40 additions & 0 deletions server/test/user/dto/forgotPassword.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { validate } from 'class-validator';
import { ForgotPasswordRequestDto } from '../../../src/user/dto/request/forgotPassword.dto';

describe('ForgotPasswordRequestDto Test', () => {
it('이메일 형식이 아니라면 유효성 검사에 실패한다.', async () => {
// given
const dto = new ForgotPasswordRequestDto('invalidEmail');

// when
const errors = await validate(dto);

// then
expect(errors).not.toHaveLength(0);
expect(errors[0].constraints).toHaveProperty('isEmail');
});

it('빈 문자열을 입력하면 유효성 검사에 실패한다.', async () => {
// given
const dto = new ForgotPasswordRequestDto('');

// when
const errors = await validate(dto);

// then
expect(errors).not.toHaveLength(0);
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
});

it('아무 값도 없다면(NULL) 유효성 검사에 실패한다.', async () => {
// given
const dto = new ForgotPasswordRequestDto(null);

// when
const errors = await validate(dto);

// then
expect(errors).not.toHaveLength(0);
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
});
});
Loading