From 52b70363d96e10db7bc1ea48ed83a2a919f3cb5a Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 20 Jun 2026 20:50:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EC=A0=95=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20(?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EB=A7=81=ED=81=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드: - 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) --- backend/src/modules/auth/auth.controller.ts | 29 +- backend/src/modules/auth/auth.service.ts | 56 ++++ .../modules/auth/dto/forgot-password.dto.ts | 13 + .../modules/auth/dto/reset-password.dto.ts | 29 ++ backend/src/modules/mail/mail.service.ts | 25 ++ .../src/modules/user/entities/user.entity.ts | 18 ++ backend/src/modules/user/user.service.ts | 31 ++ frontend/src/composables/useAuth.ts | 20 +- .../src/pages/relay/ForgotPasswordPage.vue | 215 +++++++++++++ frontend/src/pages/relay/LoginPage.vue | 12 +- .../src/pages/relay/ResetPasswordPage.vue | 292 ++++++++++++++++++ frontend/src/router/index.ts | 12 + 12 files changed, 745 insertions(+), 7 deletions(-) create mode 100644 backend/src/modules/auth/dto/forgot-password.dto.ts create mode 100644 backend/src/modules/auth/dto/reset-password.dto.ts create mode 100644 frontend/src/pages/relay/ForgotPasswordPage.vue create mode 100644 frontend/src/pages/relay/ResetPasswordPage.vue diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index f618e1a..8cfbb5e 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -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 { + 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 { + 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 { 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 { await this.loginWith(res, req.user as PublicUser); - res.redirect(`${this.webUrl()}/repos`); + res.redirect(`${this.webUrl()}/projects`); } @Post('logout') diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 45ebce9..a42a12d 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -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 { + 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 { + 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 { 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('FRONTEND_URL') || + this.config.get('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( diff --git a/backend/src/modules/auth/dto/forgot-password.dto.ts b/backend/src/modules/auth/dto/forgot-password.dto.ts new file mode 100644 index 0000000..d9dca81 --- /dev/null +++ b/backend/src/modules/auth/dto/forgot-password.dto.ts @@ -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; +} diff --git a/backend/src/modules/auth/dto/reset-password.dto.ts b/backend/src/modules/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..cfe2184 --- /dev/null +++ b/backend/src/modules/auth/dto/reset-password.dto.ts @@ -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; +} diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index b865a8b..bddba04 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -33,4 +33,29 @@ export class MailService { ].join('\n'), ); } + + // 비밀번호 재설정 메일 — 개발 환경에서만 재설정 링크(원문 토큰 포함)를 로그로 출력한다. + // 운영 환경에서는 토큰이 로그에 남지 않도록 수신자만 기록한다(실 메일러로 교체 전제). + sendPasswordResetEmail(to: string, name: string, resetUrl: string): void { + const isProd = this.config.get('NODE_ENV') === 'production'; + if (isProd) { + this.logger.log( + `[메일 발송 - 비밀번호 재설정] 수신자=${to} (재설정 링크는 보안상 로그에 남기지 않음 — 실 메일러 필요)`, + ); + return; + } + this.logger.log( + [ + '', + '──────────────── [메일 발송 - 비밀번호 재설정] ────────────────', + `받는 사람 : ${name} <${to}>`, + '제목 : [Relay] 비밀번호 재설정 안내', + '본문 : 아래 링크를 클릭해 새 비밀번호를 설정해 주세요. (1시간 유효)', + `재설정 링크 : ${resetUrl}`, + '안내 : 본인이 요청하지 않았다면 이 메일을 무시하세요.', + '────────────────────────────────────────────────────────────', + '', + ].join('\n'), + ); + } } diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index cf7ed7c..6aa7b9b 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -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; diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 59bf1a7..26d7fd7 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -152,6 +152,37 @@ export class UserService implements OnApplicationBootstrap { }); } + // 비밀번호 재설정 토큰(해시)·만료 시각 저장 + async setPasswordResetToken( + userId: string, + tokenHash: string, + expiresAt: Date, + ): Promise { + await this.userRepository.update(userId, { + passwordResetTokenHash: tokenHash, + passwordResetExpiresAt: expiresAt, + }); + } + + // 비밀번호 재설정 토큰 해시로 조회 — 토큰 컬럼은 select:false 라 명시적으로 포함. + findByPasswordResetTokenHash(tokenHash: string): Promise { + return this.userRepository + .createQueryBuilder('user') + .addSelect('user.passwordResetTokenHash') + .addSelect('user.passwordResetExpiresAt') + .where('user.passwordResetTokenHash = :tokenHash', { tokenHash }) + .getOne(); + } + + // 비밀번호 변경 — 새 해시 저장 + 재설정 토큰 폐기 + async updatePassword(userId: string, passwordHash: string): Promise { + await this.userRepository.update(userId, { + passwordHash, + passwordResetTokenHash: null, + passwordResetExpiresAt: null, + }); + } + // 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거) static toPublic(user: User): PublicUser { return { diff --git a/frontend/src/composables/useAuth.ts b/frontend/src/composables/useAuth.ts index e02759d..b4341a5 100644 --- a/frontend/src/composables/useAuth.ts +++ b/frontend/src/composables/useAuth.ts @@ -20,6 +20,16 @@ export function useAuth() { await api.post('/auth/resend-verification', { email }) } + // 비밀번호 재설정 요청 — 재설정 메일 발송(존재 여부 비노출) + async function forgotPassword(email: string): Promise { + await api.post('/auth/forgot-password', { email }) + } + + // 비밀번호 재설정 — 메일 링크의 토큰 + 새 비밀번호 + async function resetPassword(token: string, password: string): Promise { + await api.post('/auth/reset-password', { token, password }) + } + // 로그인 async function login(payload: LoginPayload): Promise { return (await api.post('/auth/login', payload)) as unknown as AuthUser @@ -36,5 +46,13 @@ export function useAuth() { return (await api.get('/auth/me', { silent: true })) as unknown as AuthUser } - return { signup, resendVerification, login, logout, fetchMe } + return { + signup, + resendVerification, + forgotPassword, + resetPassword, + login, + logout, + fetchMe, + } } diff --git a/frontend/src/pages/relay/ForgotPasswordPage.vue b/frontend/src/pages/relay/ForgotPasswordPage.vue new file mode 100644 index 0000000..cfcc280 --- /dev/null +++ b/frontend/src/pages/relay/ForgotPasswordPage.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/frontend/src/pages/relay/LoginPage.vue b/frontend/src/pages/relay/LoginPage.vue index 4d99c5f..8d3a29b 100644 --- a/frontend/src/pages/relay/LoginPage.vue +++ b/frontend/src/pages/relay/LoginPage.vue @@ -15,8 +15,10 @@ const showPassword = ref(false) const keepLogin = ref(true) const submitting = ref(false) -// 이메일 인증 결과 배너 — 백엔드 인증 링크가 /login?verified=1|0 으로 리다이렉트한다 +// 상단 안내 배너 — 이메일 인증(/login?verified=1|0) 또는 비밀번호 재설정 완료(/login?reset=1) const verifyBanner = computed<{ ok: boolean; text: string } | null>(() => { + if (route.query.reset === '1') + return { ok: true, text: '비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요.' } const v = route.query.verified if (v === '1') return { ok: true, text: '이메일 인증이 완료되었습니다. 로그인해 주세요.' } if (v === '0') @@ -288,10 +290,12 @@ const points = [ 로그인 상태 유지 - 비밀번호 찾기 + to="/forgot-password" + > + 비밀번호 찾기 +