Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,19 @@ 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) {
throw new DomainException(ApiErrorCode.AUTH_INVALID_TOKEN);
}
User user = userRepository.findByIdAndDeletedFalse(userId)
.orElseThrow(() -> new DomainException(ApiErrorCode.USER_ALREADY_DELETED));
user.markDeleted();
user.withdraw();
events.publishEvent(new UserDeletedEvent(userId));
}
}
16 changes: 16 additions & 0 deletions backend/src/main/java/com/stackup/stackup/user/domain/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,20 @@ public boolean hasGithubLink() {
public void markDeleted() {
this.deleted = true;
}

/**
* 회원 탈퇴. soft delete 와 함께 **GitHub access token 을 버린다.**
*
* <p>이 토큰은 `repo` 스코프라 비공개 레포까지 읽을 수 있는 살아있는 자격증명이다.
* hard delete 는 Phase 2 이므로 여기서 지우지 않으면 "삭제해 달라"고 한 사용자의
* GitHub 접근 권한이 우리 DB 에 무기한 남는다 — DB 가 유출되면 이미 떠난 사람들의
* 비공개 레포까지 열린다.
*
* <p>GitHub 쪽 grant 자체의 무효화는 사용자가 GitHub Settings 에서 해야 한다.
* 우리가 할 수 있는 것은 사본을 갖지 않는 것까지다.
*/
public void withdraw() {
markDeleted();
this.encryptedGithubAccessToken = null;
}
}
Original file line number Diff line number Diff line change
@@ -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)
);
Original file line number Diff line number Diff line change
@@ -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 을 남기지 않는다.
*
* <p>이 토큰은 `repo` 스코프로 발급된다 — 비공개 레포까지 읽을 수 있는 살아있는 자격증명이다.
* 탈퇴는 soft delete 만 하고 hard delete 는 Phase 2 라, 지우지 않으면 "삭제해 달라"고 한
* 사용자의 GitHub 접근 권한이 우리 DB 에 무기한 남는다.
*
* <p>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());
}
}
14 changes: 13 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 분석 위해)
Expand Down
Loading