Skip to content

Commit ab5fc7f

Browse files
authored
Merge pull request #322 from DevKor-github/dev
[codex] Enforce single active user session
2 parents c43c858 + 580c8af commit ab5fc7f

8 files changed

Lines changed: 122 additions & 5 deletions

File tree

CONTEXT.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# OnTime Backend
2+
3+
OnTime Backend handles user identity, schedules, preparation data, alarms, and account preferences for the OnTime application.
4+
5+
## Language
6+
7+
**User**:
8+
A person who owns OnTime account data and authenticates to use the app.
9+
_Avoid_: Account, member
10+
11+
**User Session**:
12+
An authenticated app session for a **User**.
13+
_Avoid_: Device login, token slot
14+
15+
**Active Session**:
16+
The single **User Session** currently allowed to access protected OnTime APIs.
17+
_Avoid_: Current token, latest device
18+
19+
## Relationships
20+
21+
- A **User** has at most one **Active Session**.
22+
- A **User Session** belongs to exactly one **User**.
23+
24+
## Example Dialogue
25+
26+
> **Dev:** "If a **User** signs in on a second phone, do both phones keep an **Active Session**?"
27+
> **Domain expert:** "No. The second sign-in becomes the **Active Session**, and the previous **User Session** is no longer allowed to use protected APIs."
28+
29+
## Flagged Ambiguities
30+
31+
- "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.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Use a Single Active User Session
2+
3+
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.

ontime-back/src/main/java/devkor/ontime_back/global/jwt/JwtTokenProvider.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,9 @@ public boolean isAccessTokenValid(String token) {
161161
.verify(token)
162162
.getClaim(USER_ID_CLAIM)
163163
.asLong();
164-
if (userId == null || userRepository.findById(userId).isEmpty()) {
164+
User user = userRepository.findById(userId)
165+
.orElseThrow(() -> new InvalidAccessTokenException("유효하지 않은 엑세스 토큰입니다."));
166+
if (!token.equals(user.getAccessToken())) {
165167
throw new InvalidAccessTokenException("유효하지 않은 엑세스 토큰입니다.");
166168
}
167169
log.info("Access credential is valid");

ontime-back/src/main/java/devkor/ontime_back/repository/UserRefreshTokenRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package devkor.ontime_back.repository;
22

3+
import devkor.ontime_back.entity.User;
34
import devkor.ontime_back.entity.UserRefreshToken;
45
import org.springframework.data.jpa.repository.JpaRepository;
56
import org.springframework.stereotype.Repository;
@@ -9,4 +10,6 @@
910
@Repository
1011
public interface UserRefreshTokenRepository extends JpaRepository<UserRefreshToken, Long> {
1112
Optional<UserRefreshToken> findByRefreshToken(String refreshToken);
13+
14+
void deleteByUser(User user);
1215
}

ontime-back/src/main/java/devkor/ontime_back/service/AuthTokenService.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ public class AuthTokenService {
2121

2222
@Transactional
2323
public AuthTokens issueLoginTokens(User user, HttpServletResponse response) {
24+
userRefreshTokenRepository.deleteByUser(user);
25+
2426
String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getId());
2527
String refreshToken = jwtTokenProvider.createRefreshToken();
2628

ontime-back/src/test/java/devkor/ontime_back/global/jwt/JwtTokenProviderTest.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,22 @@ void sendAccessTokenWritesOnlyTheAccessCredentialHeader() {
9191
}
9292

9393
@Test
94-
void accessTokenValidityRequiresExistingUserClaim() {
94+
void accessTokenValidityRequiresCurrentStoredAccessCredential() {
9595
String accessToken = jwtTokenProvider.createAccessToken("user@example.com", 7L);
96-
when(userRepository.findById(7L)).thenReturn(Optional.of(user("user@example.com")));
96+
when(userRepository.findById(7L)).thenReturn(Optional.of(user("user@example.com", accessToken)));
9797

9898
assertThat(jwtTokenProvider.isAccessTokenValid(accessToken)).isTrue();
9999
}
100100

101+
@Test
102+
void accessTokenValidityRejectsStaleAccessCredential() {
103+
String accessToken = jwtTokenProvider.createAccessToken("user@example.com", 7L);
104+
when(userRepository.findById(7L)).thenReturn(Optional.of(user("user@example.com", "newer-access-token")));
105+
106+
assertThatThrownBy(() -> jwtTokenProvider.isAccessTokenValid(accessToken))
107+
.isInstanceOf(InvalidAccessTokenException.class);
108+
}
109+
101110
@Test
102111
void accessTokenValidityRejectsValidJwtForMissingUser() {
103112
String accessToken = jwtTokenProvider.createAccessToken("user@example.com", 7L);
@@ -134,10 +143,11 @@ void refreshTokenValidityAcceptsValidRefreshCredential() {
134143
assertThat(jwtTokenProvider.isRefreshTokenValid(refreshToken)).isTrue();
135144
}
136145

137-
private User user(String email) {
146+
private User user(String email, String accessToken) {
138147
return User.builder()
139148
.id(7L)
140149
.email(email)
150+
.accessToken(accessToken)
141151
.role(Role.USER)
142152
.build();
143153
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package devkor.ontime_back.service;
2+
3+
import devkor.ontime_back.entity.Role;
4+
import devkor.ontime_back.entity.User;
5+
import devkor.ontime_back.global.jwt.JwtTokenProvider;
6+
import devkor.ontime_back.repository.UserRepository;
7+
import devkor.ontime_back.response.InvalidAccessTokenException;
8+
import devkor.ontime_back.response.InvalidRefreshTokenException;
9+
import org.junit.jupiter.api.Test;
10+
import org.springframework.beans.factory.annotation.Autowired;
11+
import org.springframework.boot.test.context.SpringBootTest;
12+
import org.springframework.mock.web.MockHttpServletResponse;
13+
import org.springframework.transaction.annotation.Transactional;
14+
15+
import static org.assertj.core.api.Assertions.assertThatCode;
16+
17+
@SpringBootTest
18+
@Transactional
19+
class AuthTokenServiceIntegrationTest {
20+
21+
@Autowired
22+
private AuthTokenService authTokenService;
23+
24+
@Autowired
25+
private UserRepository userRepository;
26+
27+
@Autowired
28+
private JwtTokenProvider jwtTokenProvider;
29+
30+
@Test
31+
void loggingInAgainMakesPreviousRefreshCredentialUnusable() {
32+
User user = userRepository.saveAndFlush(user());
33+
MockHttpServletResponse firstLoginResponse = new MockHttpServletResponse();
34+
MockHttpServletResponse secondLoginResponse = new MockHttpServletResponse();
35+
36+
AuthTokenService.AuthTokens firstLoginTokens = authTokenService.issueLoginTokens(user, firstLoginResponse);
37+
AuthTokenService.AuthTokens secondLoginTokens = authTokenService.issueLoginTokens(user, secondLoginResponse);
38+
39+
assertThatCode(() -> authTokenService.rotateRefreshToken(firstLoginTokens.refreshToken(), new MockHttpServletResponse()))
40+
.isInstanceOf(InvalidRefreshTokenException.class);
41+
assertThatCode(() -> authTokenService.rotateRefreshToken(secondLoginTokens.refreshToken(), new MockHttpServletResponse()))
42+
.doesNotThrowAnyException();
43+
}
44+
45+
@Test
46+
void loggingInAgainMakesPreviousAccessCredentialUnusable() {
47+
User user = userRepository.saveAndFlush(user());
48+
AuthTokenService.AuthTokens firstLoginTokens =
49+
authTokenService.issueLoginTokens(user, new MockHttpServletResponse());
50+
51+
authTokenService.issueLoginTokens(user, new MockHttpServletResponse());
52+
53+
assertThatCode(() -> jwtTokenProvider.isAccessTokenValid(firstLoginTokens.accessToken()))
54+
.isInstanceOf(InvalidAccessTokenException.class);
55+
}
56+
57+
private User user() {
58+
return User.builder()
59+
.email("single-session@example.com")
60+
.password("password")
61+
.name("single-session-user")
62+
.role(Role.USER)
63+
.build();
64+
}
65+
}

ontime-back/src/test/java/devkor/ontime_back/service/AuthTokenServiceTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ void setUp() {
4141
}
4242

4343
@Test
44-
void issueLoginTokensCreatesSeparateRefreshTokenRowsForEachLogin() {
44+
void issueLoginTokensClearsPreviousRefreshCredentialsBeforeSavingTheNewLogin() {
4545
User user = user();
4646
MockHttpServletResponse response = new MockHttpServletResponse();
4747
when(jwtTokenProvider.createAccessToken("user@example.com", 1L))
@@ -53,6 +53,7 @@ void issueLoginTokensCreatesSeparateRefreshTokenRowsForEachLogin() {
5353
authTokenService.issueLoginTokens(user, response);
5454

5555
ArgumentCaptor<UserRefreshToken> tokenCaptor = ArgumentCaptor.forClass(UserRefreshToken.class);
56+
verify(userRefreshTokenRepository, times(2)).deleteByUser(user);
5657
verify(userRefreshTokenRepository, times(2)).save(tokenCaptor.capture());
5758
assertThat(tokenCaptor.getAllValues())
5859
.extracting(UserRefreshToken::getRefreshToken)

0 commit comments

Comments
 (0)