diff --git a/.gitignore b/.gitignore index 4c5d06c0..5773b2ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .DS_Store .idea/ .codex/ +scripts/benchmarks/*/results/* +!scripts/benchmarks/*/results/.gitkeep diff --git a/docs/performance/apple-login-323.md b/docs/performance/apple-login-323.md new file mode 100644 index 00000000..966cc681 --- /dev/null +++ b/docs/performance/apple-login-323.md @@ -0,0 +1,54 @@ +# Apple Login Performance Evidence for Issue #323 + +## Measurement Contract + +This document records before/after evidence for reducing Apple login latency by removing repeated Apple provider round trips from the returning Apple User login path. + +Primary benchmark environment: + +- Backend: local Spring Boot process with `bench` profile +- Database: isolated Docker MySQL database `ontime_bench` +- Apple provider: local stub +- Returning Apple User: `social_type=APPLE`, `social_id=bench-apple-user` +- Stub delay: + - `GET /auth/keys`: 80 ms + - `POST /auth/token`: 300 ms +- Warmup: 2 minutes +- Measurement: 5 minutes +- Full run count: 10 per scenario + +The benchmark harness lives in `scripts/benchmarks/apple-login/`. + +## Acceptance Gate + +Primary scenario: returning Apple User, warm cache, concurrency 1. + +- JWKS network calls/request: `before 1.0 -> after 0.0` +- Apple token exchange calls/request: `before 1.0 -> after 0.0` +- Error rate: `0%` +- p95 latency: `after <= before * 0.70` + +Secondary scenarios: returning Apple User, concurrency 10 and 20. + +- Error rate: `0%` +- Apple token exchange calls/request: `after 0.0` +- p95 latency: `after <= before` + +## Results + +Populate this table from `scripts/benchmarks/apple-login/results/*/summary.csv` after running both before and after measurements. + +| scenario | version | runs | requests | error_rate | p50_ms | p95_ms | p99_ms | jwks_calls/request | token_exchange_calls/request | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| c1 returning warm-cache | before | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD | +| c1 returning warm-cache | after | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD | +| c10 returning warm-cache | before | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD | +| c10 returning warm-cache | after | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD | +| c20 returning warm-cache | before | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD | +| c20 returning warm-cache | after | TBD | TBD | TBD | TBD | TBD | TBD | TBD | TBD | + +## Notes + +- Real Apple network calls are intentionally excluded from the primary benchmark because provider and network variability would obscure backend request-path changes. +- The script labels results as `before` or `after` but does not change git refs. Select the checkout explicitly before running. +- Quick mode is for development feedback. Full mode is the acceptance evidence. diff --git a/docs/performance/jwt-auth-325.md b/docs/performance/jwt-auth-325.md new file mode 100644 index 00000000..3025b173 --- /dev/null +++ b/docs/performance/jwt-auth-325.md @@ -0,0 +1,68 @@ +# JWT Authentication Performance Evidence for Issue #325 + +## Measurement Contract + +This document records before/after evidence for removing duplicate `User` reads from the protected API JWT authentication path. + +Primary benchmark environment: + +- Backend: local Spring Boot process with `bench` profile +- Database: isolated Docker MySQL database `ontime_jwt_bench` +- Protected endpoint: `GET /users/me/punctuality-score` +- Benchmark User: created through `POST /sign-up` +- Warmup: 20 protected requests +- Measurement: 5 minutes +- Full run count: 10 per scenario + +The benchmark harness lives in `scripts/benchmarks/jwt-auth/`. + +Recorded quick evidence run: + +- before: `scripts/benchmarks/jwt-auth/results/20260628T042635Z-before-quick/summary.csv` +- after: `scripts/benchmarks/jwt-auth/results/20260628T044205Z-after-quick/summary.csv` +- mode: `quick` +- runs: 1 per scenario + +## Acceptance Gate + +Primary correctness gate: + +- Authentication `User` lookup count: `before 2 -> after 1` +- `GET /users/me/punctuality-score` whole-request SQL budget: `after <= 5` +- Invalid, expired, and replaced access token behavior remains compatible +- Refresh token rotation behavior remains compatible +- `./gradlew check` passes + +Benchmark evidence: + +- Error rate: `0%` +- p95 latency: `after <= before` +- Whole-request SQL budget sample: `after <= before - 1` + +## Results + +Populate this table from `scripts/benchmarks/jwt-auth/results/*/summary.csv` and `sql-budget-*.txt` after running both before and after measurements. + +| scenario | version | runs | requests | error_rate | p50_ms | p95_ms | p99_ms | whole_request_sql_sample | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| c1 protected request | before | 1 | 2273 | 0 | 28.190 | 46.770 | 53.554 | 7 | +| c1 protected request | after | 1 | 2404 | 0 | 21.664 | 38.093 | 48.579 | 5 | +| c10 protected request | before | 1 | 21804 | 0 | 35.879 | 52.873 | 62.045 | 7 | +| c10 protected request | after | 1 | 23409 | 0 | 26.412 | 40.129 | 48.728 | 5 | +| c20 protected request | before | 1 | 47625 | 0 | 23.957 | 36.560 | 72.784 | 7 | +| c20 protected request | after | 1 | 49550 | 0 | 19.597 | 29.620 | 39.054 | 5 | + +## Quick Run Delta + +| scenario | p95_delta_ms | p95_delta_pct | sql_sample_delta | +| --- | ---: | ---: | ---: | +| c1 protected request | -8.678 | -18.6% | -2 | +| c10 protected request | -12.744 | -24.1% | -2 | +| c20 protected request | -6.940 | -19.0% | -2 | + +## Notes + +- Query-count correctness is intentionally enforced by automated tests because latency is sensitive to JVM warmup, local machine load, and database state. +- The protected endpoint includes existing API logging work, so the whole-request SQL budget is higher than the authentication-only `User` lookup count. +- The script labels results as `before` or `after` but does not change git refs. Select the checkout explicitly before running. +- Quick mode is for development feedback. Full mode is the acceptance evidence. diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java index 38ca6319..e64b862f 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java +++ b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java @@ -136,7 +136,7 @@ public CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePassword @Bean public JwtAuthenticationFilter jwtAuthenticationProcessingFilter() { - JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService); + JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService); return jwtAuthenticationFilter; } 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 34bea3d5..f41831c7 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 @@ -2,7 +2,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import devkor.ontime_back.entity.User; -import devkor.ontime_back.repository.UserRepository; import devkor.ontime_back.response.*; import devkor.ontime_back.service.AuthTokenService; import jakarta.servlet.DispatcherType; @@ -36,7 +35,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final List NO_CHECK_URLS = List.of("/login", "/health", "/actuator/health", "/swagger-ui", "/sign-up", "/account-deletion", "/privacy-policy", "/v3/api-docs", "/oauth2/google/login", "/oauth2/kakao/login", "/oauth2/apple/login"); private final JwtTokenProvider jwtTokenProvider; - private final UserRepository userRepository; private final AuthTokenService authTokenService; private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); @@ -71,8 +69,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // 이제부터는 엑세스 토큰'만' 헤더에 담긴 요청만 생각하면 됨 // 엑세스 토큰이 있고, 유효할 경우 checkAccessTokenAndAuthentication 메서드 호출해 권한정보 저장하고 스프링 시큐리티 필터체인 계속 진행 - if (accessToken != null && jwtTokenProvider.isAccessTokenValid(accessToken)) { - checkAccessTokenAndAuthentication(request, response, filterChain); + if (accessToken != null) { + checkAccessTokenAndAuthentication(accessToken, request, response, filterChain); } // 엑세스 토큰이 없는 경우 EmptyAccessTokenException 발생 @@ -98,16 +96,12 @@ public void reIssueAccessToken(HttpServletResponse response, String refreshToken } // accessToken으로 유저의 권한정보만 저장하고 인증 허가(스프링 시큐리티 필터체인 中 인증체인 통과해 다음 체인으로 이동) - public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response, + public void checkAccessTokenAndAuthentication(String accessToken, HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { log.info("Checking access credential authentication"); - jwtTokenProvider.extractAccessToken(request) - .ifPresent(accessToken -> jwtTokenProvider.extractUserId(accessToken) - .ifPresent(userId -> { - log.info("Authenticated userId: {}", userId); - userRepository.findById(userId) - .ifPresent(this::saveAuthentication); - })); + User user = authTokenService.validateActiveAccessToken(accessToken); + log.info("Authenticated userId: {}", user.getId()); + saveAuthentication(user); filterChain.doFilter(request, response); } diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java index 1cd59a86..e327e5bc 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java @@ -54,6 +54,10 @@ public class AppleLoginService { private static final String APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys"; private static final String APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token"; private String issuer = "https://appleid.apple.com"; + @Value("${apple.keys-url:" + APPLE_KEYS_URL + "}") + private String appleKeysUrl = APPLE_KEYS_URL; + @Value("${apple.token-url:" + APPLE_TOKEN_URL + "}") + private String appleTokenUrl = APPLE_TOKEN_URL; @Value("${apple.client.id}") private String clientId; @Value("${apple.team.id}") @@ -167,7 +171,7 @@ public Claims verifyIdentityToken(String identityToken) throws log.info("Verify Apple identity credential"); Map headers = jwtUtils.parseHeaders(identityToken); // apple publickey - ApplePublicKeyResponse applePublicKeyResponse = restTemplate.getForObject(APPLE_KEYS_URL, ApplePublicKeyResponse.class); + ApplePublicKeyResponse applePublicKeyResponse = restTemplate.getForObject(appleKeysUrl, ApplePublicKeyResponse.class); PublicKey publicKey = applePublicKeyGenerator.generatePublicKey(headers, applePublicKeyResponse); // claim Claims tokenClaims = jwtUtils.getTokenClaims(identityToken, publicKey); @@ -201,7 +205,7 @@ public AppleTokenResponseDto getAppleAccessTokenAndRefreshToken(String authCode) HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); ResponseEntity responseEntity = restTemplate.exchange( - APPLE_TOKEN_URL, HttpMethod.POST, requestEntity, JsonNode.class); + appleTokenUrl, HttpMethod.POST, requestEntity, JsonNode.class); JsonNode response = responseEntity.getBody(); diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/AuthTokenService.java b/ontime-back/src/main/java/devkor/ontime_back/service/AuthTokenService.java index b5aed0b4..c8dfb241 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/AuthTokenService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/AuthTokenService.java @@ -5,6 +5,7 @@ import devkor.ontime_back.global.jwt.JwtTokenProvider; import devkor.ontime_back.repository.UserRefreshTokenRepository; import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.InvalidAccessTokenException; import devkor.ontime_back.response.InvalidRefreshTokenException; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -19,6 +20,20 @@ public class AuthTokenService { private final UserRefreshTokenRepository userRefreshTokenRepository; private final UserRepository userRepository; + @Transactional(readOnly = true) + public User validateActiveAccessToken(String accessToken) { + Long userId = jwtTokenProvider.extractUserId(accessToken) + .orElseThrow(() -> new InvalidAccessTokenException("유효하지 않은 엑세스 토큰입니다.")); + User user = userRepository.findById(userId) + .orElseThrow(() -> new InvalidAccessTokenException("유효하지 않은 엑세스 토큰입니다.")); + + if (!accessToken.equals(user.getAccessToken())) { + throw new InvalidAccessTokenException("유효하지 않은 엑세스 토큰입니다."); + } + + return user; + } + @Transactional public AuthTokens issueLoginTokens(User user, HttpServletResponse response) { userRefreshTokenRepository.deleteByUser(user); diff --git a/ontime-back/src/main/resources/application-bench.properties b/ontime-back/src/main/resources/application-bench.properties new file mode 100644 index 00000000..d8b945aa --- /dev/null +++ b/ontime-back/src/main/resources/application-bench.properties @@ -0,0 +1,53 @@ +# Database Configuration +spring.datasource.url=${SPRING_DATASOURCE_URL} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} +spring.datasource.driver-class-name=${SPRING_DATASOURCE_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver} + +# JPA / Hibernate +spring.jpa.database=mysql +spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=false + +# Flyway +spring.flyway.enabled=true +spring.flyway.baseline-on-migrate=false + +# JWT Configuration +jwt.secret.key=${JWT_SECRET_KEY} +jwt.access.expiration=${JWT_ACCESS_EXPIRATION:3600000} +jwt.refresh.expiration=${JWT_REFRESH_EXPIRATION:1209600000} +jwt.access.header=${JWT_ACCESS_HEADER:Authorization} +jwt.refresh.header=${JWT_REFRESH_HEADER:Authorization-refresh} + +# OAuth +google.web.client-id=${GOOGLE_WEB_CLIENT_ID:bench-google-web-client-id} +google.app.client-id=${GOOGLE_APP_CLIENT_ID:bench-google-app-client-id} +apple.client.id=${APPLE_CLIENT_ID} +apple.team.id=${APPLE_TEAM_ID} +apple.login.key=${APPLE_LOGIN_KEY} +apple.client.secret=${APPLE_CLIENT_SECRET:} +apple.private-key.base64=${APPLE_PRIVATE_KEY_BASE64} +apple.keys-url=${APPLE_KEYS_URL:https://appleid.apple.com/auth/keys} +apple.token-url=${APPLE_TOKEN_URL:https://appleid.apple.com/auth/token} + +# Firebase +firebase.credentials.base64=${FIREBASE_CREDENTIALS_BASE64:} + +# Logging +logging.level.root=WARN +logging.level.devkor.ontime_back=INFO + +# Feature flags +feature.apple-login.enabled=${FEATURE_APPLE_LOGIN_ENABLED:true} +analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false} + +# Actuator +management.endpoint.health.probes.enabled=true +management.endpoints.web.exposure.include=health +management.health.readinessstate.enabled=true +management.health.livenessstate.enabled=true +server.shutdown=graceful +spring.lifecycle.timeout-per-shutdown-phase=30s diff --git a/ontime-back/src/main/resources/db/migration/V14__expand_preparation_name_length.sql b/ontime-back/src/main/resources/db/migration/V14__expand_preparation_name_length.sql new file mode 100644 index 00000000..d2375c93 --- /dev/null +++ b/ontime-back/src/main/resources/db/migration/V14__expand_preparation_name_length.sql @@ -0,0 +1,5 @@ +ALTER TABLE preparation_user + MODIFY COLUMN preparation_name VARCHAR(50) NOT NULL; + +ALTER TABLE preparation_schedule + MODIFY COLUMN preparation_name VARCHAR(50) NOT NULL; diff --git a/ontime-back/src/main/resources/db/migration/V14__add_preparation_templates_and_modes.sql b/ontime-back/src/main/resources/db/migration/V18__add_preparation_templates_and_modes.sql similarity index 100% rename from ontime-back/src/main/resources/db/migration/V14__add_preparation_templates_and_modes.sql rename to ontime-back/src/main/resources/db/migration/V18__add_preparation_templates_and_modes.sql diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java index d48cd82b..972e466b 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationFilterTest.java @@ -39,7 +39,7 @@ void skipsPublicHtmlPages(String path) throws Exception { UserRepository userRepository = mock(UserRepository.class); AuthTokenService authTokenService = mock(AuthTokenService.class); FilterChain filterChain = mock(FilterChain.class); - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService); MockHttpServletRequest request = new MockHttpServletRequest("GET", path); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -56,20 +56,19 @@ void validAccessTokenAuthenticatesUserAndContinuesFilterChain() throws Exception UserRepository userRepository = mock(UserRepository.class); AuthTokenService authTokenService = mock(AuthTokenService.class); FilterChain filterChain = mock(FilterChain.class); - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules"); MockHttpServletResponse response = new MockHttpServletResponse(); User user = user("user@example.com", "encoded-password"); when(jwtTokenProvider.extractAccessToken(request)).thenReturn(Optional.of("access-token")); when(jwtTokenProvider.extractRefreshToken(request)).thenReturn(Optional.empty()); - when(jwtTokenProvider.isAccessTokenValid("access-token")).thenReturn(true); - when(jwtTokenProvider.extractUserId("access-token")).thenReturn(Optional.of(1L)); - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(authTokenService.validateActiveAccessToken("access-token")).thenReturn(user); filter.doFilter(request, response, filterChain); verify(filterChain).doFilter(request, response); + verify(authTokenService).validateActiveAccessToken("access-token"); assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo("user@example.com"); } @@ -79,7 +78,7 @@ void validRefreshTokenReissuesAccessTokenWithoutContinuingRequest() throws Excep UserRepository userRepository = mock(UserRepository.class); AuthTokenService authTokenService = mock(AuthTokenService.class); FilterChain filterChain = mock(FilterChain.class); - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules"); MockHttpServletResponse response = new MockHttpServletResponse(); User user = user("user@example.com", "encoded-password"); @@ -102,7 +101,7 @@ void missingAccessTokenReturnsTokenEmptyErrorEnvelope() throws Exception { UserRepository userRepository = mock(UserRepository.class); AuthTokenService authTokenService = mock(AuthTokenService.class); FilterChain filterChain = mock(FilterChain.class); - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -122,7 +121,7 @@ void invalidRefreshTokenReturnsRefreshSpecificErrorEnvelope() throws Exception { UserRepository userRepository = mock(UserRepository.class); AuthTokenService authTokenService = mock(AuthTokenService.class); FilterChain filterChain = mock(FilterChain.class); - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -144,13 +143,13 @@ void invalidAccessTokenReturnsAccessSpecificErrorEnvelope() throws Exception { UserRepository userRepository = mock(UserRepository.class); AuthTokenService authTokenService = mock(AuthTokenService.class); FilterChain filterChain = mock(FilterChain.class); - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository, authTokenService); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtTokenProvider, authTokenService); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/schedules"); MockHttpServletResponse response = new MockHttpServletResponse(); when(jwtTokenProvider.extractAccessToken(request)).thenReturn(Optional.of("access-token")); when(jwtTokenProvider.extractRefreshToken(request)).thenReturn(Optional.empty()); - when(jwtTokenProvider.isAccessTokenValid("access-token")) + when(authTokenService.validateActiveAccessToken("access-token")) .thenThrow(new InvalidAccessTokenException("bad access")); filter.doFilter(request, response, filterChain); @@ -162,7 +161,7 @@ void invalidAccessTokenReturnsAccessSpecificErrorEnvelope() throws Exception { @Test void socialLoginUserWithoutPasswordReceivesGeneratedAuthenticationPassword() { - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(mock(JwtTokenProvider.class), mock(UserRepository.class), mock(AuthTokenService.class)); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(mock(JwtTokenProvider.class), mock(AuthTokenService.class)); User user = user("social@example.com", null); filter.saveAuthentication(user); @@ -175,7 +174,7 @@ void socialLoginUserWithoutPasswordReceivesGeneratedAuthenticationPassword() { @Test void socialLoginUserWithoutEmailUsesUserIdAuthenticationName() { - JwtAuthenticationFilter filter = new JwtAuthenticationFilter(mock(JwtTokenProvider.class), mock(UserRepository.class), mock(AuthTokenService.class)); + JwtAuthenticationFilter filter = new JwtAuthenticationFilter(mock(JwtTokenProvider.class), mock(AuthTokenService.class)); User user = user(null, null); filter.saveAuthentication(user); diff --git a/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationQueryCountIntegrationTest.java b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationQueryCountIntegrationTest.java new file mode 100644 index 00000000..3fbe68d6 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtAuthenticationQueryCountIntegrationTest.java @@ -0,0 +1,67 @@ +package devkor.ontime_back.global.jwt; + +import devkor.ontime_back.entity.Role; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.service.AuthTokenService; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = "spring.jpa.properties.hibernate.generate_statistics=true") +@AutoConfigureMockMvc +class JwtAuthenticationQueryCountIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthTokenService authTokenService; + + @Autowired + private EntityManager entityManager; + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @Test + void protectedPunctualityScoreRequestStaysWithinThePostFixSqlBudget() throws Exception { + User user = userRepository.saveAndFlush(user()); + AuthTokenService.AuthTokens tokens = authTokenService.issueLoginTokens(user, new MockHttpServletResponse()); + userRepository.saveAndFlush(user); + entityManager.clear(); + + Statistics statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + statistics.clear(); + + mockMvc.perform(get("/users/me/punctuality-score") + .header("Authorization", "Bearer " + tokens.accessToken())) + .andExpect(status().isOk()); + + assertThat(statistics.getPrepareStatementCount()).isLessThanOrEqualTo(5); + } + + private User user() { + return User.builder() + .email("jwt-query-count@example.com") + .password("password") + .name("jwt-query-count-user") + .punctualityScore(100F) + .role(Role.USER) + .build(); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/AuthTokenServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/AuthTokenServiceTest.java index 37d64f73..8a6ac7dc 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/AuthTokenServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/AuthTokenServiceTest.java @@ -6,6 +6,7 @@ import devkor.ontime_back.global.jwt.JwtTokenProvider; import devkor.ontime_back.repository.UserRefreshTokenRepository; import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.InvalidAccessTokenException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,6 +18,7 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -40,6 +42,32 @@ void setUp() { authTokenService = new AuthTokenService(jwtTokenProvider, userRefreshTokenRepository, userRepository); } + @Test + void validateActiveAccessTokenReadsUserOnceAndReturnsTheAuthenticatedUser() { + User user = user(); + user.updateAccessToken("access-token"); + when(jwtTokenProvider.extractUserId("access-token")).thenReturn(Optional.of(1L)); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + User authenticatedUser = authTokenService.validateActiveAccessToken("access-token"); + + assertThat(authenticatedUser).isSameAs(user); + verify(userRepository, times(1)).findById(1L); + } + + @Test + void validateActiveAccessTokenRejectsAReplacedAccessToken() { + User user = user(); + user.updateAccessToken("newer-access-token"); + when(jwtTokenProvider.extractUserId("access-token")).thenReturn(Optional.of(1L)); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> authTokenService.validateActiveAccessToken("access-token")) + .isInstanceOf(InvalidAccessTokenException.class); + + verify(userRepository, times(1)).findById(1L); + } + @Test void issueLoginTokensClearsPreviousRefreshCredentialsBeforeSavingTheNewLogin() { User user = user(); diff --git a/scripts/benchmarks/apple-login/README.md b/scripts/benchmarks/apple-login/README.md new file mode 100644 index 00000000..c23e710c --- /dev/null +++ b/scripts/benchmarks/apple-login/README.md @@ -0,0 +1,96 @@ +# Apple Login Benchmark + +This benchmark measures issue #323: repeated Apple provider round trips on the returning Apple User login path. + +## Scenario + +- Endpoint: `POST /oauth2/apple/login` +- Primary path: returning Apple User, warm Apple key cache +- Seeded Apple subject: `bench-apple-user` +- Apple provider: local stub, not the real Apple network +- Stub delay: + - `GET /auth/keys`: 80 ms + - `POST /auth/token`: 300 ms + +## Why the Apple provider is stubbed + +The primary comparison should isolate backend request-path behavior. Real Apple calls include DNS, TLS, internet routing, and provider-side variability, so they are useful as smoke checks but not as the main before/after evidence. + +## Run + +Install `k6` first if it is not already available: + +```bash +brew install k6 +``` + +Run from the repository root: + +```bash +scripts/benchmarks/apple-login/run.sh before quick +scripts/benchmarks/apple-login/run.sh after quick +``` + +Full evidence runs: + +```bash +scripts/benchmarks/apple-login/run.sh before full +scripts/benchmarks/apple-login/run.sh after full +``` + +The script starts an isolated MySQL container, starts the Apple stub, starts the backend with the `bench` Spring profile, seeds the returning Apple User, runs k6, and writes results. + +The script does not run `git checkout`. Measure the checkout you have selected, and use `before` or `after` only as the result label. + +## Defaults + +- Database container: `ontime-bench-mysql` +- Database: `ontime_bench` +- MySQL port: `127.0.0.1:3307` +- Backend port: `127.0.0.1:18081` +- Apple stub port: `127.0.0.1:18080` +- Quick mode: 1 run per concurrency +- Full mode: 10 runs per concurrency +- Warmup: 2 minutes +- Measurement: 5 minutes +- Concurrency: 1, 10, 20 + +Override example: + +```bash +WARMUP_DURATION=10s MEASUREMENT_DURATION=30s SCENARIOS="1" RUNS=1 \ + scripts/benchmarks/apple-login/run.sh before quick +``` + +## Results + +Results are written under: + +```text +scripts/benchmarks/apple-login/results/-