feat: 비밀번호 재설정 플로우 (이메일 링크 기반)

백엔드:
- User 엔티티에 passwordResetTokenHash/passwordResetExpiresAt(select:false) 추가
- UserService: setPasswordResetToken/findByPasswordResetTokenHash/updatePassword
- MailService: sendPasswordResetEmail (dev=로그 출력, prod=수신자만)
- AuthService: requestPasswordReset(존재 비노출·로컬계정만·1시간 토큰),
  resetPassword(토큰 해시 검증→새 비번 저장→전 세션 폐기)
- POST /auth/forgot-password(3/분)·/auth/reset-password(5/분), 가입과 동일 비번 규칙
- OAuth 콜백의 잔여 /repos 리다이렉트를 /projects 로 교정(리팩터 누락분)

프론트:
- useAuth: forgotPassword/resetPassword
- ForgotPasswordPage(/forgot-password), ResetPasswordPage(/reset-password, 토큰=쿼리)
- LoginPage '비밀번호 찾기' 링크 연결 + 재설정 완료(?reset=1) 배너

런타임 검증: 재설정→새 비번 로그인 200·구 비번 거부·토큰 재사용 거부·약한 비번 VAL_001·
없는 이메일 동일응답(비노출). 백엔드 build/lint 0·14 tests, 프론트 type-check/lint/build 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 20:50:34 +09:00
parent cb6aa07ebc
commit 52b70363d9
12 changed files with 745 additions and 7 deletions
+27 -2
View File
@@ -19,6 +19,8 @@ import { AuthService, type SignupResult } from './auth.service';
import { SignupDto } from './dto/signup.dto';
import { LoginDto } from './dto/login.dto';
import { ResendVerificationDto } from './dto/resend-verification.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { CurrentUser } from './decorators/current-user.decorator';
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
@@ -114,6 +116,29 @@ export class AuthController {
return null;
}
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
// 메일 폭탄/계정 탐색 방지 — IP당 분당 3회
@Throttle({ default: { limit: 3, ttl: 60_000 } })
@ApiOperation({ summary: '비밀번호 재설정 요청 (재설정 메일 발송)' })
@ApiResponse({ status: 200, description: '요청 접수(존재 여부 비노출)' })
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<null> {
await this.authService.requestPasswordReset(dto.email);
return null;
}
@Post('reset-password')
@HttpCode(HttpStatus.OK)
// 토큰 추측 방지 — IP당 분당 5회
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: '비밀번호 재설정 (토큰 + 새 비밀번호)' })
@ApiResponse({ status: 200, description: '비밀번호 변경 성공' })
@ApiResponse({ status: 400, description: '토큰 만료/무효 (BIZ_001)' })
async resetPassword(@Body() dto: ResetPasswordDto): Promise<null> {
await this.authService.resetPassword(dto.token, dto.password);
return null;
}
// 이메일 인증 링크 — 메일의 버튼에서 직접 GET 진입. 검증 후 프론트로 리다이렉트.
@Get('verify-email')
@SkipTransform()
@@ -167,7 +192,7 @@ export class AuthController {
@Res() res: Response,
): Promise<void> {
await this.loginWith(res, req.user as PublicUser);
res.redirect(`${this.webUrl()}/repos`);
res.redirect(`${this.webUrl()}/projects`);
}
// ── 카카오 OAuth ──
@@ -188,7 +213,7 @@ export class AuthController {
@Res() res: Response,
): Promise<void> {
await this.loginWith(res, req.user as PublicUser);
res.redirect(`${this.webUrl()}/repos`);
res.redirect(`${this.webUrl()}/projects`);
}
@Post('logout')
+56
View File
@@ -18,6 +18,8 @@ import type { JwtPayload } from './types/jwt-payload';
const BCRYPT_ROUNDS = 12;
// 이메일 인증 토큰 유효 시간 (24시간)
const VERIFY_TTL_MS = 24 * 60 * 60 * 1000;
// 비밀번호 재설정 토큰 유효 시간 (1시간 — 보안상 짧게)
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
// refresh 세션 만료(=refresh TTL, 정리용). JWT_REFRESH_TTL(기본 14d)과 맞춘다.
const REFRESH_SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000;
// 동시 갱신(재시도) 유예 — 직전 jti 가 이 시간 내 재제출이면 재사용으로 보지 않음
@@ -112,6 +114,42 @@ export class AuthService {
await this.userService.markEmailVerified(user.id);
}
// 비밀번호 재설정 요청 — 존재/상태를 노출하지 않기 위해 항상 동일하게 응답.
// 로컬(이메일) 계정만 실제 재설정 메일 발송(소셜 전용은 비밀번호가 없음).
async requestPasswordReset(email: string): Promise<void> {
const user = await this.userService.findByEmail(email);
if (!user || user.provider !== 'local') {
return;
}
const rawToken = randomBytes(32).toString('hex');
const tokenHash = this.hashToken(rawToken);
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TTL_MS);
await this.userService.setPasswordResetToken(user.id, tokenHash, expiresAt);
const resetUrl = `${this.webUrl()}/reset-password?token=${rawToken}`;
this.mailService.sendPasswordResetEmail(user.email, user.name, resetUrl);
}
// 비밀번호 재설정 — 토큰(원문) 해시로 조회 + 만료 검사 후 새 비밀번호 저장.
// 보안상 비밀번호 변경 시 해당 사용자의 모든 세션을 폐기(전 기기 강제 로그아웃).
async resetPassword(token: string, newPassword: string): Promise<void> {
if (!token) {
throw this.invalidResetLink();
}
const tokenHash = this.hashToken(token);
const user = await this.userService.findByPasswordResetTokenHash(tokenHash);
if (
!user ||
!user.passwordResetExpiresAt ||
user.passwordResetExpiresAt.getTime() < Date.now()
) {
throw this.invalidResetLink();
}
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
await this.userService.updatePassword(user.id, passwordHash);
await this.revokeAllSessions(user.id);
}
// 로그인 검증 — 자격 확인 + 소셜전용/미인증 분기
async validateUser(dto: LoginDto): Promise<PublicUser> {
const user = await this.userService.findByEmailWithPassword(dto.email);
@@ -325,6 +363,24 @@ export class AuthService {
return createHash('sha256').update(raw).digest('hex');
}
// 프론트엔드(웹) 기준 URL — 비밀번호 재설정 폼 링크 대상(컨트롤러 webUrl 과 동일 정책)
private webUrl(): string {
const url =
this.config.get<string>('FRONTEND_URL') ||
this.config.get<string>('CORS_ORIGIN') ||
'http://localhost:5273';
return url.split(',')[0].trim().replace(/\/$/, '');
}
// 재설정 링크 무효/만료 공통 예외
private invalidResetLink(): BusinessException {
return new BusinessException(
'BIZ_001',
'비밀번호 재설정 링크가 만료되었거나 유효하지 않습니다. 다시 요청해 주세요.',
HttpStatus.BAD_REQUEST,
);
}
// 자격 불일치 공통 예외(이메일/비밀번호 구분 없이 동일 문구 — 계정 존재 노출 방지)
private invalidCredentials(): BusinessException {
return new BusinessException(
@@ -0,0 +1,13 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 비밀번호 재설정 요청 DTO — 대상 이메일
export class ForgotPasswordDto {
@ApiProperty({
description: '비밀번호 재설정 메일을 받을 이메일',
example: 'sykim@acme.co',
})
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
}
@@ -0,0 +1,29 @@
import {
IsNotEmpty,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 비밀번호 재설정 DTO — 메일 링크의 토큰 + 새 비밀번호(가입과 동일 규칙)
export class ResetPasswordDto {
@ApiProperty({ description: '재설정 토큰(메일 링크의 token 값)' })
@IsString()
@IsNotEmpty({ message: '재설정 토큰이 필요합니다.' })
token!: string;
@ApiProperty({
description: '새 비밀번호 (8~72자, 영문·숫자 포함)',
example: 'newpass123',
})
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
// bcrypt 는 72바이트 초과분을 잘라내므로 길이를 제한해 혼동을 방지
@MaxLength(72, { message: '비밀번호는 최대 72자까지 가능합니다.' })
@Matches(/(?=.*[A-Za-z])(?=.*\d)/, {
message: '비밀번호는 영문과 숫자를 모두 포함해야 합니다.',
})
password!: string;
}
+25
View File
@@ -33,4 +33,29 @@ export class MailService {
].join('\n'),
);
}
// 비밀번호 재설정 메일 — 개발 환경에서만 재설정 링크(원문 토큰 포함)를 로그로 출력한다.
// 운영 환경에서는 토큰이 로그에 남지 않도록 수신자만 기록한다(실 메일러로 교체 전제).
sendPasswordResetEmail(to: string, name: string, resetUrl: string): void {
const isProd = this.config.get<string>('NODE_ENV') === 'production';
if (isProd) {
this.logger.log(
`[메일 발송 - 비밀번호 재설정] 수신자=${to} (재설정 링크는 보안상 로그에 남기지 않음 — 실 메일러 필요)`,
);
return;
}
this.logger.log(
[
'',
'──────────────── [메일 발송 - 비밀번호 재설정] ────────────────',
`받는 사람 : ${name} <${to}>`,
'제목 : [Relay] 비밀번호 재설정 안내',
'본문 : 아래 링크를 클릭해 새 비밀번호를 설정해 주세요. (1시간 유효)',
`재설정 링크 : ${resetUrl}`,
'안내 : 본인이 요청하지 않았다면 이 메일을 무시하세요.',
'────────────────────────────────────────────────────────────',
'',
].join('\n'),
);
}
}
@@ -54,6 +54,24 @@ export class User {
})
emailVerifyExpiresAt!: Date | null;
// 비밀번호 재설정 토큰 해시(SHA-256) — 원문은 메일 링크에만, DB엔 해시만. 조회 기본 제외.
@Column({
name: 'password_reset_token_hash',
type: 'varchar',
nullable: true,
select: false,
})
passwordResetTokenHash!: string | null;
// 비밀번호 재설정 토큰 만료 시각 — 보안상 짧게(1시간). 조회 기본 제외.
@Column({
name: 'password_reset_expires_at',
type: 'timestamptz',
nullable: true,
select: false,
})
passwordResetExpiresAt!: Date | null;
// 표시 이름
@Column()
name!: string;
+31
View File
@@ -152,6 +152,37 @@ export class UserService implements OnApplicationBootstrap {
});
}
// 비밀번호 재설정 토큰(해시)·만료 시각 저장
async setPasswordResetToken(
userId: string,
tokenHash: string,
expiresAt: Date,
): Promise<void> {
await this.userRepository.update(userId, {
passwordResetTokenHash: tokenHash,
passwordResetExpiresAt: expiresAt,
});
}
// 비밀번호 재설정 토큰 해시로 조회 — 토큰 컬럼은 select:false 라 명시적으로 포함.
findByPasswordResetTokenHash(tokenHash: string): Promise<User | null> {
return this.userRepository
.createQueryBuilder('user')
.addSelect('user.passwordResetTokenHash')
.addSelect('user.passwordResetExpiresAt')
.where('user.passwordResetTokenHash = :tokenHash', { tokenHash })
.getOne();
}
// 비밀번호 변경 — 새 해시 저장 + 재설정 토큰 폐기
async updatePassword(userId: string, passwordHash: string): Promise<void> {
await this.userRepository.update(userId, {
passwordHash,
passwordResetTokenHash: null,
passwordResetExpiresAt: null,
});
}
// 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거)
static toPublic(user: User): PublicUser {
return {