# 평가: 보안 — 로그인 및 인증 과정이 안전하게 처리되고 있는가 (암호화/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 로는 절대 나가지 않는다.