Skip to content

Commit 527bab0

Browse files
authored
Merge pull request #323 from DevKor-github/feat/delete-deviceId-logout
feat: 로그아웃 API 추가, 디바이스 토큰 정리 로직 추가
2 parents 23a2374 + c79a46e commit 527bab0

15 files changed

Lines changed: 607 additions & 119 deletions

File tree

src/main/java/org/devkor/apu/saerok_server/domain/auth/api/AuthController.java

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,22 @@
55
import io.swagger.v3.oas.annotations.media.Content;
66
import io.swagger.v3.oas.annotations.media.Schema;
77
import io.swagger.v3.oas.annotations.responses.ApiResponse;
8+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
89
import io.swagger.v3.oas.annotations.tags.Tag;
910
import jakarta.annotation.security.PermitAll;
1011
import jakarta.servlet.http.HttpServletRequest;
1112
import lombok.RequiredArgsConstructor;
1213
import org.devkor.apu.saerok_server.domain.auth.api.dto.request.AppleLoginRequest;
1314
import org.devkor.apu.saerok_server.domain.auth.api.dto.request.KakaoLoginRequest;
15+
import org.devkor.apu.saerok_server.domain.auth.api.dto.request.LogoutRequest;
1416
import org.devkor.apu.saerok_server.domain.auth.api.dto.request.RefreshRequest;
1517
import org.devkor.apu.saerok_server.domain.auth.api.dto.response.AccessTokenResponse;
1618
import org.devkor.apu.saerok_server.domain.auth.application.AppleLoginService;
1719
import org.devkor.apu.saerok_server.domain.auth.application.LoginResult;
20+
import org.devkor.apu.saerok_server.domain.auth.application.LogoutService;
1821
import org.devkor.apu.saerok_server.domain.auth.application.KakaoLoginService;
1922
import org.devkor.apu.saerok_server.domain.auth.application.TokenRefreshService;
23+
import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal;
2024
import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider;
2125
import org.devkor.apu.saerok_server.global.shared.exception.UnauthorizedException;
2226
import org.devkor.apu.saerok_server.global.shared.util.ClientInfoExtractor;
@@ -25,6 +29,8 @@
2529
import org.springframework.http.HttpHeaders;
2630
import org.springframework.http.ResponseCookie;
2731
import org.springframework.http.ResponseEntity;
32+
import org.springframework.security.access.prepost.PreAuthorize;
33+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
2834
import org.springframework.web.bind.annotation.*;
2935

3036
@Tag(name = "Auth API", description = "소셜 인증 관련 API")
@@ -33,10 +39,14 @@
3339
@RequestMapping("${api_prefix}/auth/")
3440
public class AuthController {
3541

42+
private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken";
43+
private static final String REFRESH_TOKEN_COOKIE_PATH = "/api/v1/auth/refresh";
44+
3645
private final AppleLoginService appleAuthService;
3746
private final KakaoLoginService kakaoAuthService;
3847
private final TokenRefreshService tokenRefreshService;
3948
private final ClientInfoExtractor clientInfoExtractor;
49+
private final LogoutService logoutService;
4050

4151
@Value("${app.cookie.secure}")
4252
private boolean isCookieSecure;
@@ -169,7 +179,7 @@ public ResponseEntity<AccessTokenResponse> kakaoLogin(
169179
)
170180
public ResponseEntity<AccessTokenResponse> refresh(
171181
@Parameter(hidden = true)
172-
@CookieValue(name = "refreshToken", required = false) String refreshTokenCookie,
182+
@CookieValue(name = REFRESH_TOKEN_COOKIE_NAME, required = false) String refreshTokenCookie,
173183
@io.swagger.v3.oas.annotations.parameters.RequestBody(
174184
description = "쿠키에 리프레시 토큰이 없을 때, JSON 바디로 전달된 리프레시 토큰 " +
175185
"(iOS App에서 요청할 때 쓰면 편리. 웹 브라우저는 알아서 쿠키를 서버와 주고받으므로 이것을 사용할 필요 없음)"
@@ -187,6 +197,40 @@ public ResponseEntity<AccessTokenResponse> refresh(
187197
return toAuthResponse(loginResult);
188198
}
189199

200+
@PostMapping("/logout")
201+
@PreAuthorize("isAuthenticated()")
202+
@Operation(
203+
summary = "로그아웃",
204+
security = @SecurityRequirement(name = "bearerAuth"),
205+
description = """
206+
현재 인증된 사용자의 로그아웃을 처리합니다.<br>
207+
refreshToken이 요청에 포함되면 해당 세션을 revoke하고,
208+
deviceId가 포함되면 현재 디바이스의 푸시 토큰도 삭제합니다.<br>
209+
모바일 앱은 refreshTokenJson을 사용할 수 있고,
210+
refreshToken 쿠키가 요청에 포함된 경우에도 해당 값을 사용합니다.
211+
""",
212+
responses = {
213+
@ApiResponse(responseCode = "204", description = "로그아웃 완료"),
214+
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
215+
}
216+
)
217+
public ResponseEntity<Void> logout(
218+
@AuthenticationPrincipal UserPrincipal userPrincipal,
219+
@Parameter(hidden = true)
220+
@CookieValue(name = REFRESH_TOKEN_COOKIE_NAME, required = false) String refreshTokenCookie,
221+
@RequestBody(required = false) LogoutRequest request
222+
) {
223+
LogoutRequest body = request != null ? request : new LogoutRequest(null, null, null);
224+
String refreshToken = refreshTokenCookie != null ? refreshTokenCookie : body.refreshTokenJson();
225+
226+
logoutService.logout(userPrincipal.getId(), refreshToken, body.deviceId(), body.platform());
227+
228+
return ResponseEntity
229+
.noContent()
230+
.header(HttpHeaders.SET_COOKIE, clearRefreshTokenCookie().toString())
231+
.build();
232+
}
233+
190234
/**
191235
* AuthResult(비즈니스 결과)를 HTTP 응답(ResponseEntity + 쿠키)로 매핑.
192236
*/
@@ -204,12 +248,22 @@ private ResponseEntity<AccessTokenResponse> toAuthResponse(LoginResult loginResu
204248
}
205249

206250
private ResponseCookie createRefreshTokenCookie(String refreshToken) {
207-
return ResponseCookie.from("refreshToken", refreshToken)
251+
return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, refreshToken)
208252
.httpOnly(true)
209253
.secure(isCookieSecure)
210254
.sameSite("Lax")
211-
.path("/api/v1/auth/refresh")
255+
.path(REFRESH_TOKEN_COOKIE_PATH)
212256
.maxAge(RefreshTokenProvider.validDuration)
213257
.build();
214258
}
259+
260+
private ResponseCookie clearRefreshTokenCookie() {
261+
return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, "")
262+
.httpOnly(true)
263+
.secure(isCookieSecure)
264+
.sameSite("Lax")
265+
.path(REFRESH_TOKEN_COOKIE_PATH)
266+
.maxAge(0)
267+
.build();
268+
}
215269
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.devkor.apu.saerok_server.domain.auth.api.dto.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform;
5+
6+
@Schema(description = "로그아웃 요청 DTO")
7+
public record LogoutRequest(
8+
@Schema(description = "쿠키 대신 JSON Body로 전달하는 Refresh Token (모바일 앱용)")
9+
String refreshTokenJson,
10+
11+
@Schema(description = "현재 로그아웃하는 디바이스 식별자")
12+
String deviceId,
13+
14+
@Schema(description = "현재 로그아웃하는 디바이스 플랫폼", example = "IOS")
15+
DevicePlatform platform
16+
) {
17+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package org.devkor.apu.saerok_server.domain.auth.application;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.devkor.apu.saerok_server.domain.auth.core.repository.UserRefreshTokenRepository;
6+
import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService;
7+
import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform;
8+
import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider;
9+
import org.springframework.stereotype.Service;
10+
import org.springframework.transaction.annotation.Transactional;
11+
12+
@Slf4j
13+
@Service
14+
@Transactional
15+
@RequiredArgsConstructor
16+
public class LogoutService {
17+
18+
private final RefreshTokenProvider refreshTokenProvider;
19+
private final UserRefreshTokenRepository userRefreshTokenRepository;
20+
private final UserDeviceCommandService userDeviceCommandService;
21+
22+
public void logout(Long userId, String refreshToken, String deviceId, DevicePlatform platform) {
23+
revokeRefreshToken(userId, refreshToken);
24+
deactivateCurrentDevice(userId, deviceId, platform);
25+
}
26+
27+
private void revokeRefreshToken(Long userId, String refreshToken) {
28+
if (refreshToken == null || refreshToken.isBlank()) {
29+
return;
30+
}
31+
32+
userRefreshTokenRepository.findByRefreshTokenHash(refreshTokenProvider.hash(refreshToken))
33+
.ifPresent(token -> {
34+
if (!token.getUser().getId().equals(userId)) {
35+
log.warn("리프레시 토큰 소유주가 일치하지 않습니다: authenticatedUserId={}, tokenUserId={}",
36+
userId, token.getUser().getId());
37+
return;
38+
}
39+
40+
if (token.getRevokedAt() == null) {
41+
token.revoke();
42+
}
43+
});
44+
}
45+
46+
private void deactivateCurrentDevice(Long userId, String deviceId, DevicePlatform platform) {
47+
if (deviceId == null || deviceId.isBlank()) {
48+
return;
49+
}
50+
51+
userDeviceCommandService.deactivateDeviceIfPresent(userId, deviceId, platform);
52+
}
53+
}
Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package org.devkor.apu.saerok_server.domain.notification.api;
22

3-
import io.swagger.v3.oas.annotations.Hidden;
43
import io.swagger.v3.oas.annotations.Operation;
54
import io.swagger.v3.oas.annotations.media.Content;
65
import io.swagger.v3.oas.annotations.media.Schema;
@@ -11,10 +10,8 @@
1110
import org.devkor.apu.saerok_server.domain.notification.api.dto.request.RegisterTokenRequest;
1211
import org.devkor.apu.saerok_server.domain.notification.api.dto.response.RegisterUserDeviceResponse;
1312
import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService;
14-
import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform;
1513
import org.devkor.apu.saerok_server.domain.notification.mapper.UserDeviceWebMapper;
1614
import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal;
17-
import org.springframework.http.HttpStatus;
1815
import org.springframework.security.access.prepost.PreAuthorize;
1916
import org.springframework.security.core.annotation.AuthenticationPrincipal;
2017
import org.springframework.web.bind.annotation.*;
@@ -52,51 +49,4 @@ public RegisterUserDeviceResponse registerUserDevice(
5249
userDeviceWebMapper.toRegisterUserDeviceCommand(request, userPrincipal.getId())
5350
);
5451
}
55-
56-
@Hidden
57-
@DeleteMapping("/{deviceId}")
58-
@PreAuthorize("isAuthenticated()")
59-
@ResponseStatus(HttpStatus.NO_CONTENT)
60-
@Operation(
61-
summary = "특정 디바이스 토큰 삭제",
62-
security = @SecurityRequirement(name = "bearerAuth"),
63-
description = """
64-
특정 디바이스의 토큰을 삭제합니다.<br>
65-
보통 로그아웃 시 사용됩니다.<br>
66-
""",
67-
responses = {
68-
@ApiResponse(responseCode = "204", description = "삭제 성공"),
69-
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
70-
@ApiResponse(responseCode = "404", description = "해당 디바이스를 찾을 수 없음", content = @Content)
71-
}
72-
)
73-
public void deleteDevice(
74-
@AuthenticationPrincipal UserPrincipal userPrincipal,
75-
@PathVariable String deviceId,
76-
@RequestParam(required = false) DevicePlatform platform
77-
) {
78-
userDeviceCommandService.deleteDevice(userPrincipal.getId(), deviceId, platform);
79-
}
80-
81-
@Hidden
82-
@DeleteMapping("/all")
83-
@PreAuthorize("isAuthenticated()")
84-
@ResponseStatus(HttpStatus.NO_CONTENT)
85-
@Operation(
86-
summary = "사용자의 모든 디바이스 토큰 삭제",
87-
security = @SecurityRequirement(name = "bearerAuth"),
88-
description = """
89-
사용자의 모든 디바이스 토큰을 삭제합니다.<br>
90-
보통 회원 탈퇴 시 사용됩니다.<br>
91-
""",
92-
responses = {
93-
@ApiResponse(responseCode = "204", description = "전체 삭제 성공"),
94-
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
95-
}
96-
)
97-
public void deleteAllTokens(
98-
@AuthenticationPrincipal UserPrincipal userPrincipal
99-
) {
100-
userDeviceCommandService.deleteAllTokens(userPrincipal.getId());
101-
}
10252
}

src/main/java/org/devkor/apu/saerok_server/domain/notification/application/UserDeviceCommandService.java

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform;
77
import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice;
88
import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository;
9-
import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationSettingRepository;
109
import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationSettingBackfillService;
1110
import org.devkor.apu.saerok_server.domain.notification.mapper.UserDeviceWebMapper;
1211
import org.devkor.apu.saerok_server.domain.user.core.entity.User;
@@ -16,13 +15,14 @@
1615
import org.springframework.stereotype.Service;
1716
import org.springframework.transaction.annotation.Transactional;
1817

18+
import java.util.List;
19+
1920
@Service
2021
@Transactional
2122
@RequiredArgsConstructor
2223
public class UserDeviceCommandService {
2324

2425
private final UserDeviceRepository userDeviceRepository;
25-
private final NotificationSettingRepository notificationSettingRepository;
2626
private final UserRepository userRepository;
2727
private final UserDeviceWebMapper userDeviceWebMapper;
2828
private final NotificationSettingBackfillService backfillService;
@@ -31,17 +31,24 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c
3131
User user = userRepository.findById(command.userId())
3232
.orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요"));
3333

34-
if (command.deviceId() == null || command.deviceId().isEmpty()
35-
|| command.token() == null || command.token().isEmpty()) {
34+
if (command.deviceId() == null || command.deviceId().isBlank()
35+
|| command.token() == null || command.token().isBlank()) {
3636
throw new BadRequestException("deviceId, token은 필수입니다");
3737
}
3838

3939
DevicePlatform platform = command.platform() != null ? command.platform() : DevicePlatform.IOS;
4040

41+
userDeviceRepository.deactivateConflictingTokensForRegistration(
42+
command.userId(),
43+
command.deviceId(),
44+
platform,
45+
command.token()
46+
);
47+
4148
UserDevice userDevice = userDeviceRepository
4249
.findByUserIdAndDeviceIdAndPlatform(command.userId(), command.deviceId(), platform)
4350
.map(existing -> {
44-
existing.updateToken(command.token());
51+
existing.activateToken(command.token());
4552
return existing;
4653
})
4754
.orElseGet(() -> {
@@ -56,19 +63,26 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c
5663
return userDeviceWebMapper.toRegisterUserDeviceResponse(command, true);
5764
}
5865

59-
public void deleteDevice(Long userId, String deviceId, DevicePlatform platform) {
60-
userRepository.findById(userId).orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요"));
61-
platform = platform != null ? platform : DevicePlatform.IOS;
62-
userDeviceRepository.findByUserIdAndDeviceIdAndPlatform(userId, deviceId, platform)
63-
.orElseThrow(() -> new NotFoundException("해당 디바이스를 찾을 수 없어요"));
66+
public void deactivateDeviceIfPresent(Long userId, String deviceId, DevicePlatform platform) {
67+
if (deviceId == null || deviceId.isBlank()) {
68+
return;
69+
}
6470

65-
userDeviceRepository.deleteByUserIdAndDeviceIdAndPlatform(userId, deviceId, platform);
71+
DevicePlatform resolvedPlatform = platform != null ? platform : DevicePlatform.IOS;
72+
userDeviceRepository.findByUserIdAndDeviceIdAndPlatform(userId, deviceId, resolvedPlatform)
73+
.ifPresent(UserDevice::deactivateToken);
6674
}
6775

68-
public void deleteAllTokens(Long userId) {
69-
userRepository.findById(userId).orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요"));
76+
public void deactivateInvalidTokens(List<String> tokens) {
77+
if (tokens == null || tokens.isEmpty()) {
78+
return;
79+
}
80+
81+
List<String> validTokens = tokens.stream()
82+
.filter(token -> token != null && !token.isBlank())
83+
.distinct()
84+
.toList();
7085

71-
notificationSettingRepository.deleteByUserId(userId);
72-
userDeviceRepository.deleteByUserId(userId);
86+
userDeviceRepository.deactivateByTokens(validTokens);
7387
}
7488
}

src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/UserDevice.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public class UserDevice extends Auditable {
2727
@Column(name = "device_id", nullable = false, length = 256)
2828
private String deviceId;
2929

30-
@Column(name = "token", nullable = false, length = 512)
30+
@Column(name = "token", length = 512)
3131
private String token;
3232

3333
@Enumerated(EnumType.STRING)
@@ -38,10 +38,19 @@ public static UserDevice create(User user, String deviceId, String token, Device
3838
UserDevice userDevice = new UserDevice();
3939
userDevice.user = user;
4040
userDevice.deviceId = deviceId;
41-
userDevice.token = token;
4241
userDevice.platform = platform;
42+
userDevice.activateToken(token);
4343
return userDevice;
4444
}
4545

46-
public void updateToken(String newToken) { this.token = newToken; }
46+
public void activateToken(String newToken) {
47+
if (newToken == null || newToken.isBlank()) {
48+
throw new IllegalArgumentException("FCM token은 비어 있을 수 없습니다");
49+
}
50+
this.token = newToken;
51+
}
52+
53+
public void deactivateToken() {
54+
this.token = null;
55+
}
4756
}

src/main/java/org/devkor/apu/saerok_server/domain/notification/core/repository/NotificationSettingRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public List<Long> findEnabledDeviceIdsByUserAndType(Long userId, NotificationTyp
4646
where ns.userDevice.user.id = :userId
4747
and ns.type = :type
4848
and ns.enabled = true
49+
and ns.userDevice.token is not null
4950
""", Long.class)
5051
.setParameter("userId", userId)
5152
.setParameter("type", type)
@@ -60,6 +61,7 @@ public List<Long> findEnabledDeviceIdsByUserIdsAndType(List<Long> userIds, Notif
6061
where ns.userDevice.user.id in :userIds
6162
and ns.type = :type
6263
and ns.enabled = true
64+
and ns.userDevice.token is not null
6365
""", Long.class)
6466
.setParameter("userIds", userIds)
6567
.setParameter("type", type)

0 commit comments

Comments
 (0)