Skip to content

v20260920.0 - #445

Open
ZaMan0806 wants to merge 71 commits into
masterfrom
develop
Open

ZaMan0806 wants to merge 71 commits into
masterfrom
develop

Conversation

@ZaMan0806

Copy link
Copy Markdown
Collaborator

개요

main 브랜치로 머지합니다.

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

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

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

POST /authorize의 Location은 SP redirect_uri에서 핸드오프 URL로 변경된다.
코드리뷰에서 발견된 보안 결함 3건을 수정한다.

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

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

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

IssueAuthorizationCodeServiceTest를 신설하고, 핸드오프 테스트에
verifier 불일치/오픈 리다이렉트/접두사 위조 케이스를 추가한다.
교차 리뷰에서 드러난 두 가지 문제를 바로잡는다.

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 제거
세션은 유효하지만 동의 기록이 없을 때 로그인 폼으로 되돌리던 동작을
동의 화면으로 보내도록 바꾼다. 기존에는 처음 쓰는 클라이언트마다
비밀번호를 다시 입력해야 해서 SSO 체감이 반감됐다.

- POST /v1/oauth/authorize/consent 추가. 거부 시 OAuth 표준대로
  error=access_denied로 클라이언트에 리다이렉트한다.
- 세션 자격 판정을 IdpSessionResolver로 분리했다. 동의 엔드포인트는
  비밀번호 없이 코드를 발급하므로, 계정 상태·학생 정보 수정 요청 게이트가
  SSO 인가 경로와 갈라지지 않도록 한 곳에서 판정한다.
- 거부 응답의 state는 URLEncoder로 인코딩해 파라미터 주입을 막는다.
로그아웃 기능이 없어 세션 쿠키가 만료(기본 8시간)될 때까지 유지되던
공백을 메운다. 공용 PC에서 특히 문제가 된다.

- POST /v1/oauth/logout 추가. 세션을 삭제하고 쿠키를 즉시 만료시킨다.
  세션이 없어도 동일하게 204를 반환해, 쿠키 값만 가진 쪽에 유효한
  세션인지 판별할 단서를 주지 않는다.
- 쿠키 발급/만료를 IdpSessionCookieFactory로 모았다. 브라우저는
  name/path/domain이 일치해야 기존 쿠키를 덮어쓰므로, 두 형태를 따로
  조립하면 속성이 어긋나는 순간 로그아웃이 조용히 실패한다.
- 비밀번호 변경 시 해당 계정의 IdP 세션을 전부 삭제한다. 기존에는
  refresh token만 지워, 탈취된 세션 쿠키로 새 인가를 계속 받을 수 있었다.
  역방향 조회를 위해 email에 @indexed를 추가했다.

이미 발급된 access token은 만료 전까지 유효하므로, 각 SP는 자기 토큰이
만료될 때까지 로그인 상태로 보인다. 전파(back-channel logout)는 별도 과제다.
SP가 표준 OIDC 클라이언트 라이브러리(Spring Security oauth2Login,
next-auth 등)를 그대로 쓸 수 있게 한다. 기존에는 사용자를 식별하려면
/userinfo를 추가 호출해야 해서 팀마다 수동 연동 코드가 필요했다.

- openid scope 지원. 권한이 아니라 "id_token을 함께 달라"는 프로토콜
  지시자라 tb_oauth_scope 조회와 client 등록 scope 검사에서 제외한다.
  섞인 채로 조회하면 권한 데이터 오류로 500이 난다.
- nonce 파라미터를 authorize에서 받아 code에 실어 id_token까지 전달한다.
- GET /.well-known/openid-configuration 추가. 지원 목록은 GrantType과
  PkceChallengeMethod에서 끌어와, 구현이 바뀌었는데 문서만 남는 상황을 막는다.

id_token의 sub는 account.id다. OIDC는 sub가 영구적이고 재사용되지 않는
값일 것을 요구하는데 email은 변경될 수 있어, 바뀌는 순간 SP가 같은 사람을
다른 사용자로 인식한다. access token의 sub는 기존 /userinfo 소비자와의
호환을 위해 email을 유지하므로, 두 토큰의 sub는 서로 다르다.
"어떤 기기에서 로그인 중인지" 확인하고 특정 기기만 로그아웃할 수 있게 한다.

- GET /v1/oauth/idp-sessions — 현재 쿠키가 가리키는 계정의 활성 세션 목록
- DELETE /v1/oauth/idp-sessions/{targetSessionId} — 개별 세션 종료

세션 쿠키 자체를 자격 증명으로 쓰고, 조회·삭제 대상을 그 쿠키의 계정으로
한정한다. 다른 계정의 세션을 종료하려는 요청은 존재하지 않는 세션과 같은
404로 응답해, 세션 존재 여부까지 탐지되지 않게 한다.

기기 구분을 위해 userAgent와 createdAt을 세션에 기록한다. userAgent는
핸드오프(GET) 시점에 채운다. 세션을 만드는 POST는 BFF의 서버-투-서버
호출이라 User-Agent가 BFF의 것이기 때문이다. 기존 세션에는 두 값이 없으므로
nullable로 둔다.
기존에는 OIDC의 "영구 식별자" 요구를 근거로 account.id를 넣었으나,
이 시스템의 계정 email은 학교 계정에 묶여 변경되지 않으므로 그 조건을
이미 만족한다. 바뀌지 않는 값을 두고 별도 식별자를 쓸 이유가 없다.

또한 access token과 /userinfo(JwtProvider가 claims.subject를 email로
읽는다)가 모두 email을 식별자로 쓰고 있어, id_token만 account.id를 쓰면
세 곳의 sub가 어긋나 SP 연동에서 혼란을 만든다.

id_token과 access token의 sub가 같은 값인지 확인하는 테스트를 추가했다.
조회 후 없으면 insert하는 방식은 같은 (account, client)로 첫 로그인이
동시에 들어올 때 양쪽이 "없음"을 보고 둘 다 insert해 제약 위반이 난다.
이를 잡아 같은 트랜잭션에서 재시도하던 기존 처리는 두 가지 이유로
동작을 보장하지 못한다.

- flush 중 제약 위반이 나면 영속성 컨텍스트를 더 이상 신뢰할 수 없다.
- MySQL 기본 격리 수준(REPEATABLE READ)에서 재조회는 다른 트랜잭션이
  방금 커밋한 행을 보지 못해, 같은 제약 위반을 다시 던질 수 있다.

두 번째 예외는 잡히지 않아 500이 되는데, 이 시점에는 인가 코드가 이미
Redis에 저장돼 롤백되지 않으므로 사용자는 실패 응답을 받고 코드만 남는다.

INSERT ... ON DUPLICATE KEY UPDATE로 행 존재를 DB가 원자적으로 보장하게
바꾸고, scope는 NOT EXISTS로 걸러 중복 없이 추가한다. 같은 결함이 있던
CompleteOauthAuthorizeFlowServiceImpl에도 동일하게 적용했다.
openid는 권한이 아니라 id_token 발급을 요청하는 OIDC 프로토콜 지시자라
tb_oauth_scope에 행이 없다. 그런데 QueryOauthSessionService가 상태에 저장된
scope를 전부 DB에서 찾으려 해, openid를 요청한 클라이언트는 로그인·동의 화면의
정보 조회가 500으로 실패했다.

동의 화면에 보여줄 권한도 아니므로 조회 전에 제외한다.

OIDC Discovery의 sub 관련 주석도 실제 구현(email)에 맞게 고쳤다.
동의 요청은 세션 쿠키가 백엔드 호스트에 host-only로 심겨 있어 BFF를 경유할 수 없고
브라우저에서 직접 나가야 한다. 그런데 브라우저 fetch는 302를 처리할 수단이 없다.
redirect를 manual로 두면 응답이 opaqueredirect가 되어 Location을 읽을 수 없고,
follow로 두면 fetch가 클라이언트 콜백을 대신 호출해 일회용 code를 소진한다.

승인과 거부 모두 200으로 이동할 주소를 본문에 담아 반환하고, 최종 이동은 프론트가
window.location으로 수행하도록 한다. 반환 타입을 DTO로 바꿔 302로 되돌아갈 수 없게 했다.
…project

# Conflicts:
#	datagsm-common/src/main/kotlin/team/themoment/datagsm/common/domain/project/repository/custom/ProjectJpaCustomRepository.kt
#	datagsm-common/src/main/kotlin/team/themoment/datagsm/common/domain/project/repository/custom/impl/ProjectJpaCustomRepositoryImpl.kt
…e-format

[oauth] 동의 응답을 302 대신 이동 주소 본문으로 반환
ZaMan0806 and others added 14 commits September 22, 2026 14:02
[project] 프로젝트 신청 및 심사 플로우 추가
ProjectIconUrlResolver가 datagsm-common에 @component로 있어
common 패키지를 스캔하는 oauth 모듈들이 이 빈을 생성하려 했으나,
해당 모듈은 ProjectIconStorageEnvironment를 등록하지 않아
의존성 주입에 실패했다.

@component를 제거해 스캔 대상에서 빼고, 프로퍼티를 등록하는
web/openapi의 PropertiesScanConfig에서 @bean으로 직접 등록하도록
바꿔 설정과 빈의 생명주기를 한 곳에 묶었다.
…-bean-scope

[project] oauth 모듈 컨텍스트 초기화 실패 수정
ProjectJpaEntity와 ProjectEditRequestJpaEntity에 deploymentUrl 컬럼을
추가하고, 요청/응답 DTO와 웹훅 payload에 전파하였습니다.

- 엔티티: deployment_url VARCHAR(300) NULL
- 요청 DTO: ProjectReqDto, ApplyProjectReqDto (http/https 형식 검증)
- 응답 DTO: ProjectResDto, MyProjectResDto, PublicProjectResDto,
  ProjectEditRequestResDto
- 웹훅 payload: ProjectEventObject (deployment_url)
- 서비스 17개 파일의 읽기/쓰기 경로 반영
- 계약 테스트 및 서비스 테스트 추가
…t-url

[project] 프로젝트 배포 URL 필드 추가
수정 신청을 반복할 때마다 tb_project_edit_request에 행이 계속
쌓여 신청자 한 명이 조회할 데이터가 기하급수적으로 늘어났다.

재사용 대상을 PENDING에서 전체 상태로 넓혀, 이전 상태와 무관하게
기존 행을 덮어쓰도록 바꿨다. 승인 전 신규 신청도 신청자당 하나만
유지한다. 거절 이력은 남지 않지만 조회 로직이 최신 1건만 사용하고
있어 실제 동작에는 영향이 없다.
iconKey를 보내지 않으면 null로 덮어써 승인 시 아이콘이 소실됐다.
응답에는 iconUrl만 있어 클라이언트가 되돌려 보낼 키를 알 수 없는
구조라, 이름만 고쳐도 아이콘이 사라졌다.

요청에 값이 없으면 기존 값을 유지하도록 바꾸고, 응답에 iconKey를
추가해 클라이언트가 현재 키를 확인할 수 있게 했다. 학생 신청과
어드민 수정 API 모두에 적용했으며 deploymentUrl도 동일하게 처리한다.
공개 조회 응답에는 내부 키를 노출하지 않는다.
리뷰에서 지적된 네 가지를 반영했다.

- datagsm-web의 어드민 수정 API가 누락되어 여전히 전체 덮어쓰기였다
- 신청 행이 없는 첫 수정과, 어드민이 원본을 직접 바꾼 경우를 위해
  기준을 신청 행이 아닌 원본 프로젝트의 현재 값으로 바꿨다
- 신규 신청을 신청자당 하나로 묶으면 다른 프로젝트를 신청할 때
  기존 신청이 대체되고 값까지 승계되므로 제한을 없앴다
- 생략은 유지, 빈 문자열은 삭제로 iconKey와 deploymentUrl 규칙을
  통일했다. deploymentUrl의 @pattern에 빈 문자열을 허용한다
…single-row

[project] 신청 행 단일화 및 수정 시 아이콘 유실 수정
…count

[account] 선생님 계정 승인 거절 API 추가
…tions

[global] `AGENTS.md` 공통 지침 통합

This branch has not been deployed

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

Labels

waiting for review:검토 대기 확인을 대기하고 있습니다

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants