Skip to content

Commit 97aa756

Browse files
authored
Merge branch 'main' into chore/self-hosted-runner
2 parents d33250c + 33daa46 commit 97aa756

File tree

12 files changed

+477
-1
lines changed

12 files changed

+477
-1
lines changed

server/src/common/email/email.service.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
44
import { WinstonLoggerService } from '../logger/logger.service';
55
import SMTPTransport from 'nodemailer/lib/smtp-transport';
66
import {
7+
createPasswordResetMailContent,
78
createRssRegistrationContent,
89
createRssRemoveCertificateContent,
910
createVerificationMailContent,
@@ -138,4 +139,27 @@ export class EmailService {
138139
),
139140
};
140141
}
142+
143+
async sendPasswordResetEmail(user: User, uuid: string): Promise<void> {
144+
const mailOptions = this.createPasswordResetEmail(user, uuid);
145+
146+
await this.sendMail(mailOptions);
147+
}
148+
149+
private createPasswordResetEmail(
150+
user: User,
151+
uuid: string,
152+
): nodemailer.SendMailOptions {
153+
const redirectUrl = `${PRODUCT_DOMAIN}/user/password?token=${uuid}`;
154+
return {
155+
from: `Denamu<${this.emailUser}>`,
156+
to: user.email,
157+
subject: `[🎋 Denamu] 비밀번호 재설정`,
158+
html: createPasswordResetMailContent(
159+
user.userName,
160+
redirectUrl,
161+
this.emailUser,
162+
),
163+
};
164+
}
141165
}

server/src/common/email/mailContent.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Rss } from '../../rss/entity/rss.entity';
2+
23
export const PRODUCT_DOMAIN = 'https://denamu.site';
34

45
export function createRssRegistrationContent(
@@ -126,3 +127,41 @@ export function createRssRemoveCertificateContent(
126127
</div>
127128
`;
128129
}
130+
131+
export function createPasswordResetMailContent(
132+
userName: string,
133+
passwordResetLink: string,
134+
serviceAddress: string,
135+
) {
136+
return `
137+
<div style="font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', '맑은 고딕', sans-serif; margin: 0; padding: 1px; background-color: #f4f4f4;">
138+
<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);">
139+
<div style="text-align: center; padding: 20px 0; border-bottom: 2px solid #f0f0f0;">
140+
<img src="https://denamu.site/files/Denamu_Logo_KOR.png" alt="Denamu Logo" width="244" height="120">
141+
</div>
142+
<div style="padding: 20px 0;">
143+
<div style="color: #007bff; font-size: 24px; font-weight: bold; margin-bottom: 20px; text-align: center;">비밀번호 재설정</div>
144+
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 4px; margin: 15px 0;">
145+
<p><strong>안녕하세요, ${userName}님!</strong></p>
146+
<p>비밀번호 재설정을 요청하셨습니다.</p>
147+
<p>아래 버튼을 클릭하여 새로운 비밀번호를 설정해 주세요.</p>
148+
</div>
149+
<center>
150+
<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>
151+
</center>
152+
<div style="font-size: 14px; color: #6c757d; margin-top: 20px; text-align: center;">
153+
<p>버튼이 작동하지 않는 경우, 아래 링크를 복사하여 브라우저에 붙여넣기 해주세요:</p>
154+
<p style="word-break: break-all; background-color: #f8f9fa; padding: 10px; border-radius: 4px;">${passwordResetLink}</p>
155+
<p>이 링크는 10분 동안 유효합니다.</p>
156+
<p style="color: #dc3545; font-weight: bold;">만약 비밀번호 재설정을 요청하지 않으셨다면, 이 메일을 무시하셔도 됩니다.</p>
157+
</div>
158+
</div>
159+
</div>
160+
<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;">
161+
<p>본 메일은 발신전용입니다.</p>
162+
<p>문의사항이 있으시다면 ${serviceAddress}로 연락주세요.</p>
163+
</div>
164+
</div>
165+
</div>
166+
`;
167+
}

server/src/common/redis/redis.constant.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ export const REDIS_KEYS = {
1111
RSS_REMOVE_KEY: 'rss:remove',
1212
CHAT_HISTORY_KEY: 'chat:history',
1313
FULL_FEED_CRAWL_QUEUE: `feed:full-crawl:queue`,
14+
USER_RESET_PASSWORD_KEY: 'user:password_reset',
1415
};
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { applyDecorators } from '@nestjs/common';
2+
import { ApiOkResponse, ApiOperation } from '@nestjs/swagger';
3+
4+
export function ApiForgotPassword() {
5+
return applyDecorators(
6+
ApiOperation({
7+
summary: '비밀번호 변경 요청 API',
8+
}),
9+
ApiOkResponse({
10+
description: 'Ok',
11+
schema: {
12+
properties: {
13+
message: {
14+
type: 'string',
15+
},
16+
},
17+
},
18+
example: {
19+
message: '비밀번호 재설정 링크를 이메일로 발송했습니다.',
20+
},
21+
}),
22+
);
23+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { applyDecorators } from '@nestjs/common';
2+
import {
3+
ApiNotFoundResponse,
4+
ApiOkResponse,
5+
ApiOperation,
6+
} from '@nestjs/swagger';
7+
8+
export function ApiResetPassword() {
9+
return applyDecorators(
10+
ApiOperation({
11+
summary: '비밀번호 변경 API',
12+
}),
13+
ApiOkResponse({
14+
description: 'Ok',
15+
schema: {
16+
properties: {
17+
message: {
18+
type: 'string',
19+
},
20+
},
21+
},
22+
example: {
23+
message: '비밀번호가 성공적으로 수정되었습니다.',
24+
},
25+
}),
26+
ApiNotFoundResponse({
27+
description: 'Not Found',
28+
example: {
29+
message: '인증에 실패했습니다.',
30+
},
31+
}),
32+
);
33+
}

server/src/user/controller/user.controller.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import { ApiRefreshToken } from '../api-docs/refreshToken.api-docs';
2828
import { ApiLogoutUser } from '../api-docs/logoutUser.api-docs';
2929
import { UpdateUserRequestDto } from '../dto/request/updateUser.dto';
3030
import { ApiUpdateUser } from '../api-docs/updateUser.api-docs';
31+
import { ResetPasswordRequestDto } from '../dto/request/resetPassword.dto';
32+
import { ForgotPasswordRequestDto } from '../dto/request/forgotPassword.dto';
33+
import { ApiForgotPassword } from '../api-docs/forgotPassword.api-docs';
34+
import { ApiResetPassword } from '../api-docs/resetPassword.api-docs';
3135

3236
@ApiTags('User')
3337
@Controller('user')
@@ -113,4 +117,29 @@ export class UserController {
113117
'사용자 프로필 정보가 성공적으로 수정되었습니다.',
114118
);
115119
}
120+
121+
@ApiForgotPassword()
122+
@Post('/password-reset')
123+
@HttpCode(HttpStatus.OK)
124+
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordRequestDto) {
125+
await this.userService.forgotPassword(forgotPasswordDto.email);
126+
return ApiResponse.responseWithNoContent(
127+
'비밀번호 재설정 링크를 이메일로 발송했습니다.',
128+
);
129+
}
130+
131+
@ApiResetPassword()
132+
@Patch('/password')
133+
@HttpCode(HttpStatus.OK)
134+
async resetPassword(
135+
@Body() resetPasswordRequestDto: ResetPasswordRequestDto,
136+
) {
137+
await this.userService.resetPassword(
138+
resetPasswordRequestDto.uuid,
139+
resetPasswordRequestDto.password,
140+
);
141+
return ApiResponse.responseWithNoContent(
142+
'비밀번호가 성공적으로 수정되었습니다.',
143+
);
144+
}
116145
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { IsEmail, IsNotEmpty } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
4+
export class ForgotPasswordRequestDto {
5+
@ApiProperty({
6+
example: '[email protected]',
7+
description: '이메일을 입력해주세요.',
8+
})
9+
@IsEmail(
10+
{},
11+
{
12+
message: '이메일 주소 형식에 맞춰서 작성해주세요.',
13+
},
14+
)
15+
@IsNotEmpty({
16+
message: '이메일이 없습니다.',
17+
})
18+
email: string;
19+
20+
constructor(partial: Partial<ForgotPasswordRequestDto>) {
21+
Object.assign(this, partial);
22+
}
23+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, Matches } from 'class-validator';
3+
4+
export class ResetPasswordRequestDto {
5+
@ApiProperty({
6+
example: 'd2ba0d98-95ce-4905-87fc-384965ffe7c9',
7+
description: '인증 코드를 입력해주세요.',
8+
})
9+
@IsNotEmpty({
10+
message: '인증 코드를 입력해주세요.',
11+
})
12+
uuid: string;
13+
14+
@ApiProperty({
15+
example: 'example1234!',
16+
description: '비밀번호를 입력해주세요.',
17+
})
18+
@IsNotEmpty({
19+
message: '비밀번호가 없습니다.',
20+
})
21+
@Matches(
22+
/^(?=.{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])).*$/,
23+
{
24+
message:
25+
'비밀번호는 8~32자이며, 영문(대문자/소문자), 숫자, 특수문자 중 2종류 이상을 포함해야 합니다.',
26+
},
27+
)
28+
password: string;
29+
30+
constructor(partial: Partial<ResetPasswordRequestDto>) {
31+
Object.assign(this, partial);
32+
}
33+
}

server/src/user/service/user.service.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export class UserService {
8383
if (!user) {
8484
throw new NotFoundException('인증에 실패했습니다.');
8585
}
86-
this.redisService.del(`${REDIS_KEYS.USER_AUTH_KEY}:${uuid}`);
86+
await this.redisService.del(`${REDIS_KEYS.USER_AUTH_KEY}:${uuid}`);
8787
await this.userRepository.save(JSON.parse(user));
8888
}
8989

@@ -192,4 +192,46 @@ export class UserService {
192192

193193
await this.userRepository.save(user);
194194
}
195+
196+
async forgotPassword(email: string) {
197+
const user = await this.userRepository.findOne({
198+
where: { email: email },
199+
});
200+
201+
if (!user) {
202+
return;
203+
}
204+
205+
const uuid = uuidv4();
206+
await this.redisService.set(
207+
`${REDIS_KEYS.USER_RESET_PASSWORD_KEY}:${uuid}`,
208+
JSON.stringify(user.id),
209+
'EX',
210+
600,
211+
);
212+
213+
this.emailService.sendPasswordResetEmail(user, uuid);
214+
}
215+
216+
async resetPassword(uuid: string, password: string): Promise<void> {
217+
const userId = Number(
218+
await this.redisService.get(
219+
`${REDIS_KEYS.USER_RESET_PASSWORD_KEY}:${uuid}`,
220+
),
221+
);
222+
223+
if (isNaN(userId) || userId === 0) {
224+
throw new NotFoundException('인증에 실패했습니다.');
225+
}
226+
227+
const user = await this.userRepository.findOne({
228+
where: { id: userId },
229+
});
230+
user.password = await this.createHashedPassword(password);
231+
232+
await this.redisService.del(
233+
`${REDIS_KEYS.USER_RESET_PASSWORD_KEY}:${uuid}`,
234+
);
235+
await this.userRepository.save(user);
236+
}
195237
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { validate } from 'class-validator';
2+
import { ForgotPasswordRequestDto } from '../../../src/user/dto/request/forgotPassword.dto';
3+
4+
describe('ForgotPasswordRequestDto Test', () => {
5+
let dto: ForgotPasswordRequestDto;
6+
7+
beforeEach(() => {
8+
dto = new ForgotPasswordRequestDto({ email: '[email protected]' });
9+
});
10+
11+
it('email 경로가 올바를 경우 유효성 검사에 성공한다.', async () => {
12+
// when
13+
const errors = await validate(dto);
14+
15+
// then
16+
expect(errors).toHaveLength(0);
17+
});
18+
19+
describe('email', () => {
20+
it('이메일 형식이 아니라면 유효성 검사에 실패한다.', async () => {
21+
// given
22+
dto.email = 'invalid-email';
23+
24+
// when
25+
const errors = await validate(dto);
26+
27+
// then
28+
expect(errors).not.toHaveLength(0);
29+
expect(errors[0].constraints).toHaveProperty('isEmail');
30+
});
31+
32+
it('빈 문자열을 입력하면 유효성 검사에 실패한다.', async () => {
33+
// given
34+
dto.email = '';
35+
36+
// when
37+
const errors = await validate(dto);
38+
39+
// then
40+
expect(errors).not.toHaveLength(0);
41+
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
42+
});
43+
44+
it('이메일을 입력하지 않는다면 유효성 검사에 실패한다.', async () => {
45+
// given
46+
dto.email = null;
47+
48+
// when
49+
const errors = await validate(dto);
50+
51+
// then
52+
expect(errors).not.toHaveLength(0);
53+
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
54+
});
55+
});
56+
});

0 commit comments

Comments
 (0)