[oauth] 동의 화면 분기 및 동의 승인/거부 엔드포인트 - #436
Conversation
세션은 유효하지만 동의 기록이 없을 때 로그인 폼으로 되돌리던 동작을 동의 화면으로 보내도록 바꾼다. 기존에는 처음 쓰는 클라이언트마다 비밀번호를 다시 입력해야 해서 SSO 체감이 반감됐다. - POST /v1/oauth/authorize/consent 추가. 거부 시 OAuth 표준대로 error=access_denied로 클라이언트에 리다이렉트한다. - 세션 자격 판정을 IdpSessionResolver로 분리했다. 동의 엔드포인트는 비밀번호 없이 코드를 발급하므로, 계정 상태·학생 정보 수정 요청 게이트가 SSO 인가 경로와 갈라지지 않도록 한 곳에서 판정한다. - 거부 응답의 state는 URLEncoder로 인코딩해 파라미터 주입을 막는다.
There was a problem hiding this comment.
-
Overview
- SSO 인가 흐름에서 “세션은 유효하지만 동의 기록이 없음”인 경우를 기존 로그인 페이지로 보내던 것을
/oauth/consent동의 화면으로 분기하도록 변경합니다. POST /v1/oauth/authorize/consent엔드포인트를 추가해 동의 승인/거부를 처리하고, 승인 시 인가 코드 발급, 거부 시access_denied리다이렉트를 수행합니다.- SSO 세션 기반 계정 자격 검사를
IdpSessionResolver로 분리해 인가/동의 두 경로에서 동일하게 재사용합니다.
- SSO 인가 흐름에서 “세션은 유효하지만 동의 기록이 없음”인 경우를 기존 로그인 페이지로 보내던 것을
-
Intent
- 비밀번호 재입력 없이 유효한 SSO 세션을 가진 사용자에게 요청된 scope에 대한 명시적 동의 기회를 제공합니다.
- 기존에 중복·분산되었던 세션 자격 판정 로직을 중앙화해, 인가 경로와 동의 경로 간 보안 게이트 불일치를 방지하려는 의도로 보입니다.
-
Risk
- 중간~높음: OAuth 인가/동의 흐름의 보안 로직이 직접 변경되는 PR입니다.
- 특히 다음 영역은 반드시 세심하게 검토해야 합니다:
- 동의 엔드포인트의 인증·인가 조건(세션 쿠키, 계정 상태, 정보 수정 요청 게이트)이 우회되지 않는지
- state 토큰 소비 로직이 재사용·재플레이를 실제로 차단하는지
redirectUri기반 리다이렉트 구성 시 오픈 리다이렉트/파라미터 주입 가능성이 없는지application.yml에 추가된 경로가 의도대로 안전한지에 대한 확인- 동의 기록 저장 시 동시성 처리가 실제 환경에서 일관되는지
- PR에서 테스트·뮤테이션 검증을 보강했다고 설명하고 있어 회귀 위험은 낮아 보이지만, 보안 임팩트는 여전히 높습니다.
0 inline comment(s)
| saveConsent(accountId, clientId, scopes) | ||
| } catch (e: DataIntegrityViolationException) { | ||
| logger().warn("Retrying consent record after unique constraint violation for clientId {}", clientId, e) | ||
| saveConsent(accountId, clientId, scopes) |
There was a problem hiding this comment.
동시 요청으로 unique 제약 위반이 났을 때, 같은 트랜잭션(같은 영속성 컨텍스트) 안에서 saveConsent를 그대로 재시도하고 있습니다.
- Hibernate는 flush 중 제약 위반(
ConstraintViolationException)이 나면 그 영속성 컨텍스트를 더 이상 안전하게 사용하기 어려운 상태로 두는 경우가 많습니다. - 설사 재조회가 실행되더라도 MySQL 기본 격리 수준(REPEATABLE READ)에서는 같은 트랜잭션의 스냅샷이 다른 트랜잭션이 방금 커밋한 행을 보지 못할 수 있어, 재조회가 다시 빈 결과를 반환하고 재저장도 같은 제약 위반을 다시 던질 가능성이 있습니다.
이 경우 두 번째 예외는 catch되지 않아 execute() 전체가 실패(500)합니다. 이 시점에 인가 코드(Redis)는 이미 발급되어 있어서(트랜잭션 밖이라 롤백되지 않음), 클라이언트는 500을 받지만 코드는 소모 대기 상태로 남는 애매한 상황이 될 수 있습니다.
현재 테스트(CompleteOauthConsentServiceTest 유니크 제약 케이스)는 리포지토리를 mock해서 "재시도 시 성공"만 가정하고 있어 이 실제 트랜잭션/격리 수준 이슈는 잡아내지 못합니다. INSERT ... ON DUPLICATE KEY UPDATE 같은 네이티브 upsert나 REQUIRES_NEW로 별도 트랜잭션을 여는 방식을 검토해보시면 좋을 것 같습니다.
There was a problem hiding this comment.
8957035 에서 반영했습니다. 지적하신 두 지점 모두 확인했고, 재시도 방식 자체를 걷어냈습니다.
확인한 사실
- 프로젝트에 격리 수준 설정이 없어 MySQL 기본값인 REPEATABLE READ가 적용됩니다. 말씀대로 재조회가 다른 트랜잭션이 방금 커밋한 행을 보지 못할 수 있습니다.
- 같은 패턴이
CompleteOauthAuthorizeFlowServiceImpl에도 있었습니다(#433에서 들어온 코드). 두 곳 모두 고쳤습니다.
수정 방향 — 제안해주신 두 가지 중 네이티브 upsert를 택했습니다.
INSERT INTO tb_oauth_consent (account_id, client_id, updated_at)
VALUES (:accountId, :clientId, NOW())
ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at)REQUIRES_NEW는 별도 트랜잭션이 열려도 "조회 후 없으면 insert"라는 경합 구조 자체는 남아서, 행 존재 판단을 DB에 원자적으로 맡기는 쪽이 낫다고 봤습니다. INSERT ... ON DUPLICATE KEY UPDATE는 행 잠금을 잡아 동시 요청이 직렬화되므로 두 트랜잭션 모두 자기 SELECT에서 행을 보게 됩니다.
scope 추가는 tb_oauth_consent_scope에 유니크 제약이 없어 INSERT IGNORE로는 중복이 쌓이므로 NOT EXISTS로 걸렀습니다. @Modifying 이후 같은 트랜잭션에서 이 엔티티를 다시 읽는 곳이 없다는 것도 확인해, 영속성 컨텍스트가 어긋날 여지는 없습니다.
테스트에 대한 지적도 맞습니다. 기존 케이스는 "재시도하면 성공"을 가정한 것이라 의미가 없어져서, 검증 대상을 "조회 후 분기가 아니라 upsert를 쓰는가" 로 바꿨습니다. 뮤테이션으로 upsert 호출 제거(2건 실패), id 조회 실패 시 통과(1건), scope 미추가(1건)가 각각 잡히는 것을 확인했습니다.
남은 한계 하나 — 이 프로젝트에는 통합 테스트 인프라(H2/Testcontainers)가 없고 로컬에 MySQL도 띄울 수 없어서, 작성한 SQL을 실제 DB에서 실행해보지는 못했습니다. 단위 테스트는 리포지토리를 mock하므로 SQL 문법·동작은 검증 범위 밖입니다. 배포 전 실환경에서 한 번 확인이 필요합니다.
전체 175건 통과, ktlintCheck build 통과했고 스택 하위 3개 PR에도 머지로 반영했습니다.
조회 후 없으면 insert하는 방식은 같은 (account, client)로 첫 로그인이 동시에 들어올 때 양쪽이 "없음"을 보고 둘 다 insert해 제약 위반이 난다. 이를 잡아 같은 트랜잭션에서 재시도하던 기존 처리는 두 가지 이유로 동작을 보장하지 못한다. - flush 중 제약 위반이 나면 영속성 컨텍스트를 더 이상 신뢰할 수 없다. - MySQL 기본 격리 수준(REPEATABLE READ)에서 재조회는 다른 트랜잭션이 방금 커밋한 행을 보지 못해, 같은 제약 위반을 다시 던질 수 있다. 두 번째 예외는 잡히지 않아 500이 되는데, 이 시점에는 인가 코드가 이미 Redis에 저장돼 롤백되지 않으므로 사용자는 실패 응답을 받고 코드만 남는다. INSERT ... ON DUPLICATE KEY UPDATE로 행 존재를 DB가 원자적으로 보장하게 바꾸고, scope는 NOT EXISTS로 걸러 중복 없이 추가한다. 같은 결함이 있던 CompleteOauthAuthorizeFlowServiceImpl에도 동일하게 적용했다.
개요
SSO 로드맵 우선순위 1 — 동의(Consent) 화면의 백엔드 작업입니다. 세션은 유효하지만 동의 기록이 없을 때 로그인 폼으로 되돌리던 동작을, 동의 화면으로 보내도록 바꿉니다.
본문
왜 필요한가
백엔드에 동의 기록 구조(
tb_oauth_consent)는 이미 있지만 UI로 이어지는 분기가 없었습니다. 그래서 처음 쓰는 클라이언트는 세션이 있어도 로그인 페이지로 폴백해 비밀번호를 다시 입력해야 했고, 사용자가 "이 서비스에 어떤 정보를 주는지" 확인할 기회도 없었습니다.변경 내용
1. 세션 유효 + 동의 없음 → 동의 화면
StartOauthAuthorizeFlowServiceImpl이 지금까지는 "세션 없음"과 "세션은 있지만 동의 없음"을 모두null로 뭉뚱그려 로그인 폼으로 보냈습니다. 이 둘을 분리해 후자는/oauth/consent로 보냅니다.2.
POST /v1/oauth/authorize/consent추가거부 시 OAuth 2.0 표준(RFC 6749 4.1.2.1)대로
error=access_denied로 클라이언트에 리다이렉트합니다. 승인·거부 모두 state 토큰을 소비해, 같은 화면을 다시 열어 승인으로 뒤집을 수 없게 했습니다.3.
IdpSessionResolver분리이 엔드포인트는 비밀번호 없이 세션 쿠키만으로 인가 코드를 발급합니다. 그래서 SSO 인가 경로와 완전히 같은 자격 조건(계정 ACTIVE 여부, 미해소 학생 정보 수정 요청)을 다시 확인해야 합니다. 두 경로에 같은 검사를 복사해두면 한쪽만 고쳐질 때 게이트가 갈라지므로, 판정을 한 곳으로 모았습니다.
특히 학생 정보 수정 강제가 동의 화면으로 우회되지 않는 것은 불변 조건이라 별도 테스트로 못박았습니다.
검증
ktlintCheck build통과프론트엔드 필요 작업
/oauth/consent?token=...페이지 신설 — 기존GET /v1/oauth/sessions/{token}이serviceName과requestedScopes를 이미 내려주므로 그대로 재사용할 수 있습니다.POST /api/oauth/authorize/consent→ 백엔드POST /v1/oauth/authorize/consent(redirect: 'manual'필요)남은 과제
동의 화면이 붙기 전까지는 기존처럼 "비밀번호 재입력 → 자동 동의" 흐름이 유지됩니다. 이 PR은 그 분기를 열어두는 백엔드 작업입니다.