# 평가: 보안 — 로그인 및 인증 과정이 안전하게 처리되고 있는가 (암호화/JWT/OAuth) > 대분류: 보안 / 소분류: 로그인 및 인증 과정이 안전하게 처리되고 있는가 (예: 암호화, JWT, OAuth등 사용) > 기준 브랜치: `origin/dev` (작성 시점 HEAD: `cb41e36`) ## 요약 - **만족도**: **우수** (OAuth2 Authorization Code Flow + Stateless JWT + Refresh 토큰 회전(Lua CAS) + 응답 채널 분리(쿠키/본문)가 모두 갖춰져 있고, 토큰 클레임 분리(`type=ACCESS|REFRESH`)와 쿠키 보안 속성(HttpOnly/Secure/SameSite=Strict), Open redirect 방지 화이트리스트까지 구비되어 있다. 본문 모드의 query string 노출과 PKCE 미적용, 다중 디바이스 정책 부재 등 점진적 강화 여지가 있다) ### 근거 - **외부 인증은 표준 Spring Security OAuth2 Client 위에 구현 (Kakao/Google/Naver)**: Authorization Code Grant + state 파라미터 자동 검증(Spring 기본). 인가코드 ↔ 토큰 교환은 provider 표준 엔드포인트로만 수행. - `src/main/resources/application.yaml:41-83` — 3개 provider 등록, `authorization-grant-type: authorization_code`. - `src/main/java/com/groute/groute_server/auth/config/OAuth2SecurityConfig.java:54-72` — `oauth2Login` 표준 사용, `userInfoEndpoint.userService(customOAuth2UserService)`, `successHandler/failureHandler` 주입. - `src/main/java/com/groute/groute_server/auth/service/oauth/CustomOAuth2UserService.java:39, 44` — `DefaultOAuth2UserService` 를 위임해 표준 user-info 호출. - **두 단계 Security Filter Chain 분리**: `@Order(1)` 은 `/oauth2/**`, `/login/oauth2/code/**` 만, `@Order(2)` 는 그 외 API 전체에 JWT 필터. - `src/main/java/com/groute/groute_server/auth/config/OAuth2SecurityConfig.java:49-72` (Order 1). - `src/main/java/com/groute/groute_server/common/config/SecurityConfig.java:50-73` (Order 2). - 결과: OAuth2 round-trip 경로는 JWT 검증을 받지 않고, 그 외 모든 요청은 Bearer access token 강제. - **세션 stateless 강제**: `SessionCreationPolicy.STATELESS` 명시. OAuth2 round-trip 도중 만들어진 세션은 SuccessHandler 가 redirect 직전에 invalidate. - `src/main/java/com/groute/groute_server/common/config/SecurityConfig.java:57-58` — `sessionCreationPolicy(STATELESS)`. - `src/main/java/com/groute/groute_server/auth/service/oauth/OAuth2LoginSuccessHandler.java:76, 98-103` — `invalidateSession(request)` 호출. - **CSRF / formLogin / httpBasic 모두 비활성** (Stateless API 의 표준 패턴). - `SecurityConfig.java:53-56`, `OAuth2SecurityConfig.java:55-58`. - **JWT 발급/검증의 엄격한 분리**: - 알고리즘: HS256 (`io.jsonwebtoken` 0.12.6), key 는 SSM `JWT_SECRET` (32바이트 이상 강제). - `src/main/java/com/groute/groute_server/common/jwt/JwtTokenProvider.java:35` — `Keys.hmacShaKeyFor(secret.getBytes(UTF_8))`. - `JwtProperties.java:10` — "HS256 서명을 위해 secret은 32바이트(UTF-8) 이상이어야 한다" 명시. - 클레임: `subject=userId`, custom `type=ACCESS|REFRESH`. - `JwtTokenProvider.java:73-80` — `buildToken`. - `TokenType.java` — enum 분리. - 검증: 서명·형식·만료 모두 점검. 만료/위조를 enum 으로 구분(`VALID/EXPIRED/INVALID`)해 401 분기 일원화. - `JwtTokenProvider.java:47-57` — `validate(token)` (예외 미투출). - `JwtValidationResult.java` — enum. - 토큰 종류 오용 방지: 인증 필터는 access 토큰만, 재발급 API 는 refresh 토큰만 수용. - `src/main/java/com/groute/groute_server/common/filter/JwtAuthenticationFilter.java:55-56` — `validate == VALID && getTokenType == ACCESS` 둘 다 충족해야 인증 주입. - `src/main/java/com/groute/groute_server/auth/service/AuthService.java:41-46` — refresh API 는 `getTokenType == REFRESH` 강제. - **Refresh 토큰 회전이 원자(atomic) compare-and-set 으로 구현됨**: 동시 요청이 같은 refresh 로 들어와도 한 건만 통과, 나머지는 401. - `src/main/java/com/groute/groute_server/auth/repository/RefreshTokenRepository.java:35-43, 66-76` — Redis Lua CAS 스크립트: `if GET == ARGV[1] then SET EX ARGV[3] return 1 else return 0 end`. - `AuthService.java:52-55` — CAS 실패 시 `deleteByUserId` 로 토큰 패밀리 전체 무효화(탈취 대응 패턴: "오래된 refresh 재사용 감지 → 전부 무효화"). - **Refresh 토큰 저장 형태가 안전**: Redis 에 SHA-256 해시로만 저장 (원문 미보관). Redis 스냅샷·로그 유출에도 토큰 자체는 복원 불가. - `RefreshTokenRepository.java:21-22, 82-90`. - **응답 전달 채널 이원화 (cookie/body)**: 운영(prod) 은 쿠키 모드, 로컬/스테이징은 본문 모드. 동일 컴포넌트(`TokenDeliveryService`) 가 단일 책임. - `src/main/java/com/groute/groute_server/auth/service/TokenDeliveryService.java:34-72`. - 쿠키 속성: `HttpOnly + Secure + SameSite=Strict + Path=/ + Max-Age=refresh TTL` → XSS / CSRF / 크로스 사이트 자동 전송 모두 차단. - **Open redirect 방지**: SuccessHandler/FailureHandler 의 redirect target 은 사용자 입력 URL 이 그대로 들어갈 수 없도록 화이트리스트(`auth.callback` 맵)에서만 선택. - `src/main/java/com/groute/groute_server/auth/config/AuthProperties.java:42-75` — 환경별 callback 맵, 부팅 시 `defaultEnv` 가 맵에 포함되어 있는지 검증. - `src/main/java/com/groute/groute_server/auth/service/oauth/OAuthCallbackUrlResolver.java:39-50` — `oauth_env` 쿠키 → 화이트리스트 매칭 → 미매칭 시 default fallback. 화이트리스트 밖으로는 절대 redirect 안 됨. - `OAuth2LoginSuccessHandler.java:37-38` 명시적 주석: "Open redirect 안전성: 콜백 URL 은 callback 맵의 값에서만 선택되며 사용자 입력 URL 을 그대로 사용하지 않는다." - **로그아웃의 대칭성**: Redis 무효화 + 쿠키 만료를 동일 컴포넌트에서 처리, "탈취된 refresh 도 즉시 무력화". - `AuthService.java:68-71` — `deleteByUserId` 만 호출, 멱등. - `TokenDeliveryService.java:55-62` — `buildRefreshCookie("", Duration.ZERO)` 로 동일 속성 매칭하며 maxAge=0 (속성이 하나라도 다르면 별개 쿠키로 인식되어 기존 쿠키가 남는 버그 회피). - `AuthController.java:92-97`. - **CORS 와 인증 쿠키 정합**: `allowCredentials(true)` 인 경우 와일드카드 origin 금지 — `setAllowedOrigins` 로 명시적 호스트만 허용. `setMaxAge(3600L)` 로 preflight 캐싱. - `SecurityConfig.java:94-105`. - **인증 실패의 일관된 응답 포맷**: `JwtAuthenticationEntryPoint` 가 JSON 401, `JwtAccessDeniedHandler` 가 JSON 403 으로 통일. - `src/main/java/com/groute/groute_server/common/handler/JwtAuthenticationEntryPoint.java:35-45`, `src/main/java/com/groute/groute_server/common/handler/JwtAccessDeniedHandler.java:35-48`. - **PUBLIC_ENDPOINTS 최소화**: 인증 없이 통과시키는 경로가 OAuth2 round-trip, 재발급, swagger, docs/error-code, actuator로 한정. - `SecurityConfig.java:35-44`. - **DTO 검증**: `@NotBlank`, `@NotNull` 로 빈 토큰 / 누락 디바이스 토큰을 API 경계에서 거절. - `TokenReissueRequest.java:17-19`, `DeviceTokenRegisterRequest.java:16-21`. - **provider 응답 정규화의 방어적 처리**: `String.valueOf(null)` 이 "null" 문자열을 만드는 함정을 명시적으로 차단, provider 응답 형식 변경/누락 시 즉시 400. - `src/main/java/com/groute/groute_server/auth/service/oauth/OAuthAttributes.java:71-81, 26-31, 61-65`. ### 남은 To-do - [ ] **본문 모드(local/stg)에서 redirect query 의 refresh 토큰 노출 완화**: 현재 본문 모드일 때 SuccessHandler 가 `?access=...&refresh=...` 형태로 redirect URL 에 refresh 를 직접 실어 보냄. 브라우저 history / referrer / 서버 액세스 로그에 refresh 토큰이 남을 수 있다. stg 도 본문 모드를 일부 허용하는 구조이므로 stg/prod 만이라도 쿠키 모드 강제, 혹은 access 만 query 에 싣고 refresh 는 fragment(`#refresh=...`) 로 전환. - 변경 위치: `src/main/java/com/groute/groute_server/auth/service/oauth/OAuth2LoginSuccessHandler.java:71-95` (`buildRedirectUrl`), `src/main/resources/application.yaml:230-233` (stg `refresh-token.cookie-enabled` 명시적 true 검토). - [ ] **PKCE(S256) 적용**: 현재 Spring Security OAuth2 Client confidential client 모드라 PKCE 가 자동 적용되지 않음. 카카오/구글/네이버 모두 PKCE 지원. `client-authentication-method: none` 으로 바꾸지 않고도 `customizer` 로 `code_challenge` 를 추가하면 인가코드 가로채기 방어 강화. - 변경 위치: `OAuth2EnvAwareAuthorizationRequestResolver.java:55-71` — `OAuth2AuthorizationRequest.from(result).additionalParameters(...)` 로 `code_challenge`/`code_challenge_method=S256` 주입 + 세션/쿠키에 verifier 보관. 단, 세션 stateless 정책과 충돌하므로 verifier 보관 채널(쿠키) 설계 필요. - [ ] **다중 디바이스/세션 정책 결정**: 현재 `refresh:{userId}` 단일 키. iPhone 에서 로그인하면 데스크톱의 refresh 가 즉시 무효화되는 단일 활성 세션 모델. 의도된 정책이라면 README/약관에 명시, 다중 디바이스를 허용하려면 `refresh:{userId}:{deviceId}` 패턴으로 키 분리 필요. - 변경 위치: `RefreshTokenRepository.java:33` `KEY_PREFIX` 패턴, `OAuth2LoginSuccessHandler.java:62` `save`, `AuthService.java:52-55` `rotate` / `deleteByUserId`, 그리고 device-id 헤더 추출 로직 추가. - [ ] **Access 토큰 jti / 블랙리스트 도입 검토**: 코드 주석(`AuthService.java:64-66`)에 "access 토큰은 stateless 원칙 유지 — TTL(1h) 만료 대기, 블랙리스트 도입은 별도 이슈" 라 명시되어 있음. 평가 답변에서 "현재는 TTL 만료 모델, 블랙리스트는 의도적으로 보류" 라고 분명히 답할 수 있도록 ADR 한 줄 보강 권장. - [ ] **Refresh 토큰 TTL 슬라이딩 정책 명시**: 현재 rotate 시 `EX ttlSeconds` 로 TTL 을 항상 초기값(`refresh-token-expiration`)으로 리셋(`RefreshTokenRepository.java:67`). 사용자가 활동하는 한 무한히 유효 → 의도된 sliding 정책이지만, 절대 만료(absolute expiration) 추가 여부를 결정해 ADR 화 권장. (장기 미사용 계정의 토큰 자동 만료) - [ ] **OAuth2 state 검증 명시**: Spring Security 가 자동으로 state 를 발행/검증하지만, 평가 답변에서 "state CSRF 토큰 자동 검증" 사실을 짚으려면 코드 내 `customizer` 로 명시적으로 보이게 하거나 docs 보강. - [ ] **HttpServletResponse.sendRedirect 대신 ResponseEntity 사용?** 평가 외적 — `OAuth2LoginSuccessHandler.java:82` 의 302 redirect 가 표준 패턴이라 그대로 두는 게 맞음. 단, `Cache-Control: no-store` 헤더가 응답에 함께 실리는지 확인 권장(refresh 가 query 에 실린 redirect 가 캐시되지 않도록). - [ ] **CORS allowedHeaders 의 `"*"` 검토**: `SecurityConfig.java:98` — `setAllowedHeaders(List.of("*"))`. preflight 단계에서 모든 헤더 허용. 운영상 큰 위협은 아니지만, 평가 답변에서는 `Authorization`, `Content-Type` 등 화이트리스트로 좁힌다고 답할 수 있도록 변경 검토. - [ ] **OAuth provider 별 scope 최소화 검증**: - 카카오: `profile_nickname` (이메일 미요청 → 카카오 SocialAccount.email 이 null 인 이유와 일치, OK). - 구글: `profile`, `email`. - 네이버: `nickname`, `email`. - 현재 닉네임은 서비스 측에서 별도 입력(`User.nickname` 온보딩 시 채움)이고 provider 닉네임은 사용하지 않으므로, 실제로 사용하지 않는다면 scope 최소화 가능(`profile_nickname` 제거 등). 단 provider 정책상 최소 1개 scope 가 필요해 그대로 두는 게 무난. - [ ] **로그아웃 시 access 토큰 처리 정책 docs 화**: 위 jti 블랙리스트와 같은 맥락. 평가 자료에서 "Refresh 즉시 무효화 / Access 는 TTL=1h 만료까지 유효, 짧은 TTL 로 윈도우 최소화" 라고 명시. --- ## 상세 분석 ### 1. 인증 진입 시나리오 흐름 ``` [프론트] GET /oauth2/authorization/{kakao|google|naver}?env=local|production │ ├─ Spring Security 표준 OAuth2 entry → state 자동 생성 └─ OAuth2EnvAwareAuthorizationRequestResolver 가 env 값을 화이트리스트와 매칭 → oauth_env 쿠키(HttpOnly, Lax, 300s) 발행 [provider] 사용자 동의 → 인가 코드 발급 → /login/oauth2/code/{provider}로 redirect [Spring] DefaultOAuth2UserService → 인가코드 ↔ access token 교환 → /user-info 호출 [CustomOAuth2UserService] provider user-info 정규화 → SocialLoginService.upsertUser → User 생성 또는 갱신 [OAuth2LoginSuccessHandler] ├─ jwtTokenProvider.createAccessToken(userId) ── HS256, type=ACCESS, exp = now + 1h ├─ jwtTokenProvider.createRefreshToken(userId) ── HS256, type=REFRESH, exp = now + 14d ├─ refreshTokenRepository.save(userId, refreshToken) ── Redis 에 SHA-256 해시 저장, TTL=14d ├─ tokenDeliveryService.deliver │ ├─ cookieEnabled=true (prod): Set-Cookie refreshToken HttpOnly Secure SameSite=Strict │ └─ cookieEnabled=false (local): JSON 응답 본문에 refresh 포함 ├─ OAuthCallbackUrlResolver.resolveAndExpire (oauth_env 쿠키 사용 후 만료) ├─ session.invalidate └─ response.sendRedirect(callbackUrl + ?access=... [&refresh=...]) [프론트] callback URL 에서 access 추출 → 이후 모든 호출에 Authorization: Bearer <access> ``` ### 2. JWT 검증 흐름 (JwtAuthenticationFilter) ``` 모든 요청 → OncePerRequestFilter#doFilterInternal ├─ Authorization 헤더에서 Bearer 추출 (없으면 SecurityContext 비워둔 채 통과) ├─ jwtTokenProvider.validate(token) │ ├─ VALID → 다음 단계 │ ├─ EXPIRED → SecurityContext 미설정 → 보호 경로면 EntryPoint 가 401 │ └─ INVALID → SecurityContext 미설정 → 401 ├─ getTokenType(token) == ACCESS 강제 (refresh 를 Authorization 헤더에 보내는 오용 차단) ├─ getUserId(token) 추출 └─ UsernamePasswordAuthenticationToken(userId, null, [ROLE_USER]) 주입 permitAll 경로(OAuth2/reissue/swagger/docs/actuator)는 SecurityContext 비어 있어도 통과 보호 경로는 SecurityContext 비어 있으면 EntryPoint → 401 JSON ``` ### 3. Refresh 토큰 회전 패턴 ``` [클라이언트] POST /api/auth/reissue ├─ Cookie: refreshToken=<R_old> (쿠키 모드) └─ Body : { refreshToken: <R_old> } (본문 모드, fallback) [AuthController.reissue] pickRefreshToken: 쿠키 우선, 없으면 body [AuthService.reissue] ├─ null/blank 검사 → 400 REFRESH_TOKEN_REQUIRED ├─ jwtTokenProvider.validate(R_old) != VALID → 401 INVALID_REFRESH_TOKEN ├─ getTokenType(R_old) != REFRESH → 401 INVALID_REFRESH_TOKEN ├─ R_new_access = createAccessToken(userId) ├─ R_new_refresh = createRefreshToken(userId) ├─ refreshTokenRepository.rotate(userId, R_old, R_new_refresh) │ └─ Redis Lua CAS: if GET refresh:{userId} == sha256(R_old) │ then SET refresh:{userId} = sha256(R_new_refresh) EX ttl │ return 1 │ else return 0 ├─ rotate 실패 시 → deleteByUserId(userId) ← 패밀리 전체 무효화 (탈취 대응) │ → 401 INVALID_REFRESH_TOKEN └─ TokenDeliveryService.deliver(R_new_access, R_new_refresh) ``` 이 설계의 강점: 1. **재사용 감지 패턴**: 이미 회전된 R_old 로 다시 요청이 들어오면(공격자 시나리오) CAS 가 실패한다. 실패 시 단순히 거절하는 게 아니라 `deleteByUserId` 로 전체를 무효화 → 정당한 사용자가 다음 재로그인을 강제당하고, 공격자도 발을 못 들임. 2. **동시 요청 race 방지**: 모바일에서 동시에 두 개 탭이 refresh 시도하면 한 탭만 성공. 두 번째 탭은 401 → 첫 탭에서 받은 새 access/refresh 로 재시도해야 함. 일회성 토큰 정책 유지. 3. **저장값 = 해시**: Redis 가 털려도 토큰 원문 노출 없음. ### 4. 쿠키 보안 속성 매트릭스 | 쿠키 | HttpOnly | Secure | SameSite | Path | Max-Age | 발급 위치 | |------|----------|--------|----------|------|---------|----------| | `refreshToken` (cookie 모드) | ✅ | ✅ (unconditional) | Strict | / | refresh TTL (14d 기본) | `TokenDeliveryService.java:64-72` | | `refreshToken` (로그아웃) | ✅ | ✅ | Strict | / | 0 | 동일 | | `oauth_env` (OAuth2 시작) | ✅ | `request.isSecure()` 조건부 | Lax | / | 300초 | `OAuth2EnvAwareAuthorizationRequestResolver.java:87-94` | | `oauth_env` (콜백 후 만료) | ✅ | `request.isSecure()` 조건부 | Lax | / | 0 | `OAuthCallbackUrlResolver.java:66-75` | 설계 의도 주석: - "Strict 는 OAuth2 round-trip(cross-site top-level navigation)에 쿠키가 안 실려서 Lax 가 적정" (`OAuth2EnvAwareAuthorizationRequestResolver.java:28-29`). - "동일 속성으로 maxAge 만 0 으로 내려야 브라우저가 기존 쿠키와 매칭해 덮어쓰며 제거. 속성이 하나라도 다르면 별개 쿠키로 인식" (`TokenDeliveryService.java:51-54`). ### 5. CSRF / CORS - CSRF: stateless JWT API 이므로 disable. 쿠키 모드에서도 refresh 쿠키는 `SameSite=Strict` 라 cross-site 요청에 자동 첨부되지 않아 CSRF 위협 없음. - CORS: `allowCredentials(true)` 와 함께 wildcard 가 아닌 명시적 origin 화이트리스트. ### 6. 보안 관련 클래스 한눈에 보기 ``` common.config.SecurityConfig (@Order 2) ── JWT 체인, CORS, public endpoints auth.config.OAuth2SecurityConfig (@Order 1) ── /oauth2/** /login/oauth2/code/** 만 common.filter.JwtAuthenticationFilter ── Bearer 추출 + ACCESS 토큰만 수용 common.handler.JwtAuthenticationEntryPoint ── 401 JSON 통일 common.handler.JwtAccessDeniedHandler ── 403 JSON 통일 common.jwt.JwtTokenProvider ── HS256 발급/검증 common.jwt.JwtProperties ── SSM /groute/{env}/JWT_SECRET common.jwt.TokenType ── ACCESS / REFRESH common.jwt.JwtValidationResult ── VALID / EXPIRED / INVALID auth.service.AuthService ── reissue (rotate) / logout auth.service.TokenDeliveryService ── cookie/body 분기, cookie clear auth.repository.RefreshTokenRepository ── Redis Lua CAS, SHA-256 해시 저장 auth.controller.AuthController ── /api/auth/reissue, /api/auth/logout auth.service.oauth.CustomOAuth2UserService ── provider user-info → 정규화 → upsert auth.service.oauth.OAuthAttributes ── kakao/google/naver 정규화 auth.service.oauth.PrincipalUser ── OAuth2User, userId 1급 필드 auth.service.oauth.OAuth2LoginSuccessHandler ── JWT 발급 + redirect auth.service.oauth.OAuth2LoginFailureHandler ── ?error= 로 redirect auth.service.oauth.OAuth2EnvAwareAuthorizationRequestResolver ── env 쿠키 발행 auth.service.oauth.OAuthCallbackUrlResolver ── 화이트리스트 매칭 + 쿠키 만료 auth.config.AuthProperties ── 부팅 fail-fast, callback 화이트리스트 ``` ### 7. 평가 답변용 한 문장 요약 > 로그인은 카카오/구글/네이버 OAuth2 Authorization Code Grant + Spring Security 자동 state 검증으로 처리된다. 자체 비밀번호는 운영하지 않으며 인증 결과로 HS256 JWT(access 1h / refresh 14d) 를 발급한다. Refresh 토큰은 Redis 에 SHA-256 해시로만 저장되고 Lua compare-and-set 으로 원자 회전되며, 회전 실패 시 토큰 패밀리 전체를 즉시 무효화해 재사용 공격을 차단한다. 운영 환경의 refresh 토큰은 `HttpOnly + Secure + SameSite=Strict` 쿠키로만 전달되어 XSS·CSRF·크로스 사이트 자동 전송이 모두 막혀 있고, redirect target 은 화이트리스트(`auth.callback` 맵) 외 URL 로는 절대 나가지 않는다.