From db6a80a6f4d454b1779c56ebe544993beb950216 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 19 Jun 2026 22:57:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20refresh=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=99=84=EC=A0=84=20=ED=9A=8C=EC=A0=84=20+=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=ED=83=90=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RefreshSession(패밀리) 테이블 도입 — 한 로그인=1행(currentJti/prevJti/expiresAt) - /auth/refresh 회전형으로 교체: 현재 jti→새 jti 회전, 옛 토큰 재제출 시 재사용 탐지→패밀리 폐기(401) - 동시 갱신 오탐 방지: 직전 jti 15초 유예 + 프론트 useApi single-flight refresh - 로그아웃=해당 세션만 폐기, revokeAllSessions(전 기기) 준비 - tokenVersion·jwt-refresh.strategy·JwtRefreshGuard 제거(refresh는 가드 없이 서비스 직접 처리) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/modules/auth/auth.controller.ts | 31 ++-- backend/src/modules/auth/auth.module.ts | 5 +- backend/src/modules/auth/auth.service.ts | 154 +++++++++++++++--- .../auth/entities/refresh-session.entity.ts | 43 +++++ .../modules/auth/guards/jwt-refresh.guard.ts | 6 - .../auth/strategies/jwt-refresh.strategy.ts | 40 ----- backend/src/modules/auth/types/jwt-payload.ts | 6 +- .../src/modules/user/entities/user.entity.ts | 5 - backend/src/modules/user/user.service.ts | 5 - docs/api-contract.md | 3 +- docs/integration-progress.md | 2 +- docs/security-review.md | 4 +- frontend/src/composables/useApi.ts | 18 +- 13 files changed, 220 insertions(+), 102 deletions(-) create mode 100644 backend/src/modules/auth/entities/refresh-session.entity.ts delete mode 100644 backend/src/modules/auth/guards/jwt-refresh.guard.ts delete mode 100644 backend/src/modules/auth/strategies/jwt-refresh.strategy.ts diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index af0a72b..f618e1a 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -20,7 +20,6 @@ import { SignupDto } from './dto/signup.dto'; import { LoginDto } from './dto/login.dto'; import { ResendVerificationDto } from './dto/resend-verification.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; -import { JwtRefreshGuard } from './guards/jwt-refresh.guard'; import { CurrentUser } from './decorators/current-user.decorator'; import { SkipTransform } from '../../common/decorators/skip-transform.decorator'; import type { PublicUser } from '../user/user.service'; @@ -86,9 +85,9 @@ export class AuthController { }); } - // PublicUser 로 토큰 발급 + 쿠키 설정 (공통) + // PublicUser 로 새 세션 토큰 발급 + 쿠키 설정 (로그인/소셜 공통) private async loginWith(res: Response, user: PublicUser): Promise { - const tokens = await this.authService.issueTokens(user); + const tokens = await this.authService.issueTokensForNewSession(user); this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken); } @@ -200,7 +199,7 @@ export class AuthController { @Req() req: Request, @Res({ passthrough: true }) res: Response, ): Promise { - // refresh 토큰 버전을 올려 발급된 refresh 토큰을 서버측에서 무효화(탈취 토큰 재사용 차단) + // 이 세션(패밀리)을 서버측에서 폐기 — 이후 해당 refresh 토큰 재사용 차단 const cookies = req.cookies as Record | undefined; await this.authService.revokeSession(cookies?.refresh_token); this.clearAuthCookies(res); @@ -209,19 +208,29 @@ export class AuthController { @Post('refresh') @HttpCode(HttpStatus.OK) - @UseGuards(JwtRefreshGuard) - @ApiOperation({ summary: 'access 토큰 재발급 (refresh 쿠키 필요)' }) - @ApiResponse({ status: 200, description: '토큰 재발급 성공' }) + @ApiOperation({ summary: 'access/refresh 토큰 회전 (refresh 쿠키 필요)' }) + @ApiResponse({ status: 200, description: '토큰 회전 성공' }) @ApiResponse({ status: 401, - description: 'refresh 토큰 만료/누락 (AUTH_001)', + description: 'refresh 토큰 만료/누락/재사용 (AUTH_001)', }) async refresh( - @CurrentUser() user: PublicUser, + @Req() req: Request, @Res({ passthrough: true }) res: Response, ): Promise { - await this.loginWith(res, user); - return null; + const cookies = req.cookies as Record | undefined; + try { + // 회전형 refresh — 재사용 탐지 시 401(AUTH_001), 정상 시 새 토큰 쌍으로 교체 + const tokens = await this.authService.rotateTokens( + cookies?.refresh_token, + ); + this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken); + return null; + } catch (e) { + // 폐기/무효 토큰이면 더 이상 보내지 않도록 쿠키 제거 + this.clearAuthCookies(res); + throw e; + } } @Get('me') diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 50f905a..4ecc63e 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -1,13 +1,14 @@ import { Logger, Module, type Provider } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigService } from '@nestjs/config'; import { UserModule } from '../user/user.module'; import { MailModule } from '../mail/mail.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { RefreshSession } from './entities/refresh-session.entity'; import { JwtStrategy } from './strategies/jwt.strategy'; -import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy'; import { GoogleStrategy } from './strategies/google.strategy'; import { KakaoStrategy } from './strategies/kakao.strategy'; @@ -47,6 +48,7 @@ const kakaoStrategyProvider: Provider = { UserModule, MailModule, PassportModule, + TypeOrmModule.forFeature([RefreshSession]), // 토큰 서명 옵션은 AuthService 에서 호출 단위로 지정하므로 기본 등록만 한다 JwtModule.register({}), ], @@ -54,7 +56,6 @@ const kakaoStrategyProvider: Provider = { providers: [ AuthService, JwtStrategy, - JwtRefreshStrategy, googleStrategyProvider, kakaoStrategyProvider, ], diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 5cccbae..45ebce9 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -1,11 +1,14 @@ -import { HttpStatus, Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService, type JwtSignOptions } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; -import { createHash, randomBytes } from 'crypto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { createHash, randomBytes, randomUUID } from 'crypto'; import * as bcrypt from 'bcryptjs'; import { UserService, type PublicUser } from '../user/user.service'; import type { User } from '../user/entities/user.entity'; import { MailService } from '../mail/mail.service'; +import { RefreshSession } from './entities/refresh-session.entity'; import { BusinessException } from '../../common/exceptions/business.exception'; import { SignupDto } from './dto/signup.dto'; import { LoginDto } from './dto/login.dto'; @@ -15,6 +18,10 @@ import type { JwtPayload } from './types/jwt-payload'; const BCRYPT_ROUNDS = 12; // 이메일 인증 토큰 유효 시간 (24시간) const VERIFY_TTL_MS = 24 * 60 * 60 * 1000; +// refresh 세션 만료(=refresh TTL, 정리용). JWT_REFRESH_TTL(기본 14d)과 맞춘다. +const REFRESH_SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000; +// 동시 갱신(재시도) 유예 — 직전 jti 가 이 시간 내 재제출이면 재사용으로 보지 않음 +const REFRESH_GRACE_MS = 15 * 1000; // 발급된 토큰 쌍 export interface TokenPair { @@ -46,6 +53,8 @@ export class AuthService { private readonly jwtService: JwtService, private readonly config: ConfigService, private readonly mailService: MailService, + @InjectRepository(RefreshSession) + private readonly refreshRepo: Repository, ) {} // 회원가입 — 이메일 중복 검사 후 미인증 계정 생성 + 인증 메일 발송(자동 로그인 X) @@ -164,31 +173,89 @@ export class AuthService { return UserService.toPublic(user); } - // access/refresh 토큰 발급 — refresh 에는 현재 tokenVersion 을 함께 서명(폐기 검증용) - async issueTokens(user: PublicUser): Promise { - const dbUser = await this.userService.findById(user.id); - const ver = dbUser?.tokenVersion ?? 0; - const accessToken = await this.jwtService.signAsync( - { sub: user.id, email: user.email }, - { - secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), - expiresIn: (this.config.get('JWT_ACCESS_TTL') ?? - '15m') as JwtSignOptions['expiresIn'], - }, + // 로그인/소셜/가입 등 — 새 세션(패밀리)을 만들고 access/refresh 토큰 발급 + async issueTokensForNewSession(user: PublicUser): Promise { + // 만료된 이 사용자의 세션 정리(누적 방지) + await this.refreshRepo + .createQueryBuilder() + .delete() + .where('user_id = :userId', { userId: user.id }) + .andWhere('expires_at < :now', { now: new Date() }) + .execute(); + + const jti = randomUUID(); + const session = await this.refreshRepo.save( + this.refreshRepo.create({ + user: { id: user.id }, + currentJti: jti, + prevJti: null, + prevAt: null, + expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS), + }), ); - const refreshToken = await this.jwtService.signAsync( - { sub: user.id, ver }, - { - secret: this.config.getOrThrow('JWT_REFRESH_SECRET'), - expiresIn: (this.config.get('JWT_REFRESH_TTL') ?? - '14d') as JwtSignOptions['expiresIn'], - }, - ); - return { accessToken, refreshToken }; + return this.signTokens(user.id, user.email, session.id, jti); } - // 세션 폐기 — refresh 토큰을 검증해 사용자의 tokenVersion 을 증가(발급된 모든 refresh 무효화). - // 로그아웃 시 호출. 무효/만료 토큰이면 폐기할 세션이 없으므로 조용히 무시한다. + // refresh 회전 — 현재 토큰이면 새 jti 로 회전, 직전 토큰이면 유예 내 양성으로 처리, + // 그 외(이미 회전된/위조 토큰 재사용)면 탈취로 간주해 패밀리 폐기 후 401. + async rotateTokens(refreshToken: string | undefined): Promise { + if (!refreshToken) throw new UnauthorizedException(); + let payload: JwtPayload; + try { + payload = await this.jwtService.verifyAsync(refreshToken, { + secret: this.config.getOrThrow('JWT_REFRESH_SECRET'), + }); + } catch { + throw new UnauthorizedException(); + } + if (!payload.sub || !payload.fid || !payload.jti) { + throw new UnauthorizedException(); + } + + const session = await this.refreshRepo.findOne({ + where: { id: payload.fid }, + relations: ['user'], + }); + if (!session || session.user?.id !== payload.sub) { + throw new UnauthorizedException(); + } + + // 1) 정상 회전 — 현재 jti 일치 → 새 jti 발급, 직전 jti 기록 + if (payload.jti === session.currentJti) { + const newJti = randomUUID(); + session.prevJti = session.currentJti; + session.prevAt = new Date(); + session.currentJti = newJti; + session.expiresAt = new Date(Date.now() + REFRESH_SESSION_TTL_MS); + await this.refreshRepo.save(session); + return this.signTokens( + session.user.id, + session.user.email, + session.id, + newJti, + ); + } + + // 2) 동시 갱신(재시도) 유예 — 직전 jti 가 유예 시간 내면 양성으로 보고 현재 토큰 재발급(추가 회전 X) + if ( + payload.jti === session.prevJti && + session.prevAt && + Date.now() - session.prevAt.getTime() < REFRESH_GRACE_MS + ) { + return this.signTokens( + session.user.id, + session.user.email, + session.id, + session.currentJti, + ); + } + + // 3) 그 외 = 재사용 탐지 → 패밀리 전체 폐기(공격자·피해자 모두 강제 로그아웃) + await this.refreshRepo.delete({ id: session.id }); + throw new UnauthorizedException(); + } + + // 로그아웃 — refresh 토큰의 세션(패밀리)만 폐기. 무효 토큰이면 조용히 무시. async revokeSession(refreshToken: string | undefined): Promise { if (!refreshToken) return; try { @@ -196,14 +263,49 @@ export class AuthService { refreshToken, { secret: this.config.getOrThrow('JWT_REFRESH_SECRET') }, ); - if (payload?.sub) { - await this.userService.incrementTokenVersion(payload.sub); + if (payload?.fid) { + await this.refreshRepo.delete({ id: payload.fid }); } } catch { // 무효/만료 refresh 토큰 — 무시 } } + // 사용자의 모든 세션 폐기 — 비밀번호 변경/계정 보안 시 호출(전 기기 강제 로그아웃). 현재 미사용. + async revokeAllSessions(userId: string): Promise { + await this.refreshRepo + .createQueryBuilder() + .delete() + .where('user_id = :userId', { userId }) + .execute(); + } + + // access/refresh 토큰 서명 — refresh 에는 세션 fid + 현재 jti 를 담는다 + private async signTokens( + sub: string, + email: string | undefined, + fid: string, + jti: string, + ): Promise { + const accessToken = await this.jwtService.signAsync( + { sub, email }, + { + secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), + expiresIn: (this.config.get('JWT_ACCESS_TTL') ?? + '15m') as JwtSignOptions['expiresIn'], + }, + ); + const refreshToken = await this.jwtService.signAsync( + { sub, fid, jti }, + { + secret: this.config.getOrThrow('JWT_REFRESH_SECRET'), + expiresIn: (this.config.get('JWT_REFRESH_TTL') ?? + '14d') as JwtSignOptions['expiresIn'], + }, + ); + return { accessToken, refreshToken }; + } + // 인증 토큰 생성·저장 후 인증 메일(현재 로그) 발송 private async sendVerification(user: User): Promise { const rawToken = randomBytes(32).toString('hex'); diff --git a/backend/src/modules/auth/entities/refresh-session.entity.ts b/backend/src/modules/auth/entities/refresh-session.entity.ts new file mode 100644 index 0000000..d26143f --- /dev/null +++ b/backend/src/modules/auth/entities/refresh-session.entity.ts @@ -0,0 +1,43 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + type Relation, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; + +// refresh 토큰 세션(패밀리) — 회전형 refresh 토큰 + 재사용 탐지의 단위. +// 한 로그인(기기)당 1행. id=패밀리 식별자(fid), currentJti=현재 유효한 refresh 토큰 표식. +// 갱신 시 currentJti 를 새로 발급하고, 이미 회전된(옛) 토큰이 다시 제출되면 탈취로 간주해 +// 행을 삭제(패밀리 폐기)한다. 동시 갱신(재시도) 오탐 방지를 위해 직전 jti 를 짧은 유예 동안 허용. +@Entity('refresh_sessions') +export class RefreshSession { + // 패밀리 식별자(fid) — refresh JWT payload 에 담긴다 + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false }) + @JoinColumn({ name: 'user_id' }) + user!: Relation; + + // 현재 유효한 refresh 토큰의 jti + @Column({ name: 'current_jti', type: 'uuid' }) + currentJti!: string; + + // 직전 jti + 회전 시각 — 동시 갱신(재시도) 유예 판정용 + @Column({ name: 'prev_jti', type: 'uuid', nullable: true }) + prevJti!: string | null; + + @Column({ name: 'prev_at', type: 'timestamptz', nullable: true }) + prevAt!: Date | null; + + // 세션 만료(refresh TTL) — 만료분 정리용 + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt!: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/auth/guards/jwt-refresh.guard.ts b/backend/src/modules/auth/guards/jwt-refresh.guard.ts deleted file mode 100644 index e176da7..0000000 --- a/backend/src/modules/auth/guards/jwt-refresh.guard.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; - -// refresh 토큰 기반 가드 — /auth/refresh 전용 -@Injectable() -export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {} diff --git a/backend/src/modules/auth/strategies/jwt-refresh.strategy.ts b/backend/src/modules/auth/strategies/jwt-refresh.strategy.ts deleted file mode 100644 index 18488d9..0000000 --- a/backend/src/modules/auth/strategies/jwt-refresh.strategy.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { Strategy } from 'passport-jwt'; -import { ConfigService } from '@nestjs/config'; -import type { Request } from 'express'; -import { UserService, type PublicUser } from '../../user/user.service'; -import type { JwtPayload } from '../types/jwt-payload'; - -// refresh 토큰 검증 전략 — refresh_token 쿠키에서 토큰 추출 -@Injectable() -export class JwtRefreshStrategy extends PassportStrategy( - Strategy, - 'jwt-refresh', -) { - constructor( - config: ConfigService, - private readonly userService: UserService, - ) { - super({ - jwtFromRequest: (req: Request): string | null => - (req?.cookies as Record | undefined)?.refresh_token ?? - null, - ignoreExpiration: false, - secretOrKey: config.getOrThrow('JWT_REFRESH_SECRET'), - }); - } - - async validate(payload: JwtPayload): Promise { - const user = await this.userService.findById(payload.sub); - if (!user) { - throw new UnauthorizedException(); - } - // 토큰 버전 불일치 = 로그아웃/비밀번호 변경으로 폐기된 토큰 → 거부. - // (버전 미포함 구토큰은 0 으로 간주해 기존 세션 호환) - if ((payload.ver ?? 0) !== user.tokenVersion) { - throw new UnauthorizedException(); - } - return UserService.toPublic(user); - } -} diff --git a/backend/src/modules/auth/types/jwt-payload.ts b/backend/src/modules/auth/types/jwt-payload.ts index 7f4a9aa..a90574a 100644 --- a/backend/src/modules/auth/types/jwt-payload.ts +++ b/backend/src/modules/auth/types/jwt-payload.ts @@ -4,6 +4,8 @@ export interface JwtPayload { sub: string; // 이메일 (access 토큰에만 포함) email?: string; - // refresh 토큰 버전 (refresh 토큰에만 포함) — User.tokenVersion 과 일치해야 유효 - ver?: number; + // 세션(패밀리) id (refresh 토큰에만 포함) + fid?: string; + // 현재 토큰 표식 jti (refresh 토큰에만 포함) — RefreshSession.currentJti 와 일치해야 유효 + jti?: string; } diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index 037f2da..cf7ed7c 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -66,11 +66,6 @@ export class User { @Column({ name: 'avatar_url', type: 'varchar', nullable: true }) avatarUrl!: string | null; - // refresh 토큰 버전 — refresh JWT payload 에 함께 서명되며, 로그아웃·비밀번호 변경 시 증가시켜 - // 발급된 모든 refresh 토큰을 일괄 무효화한다(서버측 세션 폐기). - @Column({ name: 'token_version', type: 'int', default: 0 }) - tokenVersion!: number; - @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 1ead373..59bf1a7 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -152,11 +152,6 @@ export class UserService implements OnApplicationBootstrap { }); } - // refresh 토큰 버전 증가 — 발급된 모든 refresh 토큰 무효화(로그아웃·비밀번호 변경 시) - async incrementTokenVersion(userId: string): Promise { - await this.userRepository.increment({ id: userId }, 'tokenVersion', 1); - } - // 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거) static toPublic(user: User): PublicUser { return { diff --git a/docs/api-contract.md b/docs/api-contract.md index a98e562..6e0b85b 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -66,7 +66,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 | GET | `/auth/google` · `/auth/kakao` | — | 302 redirect | 소셜 동의 화면으로(전체 페이지 이동) | | GET | `/auth/google/callback` · `/auth/kakao/callback` | — | 302 redirect | 쿠키 set 후 `FRONTEND_URL/repos` 로 | | POST | `/auth/logout` | — | `null` | 쿠키 clear | -| POST | `/auth/refresh` | — (refresh 쿠키) | `null` | access 쿠키 재발급 | +| POST | `/auth/refresh` | — (refresh 쿠키) | `null` | **회전형** — access/refresh 모두 교체. 재사용/만료/누락 시 401(쿠키 제거) | | GET | `/auth/me` | — | `User` | 부트스트랩/가드용. 미인증 401 | `User` 응답 형태: @@ -87,6 +87,7 @@ status 매핑보다 우선해 그대로 표준 응답으로 내려보내고, 프 - **토큰**: 원문 토큰은 메일 링크에만, DB엔 SHA-256 해시 + 만료(24h)만 저장. 재발송은 사용자 존재/상태를 노출하지 않는다. - **소셜 로그인**(Google/Kakao): 프론트가 `/auth/{provider}` 로 전체 페이지 이동 → 콜백에서 이메일로 계정 매칭(없으면 생성, `emailVerified=true`) → 쿠키 발급 후 `/repos` 로. 키(`*_CLIENT_ID`) 미설정 시 해당 제공자 비활성. - **계정 모델**: `provider`('local'|'google'|'kakao'), `emailVerified`. 소셜 전용 계정은 비밀번호가 없어(`passwordHash=null`) 비밀번호 로그인 시 소셜 안내. 인증 도입 이전 로컬 계정은 부팅 시 그랜드페더링(토큰 없는 로컬 계정 → 인증 완료). +- **세션/토큰 회전**: refresh 는 **회전형 + 재사용 탐지**. `refresh_sessions`(패밀리 단위, 한 로그인=1행) 에 현재 jti 를 두고, `/auth/refresh` 마다 새 jti 로 회전. 이미 회전된 옛 토큰이 다시 오면 탈취로 간주해 패밀리 폐기(전 세션 강제 로그아웃). 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 single-flight(`useApi`)로 방지. logout=해당 패밀리만 폐기. (access 토큰은 15분 만료까지 유효 — 즉시 폐기 denylist 미적용.) - **환경변수**: `APP_API_URL`(메일 링크용 백엔드 주소), `FRONTEND_URL`(리다이렉트 대상), `GOOGLE_*`/`KAKAO_*`(OAuth 키·콜백). `.env.development.example` 참조. --- diff --git a/docs/integration-progress.md b/docs/integration-progress.md index ade847a..cdb77c7 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -293,7 +293,7 @@ - **H1** OAuth 제공자 이메일 검증 미확인(계정 탈취/선점) → 전략이 `email_verified`/`is_email_verified` 를 읽어 전달, 미검증 시 403 거부. - **H2** OAuth `state` 누락(로그인 CSRF) → 세션 없는 구조라 `CookieStateStore`(httpOnly 1회용 state 쿠키) 신설, 두 전략에 주입(새 의존성 0). -- **H3** refresh 회전/폐기 부재 → `User.tokenVersion` + refresh payload `ver` 검증, **logout 이 tokenVersion 증가로 발급 refresh 일괄 폐기**(구토큰 0 호환). +- **H3** refresh 회전/폐기 부재 → (초기) `User.tokenVersion` 폐기 → **(강화) 세션 기반 완전 회전 + 재사용 탐지로 교체**. `RefreshSession`(패밀리, 한 로그인=1행: id/currentJti/prevJti+prevAt/expiresAt) 도입. `/auth/refresh` 가 현재 jti면 새 jti로 회전, 옛 토큰 재제출이면 **재사용 탐지 → 패밀리 폐기(401)**. 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 `useApi` single-flight 로 방지. logout=패밀리 폐기, `revokeAllSessions`(전 기기) 준비. `tokenVersion`·`jwt-refresh.strategy`·`JwtRefreshGuard` 제거(refresh 는 가드 없이 서비스 직접 처리). - **M1** 업로드 MIME 화이트리스트(`fileFilter`, html/svg 차단)+거부 400, 다운로드 `attachment`+`nosniff`(저장형 XSS 차단). - **M2** `@Throttle` 라우트별(login 10·signup 5·resend 3 /분). - **M3** 첨부 삭제 업로더/admin 한정. diff --git a/docs/security-review.md b/docs/security-review.md index 3e99b9e..aafee2d 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -88,7 +88,7 @@ IDOR/교차 저장소 위조 불가, 알림 타인 접근 불가, mass-assignmen - **H1 ✅** 각 전략이 제공자 이메일 검증 플래그를 읽어 `OAuthProfile.emailVerified` 전달, `validateOrCreateOAuthUser` 가 미검증이면 403 거부(검증 이메일일 때만 매칭/생성/미인증 로컬 승격). Google `_json.email_verified`, Kakao `kakao_account.is_email_verified`. - **H2 ✅** 세션 없는 구조라 `CookieStateStore`(passport-oauth2 store 인터페이스) 신설 — 인가 시작 시 httpOnly 1회용 난수 state 쿠키 발급, 콜백에서 일치 검증. 두 전략에 `store` 주입(새 의존성 0). *런타임은 실제 제공자 콜백으로만 최종 확인 가능.* -- **H3 ✅** `User.tokenVersion`(int) 추가, refresh JWT payload 에 `ver` 서명, `jwt-refresh.strategy` 가 `ver===tokenVersion` 검증(구토큰은 0 호환). **logout 이 `revokeSession`(refresh 검증 후 tokenVersion 증가)** 으로 발급된 refresh 일괄 폐기. (완전 회전+재사용탐지는 후속.) +- **H3 ✅ → 완전 회전+재사용 탐지로 강화(2026-06-19)**: 초기엔 `tokenVersion` 폐기 수준이었으나, **세션(패밀리) 테이블 기반 회전형 refresh** 로 교체. `RefreshSession`(id=fid, currentJti, prevJti+prevAt, expiresAt) 한 로그인당 1행. `/auth/refresh` 가 현재 jti면 새 jti로 **회전**, 이미 회전된 옛 토큰 재제출이면 **재사용 탐지 → 패밀리 전체 폐기(401)**. 동시 갱신 오탐은 직전 jti 15초 유예 + 프론트 single-flight로 방지. logout=해당 패밀리만 폐기, `revokeAllSessions`(전 기기) 메서드 준비(비번 재설정 시 사용). `tokenVersion`/`jwt-refresh.strategy`/`JwtRefreshGuard` 제거(refresh는 가드 없이 서비스에서 직접 처리). - **M1 ✅** 업로드 `fileFilter` MIME 화이트리스트(html/svg 등 차단) + 거부 시 400, 다운로드 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`. - **M2 ✅** `@Throttle` 라우트별 — login 10/분, signup 5/분, resend 3/분(IP 기준). 계정 잠금/CAPTCHA·이메일별 쿨다운은 후속. - **M3 ✅** `removeFile` 가 업로더 본인 또는 admin 만 허용(`getAttachmentOrThrow` 에 uploader 로드). @@ -110,4 +110,4 @@ IDOR/교차 저장소 위조 불가, 알림 타인 접근 불가, mass-assignmen **검증**: 백엔드 build/lint 0 · 20 tests. 프론트 변경 없음. -**남은 항목**: L1(에이전트 v-html, 에이전트 작업 시 함께) + §2 후속 강화(토큰 완전회전·계정잠금/CAPTCHA·M5) + §4 기능공백(비번 재설정·SMTP·파일 콘텐츠 검증). **런타임 미검증**: H1/H2/H3 OAuth·세션 경로는 실제 제공자 콜백 + DB 로 최종 확인 필요. +**남은 항목**: L1(에이전트 v-html, 에이전트 작업 시 함께) + §2 후속 강화(~~토큰 완전회전~~ **완료** / 계정잠금·CAPTCHA / M5) + §4 기능공백(비번 재설정·SMTP·파일 콘텐츠 검증). access 토큰 즉시 폐기(denylist)는 여전히 미적용(15분 잔존, 수용). **런타임 미검증**: H1/H2/H3 OAuth·세션 경로는 실제 제공자 콜백 + DB 로 최종 확인 필요(특히 회전형 refresh의 동시성·재사용 탐지 동작). diff --git a/frontend/src/composables/useApi.ts b/frontend/src/composables/useApi.ts index 4499d7e..78c26df 100644 --- a/frontend/src/composables/useApi.ts +++ b/frontend/src/composables/useApi.ts @@ -67,6 +67,21 @@ api.interceptors.request.use( // 인증 만료 응답으로 판단할지 — /auth/* 자체 호출은 재시도 대상에서 제외 const isAuthEndpoint = (url?: string): boolean => (url ?? '').includes('/auth/') +// refresh 단일 실행(single-flight) — 동시 401 이 각자 refresh 를 호출하면 회전형 토큰이 +// 재사용으로 오탐되어 세션이 폐기될 수 있다. 진행 중 refresh 가 있으면 그 Promise 를 공유한다. +let refreshInFlight: Promise | null = null +const refreshOnce = (): Promise => { + if (!refreshInFlight) { + refreshInFlight = api + .post('/auth/refresh', null, { silent: true }) + .then(() => undefined) + .finally(() => { + refreshInFlight = null + }) + } + return refreshInFlight +} + // 응답 인터셉터 — Global Response Wrapper 언래핑 + 401 자동 refresh + 에러코드 매핑 api.interceptors.response.use( (response: AxiosResponse) => { @@ -89,10 +104,11 @@ api.interceptors.response.use( const status: number | undefined = error?.response?.status // access 토큰 만료(401) → refresh 1회 시도 후 원요청 재시도 + // (동시 401 은 single-flight 로 묶어 refresh 토큰 회전이 한 번만 일어나게 한다) if (status === 401 && config && !config._retry && !isAuthEndpoint(config.url)) { config._retry = true try { - await api.post('/auth/refresh', null, { silent: true }) + await refreshOnce() return api(config) } catch { // refresh 실패 → 세션 만료로 간주, 아래 일반 에러 처리로 진행