Skip to content

Commit 6e3e2e2

Browse files
authored
Merge pull request #7 from poyrazK/feat/identity-login-social-logout
feat(identity): implement login social-login and logout flows
2 parents edcb362 + 4c8d2e1 commit 6e3e2e2

19 files changed

Lines changed: 527 additions & 31 deletions

docs/contracts/rest-api-v1.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ This document defines the MVP API contract groups, standards, and core request/r
4747
### `POST /v1/auth/social-login`
4848
- Request: provider, provider_token, optional device info
4949
- MVP provider support: `GOOGLE` only
50+
- Current implementation uses a fake verifier for development/testing only.
51+
- Fake token format: `fake-google:<subject>:<email>`
5052
- Response: access token + refresh token + session id
5153

5254
### `POST /v1/auth/refresh`

docs/modular-implementation-roadmap.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ This roadmap breaks implementation into small, reviewable slices with one primar
2525
- Phase B (in progress): persistence foundation added (Flyway migrations, JPA entities, repositories, repository tests).
2626
- Phase C (next): JWT + refresh rotation plumbing and storage interfaces.
2727
- Phase D (next): login/social/refresh/logout service logic + tests.
28+
- Note: Google social login currently uses a fake token verifier in backend-only development mode.
2829

2930
### PR-004: content-service MVP
3031
- Draft, publish, unpublish endpoints.

docs/mvp-backend-implementation-plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@ Current completed slices:
463463
- Flyway migrations for `users`, `user_credentials`, `oauth_accounts`, `sessions`, `refresh_tokens`
464464
- JPA entities and Spring Data repositories
465465
- DataJpa repository tests for uniqueness constraints and lookup queries
466+
- Identity social login testing mode uses a fake Google token verifier with format `fake-google:<subject>:<email>`
466467

467468
Next active slice:
468469

services/java/identity-service/pom.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@
3131
<groupId>org.springframework.boot</groupId>
3232
<artifactId>spring-boot-starter-data-jpa</artifactId>
3333
</dependency>
34+
<dependency>
35+
<groupId>org.springframework.security</groupId>
36+
<artifactId>spring-security-crypto</artifactId>
37+
</dependency>
38+
<dependency>
39+
<groupId>org.bouncycastle</groupId>
40+
<artifactId>bcprov-jdk18on</artifactId>
41+
<version>1.78.1</version>
42+
</dependency>
3443
<dependency>
3544
<groupId>org.flywaydb</groupId>
3645
<artifactId>flyway-core</artifactId>

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

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
package com.cloudmedia.identity.api;
22

33
import com.cloudmedia.identity.auth.config.AuthProperties;
4+
import com.cloudmedia.identity.auth.service.AuthLoginUseCase;
5+
import com.cloudmedia.identity.auth.service.AuthLogoutUseCase;
46
import com.cloudmedia.identity.auth.service.AuthRefreshUseCase;
7+
import com.cloudmedia.identity.auth.service.AuthSocialLoginUseCase;
58
import com.cloudmedia.identity.api.dto.AuthTokensResponse;
69
import com.cloudmedia.identity.api.dto.LoginRequest;
710
import com.cloudmedia.identity.api.dto.LogoutRequest;
811
import com.cloudmedia.identity.api.dto.RefreshRequest;
912
import com.cloudmedia.identity.api.dto.SocialLoginRequest;
1013
import com.cloudmedia.identity.api.response.ApiMeta;
1114
import com.cloudmedia.identity.api.response.ApiSuccessResponse;
12-
import com.cloudmedia.identity.error.ApiException;
1315
import jakarta.validation.Valid;
1416
import java.time.Instant;
1517
import java.util.UUID;
16-
import org.springframework.http.HttpStatus;
1718
import org.springframework.http.ResponseEntity;
1819
import org.springframework.web.bind.annotation.PostMapping;
1920
import org.springframework.web.bind.annotation.RequestBody;
@@ -25,27 +26,37 @@
2526
@RequestMapping("/v1/auth")
2627
public class AuthController {
2728

29+
private final AuthLoginUseCase authLoginService;
30+
private final AuthSocialLoginUseCase authSocialLoginService;
2831
private final AuthRefreshUseCase authRefreshService;
32+
private final AuthLogoutUseCase authLogoutService;
2933
private final AuthProperties authProperties;
3034

31-
public AuthController(AuthRefreshUseCase authRefreshService, AuthProperties authProperties) {
35+
public AuthController(AuthLoginUseCase authLoginService, AuthSocialLoginUseCase authSocialLoginService,
36+
AuthRefreshUseCase authRefreshService, AuthLogoutUseCase authLogoutService, AuthProperties authProperties) {
37+
this.authLoginService = authLoginService;
38+
this.authSocialLoginService = authSocialLoginService;
3239
this.authRefreshService = authRefreshService;
40+
this.authLogoutService = authLogoutService;
3341
this.authProperties = authProperties;
3442
}
3543

3644
@PostMapping("/login")
3745
public ResponseEntity<ApiSuccessResponse<AuthTokensResponse>> login(@Valid @RequestBody LoginRequest request,
3846
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
39-
throw new ApiException(HttpStatus.NOT_IMPLEMENTED, "AUTH_NOT_IMPLEMENTED", "Login flow is not implemented yet",
40-
meta(requestId));
47+
var result = authLoginService.login(request.email(), request.password(), request.deviceInfo());
48+
AuthTokensResponse response = toAuthTokensResponse(result);
49+
return ResponseEntity.ok(new ApiSuccessResponse<>(response, meta(requestId)));
4150
}
4251

4352
@PostMapping("/social-login")
4453
public ResponseEntity<ApiSuccessResponse<AuthTokensResponse>> socialLogin(
4554
@Valid @RequestBody SocialLoginRequest request,
4655
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
47-
throw new ApiException(HttpStatus.NOT_IMPLEMENTED, "AUTH_NOT_IMPLEMENTED",
48-
"Social login flow is not implemented yet", meta(requestId));
56+
var result = authSocialLoginService.socialLogin(request.provider(), request.providerToken(),
57+
request.deviceInfo());
58+
AuthTokensResponse response = toAuthTokensResponse(result);
59+
return ResponseEntity.ok(new ApiSuccessResponse<>(response, meta(requestId)));
4960
}
5061

5162
@PostMapping("/refresh")
@@ -61,8 +72,13 @@ public ResponseEntity<ApiSuccessResponse<AuthTokensResponse>> refresh(@Valid @Re
6172
@PostMapping("/logout")
6273
public ResponseEntity<ApiSuccessResponse<Void>> logout(@Valid @RequestBody LogoutRequest request,
6374
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
64-
throw new ApiException(HttpStatus.NOT_IMPLEMENTED, "AUTH_NOT_IMPLEMENTED", "Logout flow is not implemented yet",
65-
meta(requestId));
75+
authLogoutService.logout(request.sessionId(), request.allSessions());
76+
return ResponseEntity.ok(new ApiSuccessResponse<>(null, meta(requestId)));
77+
}
78+
79+
private AuthTokensResponse toAuthTokensResponse(com.cloudmedia.identity.auth.service.RefreshResult result) {
80+
return new AuthTokensResponse(result.accessToken(), result.refreshToken(), result.sessionId(),
81+
authProperties.getAccessTokenTtl().toSeconds(), authProperties.getRefreshTokenTtl().toSeconds());
6682
}
6783

6884
private ApiMeta meta(String requestIdHeader) {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.cloudmedia.identity.auth.password;
2+
3+
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
4+
import org.springframework.stereotype.Component;
5+
6+
@Component
7+
public class PasswordHashService {
8+
9+
private final Argon2PasswordEncoder passwordEncoder = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
10+
11+
public String hash(String rawPassword) {
12+
return passwordEncoder.encode(rawPassword);
13+
}
14+
15+
public boolean matches(String rawPassword, String encodedPassword) {
16+
return passwordEncoder.matches(rawPassword, encodedPassword);
17+
}
18+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.cloudmedia.identity.auth.service;
2+
3+
import com.cloudmedia.identity.api.dto.DeviceInfo;
4+
import com.cloudmedia.identity.auth.config.AuthProperties;
5+
import com.cloudmedia.identity.auth.password.PasswordHashService;
6+
import com.cloudmedia.identity.error.ApiException;
7+
import com.cloudmedia.identity.persistence.entity.SessionEntity;
8+
import com.cloudmedia.identity.persistence.entity.UserCredentialEntity;
9+
import com.cloudmedia.identity.persistence.entity.UserEntity;
10+
import com.cloudmedia.identity.persistence.repository.SessionRepository;
11+
import com.cloudmedia.identity.persistence.repository.UserCredentialRepository;
12+
import com.cloudmedia.identity.persistence.repository.UserRepository;
13+
import java.time.LocalDateTime;
14+
import java.time.ZoneOffset;
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 AuthLoginService implements AuthLoginUseCase {
22+
23+
private final AuthProperties authProperties;
24+
private final UserRepository userRepository;
25+
private final UserCredentialRepository userCredentialRepository;
26+
private final SessionRepository sessionRepository;
27+
private final SessionLifecycleService sessionLifecycleService;
28+
private final AuthTokenIssueService authTokenIssueService;
29+
private final PasswordHashService passwordHashService;
30+
31+
public AuthLoginService(AuthProperties authProperties, UserRepository userRepository,
32+
UserCredentialRepository userCredentialRepository, SessionRepository sessionRepository,
33+
SessionLifecycleService sessionLifecycleService, AuthTokenIssueService authTokenIssueService,
34+
PasswordHashService passwordHashService) {
35+
this.authProperties = authProperties;
36+
this.userRepository = userRepository;
37+
this.userCredentialRepository = userCredentialRepository;
38+
this.sessionRepository = sessionRepository;
39+
this.sessionLifecycleService = sessionLifecycleService;
40+
this.authTokenIssueService = authTokenIssueService;
41+
this.passwordHashService = passwordHashService;
42+
}
43+
44+
@Override
45+
@Transactional
46+
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"));
49+
50+
UserCredentialEntity credential = userCredentialRepository.findByUser_Id(user.getId())
51+
.orElseThrow(() -> unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid email or password"));
52+
53+
if (!passwordHashService.matches(password, credential.getPasswordHash())) {
54+
throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid email or password");
55+
}
56+
57+
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
58+
sessionLifecycleService.enforceSessionCap(user.getId(), authProperties.getMaxActiveSessions(), now);
59+
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()));
68+
69+
SessionEntity savedSession = sessionRepository.save(session);
70+
return authTokenIssueService.issueForSession(savedSession);
71+
}
72+
73+
private ApiException unauthorized(String code, String message) {
74+
return new ApiException(HttpStatus.UNAUTHORIZED, code, message, null);
75+
}
76+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.cloudmedia.identity.auth.service;
2+
3+
import com.cloudmedia.identity.api.dto.DeviceInfo;
4+
5+
public interface AuthLoginUseCase {
6+
RefreshResult login(String email, String password, DeviceInfo deviceInfo);
7+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.cloudmedia.identity.auth.service;
2+
3+
import com.cloudmedia.identity.error.ApiException;
4+
import com.cloudmedia.identity.persistence.entity.SessionEntity;
5+
import com.cloudmedia.identity.persistence.repository.SessionRepository;
6+
import java.time.LocalDateTime;
7+
import java.time.ZoneOffset;
8+
import java.util.List;
9+
import org.springframework.http.HttpStatus;
10+
import org.springframework.stereotype.Service;
11+
import org.springframework.transaction.annotation.Transactional;
12+
13+
@Service
14+
public class AuthLogoutService implements AuthLogoutUseCase {
15+
16+
private final SessionRepository sessionRepository;
17+
private final SessionLifecycleService sessionLifecycleService;
18+
19+
public AuthLogoutService(SessionRepository sessionRepository, SessionLifecycleService sessionLifecycleService) {
20+
this.sessionRepository = sessionRepository;
21+
this.sessionLifecycleService = sessionLifecycleService;
22+
}
23+
24+
@Override
25+
@Transactional
26+
public void logout(String sessionId, boolean allSessions) {
27+
if (sessionId == null || sessionId.isBlank()) {
28+
throw new ApiException(HttpStatus.BAD_REQUEST, "LOGOUT_SESSION_REQUIRED", "Session id is required", null);
29+
}
30+
31+
SessionEntity currentSession = sessionRepository.findById(sessionId).orElseThrow(
32+
() -> new ApiException(HttpStatus.NOT_FOUND, "SESSION_NOT_FOUND", "Session not found", null));
33+
34+
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
35+
if (allSessions) {
36+
List<SessionEntity> activeSessions = sessionRepository
37+
.findByUser_IdAndRevokedAtIsNull(currentSession.getUser().getId());
38+
for (SessionEntity session : activeSessions) {
39+
sessionLifecycleService.revokeSessionAndActiveTokens(session, now);
40+
}
41+
return;
42+
}
43+
44+
sessionLifecycleService.revokeSessionAndActiveTokens(currentSession, now);
45+
}
46+
}
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 AuthLogoutUseCase {
4+
void logout(String sessionId, boolean allSessions);
5+
}

0 commit comments

Comments
 (0)