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:
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user