fix: 토큰 갱신/로그인 유지(keepLogin) 정상화
세션이 짧게 유지되고 '로그인 유지'가 동작하지 않던 문제 수정. 원인1(프론트): 401 자동 refresh 가 '/auth/' 포함 URL을 모두 제외해, 부트스트랩 /auth/me 가 access 만료(15분)로 401 이어도 refresh 를 시도하지 않아 새로고침/재방문 시 로그아웃됨. - useApi 인터셉터: refresh-재시도 제외 대상을 /auth/refresh·/auth/login 으로만 한정 → /auth/me 등은 access 만료 시 refresh 후 재시도되어 세션 복원 원인2(백엔드): keepLogin 값이 무시되어 쿠키 영속 여부에 반영되지 않음. - RefreshSession 에 persistent 플래그 추가(마이그레이션, default true) - 로그인 시 keepLogin 저장, refresh 회전 시에도 그대로 유지 - setAuthCookies: persistent=true 면 maxAge(영속), false 면 세션 쿠키 - 소셜 로그인은 체크박스가 없어 영속(true) 기본 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 로그인 유지(keepLogin) — refresh 세션에 persistent 플래그 추가.
|
||||
// true(기본): 영속 쿠키(refresh TTL), false: 세션 쿠키(브라우저 종료 시 만료).
|
||||
// 기존 세션은 기존 동작(영속) 유지를 위해 default true.
|
||||
export class AddRefreshSessionPersistent1782400000000 implements MigrationInterface {
|
||||
name = 'AddRefreshSessionPersistent1782400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "refresh_sessions" ADD "persistent" boolean NOT NULL DEFAULT true`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "refresh_sessions" DROP COLUMN "persistent"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -60,21 +60,23 @@ export class AuthController {
|
||||
};
|
||||
}
|
||||
|
||||
// access/refresh 토큰을 쿠키로 설정
|
||||
// access/refresh 토큰을 쿠키로 설정.
|
||||
// persistent=true 면 maxAge 부여(영속 쿠키), false 면 maxAge 생략(세션 쿠키 — 브라우저 종료 시 만료).
|
||||
private setAuthCookies(
|
||||
res: Response,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
persistent: boolean,
|
||||
): void {
|
||||
res.cookie('access_token', accessToken, {
|
||||
...this.cookieBase(),
|
||||
path: '/',
|
||||
maxAge: ACCESS_MAX_AGE,
|
||||
...(persistent ? { maxAge: ACCESS_MAX_AGE } : {}),
|
||||
});
|
||||
res.cookie('refresh_token', refreshToken, {
|
||||
...this.cookieBase(),
|
||||
path: REFRESH_COOKIE_PATH,
|
||||
maxAge: REFRESH_MAX_AGE,
|
||||
...(persistent ? { maxAge: REFRESH_MAX_AGE } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,9 +90,22 @@ export class AuthController {
|
||||
}
|
||||
|
||||
// PublicUser 로 새 세션 토큰 발급 + 쿠키 설정 (로그인/소셜 공통)
|
||||
private async loginWith(res: Response, user: PublicUser): Promise<void> {
|
||||
const tokens = await this.authService.issueTokensForNewSession(user);
|
||||
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
|
||||
// keepLogin: 로그인 유지 여부(영속 쿠키 vs 세션 쿠키)
|
||||
private async loginWith(
|
||||
res: Response,
|
||||
user: PublicUser,
|
||||
keepLogin: boolean,
|
||||
): Promise<void> {
|
||||
const tokens = await this.authService.issueTokensForNewSession(
|
||||
user,
|
||||
keepLogin,
|
||||
);
|
||||
this.setAuthCookies(
|
||||
res,
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
tokens.persistent,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('signup')
|
||||
@@ -171,7 +186,8 @@ export class AuthController {
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<PublicUser> {
|
||||
const user = await this.authService.validateUser(dto);
|
||||
await this.loginWith(res, user);
|
||||
// keepLogin 미지정(API 직접 호출 등)이면 세션 쿠키(false)로 안전하게 처리
|
||||
await this.loginWith(res, user, dto.keepLogin ?? false);
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -192,7 +208,8 @@ export class AuthController {
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
await this.loginWith(res, req.user as PublicUser);
|
||||
// 소셜 로그인은 별도 체크박스가 없어 로그인 유지(영속)로 처리
|
||||
await this.loginWith(res, req.user as PublicUser, true);
|
||||
res.redirect(`${this.webUrl()}/projects`);
|
||||
}
|
||||
|
||||
@@ -213,7 +230,8 @@ export class AuthController {
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
await this.loginWith(res, req.user as PublicUser);
|
||||
// 소셜 로그인은 별도 체크박스가 없어 로그인 유지(영속)로 처리
|
||||
await this.loginWith(res, req.user as PublicUser, true);
|
||||
res.redirect(`${this.webUrl()}/projects`);
|
||||
}
|
||||
|
||||
@@ -250,7 +268,12 @@ export class AuthController {
|
||||
const tokens = await this.authService.rotateTokens(
|
||||
cookies?.refresh_token,
|
||||
);
|
||||
this.setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
|
||||
this.setAuthCookies(
|
||||
res,
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
tokens.persistent,
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
// 폐기/무효 토큰이면 더 이상 보내지 않도록 쿠키 제거
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface TokenPair {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
// 세션 토큰 + 쿠키 영속 여부(keepLogin) — 컨트롤러가 쿠키 maxAge 결정에 사용
|
||||
export interface SessionTokens extends TokenPair {
|
||||
persistent: boolean;
|
||||
}
|
||||
|
||||
// 회원가입 결과 — 인증 메일 발송 후 대기 상태(자동 로그인하지 않음)
|
||||
export interface SignupResult {
|
||||
email: string;
|
||||
@@ -226,8 +231,12 @@ export class AuthService {
|
||||
return UserService.toPublic(user);
|
||||
}
|
||||
|
||||
// 로그인/소셜/가입 등 — 새 세션(패밀리)을 만들고 access/refresh 토큰 발급
|
||||
async issueTokensForNewSession(user: PublicUser): Promise<TokenPair> {
|
||||
// 로그인/소셜/가입 등 — 새 세션(패밀리)을 만들고 access/refresh 토큰 발급.
|
||||
// keepLogin(persistent): true 면 영속 쿠키, false 면 세션 쿠키로 발급되도록 세션에 기록.
|
||||
async issueTokensForNewSession(
|
||||
user: PublicUser,
|
||||
keepLogin: boolean,
|
||||
): Promise<SessionTokens> {
|
||||
// 만료된 이 사용자의 세션 정리(누적 방지)
|
||||
await this.refreshRepo
|
||||
.createQueryBuilder()
|
||||
@@ -243,15 +252,17 @@ export class AuthService {
|
||||
currentJti: jti,
|
||||
prevJti: null,
|
||||
prevAt: null,
|
||||
persistent: keepLogin,
|
||||
expiresAt: new Date(Date.now() + REFRESH_SESSION_TTL_MS),
|
||||
}),
|
||||
);
|
||||
return this.signTokens(user.id, user.email, session.id, jti);
|
||||
const tokens = await this.signTokens(user.id, user.email, session.id, jti);
|
||||
return { ...tokens, persistent: keepLogin };
|
||||
}
|
||||
|
||||
// refresh 회전 — 현재 토큰이면 새 jti 로 회전, 직전 토큰이면 유예 내 양성으로 처리,
|
||||
// 그 외(이미 회전된/위조 토큰 재사용)면 탈취로 간주해 패밀리 폐기 후 401.
|
||||
async rotateTokens(refreshToken: string | undefined): Promise<TokenPair> {
|
||||
async rotateTokens(refreshToken: string | undefined): Promise<SessionTokens> {
|
||||
if (!refreshToken) throw new UnauthorizedException();
|
||||
let payload: JwtPayload;
|
||||
try {
|
||||
@@ -281,12 +292,13 @@ export class AuthService {
|
||||
session.currentJti = newJti;
|
||||
session.expiresAt = new Date(Date.now() + REFRESH_SESSION_TTL_MS);
|
||||
await this.refreshRepo.save(session);
|
||||
return this.signTokens(
|
||||
const tokens = await this.signTokens(
|
||||
session.user.id,
|
||||
session.user.email,
|
||||
session.id,
|
||||
newJti,
|
||||
);
|
||||
return { ...tokens, persistent: session.persistent };
|
||||
}
|
||||
|
||||
// 2) 동시 갱신(재시도) 유예 — 직전 jti 가 유예 시간 내면 양성으로 보고 현재 토큰 재발급(추가 회전 X)
|
||||
@@ -295,12 +307,13 @@ export class AuthService {
|
||||
session.prevAt &&
|
||||
Date.now() - session.prevAt.getTime() < REFRESH_GRACE_MS
|
||||
) {
|
||||
return this.signTokens(
|
||||
const tokens = await this.signTokens(
|
||||
session.user.id,
|
||||
session.user.email,
|
||||
session.id,
|
||||
session.currentJti,
|
||||
);
|
||||
return { ...tokens, persistent: session.persistent };
|
||||
}
|
||||
|
||||
// 3) 그 외 = 재사용 탐지 → 패밀리 전체 폐기(공격자·피해자 모두 강제 로그아웃)
|
||||
|
||||
@@ -34,6 +34,11 @@ export class RefreshSession {
|
||||
@Column({ name: 'prev_at', type: 'timestamptz', nullable: true })
|
||||
prevAt!: Date | null;
|
||||
|
||||
// 로그인 유지(keepLogin) 여부 — true 면 영속 쿠키(refresh TTL 만큼), false 면
|
||||
// 세션 쿠키(브라우저 종료 시 만료). refresh 회전 시에도 이 값을 따라 쿠키를 재발급한다.
|
||||
@Column({ type: 'boolean', default: true })
|
||||
persistent!: boolean;
|
||||
|
||||
// 세션 만료(refresh TTL) — 만료분 정리용
|
||||
@Column({ name: 'expires_at', type: 'timestamptz' })
|
||||
expiresAt!: Date;
|
||||
|
||||
Reference in New Issue
Block a user