feat: refresh 토큰 완전 회전 + 재사용 탐지

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 22:57:01 +09:00
parent df268d3b5e
commit db6a80a6f4
13 changed files with 220 additions and 102 deletions
+20 -11
View File
@@ -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<void> {
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<null> {
// refresh 토큰 버전을 올려 발급된 refresh 토큰을 서버측에서 무효화(탈취 토큰 재사용 차단)
// 이 세션(패밀리)을 서버측에서 폐기 — 이후 해당 refresh 토큰 재사용 차단
const cookies = req.cookies as Record<string, string> | 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<null> {
await this.loginWith(res, user);
return null;
const cookies = req.cookies as Record<string, string> | 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')
+3 -2
View File
@@ -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,
],
+128 -26
View File
@@ -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<RefreshSession>,
) {}
// 회원가입 — 이메일 중복 검사 후 미인증 계정 생성 + 인증 메일 발송(자동 로그인 X)
@@ -164,31 +173,89 @@ export class AuthService {
return UserService.toPublic(user);
}
// access/refresh 토큰 발급 — refresh 에는 현재 tokenVersion 을 함께 서명(폐기 검증용)
async issueTokens(user: PublicUser): Promise<TokenPair> {
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<string>('JWT_ACCESS_SECRET'),
expiresIn: (this.config.get<string>('JWT_ACCESS_TTL') ??
'15m') as JwtSignOptions['expiresIn'],
},
// 로그인/소셜/가입 등 — 새 세션(패밀리)을 만들고 access/refresh 토큰 발급
async issueTokensForNewSession(user: PublicUser): Promise<TokenPair> {
// 만료된 이 사용자의 세션 정리(누적 방지)
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<string>('JWT_REFRESH_SECRET'),
expiresIn: (this.config.get<string>('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<TokenPair> {
if (!refreshToken) throw new UnauthorizedException();
let payload: JwtPayload;
try {
payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
secret: this.config.getOrThrow<string>('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<void> {
if (!refreshToken) return;
try {
@@ -196,14 +263,49 @@ export class AuthService {
refreshToken,
{ secret: this.config.getOrThrow<string>('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<void> {
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<TokenPair> {
const accessToken = await this.jwtService.signAsync(
{ sub, email },
{
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: (this.config.get<string>('JWT_ACCESS_TTL') ??
'15m') as JwtSignOptions['expiresIn'],
},
);
const refreshToken = await this.jwtService.signAsync(
{ sub, fid, jti },
{
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
expiresIn: (this.config.get<string>('JWT_REFRESH_TTL') ??
'14d') as JwtSignOptions['expiresIn'],
},
);
return { accessToken, refreshToken };
}
// 인증 토큰 생성·저장 후 인증 메일(현재 로그) 발송
private async sendVerification(user: User): Promise<void> {
const rawToken = randomBytes(32).toString('hex');
@@ -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<User>;
// 현재 유효한 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;
}
@@ -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') {}
@@ -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<string, string> | undefined)?.refresh_token ??
null,
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_REFRESH_SECRET'),
});
}
async validate(payload: JwtPayload): Promise<PublicUser> {
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);
}
}
@@ -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;
}
@@ -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;
-5
View File
@@ -152,11 +152,6 @@ export class UserService implements OnApplicationBootstrap {
});
}
// refresh 토큰 버전 증가 — 발급된 모든 refresh 토큰 무효화(로그아웃·비밀번호 변경 시)
async incrementTokenVersion(userId: string): Promise<void> {
await this.userRepository.increment({ id: userId }, 'tokenVersion', 1);
}
// 엔티티 → 외부 노출용 형태로 변환 (민감정보 제거)
static toPublic(user: User): PublicUser {
return {