Skip to content

Commit edcb362

Browse files
authored
Merge pull request #6 from poyrazK/feat/identity-token-session-core
feat(identity): implement token rotation and session controls
2 parents 8494d58 + fcbad34 commit edcb362

20 files changed

Lines changed: 578 additions & 2 deletions

services/java/identity-service/pom.xml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,28 @@
4040
<artifactId>postgresql</artifactId>
4141
<scope>runtime</scope>
4242
</dependency>
43+
<dependency>
44+
<groupId>io.jsonwebtoken</groupId>
45+
<artifactId>jjwt-api</artifactId>
46+
<version>0.12.6</version>
47+
</dependency>
48+
<dependency>
49+
<groupId>io.jsonwebtoken</groupId>
50+
<artifactId>jjwt-impl</artifactId>
51+
<version>0.12.6</version>
52+
<scope>runtime</scope>
53+
</dependency>
54+
<dependency>
55+
<groupId>io.jsonwebtoken</groupId>
56+
<artifactId>jjwt-jackson</artifactId>
57+
<version>0.12.6</version>
58+
<scope>runtime</scope>
59+
</dependency>
60+
<dependency>
61+
<groupId>org.springframework.boot</groupId>
62+
<artifactId>spring-boot-configuration-processor</artifactId>
63+
<optional>true</optional>
64+
</dependency>
4365
<dependency>
4466
<groupId>org.springframework.boot</groupId>
4567
<artifactId>spring-boot-starter-test</artifactId>

services/java/identity-service/src/main/java/com/cloudmedia/identity/IdentityServiceApplication.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package com.cloudmedia.identity;
22

3+
import com.cloudmedia.identity.auth.config.AuthProperties;
34
import org.springframework.boot.SpringApplication;
45
import org.springframework.boot.autoconfigure.SpringBootApplication;
6+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
57
import org.springframework.web.bind.annotation.GetMapping;
68
import org.springframework.web.bind.annotation.RequestMapping;
79
import org.springframework.web.bind.annotation.RestController;
810

911
@SpringBootApplication
12+
@EnableConfigurationProperties(AuthProperties.class)
1013
public class IdentityServiceApplication {
1114
public static void main(String[] args) {
1215
SpringApplication.run(IdentityServiceApplication.class, args);

services/java/identity-service/src/main/java/com/cloudmedia/identity/api/AuthController.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.cloudmedia.identity.api;
22

3+
import com.cloudmedia.identity.auth.config.AuthProperties;
4+
import com.cloudmedia.identity.auth.service.AuthRefreshUseCase;
35
import com.cloudmedia.identity.api.dto.AuthTokensResponse;
46
import com.cloudmedia.identity.api.dto.LoginRequest;
57
import com.cloudmedia.identity.api.dto.LogoutRequest;
@@ -23,6 +25,14 @@
2325
@RequestMapping("/v1/auth")
2426
public class AuthController {
2527

28+
private final AuthRefreshUseCase authRefreshService;
29+
private final AuthProperties authProperties;
30+
31+
public AuthController(AuthRefreshUseCase authRefreshService, AuthProperties authProperties) {
32+
this.authRefreshService = authRefreshService;
33+
this.authProperties = authProperties;
34+
}
35+
2636
@PostMapping("/login")
2737
public ResponseEntity<ApiSuccessResponse<AuthTokensResponse>> login(@Valid @RequestBody LoginRequest request,
2838
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
@@ -41,8 +51,11 @@ public ResponseEntity<ApiSuccessResponse<AuthTokensResponse>> socialLogin(
4151
@PostMapping("/refresh")
4252
public ResponseEntity<ApiSuccessResponse<AuthTokensResponse>> refresh(@Valid @RequestBody RefreshRequest request,
4353
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
44-
throw new ApiException(HttpStatus.NOT_IMPLEMENTED, "AUTH_NOT_IMPLEMENTED",
45-
"Refresh flow is not implemented yet", meta(requestId));
54+
var result = authRefreshService.rotateRefreshToken(request.refreshToken());
55+
AuthTokensResponse response = new AuthTokensResponse(result.accessToken(), result.refreshToken(),
56+
result.sessionId(), authProperties.getAccessTokenTtl().toSeconds(),
57+
authProperties.getRefreshTokenTtl().toSeconds());
58+
return ResponseEntity.ok(new ApiSuccessResponse<>(response, meta(requestId)));
4659
}
4760

4861
@PostMapping("/logout")
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.cloudmedia.identity.auth.config;
2+
3+
import java.time.Duration;
4+
import org.springframework.boot.context.properties.ConfigurationProperties;
5+
6+
@ConfigurationProperties(prefix = "auth")
7+
public class AuthProperties {
8+
9+
private String jwtIssuer = "cloudmedia-identity";
10+
11+
private String jwtSecret = "change-me-in-production-change-me-in-production";
12+
13+
private Duration accessTokenTtl = Duration.ofMinutes(15);
14+
15+
private Duration refreshTokenTtl = Duration.ofDays(30);
16+
17+
private int maxActiveSessions = 5;
18+
19+
public String getJwtIssuer() {
20+
return jwtIssuer;
21+
}
22+
23+
public void setJwtIssuer(String jwtIssuer) {
24+
this.jwtIssuer = jwtIssuer;
25+
}
26+
27+
public String getJwtSecret() {
28+
return jwtSecret;
29+
}
30+
31+
public void setJwtSecret(String jwtSecret) {
32+
this.jwtSecret = jwtSecret;
33+
}
34+
35+
public Duration getAccessTokenTtl() {
36+
return accessTokenTtl;
37+
}
38+
39+
public void setAccessTokenTtl(Duration accessTokenTtl) {
40+
this.accessTokenTtl = accessTokenTtl;
41+
}
42+
43+
public Duration getRefreshTokenTtl() {
44+
return refreshTokenTtl;
45+
}
46+
47+
public void setRefreshTokenTtl(Duration refreshTokenTtl) {
48+
this.refreshTokenTtl = refreshTokenTtl;
49+
}
50+
51+
public int getMaxActiveSessions() {
52+
return maxActiveSessions;
53+
}
54+
55+
public void setMaxActiveSessions(int maxActiveSessions) {
56+
this.maxActiveSessions = maxActiveSessions;
57+
}
58+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.cloudmedia.identity.auth.service;
2+
3+
import com.cloudmedia.identity.auth.config.AuthProperties;
4+
import com.cloudmedia.identity.auth.token.JwtAccessTokenService;
5+
import com.cloudmedia.identity.auth.token.RefreshTokenGenerator;
6+
import com.cloudmedia.identity.auth.token.RefreshTokenHasher;
7+
import com.cloudmedia.identity.error.ApiException;
8+
import com.cloudmedia.identity.persistence.entity.RefreshTokenEntity;
9+
import com.cloudmedia.identity.persistence.entity.SessionEntity;
10+
import com.cloudmedia.identity.persistence.repository.RefreshTokenRepository;
11+
import java.time.Instant;
12+
import java.time.LocalDateTime;
13+
import java.time.ZoneOffset;
14+
import java.util.List;
15+
import java.util.UUID;
16+
import org.springframework.http.HttpStatus;
17+
import org.springframework.stereotype.Service;
18+
import org.springframework.transaction.annotation.Transactional;
19+
20+
@Service
21+
public class AuthRefreshService implements AuthRefreshUseCase {
22+
23+
private final AuthProperties authProperties;
24+
private final JwtAccessTokenService jwtAccessTokenService;
25+
private final RefreshTokenGenerator refreshTokenGenerator;
26+
private final RefreshTokenHasher refreshTokenHasher;
27+
private final RefreshTokenRepository refreshTokenRepository;
28+
private final SessionLifecycleService sessionLifecycleService;
29+
30+
public AuthRefreshService(AuthProperties authProperties, JwtAccessTokenService jwtAccessTokenService,
31+
RefreshTokenGenerator refreshTokenGenerator, RefreshTokenHasher refreshTokenHasher,
32+
RefreshTokenRepository refreshTokenRepository, SessionLifecycleService sessionLifecycleService) {
33+
this.authProperties = authProperties;
34+
this.jwtAccessTokenService = jwtAccessTokenService;
35+
this.refreshTokenGenerator = refreshTokenGenerator;
36+
this.refreshTokenHasher = refreshTokenHasher;
37+
this.refreshTokenRepository = refreshTokenRepository;
38+
this.sessionLifecycleService = sessionLifecycleService;
39+
}
40+
41+
@Override
42+
@Transactional
43+
public RefreshResult rotateRefreshToken(String rawRefreshToken) {
44+
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
45+
String tokenHash = refreshTokenHasher.hash(rawRefreshToken);
46+
47+
RefreshTokenEntity currentToken = refreshTokenRepository.findByTokenHash(tokenHash)
48+
.orElseThrow(() -> unauthorized("REFRESH_TOKEN_INVALID", "Refresh token is invalid"));
49+
50+
if (currentToken.getRevokedAt() != null) {
51+
handleReuseDetection(currentToken, now);
52+
throw unauthorized("REFRESH_TOKEN_REUSED", "Refresh token reuse detected");
53+
}
54+
55+
SessionEntity session = currentToken.getSession();
56+
if (session.getRevokedAt() != null || currentToken.getExpiresAt().isBefore(now)
57+
|| session.getExpiresAt().isBefore(now)) {
58+
throw unauthorized("REFRESH_TOKEN_EXPIRED", "Refresh token is expired or revoked");
59+
}
60+
61+
String newRawRefreshToken = refreshTokenGenerator.generate();
62+
RefreshTokenEntity newToken = new RefreshTokenEntity();
63+
newToken.setId(UUID.randomUUID().toString());
64+
newToken.setSession(session);
65+
newToken.setTokenHash(refreshTokenHasher.hash(newRawRefreshToken));
66+
newToken.setFamilyId(currentToken.getFamilyId());
67+
newToken.setParentToken(currentToken);
68+
newToken.setIssuedAt(now);
69+
newToken.setExpiresAt(now.plus(authProperties.getRefreshTokenTtl()));
70+
71+
RefreshTokenEntity persistedNewToken = refreshTokenRepository.save(newToken);
72+
73+
currentToken.setRevokedAt(now);
74+
currentToken.setReplacedBy(persistedNewToken);
75+
refreshTokenRepository.save(currentToken);
76+
77+
String accessToken = jwtAccessTokenService.issueAccessToken(session.getUser().getId(), session.getId(),
78+
Instant.now());
79+
return new RefreshResult(accessToken, newRawRefreshToken, session.getId());
80+
}
81+
82+
private void handleReuseDetection(RefreshTokenEntity currentToken, LocalDateTime now) {
83+
List<RefreshTokenEntity> activeFamilyTokens = refreshTokenRepository
84+
.findByFamilyIdAndRevokedAtIsNull(currentToken.getFamilyId());
85+
for (RefreshTokenEntity token : activeFamilyTokens) {
86+
token.setRevokedAt(now);
87+
}
88+
refreshTokenRepository.saveAll(activeFamilyTokens);
89+
90+
sessionLifecycleService.revokeSessionAndActiveTokens(currentToken.getSession(), now);
91+
}
92+
93+
private ApiException unauthorized(String code, String message) {
94+
return new ApiException(HttpStatus.UNAUTHORIZED, code, message, null);
95+
}
96+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.cloudmedia.identity.auth.service;
2+
3+
public interface AuthRefreshUseCase {
4+
RefreshResult rotateRefreshToken(String rawRefreshToken);
5+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.cloudmedia.identity.auth.service;
2+
3+
import com.cloudmedia.identity.auth.config.AuthProperties;
4+
import com.cloudmedia.identity.auth.token.JwtAccessTokenService;
5+
import com.cloudmedia.identity.auth.token.RefreshTokenGenerator;
6+
import com.cloudmedia.identity.auth.token.RefreshTokenHasher;
7+
import com.cloudmedia.identity.persistence.entity.RefreshTokenEntity;
8+
import com.cloudmedia.identity.persistence.entity.SessionEntity;
9+
import com.cloudmedia.identity.persistence.repository.RefreshTokenRepository;
10+
import java.time.Instant;
11+
import java.time.LocalDateTime;
12+
import java.time.ZoneOffset;
13+
import java.util.UUID;
14+
import org.springframework.stereotype.Service;
15+
import org.springframework.transaction.annotation.Transactional;
16+
17+
@Service
18+
public class AuthTokenIssueService {
19+
20+
private final AuthProperties authProperties;
21+
private final JwtAccessTokenService jwtAccessTokenService;
22+
private final RefreshTokenGenerator refreshTokenGenerator;
23+
private final RefreshTokenHasher refreshTokenHasher;
24+
private final RefreshTokenRepository refreshTokenRepository;
25+
26+
public AuthTokenIssueService(AuthProperties authProperties, JwtAccessTokenService jwtAccessTokenService,
27+
RefreshTokenGenerator refreshTokenGenerator, RefreshTokenHasher refreshTokenHasher,
28+
RefreshTokenRepository refreshTokenRepository) {
29+
this.authProperties = authProperties;
30+
this.jwtAccessTokenService = jwtAccessTokenService;
31+
this.refreshTokenGenerator = refreshTokenGenerator;
32+
this.refreshTokenHasher = refreshTokenHasher;
33+
this.refreshTokenRepository = refreshTokenRepository;
34+
}
35+
36+
@Transactional
37+
public RefreshResult issueForSession(SessionEntity session) {
38+
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
39+
String rawRefreshToken = refreshTokenGenerator.generate();
40+
41+
RefreshTokenEntity refreshToken = new RefreshTokenEntity();
42+
refreshToken.setId(UUID.randomUUID().toString());
43+
refreshToken.setSession(session);
44+
refreshToken.setTokenHash(refreshTokenHasher.hash(rawRefreshToken));
45+
refreshToken.setFamilyId(refreshToken.getId());
46+
refreshToken.setIssuedAt(now);
47+
refreshToken.setExpiresAt(now.plus(authProperties.getRefreshTokenTtl()));
48+
49+
refreshTokenRepository.save(refreshToken);
50+
51+
String accessToken = jwtAccessTokenService.issueAccessToken(session.getUser().getId(), session.getId(),
52+
Instant.now());
53+
return new RefreshResult(accessToken, rawRefreshToken, session.getId());
54+
}
55+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package com.cloudmedia.identity.auth.service;
2+
3+
public record RefreshResult(String accessToken, String refreshToken, String sessionId) {
4+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.cloudmedia.identity.auth.service;
2+
3+
import com.cloudmedia.identity.persistence.entity.RefreshTokenEntity;
4+
import com.cloudmedia.identity.persistence.entity.SessionEntity;
5+
import com.cloudmedia.identity.persistence.repository.RefreshTokenRepository;
6+
import com.cloudmedia.identity.persistence.repository.SessionRepository;
7+
import java.time.LocalDateTime;
8+
import java.util.List;
9+
import org.springframework.stereotype.Service;
10+
import org.springframework.transaction.annotation.Transactional;
11+
12+
@Service
13+
public class SessionLifecycleService {
14+
15+
private final SessionRepository sessionRepository;
16+
private final RefreshTokenRepository refreshTokenRepository;
17+
18+
public SessionLifecycleService(SessionRepository sessionRepository, RefreshTokenRepository refreshTokenRepository) {
19+
this.sessionRepository = sessionRepository;
20+
this.refreshTokenRepository = refreshTokenRepository;
21+
}
22+
23+
@Transactional
24+
public void enforceSessionCap(String userId, int maxActiveSessions, LocalDateTime now) {
25+
List<SessionEntity> activeSessions = sessionRepository
26+
.findByUser_IdAndRevokedAtIsNullOrderByCreatedAtAsc(userId);
27+
if (activeSessions.size() < maxActiveSessions) {
28+
return;
29+
}
30+
31+
SessionEntity oldestSession = activeSessions.get(0);
32+
revokeSessionAndActiveTokens(oldestSession, now);
33+
}
34+
35+
@Transactional
36+
public void revokeSessionAndActiveTokens(SessionEntity session, LocalDateTime now) {
37+
session.setRevokedAt(now);
38+
sessionRepository.save(session);
39+
40+
List<RefreshTokenEntity> activeTokens = refreshTokenRepository
41+
.findBySession_IdAndRevokedAtIsNull(session.getId());
42+
for (RefreshTokenEntity token : activeTokens) {
43+
token.setRevokedAt(now);
44+
}
45+
refreshTokenRepository.saveAll(activeTokens);
46+
}
47+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.cloudmedia.identity.auth.token;
2+
3+
import com.cloudmedia.identity.auth.config.AuthProperties;
4+
import io.jsonwebtoken.Jwts;
5+
import io.jsonwebtoken.security.Keys;
6+
import java.nio.charset.StandardCharsets;
7+
import java.time.Instant;
8+
import java.util.Date;
9+
import javax.crypto.SecretKey;
10+
import org.springframework.stereotype.Component;
11+
12+
@Component
13+
public class JwtAccessTokenService {
14+
15+
private final AuthProperties authProperties;
16+
17+
public JwtAccessTokenService(AuthProperties authProperties) {
18+
this.authProperties = authProperties;
19+
}
20+
21+
public String issueAccessToken(String userId, String sessionId, Instant now) {
22+
Instant expiresAt = now.plus(authProperties.getAccessTokenTtl());
23+
24+
return Jwts.builder().subject(userId).issuer(authProperties.getJwtIssuer()).issuedAt(Date.from(now))
25+
.expiration(Date.from(expiresAt)).claim("sid", sessionId).signWith(secretKey()).compact();
26+
}
27+
28+
private SecretKey secretKey() {
29+
return Keys.hmacShaKeyFor(authProperties.getJwtSecret().getBytes(StandardCharsets.UTF_8));
30+
}
31+
}

0 commit comments

Comments
 (0)