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 @@ -5,18 +5,22 @@
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.security.PermitAll;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.devkor.apu.saerok_server.domain.auth.api.dto.request.AppleLoginRequest;
import org.devkor.apu.saerok_server.domain.auth.api.dto.request.KakaoLoginRequest;
import org.devkor.apu.saerok_server.domain.auth.api.dto.request.LogoutRequest;
import org.devkor.apu.saerok_server.domain.auth.api.dto.request.RefreshRequest;
import org.devkor.apu.saerok_server.domain.auth.api.dto.response.AccessTokenResponse;
import org.devkor.apu.saerok_server.domain.auth.application.AppleLoginService;
import org.devkor.apu.saerok_server.domain.auth.application.LoginResult;
import org.devkor.apu.saerok_server.domain.auth.application.LogoutService;
import org.devkor.apu.saerok_server.domain.auth.application.KakaoLoginService;
import org.devkor.apu.saerok_server.domain.auth.application.TokenRefreshService;
import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal;
import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider;
import org.devkor.apu.saerok_server.global.shared.exception.UnauthorizedException;
import org.devkor.apu.saerok_server.global.shared.util.ClientInfoExtractor;
Expand All @@ -25,6 +29,8 @@
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

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

private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken";
private static final String REFRESH_TOKEN_COOKIE_PATH = "/api/v1/auth/refresh";

private final AppleLoginService appleAuthService;
private final KakaoLoginService kakaoAuthService;
private final TokenRefreshService tokenRefreshService;
private final ClientInfoExtractor clientInfoExtractor;
private final LogoutService logoutService;

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

@PostMapping("/logout")
@PreAuthorize("isAuthenticated()")
@Operation(
summary = "로그아웃",
security = @SecurityRequirement(name = "bearerAuth"),
description = """
현재 인증된 사용자의 로그아웃을 처리합니다.<br>
refreshToken이 요청에 포함되면 해당 세션을 revoke하고,
deviceId가 포함되면 현재 디바이스의 푸시 토큰도 삭제합니다.<br>
모바일 앱은 refreshTokenJson을 사용할 수 있고,
refreshToken 쿠키가 요청에 포함된 경우에도 해당 값을 사용합니다.
""",
responses = {
@ApiResponse(responseCode = "204", description = "로그아웃 완료"),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
}
)
public ResponseEntity<Void> logout(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@Parameter(hidden = true)
@CookieValue(name = REFRESH_TOKEN_COOKIE_NAME, required = false) String refreshTokenCookie,
@RequestBody(required = false) LogoutRequest request
) {
LogoutRequest body = request != null ? request : new LogoutRequest(null, null, null);
String refreshToken = refreshTokenCookie != null ? refreshTokenCookie : body.refreshTokenJson();

logoutService.logout(userPrincipal.getId(), refreshToken, body.deviceId(), body.platform());

return ResponseEntity
.noContent()
.header(HttpHeaders.SET_COOKIE, clearRefreshTokenCookie().toString())
.build();
}

/**
* AuthResult(비즈니스 결과)를 HTTP 응답(ResponseEntity + 쿠키)로 매핑.
*/
Expand All @@ -204,12 +248,22 @@ private ResponseEntity<AccessTokenResponse> toAuthResponse(LoginResult loginResu
}

private ResponseCookie createRefreshTokenCookie(String refreshToken) {
return ResponseCookie.from("refreshToken", refreshToken)
return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, refreshToken)
.httpOnly(true)
.secure(isCookieSecure)
.sameSite("Lax")
.path("/api/v1/auth/refresh")
.path(REFRESH_TOKEN_COOKIE_PATH)
.maxAge(RefreshTokenProvider.validDuration)
.build();
}

private ResponseCookie clearRefreshTokenCookie() {
return ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, "")
.httpOnly(true)
.secure(isCookieSecure)
.sameSite("Lax")
.path(REFRESH_TOKEN_COOKIE_PATH)
.maxAge(0)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.devkor.apu.saerok_server.domain.auth.api.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform;

@Schema(description = "로그아웃 요청 DTO")
public record LogoutRequest(
@Schema(description = "쿠키 대신 JSON Body로 전달하는 Refresh Token (모바일 앱용)")
String refreshTokenJson,

@Schema(description = "현재 로그아웃하는 디바이스 식별자")
String deviceId,

@Schema(description = "현재 로그아웃하는 디바이스 플랫폼", example = "IOS")
DevicePlatform platform
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package org.devkor.apu.saerok_server.domain.auth.application;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.devkor.apu.saerok_server.domain.auth.core.repository.UserRefreshTokenRepository;
import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService;
import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform;
import org.devkor.apu.saerok_server.global.security.token.RefreshTokenProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class LogoutService {

private final RefreshTokenProvider refreshTokenProvider;
private final UserRefreshTokenRepository userRefreshTokenRepository;
private final UserDeviceCommandService userDeviceCommandService;

public void logout(Long userId, String refreshToken, String deviceId, DevicePlatform platform) {
revokeRefreshToken(userId, refreshToken);
deactivateCurrentDevice(userId, deviceId, platform);
}

private void revokeRefreshToken(Long userId, String refreshToken) {
if (refreshToken == null || refreshToken.isBlank()) {
return;
}

userRefreshTokenRepository.findByRefreshTokenHash(refreshTokenProvider.hash(refreshToken))
.ifPresent(token -> {
if (!token.getUser().getId().equals(userId)) {
log.warn("리프레시 토큰 소유주가 일치하지 않습니다: authenticatedUserId={}, tokenUserId={}",
userId, token.getUser().getId());
return;
}

if (token.getRevokedAt() == null) {
token.revoke();
}
});
}

private void deactivateCurrentDevice(Long userId, String deviceId, DevicePlatform platform) {
if (deviceId == null || deviceId.isBlank()) {
return;
}

userDeviceCommandService.deactivateDeviceIfPresent(userId, deviceId, platform);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package org.devkor.apu.saerok_server.domain.notification.api;

import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
Expand All @@ -11,10 +10,8 @@
import org.devkor.apu.saerok_server.domain.notification.api.dto.request.RegisterTokenRequest;
import org.devkor.apu.saerok_server.domain.notification.api.dto.response.RegisterUserDeviceResponse;
import org.devkor.apu.saerok_server.domain.notification.application.UserDeviceCommandService;
import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform;
import org.devkor.apu.saerok_server.domain.notification.mapper.UserDeviceWebMapper;
import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
Expand Down Expand Up @@ -52,51 +49,4 @@ public RegisterUserDeviceResponse registerUserDevice(
userDeviceWebMapper.toRegisterUserDeviceCommand(request, userPrincipal.getId())
);
}

@Hidden
@DeleteMapping("/{deviceId}")
@PreAuthorize("isAuthenticated()")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(
summary = "특정 디바이스 토큰 삭제",
security = @SecurityRequirement(name = "bearerAuth"),
description = """
특정 디바이스의 토큰을 삭제합니다.<br>
보통 로그아웃 시 사용됩니다.<br>
""",
responses = {
@ApiResponse(responseCode = "204", description = "삭제 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "404", description = "해당 디바이스를 찾을 수 없음", content = @Content)
}
)
public void deleteDevice(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@PathVariable String deviceId,
@RequestParam(required = false) DevicePlatform platform
) {
userDeviceCommandService.deleteDevice(userPrincipal.getId(), deviceId, platform);
}

@Hidden
@DeleteMapping("/all")
@PreAuthorize("isAuthenticated()")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(
summary = "사용자의 모든 디바이스 토큰 삭제",
security = @SecurityRequirement(name = "bearerAuth"),
description = """
사용자의 모든 디바이스 토큰을 삭제합니다.<br>
보통 회원 탈퇴 시 사용됩니다.<br>
""",
responses = {
@ApiResponse(responseCode = "204", description = "전체 삭제 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
}
)
public void deleteAllTokens(
@AuthenticationPrincipal UserPrincipal userPrincipal
) {
userDeviceCommandService.deleteAllTokens(userPrincipal.getId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import org.devkor.apu.saerok_server.domain.notification.core.entity.DevicePlatform;
import org.devkor.apu.saerok_server.domain.notification.core.entity.UserDevice;
import org.devkor.apu.saerok_server.domain.notification.core.repository.UserDeviceRepository;
import org.devkor.apu.saerok_server.domain.notification.core.repository.NotificationSettingRepository;
import org.devkor.apu.saerok_server.domain.notification.core.service.NotificationSettingBackfillService;
import org.devkor.apu.saerok_server.domain.notification.mapper.UserDeviceWebMapper;
import org.devkor.apu.saerok_server.domain.user.core.entity.User;
Expand All @@ -16,13 +15,14 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional
@RequiredArgsConstructor
public class UserDeviceCommandService {

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

if (command.deviceId() == null || command.deviceId().isEmpty()
|| command.token() == null || command.token().isEmpty()) {
if (command.deviceId() == null || command.deviceId().isBlank()
|| command.token() == null || command.token().isBlank()) {
throw new BadRequestException("deviceId, token은 필수입니다");
}

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

userDeviceRepository.deactivateConflictingTokensForRegistration(
command.userId(),
command.deviceId(),
platform,
command.token()
);

UserDevice userDevice = userDeviceRepository
.findByUserIdAndDeviceIdAndPlatform(command.userId(), command.deviceId(), platform)
.map(existing -> {
existing.updateToken(command.token());
existing.activateToken(command.token());
return existing;
})
.orElseGet(() -> {
Expand All @@ -56,19 +63,26 @@ public RegisterUserDeviceResponse registerUserDevice(RegisterUserDeviceCommand c
return userDeviceWebMapper.toRegisterUserDeviceResponse(command, true);
}

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

userDeviceRepository.deleteByUserIdAndDeviceIdAndPlatform(userId, deviceId, platform);
DevicePlatform resolvedPlatform = platform != null ? platform : DevicePlatform.IOS;
userDeviceRepository.findByUserIdAndDeviceIdAndPlatform(userId, deviceId, resolvedPlatform)
.ifPresent(UserDevice::deactivateToken);
}

public void deleteAllTokens(Long userId) {
userRepository.findById(userId).orElseThrow(() -> new NotFoundException("존재하지 않는 사용자 id예요"));
public void deactivateInvalidTokens(List<String> tokens) {
if (tokens == null || tokens.isEmpty()) {
return;
}

List<String> validTokens = tokens.stream()
.filter(token -> token != null && !token.isBlank())
.distinct()
.toList();

notificationSettingRepository.deleteByUserId(userId);
userDeviceRepository.deleteByUserId(userId);
userDeviceRepository.deactivateByTokens(validTokens);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class UserDevice extends Auditable {
@Column(name = "device_id", nullable = false, length = 256)
private String deviceId;

@Column(name = "token", nullable = false, length = 512)
@Column(name = "token", length = 512)
private String token;

@Enumerated(EnumType.STRING)
Expand All @@ -38,10 +38,19 @@ public static UserDevice create(User user, String deviceId, String token, Device
UserDevice userDevice = new UserDevice();
userDevice.user = user;
userDevice.deviceId = deviceId;
userDevice.token = token;
userDevice.platform = platform;
userDevice.activateToken(token);
return userDevice;
}

public void updateToken(String newToken) { this.token = newToken; }
public void activateToken(String newToken) {
if (newToken == null || newToken.isBlank()) {
throw new IllegalArgumentException("FCM token은 비어 있을 수 없습니다");
}
this.token = newToken;
}

public void deactivateToken() {
this.token = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public List<Long> findEnabledDeviceIdsByUserAndType(Long userId, NotificationTyp
where ns.userDevice.user.id = :userId
and ns.type = :type
and ns.enabled = true
and ns.userDevice.token is not null
""", Long.class)
.setParameter("userId", userId)
.setParameter("type", type)
Expand All @@ -60,6 +61,7 @@ public List<Long> findEnabledDeviceIdsByUserIdsAndType(List<Long> userIds, Notif
where ns.userDevice.user.id in :userIds
and ns.type = :type
and ns.enabled = true
and ns.userDevice.token is not null
""", Long.class)
.setParameter("userIds", userIds)
.setParameter("type", type)
Expand Down
Loading
Loading