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
31 changes: 31 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions docs/adr/0002-use-single-active-user-session.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,4 +10,6 @@
@Repository
public interface UserRefreshTokenRepository extends JpaRepository<UserRefreshToken, Long> {
Optional<UserRefreshToken> findByRefreshToken(String refreshToken);

void deleteByUser(User user);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -53,6 +53,7 @@ void issueLoginTokensCreatesSeparateRefreshTokenRowsForEachLogin() {
authTokenService.issueLoginTokens(user, response);

ArgumentCaptor<UserRefreshToken> tokenCaptor = ArgumentCaptor.forClass(UserRefreshToken.class);
verify(userRefreshTokenRepository, times(2)).deleteByUser(user);
verify(userRefreshTokenRepository, times(2)).save(tokenCaptor.capture());
assertThat(tokenCaptor.getAllValues())
.extracting(UserRefreshToken::getRefreshToken)
Expand Down
Loading