Skip to content

BE,FE/fix login token cookie auth#203

Merged
Kosw6 merged 2 commits intomainfrom
fe/fix-login-token-cookie-auth
Jan 30, 2026
Merged

BE,FE/fix login token cookie auth#203
Kosw6 merged 2 commits intomainfrom
fe/fix-login-token-cookie-auth

Conversation

@Kosw6
Copy link
Collaborator

@Kosw6 Kosw6 commented Jan 29, 2026

🔐 쿠키 기반 JWT 인증 시스템으로 전환 및 UI 개선

📋 작업 개요

기존 localStorage 기반 JWT 토큰 관리를 HttpOnly 쿠키 기반으로 전환하여 XSS 공격에 대한 보안을 강화하고, 사이드바 및 헤더의 로그인 상태 관리를 개선했습니다.


🎯 주요 변경 사항

1. 인증 시스템 리팩토링: localStorage → HttpOnly Cookie

문제점

  • Access Token과 Refresh Token을 localStorage에 저장하여 XSS 공격에 취약
  • 일반 로그인과 OAuth 로그인의 토큰 저장 방식 불일치
  • 백엔드에서 HttpOnly 쿠키를 설정하면서도 JSON 응답에 토큰 노출

해결 방법

  • 백엔드: 모든 토큰을 HttpOnly 쿠키로만 전송, JSON 응답에서 토큰 제거
  • 프론트엔드: localStorage 토큰 저장/관리 로직 제거, 쿠키 자동 전송 활용

2. 백엔드 수정 내역

AuthController.java

login() 메서드

// 변경 전: JSON 응답에 토큰 포함
return ResponseEntity.ok(response); // accessToken, refreshToken 포함

// 변경 후: 쿠키로만 토큰 전송
ResponseCookie accessCookie = ResponseCookie.from("access", accessToken)
.httpOnly(true).secure(true).sameSite("None")
.path("/").maxAge(60L * 60).build();

LoginResponse safeResponse = LoginResponse.builder()
.userId(...).email(...).name(...) // 토큰 제외
.build();

return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, accessCookie.toString())
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.body(safeResponse);

reissue() 메서드

// 변경 전: JSON 응답에 새 accessToken 포함
return response.body(Map.of("accessToken", tokens.get("accessToken")));

// 변경 후: 쿠키로만 전송
ResponseCookie accessCookie = ResponseCookie.from("access", tokens.get("accessToken"))
.httpOnly(true).secure(true).sameSite("None")
.path("/").maxAge(60L * 60).build();

return response.body(Map.of("message", "토큰 갱신 성공"));

logout() 메서드

// 변경 전: Authorization 헤더에서 토큰 읽기
@RequestHeader(value = "Authorization") String authorizationHeader

// 변경 후: 쿠키에서 토큰 읽기
@CookieValue(value = "access", required = false) String accessToken,
@CookieValue(value = "refresh", required = false) String refreshToken

// 토큰이 없어도 200 OK 반환 (멱등성 보장)

OAuth2SuccessHandler.java

쿠키 도메인 설정 개선

// 변경 전: 모든 환경에서 domain 설정
.domain(domain) // localhost 환경에서 문제 발생

// 변경 후: 로컬 환경에서는 domain 설정 안 함
boolean isLocal = profiles.isEmpty() || profiles.contains("local");
String domain = isProd ? "sjusisc.com" : isDev ? "sisc-web.duckdns.org" : null;

if (domain != null) {
cookieBuilder.domain(domain);
}

환경별 쿠키 설정

환경 secure sameSite domain
Local false Lax null
Dev true None sisc-web.duckdns.org
Prod true None sjusisc.com

📊 인증 플로우

일반 로그인

1. POST /api/auth/login
   → Body: { email, password }
2. 백엔드: access/refresh 쿠키 설정
   → Response: 유저 정보 (토큰 제외)
3. 프론트: 유저 정보만 localStorage에 저장
4. 로그인 완료

OAuth 로그인

1. OAuth 인증 성공
2. 백엔드: access/refresh 쿠키 설정
3. 리다이렉트: /oauth/success
4. GET /api/user/details (쿠키 자동 전송)
5. 유저 정보 localStorage 저장
6. 홈으로 이동

API 요청

1. 모든 API 요청에 쿠키 자동 포함 (withCredentials: true)
2. JwtAuthenticationFilter가 쿠키에서 토큰 추출
3. 토큰 검증 후 응답

토큰 갱신

1. API 요청 → 401 에러
2. Interceptor: POST /api/auth/reissue (refresh 쿠키 자동 전송)
3. 백엔드: 새 access 쿠키 설정
4. 원래 요청 재시도
5. 실패 시 → /login 리다이렉트

로그아웃

1. POST /api/auth/logout (access 쿠키 자동 전송)
2. 백엔드: access/refresh 쿠키 삭제 (maxAge=0)
3. 프론트: localStorage 'user' 삭제
4. setIsLoggedIn(false)
5. Sidebar/Header UI 즉시 업데이트
6. 홈으로 리다이렉트

🧪 테스트 체크리스트

  • 일반 로그인 → 쿠키 설정 확인
  • OAuth 로그인 → 쿠키 설정 및 유저 정보 조회
  • API 요청 → 쿠키 자동 전송
  • 토큰 만료 → 자동 갱신
  • 토큰 갱신 실패 → /login 리다이렉트
  • 로그아웃 → 쿠키 삭제 및 UI 업데이트
  • 페이지 새로고침 → 로그인 상태 유지
  • 404 에러 → 리다이렉트 안 함 (401만 리다이렉트)
  • Sidebar 로그아웃 버튼 → 즉시 UI 업데이트
  • Header 로그아웃 버튼 → 즉시 UI 업데이트
  • 토큰 없이 로그아웃 → 정상 처리 (멱등성)

📁 수정된 파일 목록

Backend (2 files)

src/main/java/org/sejongisc/backend/
├── auth/controller/AuthController.java
└── common/auth/config/OAuth2SuccessHandler.java

Frontend (6 files)

src/
├── components/
│   ├── Header.jsx
│   ├── Header.module.css
│   └── Sidebar.jsx
├── hooks/
│   └── useAuthGuard.js
├── pages/
│   └── OAuthSuccess.jsx
└── utils/
    ├── auth.js
    └── axios.js

🚀 배포 시 주의사항

환경별 설정 확인

로컬 환경

  • secure: false (HTTP)
  • sameSite: "Lax"
  • domain: null (설정 안 함)

개발/운영 환경

  • secure: true (HTTPS 필수)
  • sameSite: "None" (Cross-Site 허용)
  • domain: "your-domain.com" (서브도메인 공유)

CORS 설정

@Configuration
public class WebConfig {
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true); // 쿠키 전송 허용
        config.addAllowedOrigin("https://your-frontend-domain.com");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        // ...
    }
}

💡 추가 개선 사항

  1. Refresh Token Rotation 구현 완료
  2. 멱등성 보장: 로그아웃은 토큰 없이도 성공
  3. 에러 처리 개선: 401만 /login 리다이렉트, 404 등은 정상 처리
  4. UI 동기화: Sidebar와 Header의 로그인 상태 실시간 동기화

🔗 참고 문서

# 🔐 쿠키 기반 JWT 인증 시스템으로 전환 및 UI 개선

📋 작업 개요

기존 localStorage 기반 JWT 토큰 관리를 HttpOnly 쿠키 기반으로 전환하여 XSS 공격에 대한 보안을 강화하고, 사이드바 및 헤더의 로그인 상태 관리를 개선했습니다.


🎯 주요 변경 사항

1. 인증 시스템 리팩토링: localStorage → HttpOnly Cookie

문제점

  • Access Token과 Refresh Token을 localStorage에 저장하여 XSS 공격에 취약
  • 일반 로그인과 OAuth 로그인의 토큰 저장 방식 불일치
  • 백엔드에서 HttpOnly 쿠키를 설정하면서도 JSON 응답에 토큰 노출

해결 방법

  • 백엔드: 모든 토큰을 HttpOnly 쿠키로만 전송, JSON 응답에서 토큰 제거
  • 프론트엔드: localStorage 토큰 저장/관리 로직 제거, 쿠키 자동 전송 활용

2. 백엔드 수정 내역

AuthController.java

login() 메서드

// 변경 전: JSON 응답에 토큰 포함
return ResponseEntity.ok(response); // accessToken, refreshToken 포함

// 변경 후: 쿠키로만 토큰 전송
ResponseCookie accessCookie = ResponseCookie.from("access", accessToken)
    .httpOnly(true).secure(true).sameSite("None")
    .path("/").maxAge(60L * 60).build();

LoginResponse safeResponse = LoginResponse.builder()
    .userId(...).email(...).name(...) // 토큰 제외
    .build();

return ResponseEntity.ok()
    .header(HttpHeaders.SET_COOKIE, accessCookie.toString())
    .header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
    .body(safeResponse);

reissue() 메서드

// 변경 전: JSON 응답에 새 accessToken 포함
return response.body(Map.of("accessToken", tokens.get("accessToken")));

// 변경 후: 쿠키로만 전송
ResponseCookie accessCookie = ResponseCookie.from("access", tokens.get("accessToken"))
    .httpOnly(true).secure(true).sameSite("None")
    .path("/").maxAge(60L * 60).build();

return response.body(Map.of("message", "토큰 갱신 성공"));

logout() 메서드

// 변경 전: Authorization 헤더에서 토큰 읽기
@RequestHeader(value = "Authorization") String authorizationHeader

// 변경 후: 쿠키에서 토큰 읽기
@CookieValue(value = "access", required = false) String accessToken,
@CookieValue(value = "refresh", required = false) String refreshToken

// 토큰이 없어도 200 OK 반환 (멱등성 보장)

OAuth2SuccessHandler.java

쿠키 도메인 설정 개선

// 변경 전: 모든 환경에서 domain 설정
.domain(domain) // localhost 환경에서 문제 발생

// 변경 후: 로컬 환경에서는 domain 설정 안 함
boolean isLocal = profiles.isEmpty() || profiles.contains("local");
String domain = isProd ? "sjusisc.com" : isDev ? "sisc-web.duckdns.org" : null;

if (domain != null) {
    cookieBuilder.domain(domain);
}

환경별 쿠키 설정

환경 secure sameSite domain
Local false Lax null
Dev true None sisc-web.duckdns.org
Prod true None sjusisc.com

3. 프론트엔드 수정 내역

utils/auth.js

// 변경 전
export const login = async ({ email, password }) => {
  const res = await api.post('/api/auth/login', { email, password });
  localStorage.setItem('accessToken', res.data.accessToken);
  localStorage.setItem('refreshToken', res.data.refreshToken);
  return res.data;
};

// 변경 후
export const login = async ({ email, password }, signal) => {
  const res = await api.post('/api/auth/login', { email, password }, { signal });
  // 쿠키는 백엔드 Set-Cookie 헤더로 자동 설정
  return res.data; // 토큰 제외, 유저 정보만 반환
};

utils/axios.js

Request Interceptor

// 변경 전: Authorization 헤더 주입
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('accessToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 변경 후: 쿠키 자동 전송
api.interceptors.request.use((config) => {
  // withCredentials: true로 쿠키 자동 전송
  return config;
});

Response Interceptor

// 변경 전
if (err.response?.status === 401) {
  const refreshToken = localStorage.getItem('refreshToken');
  const { data } = await axios.post('/api/auth/reissue', { refreshToken });
  localStorage.setItem('accessToken', data.accessToken);
  originRequest.headers.Authorization = `Bearer ${data.accessToken}`;
  return api(originRequest);
}

// 변경 후
if (err.response?.status === 401 && !originRequest._retry) {
  originRequest._retry = true;
  await axios.post('/api/auth/reissue', {}, { withCredentials: true });
  // 새 accessToken은 쿠키에 자동 저장됨
  return api(originRequest);
}

pages/OAuthSuccess.jsx

// 변경 전: 쿠키에서 토큰 추출 시도 (실패)
const getCookie = (name) => { /* ... */ };
const accessToken = getCookie('access'); // HttpOnly라 접근 불가

// 변경 후: 쿠키 접근 시도 제거, API로 유저 정보 확인
useEffect(() => {
  const fetchUserInfo = async () => {
    const { data } = await api.get('/api/user/details');
    localStorage.setItem('user', JSON.stringify(data)); // 유저 정보만 저장
    nav('/', { replace: true });
  };
  fetchUserInfo();
}, [nav]);

4. 로그인 상태 관리 개선

components/Sidebar.jsx

로그인 상태 확인

const [isLoggedIn, setIsLoggedIn] = useState(false);

useEffect(() => {
  const checkLoginStatus = async () => {
    try {
      await api.get('/api/user/details');
      setIsLoggedIn(true);
    } catch (error) {
      setIsLoggedIn(false);
    }
  };
  checkLoginStatus();
}, [location.pathname]); // 페이지 이동 시마다 재확인

로그아웃 처리

const logout = async () => {
  try {
    await api.post('/api/auth/logout');
  } catch (error) {
    console.log('로그아웃 API 호출 실패:', error.message);
  } finally {
    localStorage.removeItem('user'); // 유저 정보 삭제
    setIsLoggedIn(false); // 즉시 UI 업데이트
    navigate('/');
    toast.success('로그아웃 되었습니다.');
  }
};

UI 렌더링

<ul>
  <li><NavLink to="/mypage">마이페이지</NavLink></li>
  
  {isLoggedIn ? (
    <li><NavLink onClick={logout}>로그아웃</NavLink></li>
  ) : (
    <>
      <li><NavLink to="/login">로그인</NavLink></li>
      <li><NavLink to="/signup">회원가입</NavLink></li>
    </>
  )}
</ul>

components/Header.jsx

Sidebar와 동일한 로직으로 로그인 상태 관리 및 로그아웃 처리 구현

모바일 화면 UI

<div className={styles.authLinks}>
  {isLoggedIn ? (
    <button onClick={logout}>로그아웃</button>
  ) : (
    <>
      <Link to="/login">로그인</Link>
      <span>|</span>
      <Link to="/signup">회원가입</Link>
    </>
  )}
</div>

5. useAuthGuard 훅 개선

hooks/useAuthGuard.js

// 변경 전: localStorage 확인
useEffect(() => {
  if (!localStorage.getItem('accessToken')) {
    toast.error('로그인 후 이용하실 수 있습니다.');
    nav('/login');
  }
}, [nav]);

// 변경 후: API 호출로 인증 상태 확인, 401만 리다이렉트
useEffect(() => {
  const checkAuth = async () => {
    try {
      await api.get('/api/user/details');
    } catch (error) {
      if (error.status === 401) {
        toast.error('로그인 후 이용하실 수 있습니다.');
        nav('/login');
      }
      // 404 등 다른 에러는 무시
    }
  };
  checkAuth();
}, [nav]);

🔒 보안 개선 사항

항목 변경 전 변경 후
accessToken 저장 localStorage HttpOnly Cookie
refreshToken 저장 localStorage + Cookie HttpOnly Cookie only
XSS 취약성 ❌ 토큰 접근 가능 ✅ JavaScript 접근 차단
CSRF 보호 부분적 ✅ SameSite=None + Secure
토큰 노출 JSON 응답에 포함 ✅ 응답에서 제거
로그인 상태 확인 localStorage ✅ API 호출

📊 인증 플로우

일반 로그인

1. POST /api/auth/login
   → Body: { email, password }
2. 백엔드: access/refresh 쿠키 설정
   → Response: 유저 정보 (토큰 제외)
3. 프론트: 유저 정보만 localStorage에 저장
4. 로그인 완료

OAuth 로그인

1. OAuth 인증 성공
2. 백엔드: access/refresh 쿠키 설정
3. 리다이렉트: /oauth/success
4. GET /api/user/details (쿠키 자동 전송)
5. 유저 정보 localStorage 저장
6. 홈으로 이동

API 요청

1. 모든 API 요청에 쿠키 자동 포함 (withCredentials: true)
2. JwtAuthenticationFilter가 쿠키에서 토큰 추출
3. 토큰 검증 후 응답

토큰 갱신

1. API 요청 → 401 에러
2. Interceptor: POST /api/auth/reissue (refresh 쿠키 자동 전송)
3. 백엔드: 새 access 쿠키 설정
4. 원래 요청 재시도
5. 실패 시 → /login 리다이렉트

로그아웃

1. POST /api/auth/logout (access 쿠키 자동 전송)
2. 백엔드: access/refresh 쿠키 삭제 (maxAge=0)
3. 프론트: localStorage 'user' 삭제
4. setIsLoggedIn(false)
5. Sidebar/Header UI 즉시 업데이트
6. 홈으로 리다이렉트

🧪 테스트 체크리스트

  • 일반 로그인 → 쿠키 설정 확인
  • OAuth 로그인 → 쿠키 설정 및 유저 정보 조회
  • API 요청 → 쿠키 자동 전송
  • 토큰 만료 → 자동 갱신
  • 토큰 갱신 실패 → /login 리다이렉트
  • 로그아웃 → 쿠키 삭제 및 UI 업데이트
  • 페이지 새로고침 → 로그인 상태 유지
  • 404 에러 → 리다이렉트 안 함 (401만 리다이렉트)
  • Sidebar 로그아웃 버튼 → 즉시 UI 업데이트
  • Header 로그아웃 버튼 → 즉시 UI 업데이트
  • 토큰 없이 로그아웃 → 정상 처리 (멱등성)

📁 수정된 파일 목록

Backend (2 files)

src/main/java/org/sejongisc/backend/
├── auth/controller/AuthController.java
└── common/auth/config/OAuth2SuccessHandler.java

Frontend (6 files)

src/
├── components/
│   ├── Header.jsx
│   ├── Header.module.css
│   └── Sidebar.jsx
├── hooks/
│   └── useAuthGuard.js
├── pages/
│   └── OAuthSuccess.jsx
└── utils/
    ├── auth.js
    └── axios.js

🚀 배포 시 주의사항

환경별 설정 확인

로컬 환경

  • secure: false (HTTP)
  • sameSite: "Lax"
  • domain: null (설정 안 함)

개발/운영 환경

  • secure: true (HTTPS 필수)
  • sameSite: "None" (Cross-Site 허용)
  • domain: "your-domain.com" (서브도메인 공유)

CORS 설정

@Configuration
public class WebConfig {
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true); // 쿠키 전송 허용
        config.addAllowedOrigin("https://your-frontend-domain.com");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        // ...
    }
}

💡 추가 개선 사항

  1. Refresh Token Rotation 구현 완료
  2. 멱등성 보장: 로그아웃은 토큰 없이도 성공
  3. 에러 처리 개선: 401만 /login 리다이렉트, 404 등은 정상 처리
  4. UI 동기화: Sidebar와 Header의 로그인 상태 실시간 동기화

🔗 참고 문서

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능
    • 보안 강화: HttpOnly 쿠키 기반 인증 시스템 도입으로 토큰 보안 개선
    • 개선된 로그인/로그아웃 워크플로우 및 사용자 피드백 추가
    • 로그인 상태를 정확히 반영하는 동적 헤더/사이드바 메뉴 업데이트

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 29, 2026

개요

인증 토큰 관리 방식을 Authorization 헤더 및 localStorage 기반에서 HttpOnly 쿠키 기반으로 마이그레이션합니다. 백엔드는 액세스/리프레시 토큰을 별도의 쿠키로 발급하고, 프론트엔드는 클라이언트 측 토큰 저장소를 제거하며 쿠키 기반 요청을 사용하도록 조정됩니다.

변경 사항

응집 / 파일(들) 요약
백엔드 인증 컨트롤러
backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java
로그인 응답에서 토큰을 제외한 사용자 정보만 반환하도록 변경. 로그아웃 엔드포인트를 쿠키 기반으로 재작성(Authorization 헤더에서 쿠키로), 양쪽 액세스/리프레시 쿠키 삭제. 토큰 갱신 엔드포인트는 새 액세스 토큰을 HttpOnly 쿠키로 발급.
백엔드 OAuth2 핸들러
backend/src/main/java/org/sejongisc/backend/common/auth/config/OAuth2SuccessHandler.java
액세스 및 리프레시 토큰의 쿠키 생성을 빌더 패턴으로 리팩토링. 프로드/개발 환경에서만 조건부 도메인 할당 추가.
프론트엔드 인증 유틸
frontend/src/utils/auth.js, frontend/src/utils/axios.js
localStorage에서 토큰 읽기/쓰기 로직 제거. axios 인터셉터에서 Authorization 헤더 주입 제거. 토큰 갱신 요청을 withCredentials: true로 설정하여 쿠키 기반 처리로 전환.
프론트엔드 헤더 컴포넌트
frontend/src/components/Header.jsx
API 기반 로그인 상태 검증(GET /api/user/details) 추가. 조건부 렌더링으로 로그인 상태에 따라 로그아웃 버튼 또는 로그인/회원가입 링크 표시.
프론트엔드 사이드바 컴포넌트
frontend/src/components/Sidebar.jsx
로그인 상태 검증 및 로그아웃 흐름을 쿠키 기반 API 호출로 재작성. 로그아웃 시 localStorage에서 'user' 제거. 마이페이지는 항상 표시, 로그아웃 링크는 로그인 상태일 때만 표시.
프론트엔드 인증 가드 훅
frontend/src/hooks/useAuthGuard.js
localStorage 직접 확인에서 /api/user/details API 호출로 변경. 401 응답 시 로그인 페이지로 리다이렉트.
OAuth 성공 페이지
frontend/src/pages/OAuthSuccess.jsx
쿠키 기반 토큰 추출 대신 /api/user/details API 호출로 사용자 정보 조회. localStorage에 사용자 데이터만 저장.
프론트엔드 헤더 스타일
frontend/src/components/Header.module.css
로그아웃 버튼을 위한 새로운 logoutButton 클래스 추가(호버/활성 상태 포함).

시퀀스 다이어그램

sequenceDiagram
    participant Client as 클라이언트
    participant Backend as 백엔드 API
    participant Cookie as 쿠키 저장소

    rect rgba(100, 150, 200, 0.5)
    Note over Client,Cookie: 신규 쿠키 기반 로그인 흐름
    Client->>Backend: POST /api/auth/login
    Backend->>Backend: 토큰 생성
    Backend->>Cookie: Set-Cookie: access (1h), refresh (2w)
    Backend-->>Client: 200 + 사용자 정보 (토큰 제외)
    end

    rect rgba(150, 100, 200, 0.5)
    Note over Client,Cookie: 토큰 갱신
    Client->>Backend: POST /api/auth/reissue (withCredentials)
    Backend->>Backend: 리프레시 토큰 검증
    Backend->>Cookie: Set-Cookie: access (1h 갱신)
    Backend-->>Client: 200 + 메시지
    end

    rect rgba(200, 150, 100, 0.5)
    Note over Client,Cookie: 로그아웃
    Client->>Backend: POST /api/auth/logout (쿠키 포함)
    Backend->>Cookie: Set-Cookie: access/refresh (maxAge=0)
    Backend-->>Client: 200
    Client->>Client: localStorage 정리
    end
Loading

예상 코드 리뷰 소요 시간

🎯 4 (복잡함) | ⏱️ ~45분

관련 가능성 있는 PR

  • SISC-IT/sisc-web#113: OAuth2SuccessHandler의 쿠키 생성 로직 수정으로 중복되는 변경 사항 존재
  • SISC-IT/sisc-web#150: Sidebar.jsx의 로그아웃 및 isLoggedIn 상태 구현과 직접 연관
  • SISC-IT/sisc-web#90: auth.js 및 axios.js의 프론트엔드 인증 코드 수정으로 로그인/토큰 갱신 흐름 변경과 중복

제안 검토자

  • discipline24
  • DongEun02
  • gxuoo

시 🐰

헤더에서 쿠키로, 소리 없이 흘러가고,
localStorage 안녕, 백엔드가 돌봐주네,
HttpOnly 쿠키 담긴 안전한 요청,
갉작갉작 토큰을 숨긴 토끼처럼,
더 튼튼한 인증으로 꼭꼭 봉인! 🔐✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 주요 변경 사항인 JWT 토큰을 localStorage에서 HttpOnly 쿠키로 마이그레이션하는 것을 명확하게 요약하고 있습니다.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fe/fix-login-token-cookie-auth

Tip

🧪 Unit Test Generation v2 is now available!

We have significantly improved our unit test generation capabilities.

To enable: Add this to your .coderabbit.yaml configuration:

reviews:
  finishing_touches:
    unit_tests:
      enabled: true

Try it out by using the @coderabbitai generate unit tests command on your code files or under ✨ Finishing Touches on the walkthrough!

Have feedback? Share your thoughts on our Discord thread!


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java (3)

105-125: API 문서의 응답 예시가 실제 응답과 불일치합니다.

Swagger 문서에서 응답 예시에 accessTokenrefreshToken이 포함되어 있지만, 실제 코드(lines 151-158)에서는 토큰을 제외한 safeResponse를 반환합니다. 클라이언트 개발자에게 혼란을 줄 수 있습니다.

📝 API 문서 수정 제안
                             content = `@Content`(mediaType = "application/json",
                                     examples = `@ExampleObject`(value = """
                                             {
-                                              "accessToken": "eyJhbGciOiJIUzI1NiJ9...",
-                                              "refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
                                               "userId": "1c54b9f3-8234-4e8f-b001-11cc4d9012ab",
+                                              "email": "testuser@example.com",
                                               "name": "홍길동",
                                               "role": "TEAM_MEMBER",
-                                              "phoneNumber": "01012345678"
+                                              "phoneNumber": "01012345678",
+                                              "point": 1000
                                             }
                                             """))

347-358: 회원 탈퇴 시 Access Token 쿠키 삭제가 누락되었습니다.

logout 메서드에서는 accessrefresh 쿠키를 모두 삭제하지만, withdraw에서는 refresh 쿠키만 삭제합니다. 일관성을 위해 두 쿠키 모두 삭제해야 합니다.

🔧 Access Token 쿠키 삭제 추가 제안
+        // Access Token 쿠키 삭제
+        ResponseCookie deleteAccessCookie = ResponseCookie.from("access", "")
+                .httpOnly(true)
+                .secure(true)
+                .sameSite("None")
+                .path("/")
+                .maxAge(0)
+                .build();
+
         // 브라우저 쿠키 삭제
         ResponseCookie deleteCookie = ResponseCookie.from("refresh", "")
                 .httpOnly(true)
                 .secure(true)
                 .sameSite("None")
                 .path("/")
                 .maxAge(0)
                 .build();

         return ResponseEntity.ok()
+                .header(HttpHeaders.SET_COOKIE, deleteAccessCookie.toString())
                 .header(HttpHeaders.SET_COOKIE, deleteCookie.toString())
                 .body(Map.of("message", "회원 탈퇴가 완료되었습니다."));

280-296: 쿠키 삭제 시에도 환경별 설정이 필요합니다.

쿠키를 삭제하려면 설정 시와 동일한 속성(domain, path, secure, sameSite)을 사용해야 합니다. 현재 secure(true)로 하드코딩되어 있어, 로컬 환경에서 쿠키가 제대로 삭제되지 않을 수 있습니다.

🤖 Fix all issues with AI agents
In
`@backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java`:
- Around line 225-234: The cookie settings in the reissue endpoint mirror the
login endpoint and should not hardcode secure(true) and sameSite("None"); update
the ResponseCookie construction in the reissue handler (AuthController.reissue)
to read environment or configuration (e.g., a boolean like cookieSecure and a
sameSite value or active profile) and apply those values instead of constants,
falling back to secure=false and sameSite="Lax" for local/dev; ensure you still
set httpOnly(true), path("/"), and maxAge, and use the configured values when
calling ResponseCookie.from(...) and building the cookie.
- Around line 269-278: Logout currently only calls
loginService.logout(accessToken) and does not remove the stored refresh token;
add a call to refreshTokenService.deleteByUserId(...) during the logout flow to
mirror withdraw's behavior (see withdraw which calls
refreshTokenService.deleteByUserId()). Obtain the userId by extracting it from
the access token (e.g., via an existing Jwt provider or a helper like
loginService.extractUserIdFromToken(accessToken) or change loginService.logout
to return the userId) and invoke refreshTokenService.deleteByUserId(userId)
inside the same try block (keeping the existing
JwtException/IllegalArgumentException and generic exception handling).
- Around line 132-148: AuthController currently hardcodes cookie attributes when
building accessCookie and refreshCookie (ResponseCookie.from(...) with
.httpOnly(true).secure(true).sameSite("None")...), which breaks local HTTP
development; change the cookie construction in AuthController to use the same
environment-aware logic as OAuth2SuccessHandler (or centralize into a shared
utility) so that .secure and .sameSite are set conditionally based on the
runtime environment (e.g., disable secure and use a different sameSite for local
HTTP, enable secure and "None" for production/HTTPS); update the code that
builds accessCookie and refreshCookie to call that shared helper or reuse the
OAuth2SuccessHandler policy so both flows behave identically.

In `@frontend/src/components/Header.jsx`:
- Around line 30-42: The logout function currently clears local state and shows
a success toast in the finally block even when api.post('/api/auth/logout')
fails; update the logout function so that on successful
api.post('/api/auth/logout') (inside the try) you remove localStorage 'user',
call setIsLoggedIn(false), navigate (nav('/')), and show toast.success, while in
the catch you should log the error and show a toast.error (do not clear local
auth/cookies there). Keep the api.post('/api/auth/logout') call and the catch
logging but move all success-side state changes and toast.success into the try
after the awaited API call succeeds to avoid masking failures.
- Around line 10-28: The useEffect/login check can suffer from race conditions:
make checkLoginStatus respect a cleanup flag or AbortController so stale
responses don't overwrite state; inside useEffect declare a let cancelled =
false (or const controller = new AbortController()) and before calling
setIsLoggedIn or setLoading verify !cancelled (or that the fetch wasn't
aborted), and in the effect cleanup set cancelled = true (or call
controller.abort()); update the async function checkLoginStatus and its
catch/finally branches to skip state updates when cancelled to prevent outdated
responses from reverting isLoggedIn.

In `@frontend/src/components/Sidebar.jsx`:
- Around line 31-41: The login check currently sets isLoggedIn=false for all
errors; update the checkLoginStatus error handling so that after calling
api.get('/api/user/details') you only call setIsLoggedIn(false) when the caught
error indicates a 401 Unauthorized (inspect error.response?.status === 401 or
equivalent), and for other errors do not change isLoggedIn (preserve previous
state) but still run setLoading(false); modify the catch block in the
useEffect's checkLoginStatus function to perform this conditional check around
setIsLoggedIn and keep references to api.get('/api/user/details'),
checkLoginStatus, setIsLoggedIn, and setLoading to locate the code.

In `@frontend/src/pages/OAuthSuccess.jsx`:
- Around line 15-23: The code stores PII (data.email, data.phoneNumber, and
optionally name) in localStorage via localStorage.setItem('user',
JSON.stringify(...)) in OAuthSuccess.jsx; change this to persist only
non-sensitive identifiers (e.g., data.userId, data.role, data.point — omit
data.email and data.phoneNumber and consider omitting name if not needed in UI),
and update any consumers to re-fetch sensitive profile fields from the server
via an authenticated API call when required instead of reading them from
localStorage; locate the localStorage.setItem('user', ...) call and remove
email/phone (and name if appropriate), and add/ensure a profile fetch flow to
retrieve PII on-demand.
🧹 Nitpick comments (3)
backend/src/main/java/org/sejongisc/backend/common/auth/config/OAuth2SuccessHandler.java (1)

114-121: domain = "localhost" 할당은 사용되지 않는 데드 코드입니다.

isProdisDev가 모두 false일 때 domain = "localhost"가 할당되지만, 이후 lines 135-137, 147-149의 조건문에서 로컬 환경일 때는 domain()을 설정하지 않습니다. 따라서 이 할당은 실행되어도 사용되지 않습니다.

🔧 데드 코드 제거 제안
 // 도메인 설정
-        String domain;
+        String domain = null;
         if (isProd) {
             domain = "sjusisc.com"; // 운영 도메인
         } else if (isDev) {
             domain = "sisc-web.duckdns.org"; // 개발 도메인
-        } else {
-            domain = "localhost"; // 기본값
         }
frontend/src/utils/axios.js (1)

8-36: 동시 401에서 리프레시 중복 호출 방지 필요.

동시 요청이 401을 반환하면 /api/auth/reissue가 병렬로 호출될 수 있어, 토큰 회전(Refresh rotation) 환경에서 실패/경합 가능성이 있습니다. 단일 refresh promise를 공유하도록 직렬화하는 편이 안전합니다.

🛠️ 제안 코드
+let refreshPromise = null;
+
 api.interceptors.response.use(
   (res) => res,
   async (err) => {
     const originRequest = err.config;
 
     // 액세스 토큰 만료 확인
     if (err.response?.status === 401 && !originRequest._retry) {
       originRequest._retry = true;
 
       try {
-        // refreshToken은 쿠키에서 자동으로 전송됨
-        await axios.post(
-          `${import.meta.env.VITE_API_URL}/api/auth/reissue`,
-          {},  // body 비움
-          { withCredentials: true }
-        );
+        if (!refreshPromise) {
+          // refreshToken은 쿠키에서 자동으로 전송됨
+          refreshPromise = axios.post(
+            `${import.meta.env.VITE_API_URL}/api/auth/reissue`,
+            {},  // body 비움
+            { withCredentials: true }
+          );
+        }
+        await refreshPromise;
 
         // 새로운 accessToken은 쿠키에 자동 저장됨
         // localStorage 업데이트 불필요
         // Authorization 헤더 설정 불필요
 
         // 원래 요청 재시도
         return api(originRequest);
       } catch (refreshError) {
         console.error('Token refresh failed: ', refreshError);
         // localStorage에서 토큰 제거 불필요 (쿠키는 백엔드에서 관리)
         window.location.href = '/login';
         return Promise.reject(refreshError);
+      } finally {
+        refreshPromise = null;
       }
     }
frontend/src/hooks/useAuthGuard.js (1)

15-19: 에러 상태 판별을 response.status까지 포함해 방어적으로 처리하세요.

axios 에러 객체는 error.response?.status에 값이 있을 수 있습니다. 현재는 error.status만 보아 누락될 수 있어 보강을 권장합니다.

🛠️ 제안 코드
-        if (error.status === 401) {
+        const status = error?.status ?? error?.response?.status;
+        if (status === 401) {
           toast.error('로그인 후 이용하실 수 있습니다.');
           nav('/login');
         }

Copy link
Contributor

@discipline24 discipline24 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다~

@Kosw6 Kosw6 merged commit 6b5b0c4 into main Jan 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants