From b8756d34ab576736ed0b5d2c91ba40d2a07ad51b Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 23 Jun 2026 10:56:25 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0/=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9C=A0=EC=A7=80(keep?= =?UTF-8?q?Login)=20=EC=A0=95=EC=83=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세션이 짧게 유지되고 '로그인 유지'가 동작하지 않던 문제 수정. 원인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) --- ...82400000000-AddRefreshSessionPersistent.ts | 20 +++++++++ backend/src/modules/auth/auth.controller.ts | 43 ++++++++++++++----- backend/src/modules/auth/auth.service.ts | 25 ++++++++--- .../auth/entities/refresh-session.entity.ts | 5 +++ frontend/src/composables/useApi.ts | 11 +++-- 5 files changed, 85 insertions(+), 19 deletions(-) create mode 100644 backend/src/migrations/1782400000000-AddRefreshSessionPersistent.ts diff --git a/backend/src/migrations/1782400000000-AddRefreshSessionPersistent.ts b/backend/src/migrations/1782400000000-AddRefreshSessionPersistent.ts new file mode 100644 index 0000000..16b1796 --- /dev/null +++ b/backend/src/migrations/1782400000000-AddRefreshSessionPersistent.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE "refresh_sessions" ADD "persistent" boolean NOT NULL DEFAULT true`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "refresh_sessions" DROP COLUMN "persistent"`, + ); + } +} diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index aeab156..20e4ec6 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -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 { - 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 { + 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 { 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 { - 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 { - 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) { // 폐기/무효 토큰이면 더 이상 보내지 않도록 쿠키 제거 diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 1e066a5..ee8cd45 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -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 { + // 로그인/소셜/가입 등 — 새 세션(패밀리)을 만들고 access/refresh 토큰 발급. + // keepLogin(persistent): true 면 영속 쿠키, false 면 세션 쿠키로 발급되도록 세션에 기록. + async issueTokensForNewSession( + user: PublicUser, + keepLogin: boolean, + ): Promise { // 만료된 이 사용자의 세션 정리(누적 방지) 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 { + async rotateTokens(refreshToken: string | undefined): Promise { 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) 그 외 = 재사용 탐지 → 패밀리 전체 폐기(공격자·피해자 모두 강제 로그아웃) diff --git a/backend/src/modules/auth/entities/refresh-session.entity.ts b/backend/src/modules/auth/entities/refresh-session.entity.ts index d26143f..6b00a51 100644 --- a/backend/src/modules/auth/entities/refresh-session.entity.ts +++ b/backend/src/modules/auth/entities/refresh-session.entity.ts @@ -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; diff --git a/frontend/src/composables/useApi.ts b/frontend/src/composables/useApi.ts index 64adae2..f412129 100644 --- a/frontend/src/composables/useApi.ts +++ b/frontend/src/composables/useApi.ts @@ -79,8 +79,13 @@ api.interceptors.request.use( (error: unknown) => Promise.reject(error), ) -// 인증 만료 응답으로 판단할지 — /auth/* 자체 호출은 재시도 대상에서 제외 -const isAuthEndpoint = (url?: string): boolean => (url ?? '').includes('/auth/') +// 401 refresh-재시도에서 제외할 호출 — refresh/login 자체만 제외한다. +// (refresh 무한 루프·잘못된 자격증명 재시도 방지. 단 /auth/me 등은 access 만료 시 +// refresh 후 재시도되어야 새로고침/재진입에도 세션이 복원된다) +const isRefreshExcluded = (url?: string): boolean => { + const u = url ?? '' + return u.includes('/auth/refresh') || u.includes('/auth/login') +} // refresh 단일 실행(single-flight) — 동시 401 이 각자 refresh 를 호출하면 회전형 토큰이 // 재사용으로 오탐되어 세션이 폐기될 수 있다. 진행 중 refresh 가 있으면 그 Promise 를 공유한다. @@ -120,7 +125,7 @@ api.interceptors.response.use( // access 토큰 만료(401) → refresh 1회 시도 후 원요청 재시도 // (동시 401 은 single-flight 로 묶어 refresh 토큰 회전이 한 번만 일어나게 한다) - if (status === 401 && config && !config._retry && !isAuthEndpoint(config.url)) { + if (status === 401 && config && !config._retry && !isRefreshExcluded(config.url)) { config._retry = true try { await refreshOnce() From a094283e62bb149127ea7c8d495b8d9f12d9d1bc Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 23 Jun 2026 11:32:25 +0900 Subject: [PATCH 2/2] =?UTF-8?q?style:=20=EC=9D=BC=EC=A0=95/=EC=97=85?= =?UTF-8?q?=EB=AC=B4=20UI=20=EB=8B=A4=EB=93=AC=EA=B8=B0=20=E2=80=94=20plac?= =?UTF-8?q?eholder=C2=B7=EC=B0=B8=EC=84=9D=EC=9E=90=20=EC=BD=A4=EB=B3=B4?= =?UTF-8?q?=C2=B7AvatarStack=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 일정 모달 placeholder 를 프로젝트 기존 톤("~을 입력하세요/선택하세요")으로 통일 - 참석자 등록을 업무 생성의 담당자 등록과 동일한 콤보(검색 입력+후보 드롭다운+선택 칩)로 개편 ('전 직원'은 드롭다운 항목/단일 칩으로 처리) - 아바타 스택+오버플로(+N)+팝오버 목록을 공용 AvatarStack 으로 추출, 일정 상세 참석자 + 업무 상세 담당자 카드에 적용(담당자 팝오버에 역할 표시) - 사용처가 사라진 ScheduleAvatar 삭제(UserAvatar/AvatarStack 으로 일원화) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/common/AvatarStack.vue | 205 +++++++++++ .../components/schedule/ScheduleAvatar.vue | 38 -- .../schedule/ScheduleDetailPanel.vue | 154 +------- .../schedule/ScheduleEventModal.vue | 342 ++++++++++-------- frontend/src/pages/relay/TaskDetailPage.vue | 54 +-- 5 files changed, 422 insertions(+), 371 deletions(-) create mode 100644 frontend/src/components/common/AvatarStack.vue delete mode 100644 frontend/src/components/schedule/ScheduleAvatar.vue diff --git a/frontend/src/components/common/AvatarStack.vue b/frontend/src/components/common/AvatarStack.vue new file mode 100644 index 0000000..7d6b9ee --- /dev/null +++ b/frontend/src/components/common/AvatarStack.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/frontend/src/components/schedule/ScheduleAvatar.vue b/frontend/src/components/schedule/ScheduleAvatar.vue deleted file mode 100644 index 79fd58c..0000000 --- a/frontend/src/components/schedule/ScheduleAvatar.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - - - diff --git a/frontend/src/components/schedule/ScheduleDetailPanel.vue b/frontend/src/components/schedule/ScheduleDetailPanel.vue index 734ec6a..66d2eb1 100644 --- a/frontend/src/components/schedule/ScheduleDetailPanel.vue +++ b/frontend/src/components/schedule/ScheduleDetailPanel.vue @@ -1,8 +1,7 @@