# 평가: 보안 — 민감한 사용자 정보와 자격증명이 안전하게 저장 및 관리되고 있는가
> 대분류: 보안 / 소분류: 민감한 사용자 정보와 자격증명이 안전하게 저장 및 관리되고 있는가
> 기준 브랜치: `origin/dev` (작성 시점 HEAD: `cb41e36`)
## 요약
- **만족도**: **우수** (자격증명은 SSM Parameter Store(KMS 암호화 기반 SecureString)로 일원화, 배포 파이프라인은 GitHub OIDC + IAM Role 로 정적 키 0개, refresh 토큰은 SHA-256 해시로만 Redis 저장, 코드/로그에 토큰 평문 노출 없음. 다만 SocialAccount.email 평문 저장 / 일부 yaml 기본값 / `BusinessException` 메시지 직접 노출 같이 점진 개선 여지가 있다)
### 근거
- **자격증명 단일 진실(Single Source of Truth)**: `application.yaml` 의 모든 시크릿은 `${VAR_NAME}` placeholder. 운영/스테이징은 SSM Parameter Store `/groute/{env}/*` 에서 `--with-decryption` 으로 읽혀 EC2 의 `/tmp/groute.env` 로 변환되고, `docker-compose` 가 `env_file` 로 컨테이너에 주입한다.
- `.github/workflows/deploy.yml` — `aws ssm get-parameters-by-path --path $SSM_PATH --recursive --with-decryption ... > /tmp/groute.env`, 종료 시 `trap cleanup EXIT` 로 `/tmp/groute.env` 삭제.
- `docker-compose.yml:6-7` — `env_file: /tmp/groute.env`.
- **정적 AWS 자격증명 0개**: GitHub Actions 는 OIDC `id-token` 으로 `STG_ROLE_ARN` / `PROD_ROLE_ARN` 을 STS AssumeRole. Access Key 가 리포지토리/시크릿 어디에도 평문/장기로 존재하지 않는다.
- `.github/workflows/ci.yml` — `permissions: id-token: write` + `aws-actions/configure-aws-credentials@v4` with `role-to-assume`.
- `.github/workflows/deploy.yml` — 동일 패턴.
- EC2 인스턴스 측 AWS 호출은 `DefaultCredentialsProvider` 가 인스턴스 프로파일을 자동 픽업(`S3Config.java:30, 38`).
- **부팅 단계 fail-fast**: 필수 시크릿 placeholder 가 비어 있으면 컨텍스트 로드 자체가 실패. 운영에 "기본값으로 부팅" 사고가 발생하지 않는다.
- `src/main/resources/application.yaml:108` — `auth.default-env: ${AUTH_DEFAULT_ENV}` (default 미지정).
- `src/main/java/com/groute/groute_server/auth/config/AuthProperties.java:49-63` — compact ctor 가 `callback` 비어 있거나 `defaultEnv` 누락 시 `IllegalStateException`.
- `src/main/resources/application.yaml:130` — `default-profile-image-url: ${USER_DEFAULT_PROFILE_IMAGE_URL}` (prod/stg 미주입 시 부팅 실패).
- `src/main/resources/application.yaml:158` — `firebase.credentials-json: ${FIREBASE_CREDENTIALS_JSON}`.
- **Refresh 토큰 저장 형태가 안전**: Redis 에는 원문이 아닌 SHA-256 해시(hex) 만 저장. Redis 스냅샷·로그·SDK debug 노출 시에도 토큰 자체는 복원 불가.
- `src/main/java/com/groute/groute_server/auth/repository/RefreshTokenRepository.java:21-22,82-90` — `private String hash(String token) { ... MessageDigest "SHA-256" ... }`, 저장값은 `hash(refreshToken)`.
- 검증(rotate)도 해시 비교(`:66-76`) — 원문 토큰을 Redis 에서 꺼낼 일이 코드 경로상 존재하지 않는다.
- **사용자 비밀번호 자체가 시스템에 존재하지 않음**: 인증은 카카오/구글/네이버 OAuth2 전용이며 `User` 엔티티에 password 컬럼이 없다. 즉 "비밀번호 해시 보관" 문제가 처음부터 발생하지 않는다.
- `src/main/java/com/groute/groute_server/user/entity/User.java:33-108` — password 필드 없음(전체 검색 0건).
- `src/main/java/com/groute/groute_server/auth/entity/SocialAccount.java` — `provider`, `providerUid`, `email` 만 저장.
- **민감 토큰의 로그/응답 노출 차단**:
- `src/main/java/com/groute/groute_server/auth/service/oauth/OAuth2LoginSuccessHandler.java:39-41` — 명시적으로 "토큰 값은 절대 로깅하지 않는다. userId·provider·callback URL 만 출력" 정책. `log.info` 호출(`:77-82`)에 토큰 0건.
- `src/main/java/com/groute/groute_server/auth/service/AuthService.java:57, 70` — `log.debug("리프레시 성공: userId={}", userId)` / `log.debug("로그아웃 성공: userId={}", userId)` — userId 만.
- `src/main/java/com/groute/groute_server/auth/dto/TokenReissueRequest.java:17-19` — `@Schema(accessMode = WRITE_ONLY)` 로 OpenAPI 응답 스키마에서도 토큰 제외.
- `src/main/java/com/groute/groute_server/auth/dto/TokenResponse.java:13` — `@JsonInclude(NON_NULL)` 로 쿠키 모드에서 refresh 가 응답 본문에서 자동 제거.
- `src/main/java/com/groute/groute_server/auth/service/oauth/OAuth2LoginFailureHandler.java:30-31` — 에러 query 에 사용자 메시지(잠재 PII) 노출 금지 정책 + 상세 사유는 서버 로그만.
- **Redis 자체 인증/메모리 정책**: 컨테이너 redis 도 `--requirepass` 로 인증 강제. EC2 → Redis 트래픽은 컨테이너 네트워크 내부.
- `docker-compose.yml:24-32` — `redis-server --requirepass ${REDIS_PASSWORD} ...`, 배포 스크립트에서 `REDIS_PASSWORD` SSM 부재 시 `exit 1` (`.github/workflows/deploy.yml`).
- **민감 파일 git 누출 차단**:
- `.gitignore:40-49` — `.env`, `.env.*`, `application-local*.yml`, `application-secret*.yml`, `application-local*.properties`, `application-secret*.properties` 광범위 차단.
- `.gitignore:67-69` — `.omc/`, `.claude/` 차단(에이전트 산출물에 토큰이 새지 않도록).
- **OAuth Client Secret 보관**: 카카오/네이버는 `client_secret_post` 방식이라 application 측에서 secret 보유 필요. 모두 SSM 주입(`application.yaml:47-71`).
- **Internal API Token (서버 ↔ AI 서버 인증)**: 정적 토큰을 SSM 에서 주입하여 `X-Internal-Token` 헤더로만 사용. 코드 상수 0건.
- `src/main/java/com/groute/groute_server/record/adapter/out/ai/AiTaggingClientAdapter.java:47,59` / `src/main/java/com/groute/groute_server/report/adapter/out/ai/AiReportClientAdapter.java:61,72`.
- **Discord 에러 웹훅 본문**: unhandled 500 만 알림. BusinessException(검증 가능한 오류)는 webhook 전송 안 함 — Discord 채널에 PII/토큰이 흘러가지 않게 첫 단계에서 막음.
- `src/main/java/com/groute/groute_server/common/exception/GlobalExceptionHandler.java:25-35,116-123`.
- **DB 접근 권한**: PostgreSQL credentials 도 SSM 주입(`application.yaml:15-19` placeholder).
### 남은 To-do
- [ ] **`SocialAccount.email` 평문 저장 → 일방향 해시 또는 컬럼 암호화로 전환**: 현재 provider 에서 받은 이메일을 그대로 저장. 본 서비스는 이메일을 "재로그인 시 동일 계정 식별"용으로만 쓰고 직접 노출/매핑 키로 쓰지 않으므로, `email_hash`(SHA-256) 컬럼으로 대체하거나 JPA `AttributeConverter` 로 AES-GCM 양방향 암호화 적용.
- 변경 위치: `src/main/java/com/groute/groute_server/auth/entity/SocialAccount.java:41-42` + `src/main/java/com/groute/groute_server/auth/service/SocialLoginService.java:39` (`updateEmail` 호출부) + Flyway migration 추가.
- [ ] **JWT secret 로컬 fallback 값 제거 또는 명시적 dev-only 마킹**: `application.yaml:95` 의 `JWT_SECRET` 기본값 `local-dev-secret-key-must-be-changed-in-production-env` 가 stg/prod 에서 잘못 주입 누락되었을 경우 fallback 으로 부팅되어 토큰 위조 위험. fail-fast 가 동작하긴 하지만(secret 자체는 항상 채워지므로), 안전을 위해 prod 프로파일에서 placeholder 를 `${JWT_SECRET}` (default 없음)으로 오버라이드해서 누락 시 부팅 실패하도록 강화.
- 변경 위치: `src/main/resources/application.yaml` prod 프로파일에 `jwt.secret: ${JWT_SECRET}` (default 미지정) 명시.
- [ ] **DB 자격증명 기본값 제거 (prod 보강)**: `application.yaml:16-18` 의 datasource username/password 기본값(`postgres/postgres`)은 로컬용. prod 프로파일에서 `${SPRING_DATASOURCE_PASSWORD}` (no default)로 강제 override 하는 게 더 안전.
- [ ] **로그 표준 마스킹 정책 도입**: 현재 토큰은 안 찍히지만, `BusinessException(e.getMessage())` 가 `GlobalExceptionHandler.java:39-43` 에서 응답 본문에 그대로 실린다. 메시지에 내부 키/식별자가 우연히 들어가는 사고 방지를 위해 `BusinessException.message` 는 사용자 안전 문구로 한정하고, 디테일은 `log.warn` 만 가도록 가이드 보강.
- 평가용 액션: 모든 `throw new BusinessException(...)` 의 message 검토 (특히 `OAuthAttributes.java:28-30,40-42,63-64,77-78` 에서 provider 응답값을 message 에 끼우는 부분 — provider 식별자가 사용자 응답에 노출되는 패턴).
- [ ] **MethodArgumentTypeMismatchException 응답 본문에 잘못된 값 echo 검토**: `GlobalExceptionHandler.java:99-114` — `e.getValue().toString()` 을 응답 fieldError 로 그대로 노출. 사용자가 보낸 값을 그대로 돌려주므로 잠재적으로 자기 자신의 토큰을 잘못 끼워 보냈을 때 echo 될 가능성. 일반적인 패턴이지만, `Authorization` 헤더가 path/query/body 에 잘못 들어오면 echo 될 수 있으므로 잘라내기(truncate) 또는 마스킹 권장.
- [ ] **개인정보 처리방침/보유기간 코드 동기화 점검**: `User.scheduleHardDelete()` 가 30일 grace 후 hard delete (`application.yaml:133`). 처방서·약관과 30일 동일 명시 여부를 확인하고 docs 에 짧게 기록(증빙용).
- [ ] **SSM Parameter Store 의 KMS 키 정책/접근 권한 캡처**: 평가 자료에 "SecureString 이며 customer-managed KMS 로 암호화" 사실을 1장으로 정리. (코드 변경 없음, 운영 증빙용)
- [ ] **CI 비밀 사용량 감사**: `secrets.STG_ROLE_ARN`, `secrets.PROD_ROLE_ARN`, `secrets.JIRA_*` 를 제외하고 GitHub Actions 시크릿에 다른 정적 키가 남아있지 않은지 확인(`gh secret list`). 과거에 임시로 넣어둔 키가 잔존하지 않게.
- [ ] **(권장) Refresh 토큰 회전 시 access 토큰 jti 블랙리스트 도입 검토**: 현재 access 토큰은 stateless TTL 1h. 탈취 의심 시(rotate 실패 → `deleteByUserId`)에도 발급된 access 토큰은 만료까지 살아있다. 토큰 패밀리 단위로 jti 블랙리스트를 두면 보안 강화. (성능 trade-off 검토 필요 — 단순 평가용으론 ‘인식하고 있음’ 만 명시해도 됨)
- 참고: `src/main/java/com/groute/groute_server/auth/service/AuthService.java:64-66` 가 이미 "access 토큰은 stateless 원칙 유지 — TTL(1h) 만료 대기, 블랙리스트 도입은 별도 이슈" 로 명시되어 있음.
---
## 상세 분석
### 1. 자격증명 수명주기
```
[리포지토리 코드]
yaml: username/password/secret = ${PLACEHOLDER} ← 평문/하드코딩 없음
│
│ (시작 시)
▼
[Spring Boot 부팅]
Placeholder 미해석 → 부팅 실패 (fail-fast)
│
▼
[배포 파이프라인]
GitHub Actions OIDC → AssumeRole(STG_ROLE_ARN | PROD_ROLE_ARN)
▼
ssm get-parameters-by-path /groute/{env} --with-decryption
▼
/tmp/groute.env ← trap cleanup EXIT 로 종료 시 즉시 삭제
▼
docker compose up (env_file: /tmp/groute.env)
▼
[컨테이너 환경변수]
▼
[Spring Boot 컨텍스트]
JwtProperties.secret ← SSM /groute/{env}/JWT_SECRET
FirebaseProperties.credentialsJson ← SSM /groute/{env}/FIREBASE_CREDENTIALS_JSON
S3Properties.bucket / cdnBaseUrl ← SSM
DataSource.password ← SSM /groute/{env}/SPRING_DATASOURCE_PASSWORD
Redis password ← SSM /groute/{env}/REDIS_PASSWORD
OAuth client_secret ← SSM /groute/{env}/{KAKAO|GOOGLE|NAVER}_CLIENT_SECRET
AI internal token ← SSM /groute/{env}/INTERNAL_API_TOKEN
```
이 사이클의 핵심은:
1. 정적 키가 리포지토리·CI 시크릿·EC2 디스크 어디에도 영속화되지 않는다.
2. SSM 으로 KMS 암호화된 SecureString 으로 보관 → 평문 노출은 EC2 메모리(`/tmp/groute.env` 짧은 윈도우) 한 곳뿐.
3. 부팅 단계 fail-fast 가 "기본값 fallback 으로 운영 부팅" 사고를 막는다.
### 2. 시크릿 카테고리별 보관 방식
| 시크릿 | 보관 | 코드 진입점 | 비고 |
|--------|------|------------|------|
| JWT signing key | SSM `/groute/{env}/JWT_SECRET` | `JwtProperties.java`, `JwtTokenProvider.java:35` | HS256 대칭키. 누락 시 부팅 실패는 prod 프로파일에 명시되어 있지 않아 보강 필요(요약 To-do). |
| OAuth client secret (Kakao/Google/Naver) | SSM 각 `${...}_CLIENT_SECRET` | `application.yaml:48,57,66` | `client_secret_post` 인증 |
| Refresh token (사용자 발행분) | Redis `refresh:{userId}` (SHA-256 해시) | `RefreshTokenRepository.java:21-22, 82-90` | 원문 미저장 |
| Refresh cookie (브라우저) | `HttpOnly; Secure; SameSite=Strict` | `TokenDeliveryService.java:64-72` | XSS 추출 불가 |
| DB credential | SSM | `application.yaml:15-19` placeholder | prod 기본값 제거 권장(To-do) |
| Redis password | SSM `REDIS_PASSWORD` | `docker-compose.yml:25-26` | 미주입 시 deploy 단계 `exit 1` |
| Firebase service account JSON | SSM `FIREBASE_CREDENTIALS_JSON` | `FirebaseConfig.java:43-48` | JSON 전체. 누락 시 빈 부팅 비등록 (`ConditionalOnExpression`) |
| AWS credentials (runtime) | EC2 IAM instance profile | `S3Config.java:30,38` `DefaultCredentialsProvider` | 정적 키 0 |
| AWS credentials (deploy) | GitHub OIDC → STS AssumeRole | `.github/workflows/ci.yml`, `deploy.yml` | 정적 키 0 |
| AI internal token | SSM `INTERNAL_API_TOKEN` | `AiTaggingClientAdapter.java:47,59`, `AiReportClientAdapter.java:61,72` | `X-Internal-Token` 헤더 |
| Discord webhook URL | SSM `DISCORD_WEBHOOK_URL` | `application.yaml:152, 238, 277` | stg/prod 활성, local 비활성 |
| Jira API token (CI) | GitHub secret | `.github/workflows/jira-issue-*.yml` | issue 닫힘 시 Jira transition (코드 외부) |
### 3. 사용자 민감 정보(PII) 보관 형태
- `User` 엔티티: 닉네임(`length=12`), 직군(enum), 상태(enum), 브랜딩 문장(`length=100`), 마지막 로그인 시각, hard-delete 예약 시각, 카운터들. **이메일·전화번호·이름·주소 등 직접 PII 컬럼 없음.**
- `SocialAccount` 엔티티: `provider`, `provider_uid`, `email`. **email 만 잠재 PII.** 일부 provider(카카오)는 nullable. 이메일을 직접 마케팅·노출에 쓰지 않고 "재로그인 매칭"용으로만 사용하므로 해시화/암호화로 전환 권장(요약 To-do).
- `DeviceToken` 엔티티: `push_token` 평문 보관. FCM/APNs 토큰은 그 자체로 발송 외 다른 용도가 없고, FCM 측 토큰은 디바이스 reset 시 회전되므로 일반적으로 평문 저장이 업계 표준. 발송 실패 시 즉시 `is_active=false` 비활성, 탈퇴 시 hard-delete(`DeviceTokenRepository.java:60-66`).
- 탈퇴 처리: `User.scheduleHardDelete(clock, grace=Duration.ofDays(30))` 로 30일 grace 후 물리 삭제 예약. 배치가 hard-delete 수행.
### 4. 로그·예외 응답에서의 민감 정보 노출 점검
- 모든 `auth` 패키지 로그 grep 결과(`OAuth2LoginSuccessHandler:77`, `OAuth2LoginFailureHandler:50`, `OAuthCallbackUrlResolver:44`, `OAuth2EnvAwareAuthorizationRequestResolver:79`, `AuthService:57,70`, `SocialLoginService:52`) — userId / provider / callback URL / env 만 출력. **토큰 평문 노출 0건 확인.**
- DTO 직렬화 측 보호:
- `TokenReissueRequest`: `Schema(accessMode = WRITE_ONLY)` (요청 전용, OpenAPI 응답 스키마에서 제거).
- `TokenResponse`: `@JsonInclude(NON_NULL)` (쿠키 모드 시 `refreshToken` 자동 누락).
- 예외 응답:
- `GlobalExceptionHandler.handleBusinessException` 가 `e.getMessage()` 를 그대로 노출 — 위 To-do 참고. OAuth 응답 정규화 실패 시 message 에 provider 이름이 들어가는 정도라 직접적인 PII 누출은 아니지만, 규모가 커지면 표준화 필요.
- 디버그 모드 차이:
- local: `logging.level.com.groute: DEBUG` + `hibernate.SQL: DEBUG` + `jdbc.bind: TRACE` — 쿼리 파라미터까지 찍힘(개발 편의).
- stg: `com.groute: INFO`, `root: WARN`.
- prod: `com.groute: WARN`, `root: ERROR`. 운영에서는 debug 라인이 출력되지 않음 → 위 `AuthService.log.debug("리프레시 성공: userId={}", userId)` 도 prod 에선 안 찍힘.
### 5. 평가 답변용 한 문장 요약
> 본 서비스는 모든 자격증명을 AWS SSM Parameter Store SecureString 에 보관하고 부팅 시 환경변수로만 주입하며, 운영 EC2/CI 어디에도 정적 AWS Access Key 가 존재하지 않는다. 사용자 비밀번호 자체가 없는 소셜 로그인 전용 구조이며, 발행한 Refresh 토큰은 Redis 에 SHA-256 해시로만 저장(원문 미저장), 응답·로그·OpenAPI 스키마 모두에서 토큰 평문 노출을 차단한다. 부팅 단계 fail-fast 로 시크릿 누락이 곧바로 노출되어 운영 사고를 막는다.