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:
2026-06-23 10:56:25 +09:00
parent 0cdd2bb98e
commit b8756d34ab
5 changed files with 85 additions and 19 deletions
@@ -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"`,
);
}
}
+33 -10
View File
@@ -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) {
// 폐기/무효 토큰이면 더 이상 보내지 않도록 쿠키 제거
+19 -6
View File
@@ -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;