diff --git a/backend/src/main/java/com/stackup/stackup/user/application/UserService.java b/backend/src/main/java/com/stackup/stackup/user/application/UserService.java index e2963e00..4363a58b 100644 --- a/backend/src/main/java/com/stackup/stackup/user/application/UserService.java +++ b/backend/src/main/java/com/stackup/stackup/user/application/UserService.java @@ -42,9 +42,11 @@ public UserProfileResult getCurrentUser(Long userId) { ); } - // 회원 탈퇴: User soft delete + UserDeletedEvent 발행. - // auth 슬라이스 listener 가 모든 refresh token 을 revoke 한다 (도메인 분리). - // GitHub 측 access_token 무효화는 사용자가 GitHub Settings 에서 별도 수행. + // 회원 탈퇴: User soft delete + 보관 중이던 GitHub access token 폐기 + UserDeletedEvent 발행. + // auth 슬라이스 listener 가 모든 refresh token 을, session 슬라이스 listener 가 공유 + // 토큰을 revoke 한다 (도메인 분리). + // GitHub 쪽 grant 자체의 무효화는 사용자가 GitHub Settings 에서 별도 수행해야 한다 — + // 우리가 할 수 있는 건 사본을 갖지 않는 것까지다(User.withdraw). @Transactional public void deleteAccount(Long userId) { if (userId == null) { @@ -52,7 +54,7 @@ public void deleteAccount(Long userId) { } User user = userRepository.findByIdAndDeletedFalse(userId) .orElseThrow(() -> new DomainException(ApiErrorCode.USER_ALREADY_DELETED)); - user.markDeleted(); + user.withdraw(); events.publishEvent(new UserDeletedEvent(userId)); } } diff --git a/backend/src/main/java/com/stackup/stackup/user/domain/User.java b/backend/src/main/java/com/stackup/stackup/user/domain/User.java index da5b93f7..bb82c5fd 100644 --- a/backend/src/main/java/com/stackup/stackup/user/domain/User.java +++ b/backend/src/main/java/com/stackup/stackup/user/domain/User.java @@ -136,4 +136,20 @@ public boolean hasGithubLink() { public void markDeleted() { this.deleted = true; } + + /** + * 회원 탈퇴. soft delete 와 함께 **GitHub access token 을 버린다.** + * + *

이 토큰은 `repo` 스코프라 비공개 레포까지 읽을 수 있는 살아있는 자격증명이다. + * hard delete 는 Phase 2 이므로 여기서 지우지 않으면 "삭제해 달라"고 한 사용자의 + * GitHub 접근 권한이 우리 DB 에 무기한 남는다 — DB 가 유출되면 이미 떠난 사람들의 + * 비공개 레포까지 열린다. + * + *

GitHub 쪽 grant 자체의 무효화는 사용자가 GitHub Settings 에서 해야 한다. + * 우리가 할 수 있는 것은 사본을 갖지 않는 것까지다. + */ + public void withdraw() { + markDeleted(); + this.encryptedGithubAccessToken = null; + } } diff --git a/backend/src/main/resources/db/migration/V28__allow_null_github_token_for_withdrawn_users.sql b/backend/src/main/resources/db/migration/V28__allow_null_github_token_for_withdrawn_users.sql new file mode 100644 index 00000000..ca39961f --- /dev/null +++ b/backend/src/main/resources/db/migration/V28__allow_null_github_token_for_withdrawn_users.sql @@ -0,0 +1,15 @@ +-- 탈퇴한 계정은 GitHub access token 을 지운다(비공개 레포까지 읽는 `repo` 스코프 자격증명을 +-- 떠난 사용자 몫으로 계속 들고 있지 않기 위해). 그런데 V22 의 CHECK 제약이 +-- provider='GITHUB' 인 모든 행에 토큰 NOT NULL 을 요구해서 UPDATE 가 거부된다. +-- +-- 제약의 의도는 "살아있는 계정은 provider 에 맞는 식별자를 갖춰야 한다" 이므로, +-- 삭제된 행을 예외로 둔다. users 의 유니크 인덱스들(V3·V22)이 이미 +-- `WHERE is_deleted = FALSE` 로 같은 규약을 쓰고 있다. +ALTER TABLE users DROP CONSTRAINT IF EXISTS ck_users_provider_identity; + +ALTER TABLE users + ADD CONSTRAINT ck_users_provider_identity CHECK ( + is_deleted = TRUE + OR (provider = 'GITHUB' AND github_id IS NOT NULL AND encrypted_github_access_token IS NOT NULL) + OR (provider = 'GOOGLE' AND google_id IS NOT NULL) + ); diff --git a/backend/src/test/java/com/stackup/stackup/user/domain/UserWithdrawalTest.java b/backend/src/test/java/com/stackup/stackup/user/domain/UserWithdrawalTest.java new file mode 100644 index 00000000..243d7b27 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/user/domain/UserWithdrawalTest.java @@ -0,0 +1,63 @@ +package com.stackup.stackup.user.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.support.PostgresRepositoryTest; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * 탈퇴하면 GitHub access token 을 남기지 않는다. + * + *

이 토큰은 `repo` 스코프로 발급된다 — 비공개 레포까지 읽을 수 있는 살아있는 자격증명이다. + * 탈퇴는 soft delete 만 하고 hard delete 는 Phase 2 라, 지우지 않으면 "삭제해 달라"고 한 + * 사용자의 GitHub 접근 권한이 우리 DB 에 무기한 남는다. + * + *

DB 까지 내려가서 확인하는 이유: users 에 provider 별 식별자 CHECK 제약이 걸려 있어 + * (`ck_users_provider_identity`) 엔티티에서 null 로 만들어도 UPDATE 가 거부될 수 있다. + * 메모리 상태만 보는 테스트로는 그걸 못 잡는다. + */ +@PostgresRepositoryTest +class UserWithdrawalTest { + + @Autowired UserRepository userRepository; + @Autowired EntityManager em; + + @Test + void withdrawClearsGithubAccessToken() { + User user = userRepository.save( + User.createGithubUser(98001L, "leaving-user", "u@example.com", null, "encrypted-token")); + assertThat(user.hasGithubLink()).isTrue(); + + user.withdraw(); + userRepository.save(user); + em.flush(); + + assertThat(user.getEncryptedGithubAccessToken()).isNull(); + // GitHub 연동 기능은 자격증명이 사라진 계정으로 인식해야 한다 + // (InternalGithubTokenService 가 NPE 대신 AUTH_GITHUB_NOT_LINKED 로 떨어진다). + assertThat(user.hasGithubLink()).isFalse(); + } + + // 탈퇴해도 같은 GitHub 계정으로 다시 가입할 수 있어야 한다(docs/security.md §5.3). + // 부분 유니크 인덱스(V3)가 살아있는 행끼리만 유일성을 강제하므로 가능하다. + @Test + void sameGithubAccountCanSignUpAgainAfterWithdrawal() { + User first = userRepository.save( + User.createGithubUser(98002L, "rejoiner", null, null, "token-1")); + first.withdraw(); + userRepository.save(first); + em.flush(); + + User second = userRepository.save( + User.createGithubUser(98002L, "rejoiner", null, null, "token-2")); + em.flush(); + + assertThat(second.getId()).isNotEqualTo(first.getId()); + assertThat(userRepository.findByGithubIdAndDeletedFalse(98002L)) + .get() + .extracting(User::getId) + .isEqualTo(second.getId()); + } +} diff --git a/docs/security.md b/docs/security.md index f2b05195..0bcb14a7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -142,8 +142,20 @@ public class GithubTokenCipher { ### 5.3 회원 탈퇴 (US-04) - soft delete (`is_deleted = TRUE`) - refresh_tokens 전부 revoke +- **보관 중이던 GitHub access token 폐기** (`User.withdraw`) — `repo` 스코프라 비공개 레포까지 + 읽는 살아있는 자격증명이다. hard delete 가 Phase 2 인 만큼, 여기서 지우지 않으면 떠난 + 사용자의 GitHub 접근 권한을 무기한 보관하게 된다. 삭제된 행은 provider 식별자 CHECK 제약에서 + 제외된다(V28) — 살아있는 유니크 인덱스(V3·V22)와 같은 규약. + - GitHub 쪽 grant 자체의 무효화는 사용자가 GitHub Settings 에서 해야 한다. 우리가 할 수 + 있는 건 사본을 갖지 않는 것까지다. +- 피드백 공유 토큰 전부 revoke (`UserDeletionShareRevokeListener`) — 탈퇴 후에도 열리는 공개 + 링크가 남지 않게 - 30일 후 hard delete + S3 객체 삭제 (Phase 2 자동화) -- 동일 GitHub 계정 재가입 시 신규 사용자로 생성 (기존 데이터 복구 X) +- 동일 GitHub 계정 재가입 시 신규 사용자로 생성 (기존 데이터 복구 X) — 유니크 인덱스가 + `WHERE is_deleted = FALSE` 부분 인덱스라 가능하다(V3·V22) + +> **미구현**: 탈퇴를 실행할 프론트엔드 화면이 없다. `DELETE /api/users/me` 는 동작하지만 +> 사용자가 도달할 경로가 없다. ### 5.4 데이터 최소 수집 - GitHub OAuth 시 요청 scope 최소화: `read:user`, `user:email`, `repo` (private 분석 위해)