From 580c8afa4e95e19c968df724b4235defc978e563 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Sat, 27 Jun 2026 01:18:06 +0900 Subject: [PATCH] Enforce single active user session --- CONTEXT.md | 31 +++++++++ .../0002-use-single-active-user-session.md | 3 + .../global/jwt/JwtTokenProvider.java | 4 +- .../UserRefreshTokenRepository.java | 3 + .../ontime_back/service/AuthTokenService.java | 2 + .../global/jwt/JwtTokenProviderTest.java | 16 ++++- .../AuthTokenServiceIntegrationTest.java | 65 +++++++++++++++++++ .../service/AuthTokenServiceTest.java | 3 +- 8 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0002-use-single-active-user-session.md create mode 100644 ontime-back/src/test/java/devkor/ontime_back/service/AuthTokenServiceIntegrationTest.java diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..7b59f161 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,31 @@ +# OnTime Backend + +OnTime Backend handles user identity, schedules, preparation data, alarms, and account preferences for the OnTime application. + +## Language + +**User**: +A person who owns OnTime account data and authenticates to use the app. +_Avoid_: Account, member + +**User Session**: +An authenticated app session for a **User**. +_Avoid_: Device login, token slot + +**Active Session**: +The single **User Session** currently allowed to access protected OnTime APIs. +_Avoid_: Current token, latest device + +## Relationships + +- A **User** has at most one **Active Session**. +- A **User Session** belongs to exactly one **User**. + +## Example Dialogue + +> **Dev:** "If a **User** signs in on a second phone, do both phones keep an **Active Session**?" +> **Domain expert:** "No. The second sign-in becomes the **Active Session**, and the previous **User Session** is no longer allowed to use protected APIs." + +## Flagged Ambiguities + +- "device login" was used to mean both a physical device and an authenticated **User Session**. Resolved: the login limit applies to **User Sessions**, not to registered alarm devices. diff --git a/docs/adr/0002-use-single-active-user-session.md b/docs/adr/0002-use-single-active-user-session.md new file mode 100644 index 00000000..112470f5 --- /dev/null +++ b/docs/adr/0002-use-single-active-user-session.md @@ -0,0 +1,3 @@ +# Use a Single Active User Session + +OnTime allows at most one active authenticated session per user. A new successful login becomes the active session and invalidates earlier access and refresh credentials, matching OWASP's simultaneous-logon guidance and Spring Security's common "expire the older session" concurrency strategy while fitting the app's stateless JWT architecture. 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 dfc28dd1..8722b2a3 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 @@ -161,7 +161,9 @@ public boolean isAccessTokenValid(String token) { .verify(token) .getClaim(USER_ID_CLAIM) .asLong(); - if (userId == null || userRepository.findById(userId).isEmpty()) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new InvalidAccessTokenException("유효하지 않은 엑세스 토큰입니다.")); + if (!token.equals(user.getAccessToken())) { throw new InvalidAccessTokenException("유효하지 않은 엑세스 토큰입니다."); } log.info("Access credential is valid"); diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/UserRefreshTokenRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/UserRefreshTokenRepository.java index 108772ae..0b80e6a6 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/repository/UserRefreshTokenRepository.java +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/UserRefreshTokenRepository.java @@ -1,5 +1,6 @@ package devkor.ontime_back.repository; +import devkor.ontime_back.entity.User; import devkor.ontime_back.entity.UserRefreshToken; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -9,4 +10,6 @@ @Repository public interface UserRefreshTokenRepository extends JpaRepository { Optional findByRefreshToken(String refreshToken); + + void deleteByUser(User user); } 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 3b4da367..b5aed0b4 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 @@ -21,6 +21,8 @@ public class AuthTokenService { @Transactional public AuthTokens issueLoginTokens(User user, HttpServletResponse response) { + userRefreshTokenRepository.deleteByUser(user); + String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getId()); String refreshToken = jwtTokenProvider.createRefreshToken(); 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 index ae1cfbb5..4487f232 100644 --- 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 @@ -91,13 +91,22 @@ void sendAccessTokenWritesOnlyTheAccessCredentialHeader() { } @Test - void accessTokenValidityRequiresExistingUserClaim() { + void accessTokenValidityRequiresCurrentStoredAccessCredential() { String accessToken = jwtTokenProvider.createAccessToken("user@example.com", 7L); - when(userRepository.findById(7L)).thenReturn(Optional.of(user("user@example.com"))); + when(userRepository.findById(7L)).thenReturn(Optional.of(user("user@example.com", accessToken))); assertThat(jwtTokenProvider.isAccessTokenValid(accessToken)).isTrue(); } + @Test + void accessTokenValidityRejectsStaleAccessCredential() { + String accessToken = jwtTokenProvider.createAccessToken("user@example.com", 7L); + when(userRepository.findById(7L)).thenReturn(Optional.of(user("user@example.com", "newer-access-token"))); + + assertThatThrownBy(() -> jwtTokenProvider.isAccessTokenValid(accessToken)) + .isInstanceOf(InvalidAccessTokenException.class); + } + @Test void accessTokenValidityRejectsValidJwtForMissingUser() { String accessToken = jwtTokenProvider.createAccessToken("user@example.com", 7L); @@ -134,10 +143,11 @@ void refreshTokenValidityAcceptsValidRefreshCredential() { assertThat(jwtTokenProvider.isRefreshTokenValid(refreshToken)).isTrue(); } - private User user(String email) { + private User user(String email, String accessToken) { return User.builder() .id(7L) .email(email) + .accessToken(accessToken) .role(Role.USER) .build(); } diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/AuthTokenServiceIntegrationTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/AuthTokenServiceIntegrationTest.java new file mode 100644 index 00000000..c0152476 --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/service/AuthTokenServiceIntegrationTest.java @@ -0,0 +1,65 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.entity.Role; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.global.jwt.JwtTokenProvider; +import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.InvalidAccessTokenException; +import devkor.ontime_back.response.InvalidRefreshTokenException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThatCode; + +@SpringBootTest +@Transactional +class AuthTokenServiceIntegrationTest { + + @Autowired + private AuthTokenService authTokenService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Test + void loggingInAgainMakesPreviousRefreshCredentialUnusable() { + User user = userRepository.saveAndFlush(user()); + MockHttpServletResponse firstLoginResponse = new MockHttpServletResponse(); + MockHttpServletResponse secondLoginResponse = new MockHttpServletResponse(); + + AuthTokenService.AuthTokens firstLoginTokens = authTokenService.issueLoginTokens(user, firstLoginResponse); + AuthTokenService.AuthTokens secondLoginTokens = authTokenService.issueLoginTokens(user, secondLoginResponse); + + assertThatCode(() -> authTokenService.rotateRefreshToken(firstLoginTokens.refreshToken(), new MockHttpServletResponse())) + .isInstanceOf(InvalidRefreshTokenException.class); + assertThatCode(() -> authTokenService.rotateRefreshToken(secondLoginTokens.refreshToken(), new MockHttpServletResponse())) + .doesNotThrowAnyException(); + } + + @Test + void loggingInAgainMakesPreviousAccessCredentialUnusable() { + User user = userRepository.saveAndFlush(user()); + AuthTokenService.AuthTokens firstLoginTokens = + authTokenService.issueLoginTokens(user, new MockHttpServletResponse()); + + authTokenService.issueLoginTokens(user, new MockHttpServletResponse()); + + assertThatCode(() -> jwtTokenProvider.isAccessTokenValid(firstLoginTokens.accessToken())) + .isInstanceOf(InvalidAccessTokenException.class); + } + + private User user() { + return User.builder() + .email("single-session@example.com") + .password("password") + .name("single-session-user") + .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 8bc8a97a..37d64f73 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 @@ -41,7 +41,7 @@ void setUp() { } @Test - void issueLoginTokensCreatesSeparateRefreshTokenRowsForEachLogin() { + void issueLoginTokensClearsPreviousRefreshCredentialsBeforeSavingTheNewLogin() { User user = user(); MockHttpServletResponse response = new MockHttpServletResponse(); when(jwtTokenProvider.createAccessToken("user@example.com", 1L)) @@ -53,6 +53,7 @@ void issueLoginTokensCreatesSeparateRefreshTokenRowsForEachLogin() { authTokenService.issueLoginTokens(user, response); ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(UserRefreshToken.class); + verify(userRefreshTokenRepository, times(2)).deleteByUser(user); verify(userRefreshTokenRepository, times(2)).save(tokenCaptor.capture()); assertThat(tokenCaptor.getAllValues()) .extracting(UserRefreshToken::getRefreshToken)