Skip to content

[oauth] SSO IdP 세션 도입 - #433

Open
ZaMan0806 wants to merge 4 commits into
feature/oauth-scope-segmentationfrom
add/oauth-sso-idp-session
Open

[oauth] SSO IdP 세션 도입#433
ZaMan0806 wants to merge 4 commits into
feature/oauth-scope-segmentationfrom
add/oauth-sso-idp-session

Conversation

@ZaMan0806

@ZaMan0806 ZaMan0806 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

개요

OAuth 로그인 상태를 IdP(datagsm-server)에 보관해, 여러 클라이언트를 오갈 때 재로그인 없이 인증이 이어지도록 합니다.

기존에는 로그인 성공 결과가 어디에도 남지 않아(요청 1건에 대한 code 발급 후 종료) 다른 클라이언트로 이동하면 항상 다시 로그인해야 했습니다. 이 PR은 "로그인 성공"과 "code 발급"을 분리해 SSO를 가능하게 합니다.

변경 흐름

GET /v1/oauth/authorize  (브라우저 최상위 이동 → 쿠키 자동 실림)
  ├─ 세션 유효 ∧ 계정 ACTIVE ∧ 미해소 정보수정요청 없음 ∧ 요청 scope가 동의 기록에 포함
  │    → 로그인 생략하고 code 발급 → 클라이언트로 302
  └─ 하나라도 미충족 → 기존 로그인 페이지로 302 (폴백)

POST /v1/oauth/authorize  (BFF 서버-투-서버)
  → 자격증명 검증 → 동의 기록 → 세션 생성 → 핸드오프 URL로 302

GET /v1/oauth/authorize/session?ticket=  (신규, 브라우저 최상위 이동)
  → 티켓 검증 후 즉시 삭제(일회용) → Set-Cookie → 클라이언트로 302

핸드오프 단계가 필요한 이유

POST /v1/oauth/authorize는 프론트 BFF가 서버-투-서버 fetch로 호출하므로, 이 응답에 Set-Cookie를 실어도 브라우저가 아닌 Next.js 서버가 받게 됩니다. 따라서 브라우저가 최상위 내비게이션으로 백엔드를 한 번 경유하도록 일회용 티켓을 두고, 그 지점에서 세션 쿠키를 심습니다.

보안 고려

  • 정보 수정 강제가 SSO로 우회되지 않습니다. 세션이 있어도 미해소 StudentDataEditRequest가 있으면 code를 발급하지 않고 로그인 페이지로 폴백합니다.
  • 세션 쿠키는 HttpOnly / Secure / SameSite=Lax / Max-Age 적용.
  • 핸드오프 티켓은 일회용이며 사용 즉시 삭제됩니다(기본 TTL 60초).
  • 클라이언트별 scope 동의를 기록해, 동의하지 않은 scope는 세션이 있어도 자동 승인되지 않습니다.

주요 변경 파일

신규

  • IdpSessionRedisEntity / IdpSessionRedisRepository — 로그인 세션 (기본 8시간)
  • IdpSessionHandoffRedisEntity / IdpSessionHandoffRedisRepository — 일회용 핸드오프 티켓
  • OauthConsentJpaEntity / OauthConsentJpaRepository — 클라이언트별 scope 동의 기록
  • IssueAuthorizationCodeService(+Impl) — code 발급 로직을 GET/POST 양쪽에서 재사용하도록 추출
  • CompleteIdpSessionHandoffService(+Impl) — 티켓 소비 및 쿠키 설정

수정

  • StartOauthAuthorizeFlowServiceImpl — SSO 세션 분기 추가 (#431의 스코프 변경과 별개)
  • CompleteOauthAuthorizeFlowServiceImpl — 세션·동의·핸드오프 생성, code 발급 위임
  • OauthController — 쿠키 파라미터, 핸드오프 엔드포인트
  • OauthEnvironment / application.yml — 세션·쿠키 설정 추가

⚠️ Breaking Change (프론트 수정 필요)

POST /v1/oauth/authorize의 응답 Location이 변경됩니다.

변경 전: https://client.example.com/callback?code=xxx&state=yyy
변경 후: https://oauth.authorization.datagsm.kr/v1/oauth/authorize/session?ticket=zzz

BFF가 이 Location을 가공 없이 그대로 브라우저에 반환하면 정상 동작합니다. 다만 다음에 해당하면 수정이 필요합니다.

  • fetch에 redirect: 'follow'가 걸려 있으면 → redirect: 'manual'로 변경 필수 (서버가 리다이렉트를 따라가면 쿠키가 브라우저에 심기지 않습니다)
  • Location이 클라이언트 주소인지 검증하는 로직이 있으면 → 백엔드 도메인도 허용
  • Location에서 code/state를 파싱해 쓰고 있으면 → 제거

프론트 수정은 이 PR 머지 전에 완료될 예정입니다.

배포 환경변수

OAUTH_ISSUER_URL=https://oauth.authorization.datagsm.kr
OAUTH_IDP_SESSION_COOKIE_DOMAIN=.datagsm.kr

OAUTH_ISSUER_URL이 잘못되면 핸드오프 리다이렉트가 깨집니다. 나머지(세션 8시간, 쿠키명, Secure=true)는 기본값으로 충분합니다.

테스트

  • 전 모듈 643개 통과 (authorization 모듈 111개)
  • 신규 11개: SSO 성공 경로 1, 폴백 6(세션 없음/만료/비ACTIVE/정보수정요청/동의없음/scope부족), 핸드오프 4

후속 작업 (별도 PR)

  • 동의 화면 UI — 현재는 처음 쓰는 클라이언트에서 재로그인 후 자동 동의
  • 로그아웃 및 전파 — 현재 로그아웃 기능 자체가 없어 세션 만료(8시간)에 의존
  • 비밀번호 변경 시 기존 세션 무효화 — 현재 비밀번호를 바꿔도 세션이 유지됨
  • id_token + openid scope, OIDC discovery 문서

코드리뷰 반영 (보안 수정)

셀프 코드리뷰에서 발견한 결함 3건을 이 PR 내에서 수정했습니다.

1. 핸드오프 URL 재사용으로 세션이 탈취되던 문제 (CRITICAL)

ticket은 URL 쿼리라 Location 헤더·Referer·BFF 로그·브라우저 히스토리에 남습니다. 그것만으로 세션 쿠키를 내주면, 티켓을 입수한 공격자가 만료 전에 먼저 열어 피해자 계정의 SSO 세션을 획득할 수 있었습니다.

처음에는 verifier를 추가 발급해 해시만 저장하는 방식으로 접근했으나, 교차 리뷰에서 이 방식이 목적을 달성하지 못한다는 점이 드러났습니다. ticketverifier가 같은 URL 쿼리에 함께 실리므로, 위 채널들에서 둘은 항상 같이 노출됩니다. 비밀을 둘로 쪼갰지만 같은 봉투에 넣은 셈입니다.

BFF가 서버-투-서버라 POST 응답에 쿠키를 심을 수 없다는 제약 때문에, 리다이렉트 URL만으로는 채널 분리가 원리적으로 불가능합니다. 그래서 요청의 형태를 검증하는 방향으로 전환했습니다.

  • Sec-Fetch-Site / Sec-Fetch-Mode브라우저 최상위 내비게이션만 허용
  • 나중에 URL을 입수해 fetch·<img>로 재사용하는 시도가 차단됨
  • 헤더 미전송 클라이언트는 기본 통과(로그인 차단 방지), 엄격 모드는 OAUTH_IDP_SESSION_HANDOFF_REQUIRE_FETCH_METADATA=true
  • verifier 대조는 부분 유출 대비 보조 방어로 유지 (MessageDigest.isEqual)

⚠️ 프론트 주의: 핸드오프 URL을 서버에서 fetch로 열면 400이 납니다. 반드시 브라우저가 직접 이동해야 합니다.

2. 오픈 리다이렉트 (HIGH)

저장된 redirectUrl을 검증 없이 Location에 넣고 있었습니다. 화이트리스트 검증이 저장 이전 단계에만 있어 방어가 한 겹뿐이었습니다.

  • 소비 시점에 client.redirectUrls로 재검증
  • 접두사 비교는 https://example.com.attacker.io 같은 도메인 위조를 허용하므로, 이어지는 문자가 ?/& 구분자인지까지 확인

3. state URL 인코딩 누락 (HIGH)

state는 클라이언트가 임의 값을 넣는 CSRF 방어 값이라, 인코딩 없이 붙이면 & 삽입으로 파라미터를 조작할 수 있었습니다.

  • code/state 모두 URLEncoder로 인코딩

테스트

IssueAuthorizationCodeServiceTest를 신설하고(6개), 핸드오프 테스트에 verifier 불일치·오픈 리다이렉트·접두사 위조·Sec-Fetch 차단 케이스를 추가했습니다. 모듈 전체 129개 테스트 통과.

두 방어(Sec-Fetch 검증, 티켓 보존)는 뮤테이션 테스트로 실효성을 확인했습니다 — 검증을 제거하면 각각 6건, 1건이 실패합니다.

2차 교차 리뷰에서 함께 제거한 문제

티켓 삭제 DoS (HIGH) — verifier 불일치 시 티켓을 삭제하던 동작을 걷어냈습니다. ticket만 아는 공격자가 아무 값이나 verifier로 넣어 요청 한 번으로 정상 사용자의 로그인을 무효화할 수 있었습니다. 탈취(두 값 모두 필요)보다 적은 정보로 가능한 공격이라, 무차별 대입 차단 이득보다 손실이 컸습니다.

남은 후속 과제

로그아웃 부재, 비밀번호 변경 시 세션 무효화, 동의 화면 UI는 별도 PR로 진행합니다.

2차 리뷰에서 추가로 확인된 항목(별도 PR 예정):

  • recordConsent의 동시성 — 인가 코드가 Redis에 발급된 consent를 기록하는데, @Transactional은 Redis를 롤백하지 않아 동시 요청 시 코드는 발급된 채 500이 반환될 수 있음
  • SSO 세션 무효화 경로 부재 — idpSessionRedisRepositorydelete 호출이 없어 쿠키 유출 시 8시간 동안 서버에서 끊을 수 없음
  • resolveSsoAccount의 6중 폴백에 진단 로그 부재 (폴백 자체는 안전한 설계로 확인됨)
  • 계정 삭제 기능이 추가될 때 tb_oauth_consent 행도 함께 정리해야 함accountId가 FK 없는 단순 컬럼이라 자동 정리되지 않음 (현재 계정 삭제 경로 자체가 없어 이번 PR 범위 밖)

로그인 상태를 IdP에 보관해 여러 클라이언트 간 재로그인 없이
인증이 이어지도록 한다.

- IdpSessionRedisEntity: 로그인 세션 저장 (기본 8시간)
- IdpSessionHandoffRedisEntity: BFF가 서버-투-서버로 POST를 호출해
  응답에 쿠키를 심을 수 없으므로, 브라우저를 백엔드로 한 번 경유시키는
  일회용 티켓
- OauthConsentJpaEntity: 클라이언트별 scope 동의 기록
- IssueAuthorizationCodeService: code 발급 로직을 GET/POST 양쪽에서
  재사용하도록 추출

GET /authorize는 세션 쿠키가 유효하고 계정이 ACTIVE이며 미해소 정보
수정 요청이 없고 요청 scope가 동의 기록에 포함될 때만 로그인을 생략한다.
어느 조건이든 어긋나면 기존 로그인 플로우로 폴백해, 정보 수정 강제가
SSO로 우회되지 않도록 한다.

POST /authorize의 Location은 SP redirect_uri에서 핸드오프 URL로 변경된다.

@pr-agent-demo pr-agent-demo Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Overview

  • IdP(datagsm-server)에 SSO 세션을 도입하는 PR입니다.
  • OAuth authorize 성공 시 세션과 일회용 핸드오프 티켓을 생성하고, 브라우저 경유 엔드포인트에서 세션 쿠키를 설정한 뒤 클라이언트로 리다이렉트하도록 변경합니다.
  • 기존 “로그인 성공”과 “code 발급”을 분리하고, 세션/동의 상태가 있으면 로그인 없이 code를 발급하는 SSO 분기를 추가합니다.

Intent

  • 여러 OAuth 클라이언트 간 재로그인을 줄이고 SSO 경험을 제공한다.
  • 서버-투-서버 호출에서 브라우저 쿠키를 직접 심을 수 없는 문제를 일회용 티켓 핸드오프로 우회한다.
  • 클라이언트별 scope 동의 기록을 남겨, 세션이 있어도 동의되지 않은 scope는 자동 승인되지 않도록 한다.

Risk

  • 높음: OAuth 인증/인가 흐름, 세션, 쿠키, 리다이렉트, 동의 정책 등 보안 핵심 영역 변경.
  • 집중 검토 지점:
    • POST /v1/oauth/authorize 응답 Location이 핸드오프 URL로 바뀌는 Breaking Change와 프론트/BFF 연동 상태
    • 세션 쿠키 도메인/Secure/SameSite 설정, OAUTH_ISSUER_URL 등 배포 환경변수 정확성
    • 일회용 티켓 생성/소비/만료 처리와 중복 사용 방지
    • SSO 우회 방지 조건(계정 상태, 정보수정요청, scope 동의)의 완결성
    • 세션/코드 발급/동의 저장 간 트랜잭션·동시성 처리
    • 기존 OAuth 플로우와의 호환성 및 회귀 영향

0 inline comment(s)

코드리뷰에서 발견된 보안 결함 3건을 수정한다.

핸드오프 티켓 단독 세션 발급 (세션 고정)
- ticket은 URL에 노출되어 로그/Referer/브라우저 히스토리에 남으므로
  그것만으로 세션 쿠키를 발급하면 티켓 탈취자가 피해자 세션을 획득한다.
- verifier를 함께 발급해 해시(SHA-256)만 저장하고, 소비 시점에 대조한다.
- 불일치 시에도 티켓을 즉시 폐기해 무차별 대입을 차단한다.

오픈 리다이렉트
- 저장된 redirectUrl을 검증 없이 Location에 반영하던 것을,
  소비 시점에 client.redirectUrls로 재검증하도록 변경한다.
- 접두사 비교는 도메인 위조를 허용하므로 구분자까지 확인해 정확히 일치시킨다.

state URL 인코딩 누락
- state는 클라이언트가 임의 값을 넣는 CSRF 방어 값이라,
  인코딩하지 않으면 '&' 삽입으로 파라미터 조작이 가능하다.
- code/state 모두 URLEncoder로 인코딩한다.

IssueAuthorizationCodeServiceTest를 신설하고, 핸드오프 테스트에
verifier 불일치/오픈 리다이렉트/접두사 위조 케이스를 추가한다.
@ZaMan0806 ZaMan0806 self-assigned this Sep 7, 2026
@snowykte0426 snowykte0426 added enhancement:개선작업 새 기능 또는 기존 코드 개선에 관한 내용입니다 waiting for review:검토 대기 확인을 대기하고 있습니다 labels Sep 9, 2026
교차 리뷰에서 드러난 두 가지 문제를 바로잡는다.

verifier 분리의 한계 보완
- ticket과 verifier가 같은 URL 쿼리로 전달되므로, 로그·Referer·브라우저
  히스토리에 노출되는 상황에서는 두 값이 항상 함께 새어나간다.
  즉 verifier만으로는 애초 의도한 유출 방어가 성립하지 않는다.
- 정상 흐름이 브라우저 최상위 내비게이션이라는 점을 이용해
  Sec-Fetch-Site/Sec-Fetch-Mode로 그 형태만 통과시킨다.
  나중에 URL을 입수해 fetch나 이미지 로드로 재사용하는 시도가 차단된다.
- 헤더를 보내지 않는 구형 브라우저는 기본적으로 통과시켜 로그인을 막지
  않으며, 엄격 모드는 설정으로 켤 수 있다.

티켓 삭제 DoS 제거
- verifier 불일치 시 티켓을 삭제하던 동작을 걷어낸다.
- ticket만 아는 공격자가 아무 verifier로 요청 한 번을 보내
  정상 사용자의 로그인을 무효화할 수 있었다. 탈취보다 적은 정보로
  가능한 공격이라 무차별 대입 차단 이득보다 손실이 크다.

핸드오프 서비스가 JPA를 조회하므로 @transactional(readOnly = true)를 명시한다.
리뷰에서 지적된 항목을 반영한다.

세션 쿠키를 host-only로 고정
- 쿠키를 심는 곳과 읽는 곳이 모두 authorization 모듈의 같은 호스트라
  Domain 확장이 필요 없다. 상위 도메인으로 넓히면 모든 서브도메인이
  요청마다 세션 쿠키를 받게 되어, 하나만 침해돼도 SSO 전체가 넘어간다.
- OAUTH_IDP_SESSION_COOKIE_DOMAIN 설정을 제거한다.

SSO 경로 rate limit 누락
- GET 경로도 이제 인가 코드를 발급하는데 한도가 걸려 있지 않았다.
- 한도 검사를 IssueAuthorizationCodeService로 옮겨 두 경로가
  같은 정책을 따르게 한다.

동의 기록 동시성
- 같은 (account, client)로 첫 로그인이 동시에 들어오면 유니크 제약을
  위반한다. 이 시점에는 code와 세션이 이미 Redis에 저장돼 롤백되지 않아
  사용자만 500을 받았다.
- DataIntegrityViolationException을 잡아 재조회 후 병합한다.

핸드오프 검증 순서
- 티켓을 지우기 전에 redirect_uri를 먼저 검증한다. 순서가 반대면
  클라이언트 설정이 바뀐 순간 티켓만 소비되고 세션은 쓰이지 못한 채 남았다.
- 검증 실패 시 티켓과 세션을 함께 정리한다.

그 외
- verifier 누락을 스프링 기본 400이 아닌 InvalidRequest로 통일
- sha256 해시를 OpaqueTokenHashUtil로 추출해 발급/검증이 갈라지지 않게 함
- matchesRegisteredRedirectUri를 Boolean 반환으로 단순화
- account.id를 requireNotNull로 바꿔 동의 기록이 조용히 누락되지 않게 함
- uk 최좌측 접두사와 중복되는 idx_oauth_consent_account_id 제거
@ZaMan0806

Copy link
Copy Markdown
Collaborator Author

리뷰 반영 완료 (7eb1150)

인라인 지적 11건을 모두 확인했습니다. 10건 수정, 1건 보류입니다.

수정한 항목

지적 처리
verifier가 ticket과 같은 채널 Sec-Fetch-Site/Mode로 최상위 내비게이션만 허용 (f3e4498)
쿠키 도메인 확장 위험 host-only 고정, COOKIE_DOMAIN 설정 자체를 제거
SSO 경로 rate limit 누락 한도 검사를 IssueAuthorizationCodeService로 이동
동의 기록 동시성 DataIntegrityViolationException 재조회·병합
티켓 삭제/검증 순서 검증 먼저, 실패 시 티켓+세션 함께 정리
verifier required required = false + InvalidRequest로 통일
account.id?.let 침묵 requireNotNull로 전환
extract...() 반환형 우회 Boolean 반환으로 단순화
sha256Hex 중복 OpaqueTokenHashUtil로 추출
중복 인덱스 idx_oauth_consent_account_id 제거

보류한 항목

계정 삭제 시 동의 기록 정리 — 계정 삭제 경로가 코드베이스에 아직 없어 지금 넣을 대상이 없습니다. 놓치지 않도록 PR 본문 후속 과제에 명시했습니다.

검증

  • 132개 테스트 통과 (기존 129 + 동시성 복구, 검증 순서, verifier 누락)
  • ./gradlew ktlintCheck build --parallel --build-cache 통과
  • 핵심 방어는 뮤테이션으로 실효성 확인 — 검증 순서를 되돌리면 3건, Sec-Fetch를 제거하면 6건 실패

특히 verifier가 무의미하다는 지적과 동의 기록 실패 시점에 이미 Redis에 코드가 저장돼 있다는 지적은 제가 놓친 부분이었습니다. 짚어주셔서 감사합니다.

ℹ️ 이 PR은 base가 feature/oauth-scope-segmentation이라 CI가 트리거되지 않습니다(워크플로가 develop/master 대상 PR에만 반응). 위 검증은 CI와 동일한 명령을 로컬에서 실행한 결과입니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement:개선작업 새 기능 또는 기존 코드 개선에 관한 내용입니다 waiting for review:검토 대기 확인을 대기하고 있습니다

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants