From 13dcebdf5db7b5ff7f52ae9ee0f59006dfd4c55c Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Tue, 5 May 2026 22:02:59 +0900 Subject: [PATCH 1/2] docs: add swagger docs for alarm endpoints --- .../controller/AlarmController.java | 92 +++++++++++++++++++ .../controller/FirebaseTokenController.java | 2 +- .../controller/ScheduleController.java | 26 ++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/AlarmController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/AlarmController.java index c4e498b0..a656df31 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/AlarmController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/AlarmController.java @@ -4,6 +4,11 @@ import devkor.ontime_back.response.ApiResponseForm; import devkor.ontime_back.service.AlarmService; import devkor.ontime_back.service.UserAuthService; +import io.swagger.v3.oas.annotations.Operation; +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.responses.ApiResponses; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -19,6 +24,14 @@ public class AlarmController { private final UserAuthService userAuthService; private final AlarmService alarmService; + @Operation(summary = "Get current user's native alarm settings") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Alarm settings lookup succeeded", content = @Content( + mediaType = "application/json", + schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"alarmsEnabled\": true,\n \"defaultAlarmOffsetMinutes\": 10,\n \"updatedAt\": \"2026-05-05T00:00:00Z\"\n }\n}") + )), + @ApiResponse(responseCode = "4XX", description = "Alarm settings lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message"))) + }) @GetMapping("/users/me/alarm-settings") public ResponseEntity> getAlarmSettings(HttpServletRequest request) { Long userId = userAuthService.getUserIdFromToken(request); @@ -26,6 +39,24 @@ public ResponseEntity> getAlarmSetting .body(ApiResponseForm.success(alarmService.getAlarmSettings(userId))); } + @Operation( + summary = "Update current user's native alarm settings", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Partial alarm settings update. Omit fields that should not change.", + required = true, + content = @Content(schema = @Schema( + type = "object", + example = "{\"alarmsEnabled\": true, \"defaultAlarmOffsetMinutes\": 10}" + )) + ) + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Alarm settings update succeeded", content = @Content( + mediaType = "application/json", + schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"alarmsEnabled\": true,\n \"defaultAlarmOffsetMinutes\": 10,\n \"updatedAt\": \"2026-05-05T00:00:00Z\"\n }\n}") + )), + @ApiResponse(responseCode = "4XX", description = "Alarm settings update failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message"))) + }) @PatchMapping("/users/me/alarm-settings") public ResponseEntity> patchAlarmSettings( HttpServletRequest request, @@ -35,6 +66,24 @@ public ResponseEntity> patchAlarmSetti .body(ApiResponseForm.success(alarmService.patchAlarmSettings(userId, requestBody))); } + @Operation( + summary = "Register current device for native alarm ownership", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Current device metadata used to bind the logged-in access token to native alarm status.", + required = true, + content = @Content(schema = @Schema( + type = "object", + example = "{\"deviceId\": \"ios-device-000001\", \"platform\": \"ios\", \"appVersion\": \"1.2.3\", \"osVersion\": \"iOS 18.0\", \"supportsNativeAlarm\": true, \"nativeAlarmProvider\": \"iosAlarmKit\", \"fallbackProvider\": \"localNotification\"}" + )) + ) + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Current device registration succeeded", content = @Content( + mediaType = "application/json", + schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"deviceId\": \"ios-device-000001\",\n \"active\": true,\n \"lastSeenAt\": \"2026-05-05T00:00:00Z\"\n }\n}") + )), + @ApiResponse(responseCode = "4XX", description = "Current device registration failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message"))) + }) @PutMapping("/users/me/devices/current") public ResponseEntity> registerCurrentDevice( HttpServletRequest request, @@ -48,6 +97,23 @@ public ResponseEntity> registerCu userAuthService.getRefreshTokenFromRequest(request)))); } + @Operation( + summary = "Unregister current device from native alarm ownership", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Optional device ID. If omitted, the device bound to the access token is unregistered.", + content = @Content(schema = @Schema( + type = "object", + example = "{\"deviceId\": \"ios-device-000001\"}" + )) + ) + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Current device unregister succeeded", content = @Content( + mediaType = "application/json", + schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"active\": false\n }\n}") + )), + @ApiResponse(responseCode = "4XX", description = "Current device unregister failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message"))) + }) @DeleteMapping("/users/me/devices/current") public ResponseEntity> unregisterCurrentDevice( HttpServletRequest request, @@ -60,6 +126,24 @@ public ResponseEntity> unregis userAuthService.getAccessTokenFromRequest(request)))); } + @Operation( + summary = "Report current native alarm reconciliation status", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Native alarm reconciliation status for the current device and schedule window.", + required = true, + content = @Content(schema = @Schema( + type = "object", + example = "{\"deviceId\": \"ios-device-000001\", \"reconciledAt\": \"2026-05-05T09:00:00+09:00\", \"scheduleWindowStart\": \"2026-05-05T00:00:00\", \"scheduleWindowEnd\": \"2026-05-06T00:00:00\", \"alarmCoverageStart\": \"2026-05-05T00:00:00\", \"alarmCoverageEnd\": \"2026-05-06T00:00:00\", \"status\": \"armed\", \"permissionIssue\": null, \"nativeAlarmProvider\": \"iosAlarmKit\", \"fallbackProvider\": \"localNotification\", \"armedScheduleCount\": 1, \"armedScheduleIds\": [\"3fa85f64-5717-4562-b3fc-2c963f66afe5\"], \"skippedScheduleCount\": 0, \"failures\": []}" + )) + ) + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Alarm status report succeeded", content = @Content( + mediaType = "application/json", + schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"received\": true\n }\n}") + )), + @ApiResponse(responseCode = "4XX", description = "Alarm status report failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message"))) + }) @PostMapping("/users/me/alarm-status") public ResponseEntity> reportAlarmStatus( HttpServletRequest request, @@ -72,6 +156,14 @@ public ResponseEntity> reportAlarm userAuthService.getAccessTokenFromRequest(request)))); } + @Operation(summary = "Get current native alarm reconciliation status") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Current alarm status lookup succeeded", content = @Content( + mediaType = "application/json", + schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"deviceId\": \"ios-device-000001\",\n \"active\": true,\n \"platform\": \"ios\",\n \"appVersion\": \"1.2.3\",\n \"osVersion\": \"iOS 18.0\",\n \"supportsNativeAlarm\": true,\n \"nativeAlarmProvider\": \"iosAlarmKit\",\n \"fallbackProvider\": \"localNotification\",\n \"lastSeenAt\": \"2026-05-05T00:00:00Z\",\n \"reconciledAt\": \"2026-05-05T00:00:00Z\",\n \"scheduleWindowStart\": \"2026-05-05T00:00:00\",\n \"scheduleWindowEnd\": \"2026-05-06T00:00:00\",\n \"alarmCoverageStart\": \"2026-05-05T00:00:00\",\n \"alarmCoverageEnd\": \"2026-05-06T00:00:00\",\n \"status\": \"armed\",\n \"permissionIssue\": null,\n \"armedScheduleCount\": 1,\n \"armedScheduleIds\": [\"3fa85f64-5717-4562-b3fc-2c963f66afe5\"],\n \"skippedScheduleCount\": 0,\n \"failures\": [],\n \"updatedAt\": \"2026-05-05T00:00:00Z\"\n }\n}") + )), + @ApiResponse(responseCode = "4XX", description = "Current alarm status lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message"))) + }) @GetMapping("/users/me/alarm-status") public ResponseEntity> getCurrentAlarmStatus(HttpServletRequest request) { Long userId = userAuthService.getUserIdFromToken(request); diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/FirebaseTokenController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/FirebaseTokenController.java index 8350e011..1e931f1e 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/FirebaseTokenController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/FirebaseTokenController.java @@ -32,7 +32,7 @@ public class FirebaseTokenController { content = @Content( schema = @Schema( type = "object", - example = "{\"firebaseToken\": \"token1234abcd(실제로는 firebase에서 받은 토큰을 기입해야 함)\"}" + example = "{\"firebaseToken\": \"token1234abcd(실제로는 firebase에서 받은 토큰을 기입해야 함)\", \"deviceId\": \"ios-device-000001\"}" ) ) ) diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java index 4fe69833..6cc271a6 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java @@ -70,10 +70,36 @@ public ResponseEntity>> getPeriodSchedule(Http return ResponseEntity.status(HttpStatus.OK).body(ApiResponseForm.success(schedules)); } + @Operation(summary = "Native alarm schedule window lookup", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "No JSON body is required. Send access token in the header and use startDate/endDate query parameters." + ) + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Alarm schedule window lookup succeeded", + content = @Content( + mediaType = "application/json", + schema = @Schema( + example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": [\n {\n \"scheduleId\": \"3fa85f64-5717-4562-b3fc-2c963f66afe5\",\n \"scheduleName\": \"Morning meeting\",\n \"place\": {\n \"placeId\": \"3fa85f64-5717-4562-b3fc-2c963f66afe6\",\n \"placeName\": \"Office\"\n },\n \"scheduleTime\": \"2026-05-05T09:30:00\",\n \"moveTime\": 20,\n \"scheduleSpareTime\": 10,\n \"doneStatus\": \"NOT_ENDED\",\n \"preparationStartTime\": \"2026-05-05T08:40:00\",\n \"defaultAlarmTime\": \"2026-05-05T08:30:00\",\n \"preparations\": [],\n \"alarmSettings\": null\n }\n ]\n}" + ) + )), + @ApiResponse(responseCode = "4XX", description = "Alarm schedule window lookup failed", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = "Failure message") + ) + ) + }) @GetMapping("/alarm-window") public ResponseEntity>> getAlarmWindowSchedules( HttpServletRequest request, + @Parameter(description = "Alarm window start date-time. Supports ISO local date-time or ISO offset date-time.", + required = true, + example = "2026-05-05T00:00:00") @RequestParam String startDate, + @Parameter(description = "Alarm window end date-time. Supports ISO local date-time or ISO offset date-time.", + required = true, + example = "2026-05-06T00:00:00") @RequestParam String endDate) { Long userId = userAuthService.getUserIdFromToken(request); List schedules = scheduleService.getAlarmWindowSchedules( From a4952910a8dca9cad9fb979f564580d0569364ed Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Wed, 6 May 2026 02:00:42 +0900 Subject: [PATCH 2/2] Fix duplicate Google relogin handling --- .../global/jwt/JwtAuthenticationFilter.java | 17 +++++------ .../global/jwt/JwtTokenProvider.java | 3 +- .../oauth/google/GoogleLoginFilter.java | 19 +++++++++---- .../global/jwt/JwtTokenProviderTest.java | 28 +++++++++++++++++++ 4 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtTokenProviderTest.java diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java index 560c34f1..25bc5616 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilter.java @@ -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); } @@ -192,4 +189,4 @@ private void handleInvalidTokenException(HttpServletResponse response, InvalidTo response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); } } -} \ No newline at end of file +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtTokenProvider.java b/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtTokenProvider.java index d48df727..92beb7aa 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtTokenProvider.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtTokenProvider.java @@ -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)); } @@ -199,4 +200,4 @@ public String createExpiredAccessToken(String email) { .sign(Algorithm.HMAC512(secretKey)); } -} \ No newline at end of file +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilter.java index 0eea0280..928a90b7 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginFilter.java @@ -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 LOGIN_LOCKS = new ConcurrentHashMap<>(); + private final UserRepository userRepository; private final GoogleLoginService googleLoginService; @@ -42,13 +46,16 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ GoogleIdToken.Payload googlePayload = googleLoginService.verifyIdentityToken(oAuthGoogleRequestDto.getIdToken()); String googleUserId = googlePayload.getSubject(); - Optional existingUser = userRepository.findBySocialTypeAndSocialId(SocialType.GOOGLE, googleUserId); + Object loginLock = LOGIN_LOCKS.computeIfAbsent(googleUserId, key -> new Object()); + synchronized (loginLock) { + Optional 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) { diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtTokenProviderTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtTokenProviderTest.java new file mode 100644 index 00000000..6b88b22c --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtTokenProviderTest.java @@ -0,0 +1,28 @@ +package devkor.ontime_back.global.jwt; + +import devkor.ontime_back.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class JwtTokenProviderTest { + + @DisplayName("동일한 시각에 발급한 리프레시 토큰도 서로 다르다") + @Test + void createRefreshTokenGeneratesUniqueTokens() { + // given + JwtTokenProvider jwtTokenProvider = new JwtTokenProvider(mock(UserRepository.class)); + ReflectionTestUtils.setField(jwtTokenProvider, "secretKey", "test-secret-key-that-is-long-enough"); + ReflectionTestUtils.setField(jwtTokenProvider, "refreshTokenExpirationPeriod", 3600000L); + + // when + String refreshToken1 = jwtTokenProvider.createRefreshToken(); + String refreshToken2 = jwtTokenProvider.createRefreshToken(); + + // then + assertThat(refreshToken1).isNotEqualTo(refreshToken2); + } +}