Skip to content

Commit 686c231

Browse files
authored
Merge pull request #8 from poyrazK/feat/identity-hardening-events
feat(identity): add event stubs and auth hardening
2 parents 6e3e2e2 + 6815089 commit 686c231

16 files changed

Lines changed: 316 additions & 53 deletions

docs/modular-implementation-roadmap.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ This roadmap breaks implementation into small, reviewable slices with one primar
2222

2323
### PR-003: identity-service MVP
2424
- Phase A (done): auth controller stubs and request/response contracts.
25-
- Phase B (in progress): persistence foundation added (Flyway migrations, JPA entities, repositories, repository tests).
26-
- Phase C (next): JWT + refresh rotation plumbing and storage interfaces.
27-
- Phase D (next): login/social/refresh/logout service logic + tests.
25+
- Phase B (done): persistence foundation added (Flyway migrations, JPA entities, repositories, repository tests).
26+
- Phase C (done): JWT + refresh rotation plumbing and storage interfaces.
27+
- Phase D (done): login/social/refresh/logout service logic + tests.
28+
- Phase E (in progress): identity hardening (events, metrics, config cleanup, logout edge cases).
2829
- Note: Google social login currently uses a fake token verifier in backend-only development mode.
2930

3031
### PR-004: content-service MVP
@@ -75,4 +76,4 @@ This roadmap breaks implementation into small, reviewable slices with one primar
7576

7677
- PR-001: completed
7778
- PR-002: completed
78-
- PR-003: in progress (Phase A and persistence foundation completed)
79+
- PR-003: in progress (Phases A-D complete, Phase E in progress)

docs/mvp-backend-implementation-plan.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -464,8 +464,16 @@ Current completed slices:
464464
- JPA entities and Spring Data repositories
465465
- DataJpa repository tests for uniqueness constraints and lookup queries
466466
- Identity social login testing mode uses a fake Google token verifier with format `fake-google:<subject>:<email>`
467+
- Identity token/session core is implemented:
468+
- JWT access token issuing
469+
- refresh token rotation and reuse detection
470+
- max-5 active session cap enforcement
471+
- `/v1/auth/refresh` is fully wired
472+
- Identity login/social-login/logout flows are implemented end-to-end
467473

468474
Next active slice:
469475

470-
- Identity token/session logic (JWT issue/verify, refresh rotation, reuse detection, and max-5 session cap behavior)
471-
- Kafka event contract: `docs/contracts/kafka-event-catalog.md`
476+
- Identity hardening:
477+
- identity event publishing stubs (`user.created`, `user.updated`)
478+
- auth flow metrics and config hardening
479+
- logout edge-case coverage

services/java/identity-service/src/main/java/com/cloudmedia/identity/auth/service/AuthLoginService.java

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.cloudmedia.identity.auth.config.AuthProperties;
55
import com.cloudmedia.identity.auth.password.PasswordHashService;
66
import com.cloudmedia.identity.error.ApiException;
7+
import com.cloudmedia.identity.metrics.AuthMetrics;
78
import com.cloudmedia.identity.persistence.entity.SessionEntity;
89
import com.cloudmedia.identity.persistence.entity.UserCredentialEntity;
910
import com.cloudmedia.identity.persistence.entity.UserEntity;
@@ -27,47 +28,56 @@ public class AuthLoginService implements AuthLoginUseCase {
2728
private final SessionLifecycleService sessionLifecycleService;
2829
private final AuthTokenIssueService authTokenIssueService;
2930
private final PasswordHashService passwordHashService;
31+
private final AuthMetrics authMetrics;
3032

3133
public AuthLoginService(AuthProperties authProperties, UserRepository userRepository,
3234
UserCredentialRepository userCredentialRepository, SessionRepository sessionRepository,
3335
SessionLifecycleService sessionLifecycleService, AuthTokenIssueService authTokenIssueService,
34-
PasswordHashService passwordHashService) {
36+
PasswordHashService passwordHashService, AuthMetrics authMetrics) {
3537
this.authProperties = authProperties;
3638
this.userRepository = userRepository;
3739
this.userCredentialRepository = userCredentialRepository;
3840
this.sessionRepository = sessionRepository;
3941
this.sessionLifecycleService = sessionLifecycleService;
4042
this.authTokenIssueService = authTokenIssueService;
4143
this.passwordHashService = passwordHashService;
44+
this.authMetrics = authMetrics;
4245
}
4346

4447
@Override
4548
@Transactional
4649
public RefreshResult login(String email, String password, DeviceInfo deviceInfo) {
47-
UserEntity user = userRepository.findByEmail(email)
48-
.orElseThrow(() -> unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid email or password"));
50+
try {
51+
UserEntity user = userRepository.findByEmail(email)
52+
.orElseThrow(() -> unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid email or password"));
4953

50-
UserCredentialEntity credential = userCredentialRepository.findByUser_Id(user.getId())
51-
.orElseThrow(() -> unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid email or password"));
54+
UserCredentialEntity credential = userCredentialRepository.findByUser_Id(user.getId())
55+
.orElseThrow(() -> unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid email or password"));
5256

53-
if (!passwordHashService.matches(password, credential.getPasswordHash())) {
54-
throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid email or password");
55-
}
57+
if (!passwordHashService.matches(password, credential.getPasswordHash())) {
58+
throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid email or password");
59+
}
5660

57-
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
58-
sessionLifecycleService.enforceSessionCap(user.getId(), authProperties.getMaxActiveSessions(), now);
61+
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
62+
sessionLifecycleService.enforceSessionCap(user.getId(), authProperties.getMaxActiveSessions(), now);
5963

60-
SessionEntity session = new SessionEntity();
61-
session.setId(UUID.randomUUID().toString());
62-
session.setUser(user);
63-
session.setDeviceId(deviceInfo != null ? deviceInfo.deviceId() : null);
64-
session.setUserAgent(deviceInfo != null ? deviceInfo.userAgent() : null);
65-
session.setIpAddress(deviceInfo != null ? deviceInfo.ipAddress() : null);
66-
session.setCreatedAt(now);
67-
session.setExpiresAt(now.plus(authProperties.getRefreshTokenTtl()));
64+
SessionEntity session = new SessionEntity();
65+
session.setId(UUID.randomUUID().toString());
66+
session.setUser(user);
67+
session.setDeviceId(deviceInfo != null ? deviceInfo.deviceId() : null);
68+
session.setUserAgent(deviceInfo != null ? deviceInfo.userAgent() : null);
69+
session.setIpAddress(deviceInfo != null ? deviceInfo.ipAddress() : null);
70+
session.setCreatedAt(now);
71+
session.setExpiresAt(now.plus(authProperties.getRefreshTokenTtl()));
6872

69-
SessionEntity savedSession = sessionRepository.save(session);
70-
return authTokenIssueService.issueForSession(savedSession);
73+
SessionEntity savedSession = sessionRepository.save(session);
74+
RefreshResult result = authTokenIssueService.issueForSession(savedSession);
75+
authMetrics.onLoginSuccess();
76+
return result;
77+
} catch (ApiException exception) {
78+
authMetrics.onLoginFailure();
79+
throw exception;
80+
}
7181
}
7282

7383
private ApiException unauthorized(String code, String message) {

services/java/identity-service/src/main/java/com/cloudmedia/identity/auth/service/AuthLogoutService.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.cloudmedia.identity.auth.service;
22

33
import com.cloudmedia.identity.error.ApiException;
4+
import com.cloudmedia.identity.metrics.AuthMetrics;
45
import com.cloudmedia.identity.persistence.entity.SessionEntity;
56
import com.cloudmedia.identity.persistence.repository.SessionRepository;
67
import java.time.LocalDateTime;
@@ -15,10 +16,13 @@ public class AuthLogoutService implements AuthLogoutUseCase {
1516

1617
private final SessionRepository sessionRepository;
1718
private final SessionLifecycleService sessionLifecycleService;
19+
private final AuthMetrics authMetrics;
1820

19-
public AuthLogoutService(SessionRepository sessionRepository, SessionLifecycleService sessionLifecycleService) {
21+
public AuthLogoutService(SessionRepository sessionRepository, SessionLifecycleService sessionLifecycleService,
22+
AuthMetrics authMetrics) {
2023
this.sessionRepository = sessionRepository;
2124
this.sessionLifecycleService = sessionLifecycleService;
25+
this.authMetrics = authMetrics;
2226
}
2327

2428
@Override
@@ -38,9 +42,11 @@ public void logout(String sessionId, boolean allSessions) {
3842
for (SessionEntity session : activeSessions) {
3943
sessionLifecycleService.revokeSessionAndActiveTokens(session, now);
4044
}
45+
authMetrics.onLogoutSuccess();
4146
return;
4247
}
4348

4449
sessionLifecycleService.revokeSessionAndActiveTokens(currentSession, now);
50+
authMetrics.onLogoutSuccess();
4551
}
4652
}

services/java/identity-service/src/main/java/com/cloudmedia/identity/auth/service/AuthRefreshService.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.cloudmedia.identity.auth.token.RefreshTokenGenerator;
66
import com.cloudmedia.identity.auth.token.RefreshTokenHasher;
77
import com.cloudmedia.identity.error.ApiException;
8+
import com.cloudmedia.identity.metrics.AuthMetrics;
89
import com.cloudmedia.identity.persistence.entity.RefreshTokenEntity;
910
import com.cloudmedia.identity.persistence.entity.SessionEntity;
1011
import com.cloudmedia.identity.persistence.repository.RefreshTokenRepository;
@@ -26,16 +27,19 @@ public class AuthRefreshService implements AuthRefreshUseCase {
2627
private final RefreshTokenHasher refreshTokenHasher;
2728
private final RefreshTokenRepository refreshTokenRepository;
2829
private final SessionLifecycleService sessionLifecycleService;
30+
private final AuthMetrics authMetrics;
2931

3032
public AuthRefreshService(AuthProperties authProperties, JwtAccessTokenService jwtAccessTokenService,
3133
RefreshTokenGenerator refreshTokenGenerator, RefreshTokenHasher refreshTokenHasher,
32-
RefreshTokenRepository refreshTokenRepository, SessionLifecycleService sessionLifecycleService) {
34+
RefreshTokenRepository refreshTokenRepository, SessionLifecycleService sessionLifecycleService,
35+
AuthMetrics authMetrics) {
3336
this.authProperties = authProperties;
3437
this.jwtAccessTokenService = jwtAccessTokenService;
3538
this.refreshTokenGenerator = refreshTokenGenerator;
3639
this.refreshTokenHasher = refreshTokenHasher;
3740
this.refreshTokenRepository = refreshTokenRepository;
3841
this.sessionLifecycleService = sessionLifecycleService;
42+
this.authMetrics = authMetrics;
3943
}
4044

4145
@Override
@@ -49,6 +53,7 @@ public RefreshResult rotateRefreshToken(String rawRefreshToken) {
4953

5054
if (currentToken.getRevokedAt() != null) {
5155
handleReuseDetection(currentToken, now);
56+
authMetrics.onRefreshReuseDetected();
5257
throw unauthorized("REFRESH_TOKEN_REUSED", "Refresh token reuse detected");
5358
}
5459

@@ -76,6 +81,7 @@ public RefreshResult rotateRefreshToken(String rawRefreshToken) {
7681

7782
String accessToken = jwtAccessTokenService.issueAccessToken(session.getUser().getId(), session.getId(),
7883
Instant.now());
84+
authMetrics.onRefreshSuccess();
7985
return new RefreshResult(accessToken, newRawRefreshToken, session.getId());
8086
}
8187

services/java/identity-service/src/main/java/com/cloudmedia/identity/auth/service/AuthSocialLoginService.java

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
import com.cloudmedia.identity.auth.social.GoogleIdentity;
77
import com.cloudmedia.identity.auth.social.GoogleTokenVerifier;
88
import com.cloudmedia.identity.error.ApiException;
9+
import com.cloudmedia.identity.events.IdentityEventEnvelope;
10+
import com.cloudmedia.identity.events.IdentityEventPublisher;
11+
import com.cloudmedia.identity.events.UserCreatedPayload;
12+
import com.cloudmedia.identity.events.UserUpdatedPayload;
13+
import com.cloudmedia.identity.metrics.AuthMetrics;
914
import com.cloudmedia.identity.persistence.entity.OAuthAccountEntity;
1015
import com.cloudmedia.identity.persistence.entity.OAuthProvider;
1116
import com.cloudmedia.identity.persistence.entity.SessionEntity;
@@ -14,6 +19,7 @@
1419
import com.cloudmedia.identity.persistence.repository.OAuthAccountRepository;
1520
import com.cloudmedia.identity.persistence.repository.SessionRepository;
1621
import com.cloudmedia.identity.persistence.repository.UserRepository;
22+
import java.time.Instant;
1723
import java.time.LocalDateTime;
1824
import java.time.ZoneOffset;
1925
import java.util.UUID;
@@ -31,56 +37,73 @@ public class AuthSocialLoginService implements AuthSocialLoginUseCase {
3137
private final SessionRepository sessionRepository;
3238
private final SessionLifecycleService sessionLifecycleService;
3339
private final AuthTokenIssueService authTokenIssueService;
40+
private final IdentityEventPublisher identityEventPublisher;
41+
private final AuthMetrics authMetrics;
3442

3543
public AuthSocialLoginService(AuthProperties authProperties, GoogleTokenVerifier googleTokenVerifier,
3644
UserRepository userRepository, OAuthAccountRepository oAuthAccountRepository,
3745
SessionRepository sessionRepository, SessionLifecycleService sessionLifecycleService,
38-
AuthTokenIssueService authTokenIssueService) {
46+
AuthTokenIssueService authTokenIssueService, IdentityEventPublisher identityEventPublisher,
47+
AuthMetrics authMetrics) {
3948
this.authProperties = authProperties;
4049
this.googleTokenVerifier = googleTokenVerifier;
4150
this.userRepository = userRepository;
4251
this.oAuthAccountRepository = oAuthAccountRepository;
4352
this.sessionRepository = sessionRepository;
4453
this.sessionLifecycleService = sessionLifecycleService;
4554
this.authTokenIssueService = authTokenIssueService;
55+
this.identityEventPublisher = identityEventPublisher;
56+
this.authMetrics = authMetrics;
4657
}
4758

4859
@Override
4960
@Transactional
5061
public RefreshResult socialLogin(SocialProvider provider, String providerToken, DeviceInfo deviceInfo) {
51-
if (provider != SocialProvider.GOOGLE) {
52-
throw new ApiException(HttpStatus.BAD_REQUEST, "SOCIAL_PROVIDER_UNSUPPORTED",
53-
"Only GOOGLE provider is supported", null);
54-
}
62+
try {
63+
if (provider != SocialProvider.GOOGLE) {
64+
throw new ApiException(HttpStatus.BAD_REQUEST, "SOCIAL_PROVIDER_UNSUPPORTED",
65+
"Only GOOGLE provider is supported", null);
66+
}
5567

56-
GoogleIdentity googleIdentity = googleTokenVerifier.verify(providerToken);
57-
UserEntity user = resolveOrCreateUser(googleIdentity);
68+
GoogleIdentity googleIdentity = googleTokenVerifier.verify(providerToken);
69+
SocialResolution resolution = resolveOrCreateUser(googleIdentity);
5870

59-
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
60-
sessionLifecycleService.enforceSessionCap(user.getId(), authProperties.getMaxActiveSessions(), now);
61-
62-
SessionEntity session = new SessionEntity();
63-
session.setId(UUID.randomUUID().toString());
64-
session.setUser(user);
65-
session.setDeviceId(deviceInfo != null ? deviceInfo.deviceId() : null);
66-
session.setUserAgent(deviceInfo != null ? deviceInfo.userAgent() : null);
67-
session.setIpAddress(deviceInfo != null ? deviceInfo.ipAddress() : null);
68-
session.setCreatedAt(now);
69-
session.setExpiresAt(now.plus(authProperties.getRefreshTokenTtl()));
70-
71-
SessionEntity savedSession = sessionRepository.save(session);
72-
return authTokenIssueService.issueForSession(savedSession);
71+
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
72+
sessionLifecycleService.enforceSessionCap(resolution.user().getId(), authProperties.getMaxActiveSessions(),
73+
now);
74+
75+
SessionEntity session = new SessionEntity();
76+
session.setId(UUID.randomUUID().toString());
77+
session.setUser(resolution.user());
78+
session.setDeviceId(deviceInfo != null ? deviceInfo.deviceId() : null);
79+
session.setUserAgent(deviceInfo != null ? deviceInfo.userAgent() : null);
80+
session.setIpAddress(deviceInfo != null ? deviceInfo.ipAddress() : null);
81+
session.setCreatedAt(now);
82+
session.setExpiresAt(now.plus(authProperties.getRefreshTokenTtl()));
83+
84+
SessionEntity savedSession = sessionRepository.save(session);
85+
publishIdentityEvent(resolution);
86+
RefreshResult result = authTokenIssueService.issueForSession(savedSession);
87+
authMetrics.onSocialLoginSuccess();
88+
return result;
89+
} catch (ApiException exception) {
90+
authMetrics.onSocialLoginFailure();
91+
throw exception;
92+
}
7393
}
7494

75-
private UserEntity resolveOrCreateUser(GoogleIdentity identity) {
95+
private SocialResolution resolveOrCreateUser(GoogleIdentity identity) {
7696
return oAuthAccountRepository.findByProviderAndProviderSubject(OAuthProvider.GOOGLE, identity.subject())
77-
.map(OAuthAccountEntity::getUser).orElseGet(() -> createOrLinkUser(identity));
97+
.map(account -> new SocialResolution(account.getUser(), false, false))
98+
.orElseGet(() -> createOrLinkUser(identity));
7899
}
79100

80-
private UserEntity createOrLinkUser(GoogleIdentity identity) {
101+
private SocialResolution createOrLinkUser(GoogleIdentity identity) {
81102
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
103+
boolean[] createdNewUser = new boolean[]{false};
82104

83105
UserEntity user = userRepository.findByEmail(identity.email()).orElseGet(() -> {
106+
createdNewUser[0] = true;
84107
UserEntity newUser = new UserEntity();
85108
newUser.setId(UUID.randomUUID().toString());
86109
newUser.setEmail(identity.email());
@@ -98,6 +121,25 @@ private UserEntity createOrLinkUser(GoogleIdentity identity) {
98121
account.setLinkedAt(now);
99122
oAuthAccountRepository.save(account);
100123

101-
return user;
124+
boolean linkedExistingUser = !createdNewUser[0];
125+
return new SocialResolution(user, createdNewUser[0], linkedExistingUser);
126+
}
127+
128+
private void publishIdentityEvent(SocialResolution resolution) {
129+
if (resolution.createdUser()) {
130+
identityEventPublisher.publish(new IdentityEventEnvelope(UUID.randomUUID().toString(), "user.created", 1,
131+
Instant.now(), "identity-service", "user", resolution.user().getId(), null, new UserCreatedPayload(
132+
resolution.user().getId(), resolution.user().getEmail(), "google-social-login")));
133+
return;
134+
}
135+
136+
if (resolution.linkedExistingUser()) {
137+
identityEventPublisher.publish(new IdentityEventEnvelope(UUID.randomUUID().toString(), "user.updated", 1,
138+
Instant.now(), "identity-service", "user", resolution.user().getId(), null, new UserUpdatedPayload(
139+
resolution.user().getId(), resolution.user().getEmail(), "social-account-linked")));
140+
}
141+
}
142+
143+
private record SocialResolution(UserEntity user, boolean createdUser, boolean linkedExistingUser) {
102144
}
103145
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.cloudmedia.identity.events;
2+
3+
import java.time.Instant;
4+
5+
public record IdentityEventEnvelope(String eventId, String eventType, int eventVersion, Instant occurredAt,
6+
String producer, String entityType, String entityId, String traceId, Object payload) {
7+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.cloudmedia.identity.events;
2+
3+
public interface IdentityEventPublisher {
4+
void publish(IdentityEventEnvelope event);
5+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.cloudmedia.identity.events;
2+
3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
5+
import org.springframework.stereotype.Component;
6+
7+
@Component
8+
public class NoopIdentityEventPublisher implements IdentityEventPublisher {
9+
10+
private static final Logger LOGGER = LoggerFactory.getLogger(NoopIdentityEventPublisher.class);
11+
12+
@Override
13+
public void publish(IdentityEventEnvelope event) {
14+
LOGGER.debug("Noop identity event publisher dropped event type={} id={}", event.eventType(), event.eventId());
15+
}
16+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package com.cloudmedia.identity.events;
2+
3+
public record UserCreatedPayload(String userId, String email, String source) {
4+
}

0 commit comments

Comments
 (0)