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
180 changes: 180 additions & 0 deletions docs/account-deletion-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# Account Deletion API

Frontend integration guide for deleting an OnTime account with optional withdrawal feedback.

## Summary

Account deletion hard-deletes the user from OnTime. The request can optionally include feedback. If feedback is provided, the backend stores it separately from the `User` table so it remains available after the account is deleted.

For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account.

## Authentication

All endpoints require the current OnTime access token.

```http
Authorization: Bearer {accessToken}
Content-Type: application/json
```

## Request Body

The request body is optional for every deletion endpoint.

```json
{
"feedbackId": "d784cde3-9ff9-4054-872a-500bbcc2198a",
"message": "I do not use the app anymore."
}
```

Fields:

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `feedbackId` | UUID string | No | Client-generated ID. If omitted, the backend generates one. |
| `message` | string | No | If missing, blank, or only whitespace, feedback is not saved and deletion still proceeds. |

## General Account Deletion

Use this endpoint for normal OnTime account deletion.

```http
DELETE /users/me/delete
```

Example without feedback:

```http
DELETE /users/me/delete
Authorization: Bearer {accessToken}
Content-Type: application/json

{}
```

Example with feedback:

```http
DELETE /users/me/delete
Authorization: Bearer {accessToken}
Content-Type: application/json

{
"feedbackId": "d784cde3-9ff9-4054-872a-500bbcc2198a",
"message": "The notifications were not useful for me."
}
```

Success response:

```json
{
"status": "success",
"code": "200",
"message": "계정이 성공적으로 삭제되었습니다!",
"data": null
}
```

## Google Account Deletion

Use this endpoint when the current account is linked through Google login.

```http
DELETE /oauth2/google/me
```

Example:

```http
DELETE /oauth2/google/me
Authorization: Bearer {accessToken}
Content-Type: application/json

{
"message": "I am switching to another calendar app."
}
```

Success response:

```text
구글 로그인 회원탈퇴 성공
```

Behavior:

- Revokes the stored Google OAuth token for OnTime.
- Deletes the local OnTime account.
- Saves optional feedback if `message` is nonblank.
- Does not delete the user's actual Google account.

Expected frontend result after successful revoke:

- If the user signs up or logs in with the same Google account again, Google may show an "OnTime에 다시 로그인하는 중입니다" confirmation screen.
- The Google account can still be preselected because the user is still signed in to Google on the device/browser.

Important caveat:

- The Google unlink only works if the backend has a valid Google refresh/access token saved in `socialLoginToken`.
- If the client never provided a real Google refresh token, Google revoke may fail and the endpoint may return an error before local deletion.

## Apple Account Deletion

Use this endpoint when the current account is linked through Apple login.

```http
DELETE /oauth2/apple/me
```

Example:

```http
DELETE /oauth2/apple/me
Authorization: Bearer {accessToken}
Content-Type: application/json

{
"feedbackId": "85fc54e0-e6c7-4c7e-9312-7784a52bf120",
"message": "I want to restart with a fresh account."
}
```

Success response:

```text
애플 로그인 회원탈퇴 성공
```

Behavior:

- Revokes the stored Apple OAuth token for OnTime.
- Deletes the local OnTime account.
- Saves optional feedback if `message` is nonblank.
- Does not delete the user's Apple ID.

## What Gets Stored For Feedback

When feedback is provided, the backend stores:

| Stored Field | Notes |
| --- | --- |
| `feedbackId` | Client-provided UUID or backend-generated UUID |
| `deletedUserId` | Previous OnTime user ID |
| `socialType` | `GOOGLE`, `APPLE`, or null for non-social accounts |
| `emailHash` | SHA-256 hash of the normalized email, not plaintext email |
| `message` | User feedback text |
| `createdAt` | Server timestamp |

Feedback is not linked by foreign key to the deleted user.

## Frontend Recommendations

- Treat feedback as optional. Do not block deletion if the user skips it.
- Generate a UUID for `feedbackId` if convenient, but it is safe to omit.
- Use `/oauth2/google/me` for Google-linked accounts if the product requirement is to unlink Google access.
- Use `/oauth2/apple/me` for Apple-linked accounts if the product requirement is to unlink Apple access.
- Use `/users/me/delete` for normal local deletion.
- After a successful deletion response, clear local auth state and navigate to the logged-out screen.
- Do not retry automatically on social revoke errors without showing the user, because the local account may not have been deleted.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package devkor.ontime_back.controller;

import devkor.ontime_back.dto.FeedbackAddDto;
import devkor.ontime_back.dto.OAuthAppleRequestDto;
import devkor.ontime_back.dto.OAuthGoogleUserDto;
import devkor.ontime_back.dto.OAuthKakaoUserDto;
Expand Down Expand Up @@ -112,24 +113,24 @@ public String appleRegisterOrLogin(@RequestBody OAuthAppleRequestDto appleLoginR
summary = "애플 소셜 로그인 회원탈퇴"
)
@DeleteMapping("/apple/me")
public String appleDeleteUser(HttpServletRequest request, HttpServletResponse response) throws Exception {
public String appleDeleteUser(HttpServletRequest request, HttpServletResponse response, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) throws Exception {
Long userId = userAuthService.getUserIdFromToken(request);
log.info("userId: {}", userId);
appleLoginService.revokeToken(userId);
userAuthService.deleteUser(userId);
userAuthService.deleteUser(userId, feedbackAddDto);
return "애플 로그인 회원탈퇴 성공";
}

@Operation(
summary = "구글 소셜 로그인 회원탈퇴"
)
@DeleteMapping("/google/me")
public String googleDeleteUser(HttpServletRequest request, HttpServletResponse response) throws Exception {
public String googleDeleteUser(HttpServletRequest request, HttpServletResponse response, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) throws Exception {
Long userId = userAuthService.getUserIdFromToken(request);
log.info("userId: {}", userId);
googleLoginService.revokeToken(userId);
userAuthService.deleteUser(userId);
return "애플 로그인 회원탈퇴 성공";
userAuthService.deleteUser(userId, feedbackAddDto);
return "구글 로그인 회원탈퇴 성공";
}


Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package devkor.ontime_back.controller;

import devkor.ontime_back.dto.ChangePasswordDto;
import devkor.ontime_back.dto.FeedbackAddDto;
import devkor.ontime_back.dto.UserInfoResponse;
import devkor.ontime_back.dto.UserSignUpDto;
import devkor.ontime_back.entity.User;
Expand Down Expand Up @@ -107,11 +108,11 @@ public ResponseEntity<ApiResponseForm<String>> changePassword(HttpServletRequest
@Operation(
summary = "계정 삭제 (User 데이터 하드 삭제)",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "계정 삭제 요청 JSON 데이터는 없음. 헤더에 토큰만 있으면 됨",
description = "계정 삭제 요청 JSON 데이터는 선택사항. 탈퇴 피드백을 남기려면 feedbackId, message를 전달",
content = @Content(
schema = @Schema(
type = "object",
example = "{}"
example = "{\"feedbackId\": \"d784cde3-9ff9-4054-872a-500bbcc2198a\", \"message\": \"탈퇴 피드백입니다.\"}"
)
)
)
Expand All @@ -126,9 +127,9 @@ public ResponseEntity<ApiResponseForm<String>> changePassword(HttpServletRequest
@ApiResponse(responseCode = "4XX", description = "계정 삭제 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(토큰 오류 제외 비즈니스 로직 오류는 없음)")))
})
@DeleteMapping("/users/me/delete")
public ResponseEntity<ApiResponseForm<?>> deleteUser(HttpServletRequest request) {
public ResponseEntity<ApiResponseForm<?>> deleteUser(HttpServletRequest request, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) {
Long userId = userAuthService.getUserIdFromToken(request);
userAuthService.deleteUser(userId);
userAuthService.deleteUser(userId, feedbackAddDto);
String message = "계정이 성공적으로 삭제되었습니다!";
return ResponseEntity.ok(ApiResponseForm.success(null, message));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package devkor.ontime_back.dto;

import lombok.Getter;
import lombok.ToString;
import lombok.*;

import java.util.UUID;

@ToString
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FeedbackAddDto {
private UUID feedbackId;
private String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package devkor.ontime_back.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;
import java.util.UUID;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class AccountDeletionFeedback {

@Id
private UUID feedbackId;

private Long deletedUserId;

@Enumerated(EnumType.STRING)
private SocialType socialType;

@Column(length = 64)
private String emailHash;

@Lob
@Column(columnDefinition = "TEXT")
private String message;

private LocalDateTime createdAt;
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,12 @@ public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpSe
FilterChain filterChain) throws ServletException, IOException {
log.info("checkAccessTokenAndAuthentication() 호출");
jwtTokenProvider.extractAccessToken(request)
.ifPresent(accessToken -> {
jwtTokenProvider.extractEmail(accessToken)
.ifPresent(email -> userRepository.findByEmail(email)
.ifPresent(this::saveAuthentication)
);

jwtTokenProvider.extractUserId(accessToken)
.ifPresent(userId -> log.info("추출된 userId: {}", userId));
});
.ifPresent(accessToken -> jwtTokenProvider.extractUserId(accessToken)
.ifPresent(userId -> {
log.info("추출된 userId: {}", userId);
userRepository.findById(userId)
.ifPresent(this::saveAuthentication);
}));

filterChain.doFilter(request, response);
}
Expand Down Expand Up @@ -192,4 +189,4 @@ private void handleInvalidTokenException(HttpServletResponse response, InvalidTo
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public String createRefreshToken() {
return JWT.create()
.withSubject(REFRESH_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
.withJWTId(UUID.randomUUID().toString())
.sign(Algorithm.HMAC512(secretKey));
}

Expand Down Expand Up @@ -199,4 +200,4 @@ public String createExpiredAccessToken(String email) {
.sign(Algorithm.HMAC512(secretKey));
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;

import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
public class GoogleLoginFilter extends AbstractAuthenticationProcessingFilter {
private static final Map<String, Object> LOGIN_LOCKS = new ConcurrentHashMap<>();

private final UserRepository userRepository;
private final GoogleLoginService googleLoginService;

Expand All @@ -42,13 +46,16 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ
GoogleIdToken.Payload googlePayload = googleLoginService.verifyIdentityToken(oAuthGoogleRequestDto.getIdToken());
String googleUserId = googlePayload.getSubject();

Optional<User> existingUser = userRepository.findBySocialTypeAndSocialId(SocialType.GOOGLE, googleUserId);
Object loginLock = LOGIN_LOCKS.computeIfAbsent(googleUserId, key -> new Object());
synchronized (loginLock) {
Optional<User> existingUser = userRepository.findBySocialTypeAndSocialId(SocialType.GOOGLE, googleUserId);

if (existingUser.isPresent()) {
return googleLoginService.handleLogin(oAuthGoogleRequestDto, existingUser.get(), response);
} else {
OAuthGoogleUserDto oAuthGoogleUserDto = new OAuthGoogleUserDto(googleUserId, (String) googlePayload.get("name"), (String) googlePayload.get("picture"), googlePayload.getEmail());
return googleLoginService.handleRegister(oAuthGoogleRequestDto, oAuthGoogleUserDto, response);
if (existingUser.isPresent()) {
return googleLoginService.handleLogin(oAuthGoogleRequestDto, existingUser.get(), response);
} else {
OAuthGoogleUserDto oAuthGoogleUserDto = new OAuthGoogleUserDto(googleUserId, (String) googlePayload.get("name"), (String) googlePayload.get("picture"), googlePayload.getEmail());
return googleLoginService.handleRegister(oAuthGoogleRequestDto, oAuthGoogleUserDto, response);
}
}

} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package devkor.ontime_back.repository;

import devkor.ontime_back.entity.AccountDeletionFeedback;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.UUID;

@Repository
public interface AccountDeletionFeedbackRepository extends JpaRepository<AccountDeletionFeedback, UUID> {
}
Loading
Loading