Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b2acef381 | ||
|
|
e3433799f1 |
No files matched your search
@@ -26,7 +26,12 @@ To Be Played is split into three repositories:
|
||||
token that comes back revokes all tokens of that login. Logout revokes them too.
|
||||
- CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the
|
||||
frontend origin. A `Bearer` header still works for API clients and tests.
|
||||
- User settings: display name (unique, case-insensitive), Steam ID, language, dark mode.
|
||||
- User settings: display name (unique, case-insensitive), language, dark mode.
|
||||
- Steam account linked through **"Sign in through Steam"** (Steam OpenID 2.0, no API key), never typed in. Steam
|
||||
proves the SteamID64, and Auth asks Steam to confirm the answer (`check_authentication`) before it stores the ID
|
||||
as verified. A one-time state plus a binding cookie tie the answer to the browser that started the link, so a
|
||||
foreign Steam login link cannot move someone's Steam ID into another account. IDs typed in before this stay, but
|
||||
unverified: a verified link from another account takes them away. Unlinking is possible.
|
||||
- Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard
|
||||
banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
|
||||
- Opt-in public profile at `/u/<display name>`. Auth answers with a short-lived **profile ticket** (EdDSA JWT,
|
||||
@@ -42,7 +47,10 @@ To Be Played is split into three repositories:
|
||||
| `POST` | `/api/auth/refresh` | refresh cookie, replaces both cookies |
|
||||
| `POST` | `/api/auth/logout` | refresh cookie, clears both cookies |
|
||||
| `GET` | `/api/users/me` | access token |
|
||||
| `PUT` | `/api/users/settings` | access token, sets a new access cookie |
|
||||
| `PUT` | `/api/users/settings` | access token |
|
||||
| `POST` | `/api/users/steam-link` | access token, returns Steam's login URL and sets the binding cookie |
|
||||
| `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie |
|
||||
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings |
|
||||
| `PUT` | `/api/users/settings/appearance` | access token |
|
||||
| `GET` | `/api/public/profiles/{displayName}` | public |
|
||||
| `GET` | `/api/stats/public` | public |
|
||||
@@ -66,12 +74,62 @@ layers:
|
||||
| Layer | Package | Rule |
|
||||
|---|---|---|
|
||||
| API | `api` | REST controllers, request/response records, `ApiMapper`. Calls services, never repositories. |
|
||||
| Domain | `model`, `service`, `exception` | Plain Java models and business logic. No dependency on API or persistence. |
|
||||
| Domain | `model`, `service`, `exception` | Plain Java models and business logic. Uses repositories, never API classes, entities or `EntityMapper`. |
|
||||
| Persistence | `persistence` | JPA entities, Spring Data repositories, `EntityMapper`. Entities never reach the API layer. |
|
||||
|
||||
`profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package.
|
||||
|
||||
ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer
|
||||
violation fails the build. Test base classes (`AbstractUnitTest`, `AbstractWebMvcTest`, `AbstractPersistenceTest`,
|
||||
`AbstractIntegrationTest`) live in `auth_service.shared.test`; all integration tests share one PostgreSQL container.
|
||||
violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
|
||||
classes (`AbstractUnitTest`, `AbstractWebMvcTest`, `AbstractPersistenceTest`, `AbstractIntegrationTest`) live in
|
||||
`auth_service.shared.test`; all integration tests share one PostgreSQL container.
|
||||
|
||||
### Login flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser (Frontend)
|
||||
participant A as Auth
|
||||
participant C as Core
|
||||
B->>A: POST /api/auth/login
|
||||
A-->>B: Set-Cookie tbp_access (15 min), tbp_refresh (30 days, path /api/auth)
|
||||
B->>C: API call with tbp_access
|
||||
C->>A: GET /.well-known/jwks.json (new kid only)
|
||||
A-->>C: Public keys (Ed25519)
|
||||
C-->>B: 200, or 401 when the access token expired
|
||||
B->>A: POST /api/auth/refresh with tbp_refresh
|
||||
A-->>B: New tbp_access and tbp_refresh, old refresh token rotated
|
||||
B->>C: Same API call again
|
||||
```
|
||||
|
||||
1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script
|
||||
can read them.
|
||||
2. Core verifies the access token with Auth's public keys only. `JwksController` publishes them; Core loads them
|
||||
again only for an unknown `kid`.
|
||||
3. On a `401` the frontend calls `POST /api/auth/refresh` once and sends the request again.
|
||||
`RefreshTokenService` replaces the refresh token. A replaced token that comes back revokes the whole login.
|
||||
|
||||
### Steam link flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser (Frontend)
|
||||
participant A as Auth
|
||||
participant S as Steam
|
||||
B->>A: POST /api/users/steam-link (logged in)
|
||||
A-->>B: Steam login URL, Set-Cookie tbp_steam_link (state, 10 min)
|
||||
B->>S: Sign in on steamcommunity.com
|
||||
S-->>B: Redirect to /api/steam-link/callback?state=...&openid.*
|
||||
B->>A: GET callback with tbp_steam_link
|
||||
A->>S: check_authentication (openid.* sent back)
|
||||
S-->>A: is_valid:true
|
||||
A-->>B: Redirect to /settings?steam=linked
|
||||
B->>A: POST /api/auth/refresh (new steamId claim for Core)
|
||||
```
|
||||
|
||||
`SteamLinkService` checks the state against the binding cookie before it asks Steam anything, and uses each state
|
||||
once. `SteamOpenIdService` checks mode, endpoint, `return_to` and the signed fields, then asks Steam.
|
||||
`UserService.linkVerifiedSteamId` stores the ID and takes it from an account that only typed it in.
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -97,6 +155,8 @@ Configuration comes from environment variables:
|
||||
| `JWT_KID` | Key id written into the token header |
|
||||
| `AUTH_INVITATION_CODE` | Code required for registration |
|
||||
| `DB_PASSWORD` | Local PostgreSQL password |
|
||||
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) |
|
||||
| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
|
||||
|
||||
Generate a keypair:
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ public class SecurityConfig {
|
||||
"/api/registration/**", // Allow new user registration
|
||||
"/api/stats/public", // Public landing-page counters (cached, no PII)
|
||||
"/api/public/**", // Opt-in public profiles, see PublicProfileController
|
||||
"/api/steam-link/callback", // Steam's redirect back; bound to the browser by a cookie, see SteamLinkService
|
||||
"/actuator/health", // Docker health check; details are off
|
||||
"/.well-known/jwks.json", // Public verify keys for Core
|
||||
"/error" // Default Spring Boot error page routing
|
||||
|
||||
-6
@@ -4,7 +4,6 @@ import auth_service.shared.api.dto.response.ErrorResponse;
|
||||
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||
import auth_service.modules.registration.exception.InvalidInvitationCodeException;
|
||||
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
||||
import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException;
|
||||
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
|
||||
import lombok.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -53,11 +52,6 @@ public class RegistrationExceptionController {
|
||||
return response(body, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@ExceptionHandler(SteamIdAlreadyRegisteredException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(SteamIdAlreadyRegisteredException ex) {
|
||||
var body = new ErrorResponse("STEAM_ID_ALREADY_IN_USE", ex.getMessage());
|
||||
return response(body, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -13,7 +13,6 @@ public abstract class ApiMapper {
|
||||
rawPassword(registrationRequest.password()).
|
||||
inviteCode(registrationRequest.inviteCode()).
|
||||
displayName(registrationRequest.displayName()).
|
||||
steamId(registrationRequest.steamId() == null ? null : Long.valueOf(registrationRequest.steamId())).
|
||||
build();
|
||||
}
|
||||
|
||||
|
||||
+3
-5
@@ -1,15 +1,13 @@
|
||||
package auth_service.modules.registration.api.dto.request;
|
||||
|
||||
import auth_service.shared.api.validation.DisplayNameFormat;
|
||||
import auth_service.shared.api.validation.SteamIdDeserializer;
|
||||
import auth_service.shared.api.validation.SteamIdFormat;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Builder;
|
||||
import tools.jackson.databind.annotation.JsonDeserialize;
|
||||
|
||||
/**
|
||||
* No Steam ID: it comes only from "Sign in through Steam", later in the settings.
|
||||
*/
|
||||
@Builder
|
||||
public record RegistrationRequest(String email, String password, String inviteCode,
|
||||
@Pattern(regexp = SteamIdFormat.PATTERN)
|
||||
@JsonDeserialize(using = SteamIdDeserializer.class) String steamId,
|
||||
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName) {
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package auth_service.modules.registration.exception;
|
||||
|
||||
public class SteamIdAlreadyRegisteredException extends RuntimeException {
|
||||
public SteamIdAlreadyRegisteredException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,5 @@ public class Registration {
|
||||
private String rawPassword;
|
||||
private String encryptedPassword;
|
||||
private String inviteCode;
|
||||
private Long steamId;
|
||||
private String displayName;
|
||||
}
|
||||
-2
@@ -5,8 +5,6 @@ import auth_service.modules.registration.model.Registration;
|
||||
public interface RegisteredUserRepository {
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
boolean existsBySteamId(long steamId);
|
||||
|
||||
boolean existsByDisplayName(String displayName);
|
||||
|
||||
long save(Registration registration);
|
||||
|
||||
-10
@@ -24,16 +24,6 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsBySteamId(long steamId) {
|
||||
var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE u.steamId = :steamId";
|
||||
var count = entityManager.createQuery(jpql, Long.class)
|
||||
.setParameter("steamId", steamId)
|
||||
.getSingleResult();
|
||||
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByDisplayName(String displayName) {
|
||||
var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE LOWER(u.displayName) = LOWER(:displayName)";
|
||||
|
||||
-3
@@ -27,9 +27,6 @@ public class RegisteredUserEntity {
|
||||
@Column(nullable = false)
|
||||
private boolean enabled;
|
||||
|
||||
@Column(name = "steam_id", unique = true)
|
||||
private Long steamId;
|
||||
|
||||
@Column(name = "display_name", length = 24)
|
||||
private String displayName;
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ public abstract class EntityMapper {
|
||||
registrationDate(LocalDateTime.now(Clock.systemUTC())).
|
||||
password(userModel.getEncryptedPassword()).
|
||||
enabled(true).
|
||||
steamId(userModel.getSteamId()).
|
||||
displayName(userModel.getDisplayName()).
|
||||
lastLoginDate(null).
|
||||
build();
|
||||
|
||||
@@ -3,7 +3,6 @@ package auth_service.modules.registration.service;
|
||||
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||
import auth_service.modules.registration.exception.InvalidInvitationCodeException;
|
||||
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
||||
import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException;
|
||||
import auth_service.modules.registration.model.Registration;
|
||||
import auth_service.modules.registration.persistence.RegisteredUserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -39,10 +38,6 @@ public class RegistrationService {
|
||||
throw new EmailAlreadyInUseException("The provided email address is already in use.");
|
||||
}
|
||||
|
||||
if (registration.getSteamId() != null && registeredUserRepository.existsBySteamId(registration.getSteamId())) {
|
||||
throw new SteamIdAlreadyRegisteredException("The steam id is already in use.");
|
||||
}
|
||||
|
||||
if (registration.getDisplayName() != null && registeredUserRepository.existsByDisplayName(registration.getDisplayName())) {
|
||||
throw new DisplayNameAlreadyRegisteredException("The display name is already in use.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package auth_service.modules.user.api;
|
||||
|
||||
import auth_service.modules.user.api.dto.response.SteamLinkResponse;
|
||||
import auth_service.modules.user.model.SteamLinkOutcome;
|
||||
import auth_service.modules.user.service.SteamLinkService;
|
||||
import auth_service.modules.user.service.UserService;
|
||||
import auth_service.shared.api.AuthCookies;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Links the Steam ID through "Sign in through Steam" (Steam OpenID). Linking only: it is no login.
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class SteamLinkController {
|
||||
|
||||
private final SteamLinkService steamLinkService;
|
||||
private final UserService userService;
|
||||
private final AuthCookies authCookies;
|
||||
|
||||
@PostMapping(value = "/api/users/steam-link", version = "1.0", produces = "application/json")
|
||||
public ResponseEntity<SteamLinkResponse> start(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
SteamLinkService.Start start = steamLinkService.start(principal.getIdAsLong());
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, authCookies.steamLink(start.state(), SteamLinkService.STATE_TTL).toString())
|
||||
.body(new SteamLinkResponse(start.redirectUrl()));
|
||||
}
|
||||
|
||||
/**
|
||||
* A new access cookie without the steamId claim, so Core stops syncing at once.
|
||||
*/
|
||||
@DeleteMapping(value = "/api/users/steam-link", version = "1.0")
|
||||
public ResponseEntity<Void> unlink(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
String token = userService.unlinkSteamId(principal.getIdAsLong(), principal.email());
|
||||
return ResponseEntity.noContent()
|
||||
.header(HttpHeaders.SET_COOKIE, authCookies.access(token).toString())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Steam's redirect back. Always sends the browser on to the settings page with the outcome.
|
||||
*/
|
||||
@GetMapping(value = SteamLinkService.CALLBACK_PATH, version = "1.0")
|
||||
public ResponseEntity<Void> callback(@RequestParam(required = false) String state,
|
||||
@RequestParam Map<String, String> params,
|
||||
HttpServletRequest request) {
|
||||
String cookieState = AuthCookies.read(request, AuthCookies.STEAM_LINK_COOKIE).orElse(null);
|
||||
SteamLinkOutcome outcome = steamLinkService.complete(state, cookieState, params);
|
||||
ResponseEntity.BodyBuilder redirect = ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header(HttpHeaders.LOCATION, steamLinkService.frontendRedirect(outcome));
|
||||
// A forged callback must not clear the cookie of a link that is still on its way.
|
||||
if (SteamLinkService.sameState(state, cookieState)) {
|
||||
redirect.header(HttpHeaders.SET_COOKIE, authCookies.steamLinkCleared().toString());
|
||||
}
|
||||
return redirect.build();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package auth_service.modules.user.api;
|
||||
|
||||
import auth_service.shared.api.AuthCookies;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import auth_service.modules.user.api.dto.request.AppearanceRequest;
|
||||
import auth_service.modules.user.api.dto.request.UserSettingsRequest;
|
||||
@@ -10,7 +9,6 @@ import auth_service.modules.user.service.UserService;
|
||||
import io.micrometer.core.annotation.Timed;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -24,7 +22,6 @@ import org.springframework.web.bind.annotation.*;
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
private final AuthCookies authCookies;
|
||||
|
||||
/**
|
||||
* Version 1 of the 'me' endpoint, accessed via /api/users/me?v=1
|
||||
@@ -44,12 +41,8 @@ public class UserController {
|
||||
@Timed(value = "user.me.v1.requests.time", description = "Time taken to process v1 user profile requests")
|
||||
public ResponseEntity<Void> updateUserSettings(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody UserSettingsRequest request) {
|
||||
var token = this.userService.updateUserSettings(principal.getIdAsLong(), principal.email(),
|
||||
ApiMapper.toSteamId(request), request.displayName(), ApiMapper.toDomain(request));
|
||||
// A new access cookie, so the steamId claim that Core reads matches the saved settings.
|
||||
return ResponseEntity.noContent()
|
||||
.header(HttpHeaders.SET_COOKIE, authCookies.access(token).toString())
|
||||
.build();
|
||||
this.userService.updateUserSettings(principal.getIdAsLong(), request.displayName(), ApiMapper.toDomain(request));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping(value = "/settings/appearance", version = "1.0")
|
||||
|
||||
@@ -2,7 +2,6 @@ package auth_service.modules.user.api;
|
||||
|
||||
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
import lombok.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -17,7 +16,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
@Order(value = 1)
|
||||
@Slf4j
|
||||
public class UserExceptionController {
|
||||
private static final String STEAM_ID_CONSTRAINT = "uc_steam_id";
|
||||
private static final String DISPLAY_NAME_CONSTRAINT = "uq_users_display_name_lower";
|
||||
|
||||
private static @NonNull ResponseEntity<ErrorResponse> response(ErrorResponse body, HttpStatus status) {
|
||||
@@ -39,18 +37,10 @@ public class UserExceptionController {
|
||||
return response(body, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@ExceptionHandler(SteamIdAlreadyInUseException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(SteamIdAlreadyInUseException ex) {
|
||||
return steamIdInUse();
|
||||
}
|
||||
|
||||
// A parallel request can save the same Steam ID or display name after the service check. Then the DB constraint fails.
|
||||
// A parallel request can save the same display name after the service check. Then the DB constraint fails.
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<ErrorResponse> dataIntegrityError(DataIntegrityViolationException ex) {
|
||||
var cause = ex.getMostSpecificCause().getMessage();
|
||||
if (cause != null && cause.contains(STEAM_ID_CONSTRAINT)) {
|
||||
return steamIdInUse();
|
||||
}
|
||||
if (cause != null && cause.contains(DISPLAY_NAME_CONSTRAINT)) {
|
||||
return displayNameInUse();
|
||||
}
|
||||
@@ -68,9 +58,4 @@ public class UserExceptionController {
|
||||
return response(body, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
private static ResponseEntity<ErrorResponse> steamIdInUse() {
|
||||
var body = new ErrorResponse("STEAM_ID_ALREADY_IN_USE", "The steam id is already in use.");
|
||||
return response(body, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
package auth_service.modules.user.api.dto.request;
|
||||
|
||||
import auth_service.shared.api.validation.DisplayNameFormat;
|
||||
import auth_service.shared.api.validation.SteamIdDeserializer;
|
||||
import auth_service.shared.api.validation.SteamIdFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import tools.jackson.databind.annotation.JsonDeserialize;
|
||||
|
||||
/**
|
||||
* @param steamId null removes the Steam ID
|
||||
* No Steam ID: it comes only from "Sign in through Steam".
|
||||
*
|
||||
* @param displayName null removes the display name
|
||||
* @param publicProfile missing counts as false, so an old client can only make a profile private
|
||||
*/
|
||||
public record UserSettingsRequest(boolean useDarkTheme, String language,
|
||||
@Pattern(regexp = SteamIdFormat.PATTERN)
|
||||
@JsonDeserialize(using = SteamIdDeserializer.class) String steamId,
|
||||
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName,
|
||||
Boolean publicProfile) {
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.user.api.dto.response;
|
||||
|
||||
/**
|
||||
* @param redirectUrl Steam's login page; the browser goes there
|
||||
*/
|
||||
public record SteamLinkResponse(String redirectUrl) {
|
||||
}
|
||||
@@ -5,5 +5,5 @@ import lombok.Builder;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Builder
|
||||
public record UserProfileResponse(long id, String email, String role, LocalDateTime lastLogin, LocalDateTime createdDate, String steamId, String displayName, SettingsResponse settings) {
|
||||
public record UserProfileResponse(long id, String email, String role, LocalDateTime lastLogin, LocalDateTime createdDate, String steamId, boolean steamIdVerified, String displayName, SettingsResponse settings) {
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public abstract class ApiMapper {
|
||||
.createdDate(user.getCreatedDate())
|
||||
.lastLogin(user.getLastLogin())
|
||||
.steamId(user.getSteamId() == null ? null : String.valueOf(user.getSteamId()))
|
||||
.steamIdVerified(user.isSteamIdVerified())
|
||||
.displayName(user.getDisplayName())
|
||||
.role("USER")
|
||||
.settings(SettingsResponse.builder()
|
||||
@@ -44,10 +45,6 @@ public abstract class ApiMapper {
|
||||
request.backgroundAppid(), request.bannerAppid(), !Boolean.FALSE.equals(request.showBanner()));
|
||||
}
|
||||
|
||||
public static Long toSteamId(UserSettingsRequest request) {
|
||||
return request.steamId() == null ? null : Long.valueOf(request.steamId());
|
||||
}
|
||||
|
||||
public static UserSettings toDomain(UserSettingsRequest request) {
|
||||
return UserSettings.builder()
|
||||
.theme(request.useDarkTheme() ? "dark" : "light")
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package auth_service.modules.user.exception;
|
||||
|
||||
public class SteamIdAlreadyInUseException extends RuntimeException {
|
||||
public SteamIdAlreadyInUseException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package auth_service.modules.user.model;
|
||||
|
||||
public record SteamIdHolder(long userId, boolean verified) {
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package auth_service.modules.user.model;
|
||||
|
||||
/**
|
||||
* How a "Sign in through Steam" link ended, sent to the frontend as the {@code steam} query parameter.
|
||||
*/
|
||||
public enum SteamLinkOutcome {
|
||||
LINKED("linked"),
|
||||
/** Another account has proved this Steam ID already. */
|
||||
IN_USE("in-use"),
|
||||
CANCELLED("cancelled"),
|
||||
FAILED("failed");
|
||||
|
||||
private final String queryValue;
|
||||
|
||||
SteamLinkOutcome(String queryValue) {
|
||||
this.queryValue = queryValue;
|
||||
}
|
||||
|
||||
public String queryValue() {
|
||||
return queryValue;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public class User {
|
||||
private LocalDateTime lastLogin;
|
||||
private LocalDateTime createdDate;
|
||||
private Long steamId;
|
||||
private boolean steamIdVerified;
|
||||
private String displayName;
|
||||
|
||||
private UserSettings settings;
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth_service.modules.user.persistence;
|
||||
|
||||
|
||||
import auth_service.modules.user.model.Appearance;
|
||||
import auth_service.modules.user.model.SteamIdHolder;
|
||||
import auth_service.modules.user.model.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
import jakarta.transaction.Transactional;
|
||||
@@ -25,12 +26,21 @@ public interface UserRepository {
|
||||
UserSettings findSettingsByUserId(String userId);
|
||||
|
||||
@Transactional
|
||||
boolean updateSettings(long userId, Long steamId, String displayName, UserSettings settings);
|
||||
void updateSettings(long userId, String displayName, UserSettings settings);
|
||||
|
||||
@Transactional
|
||||
void updateAppearance(long userId, Appearance appearance);
|
||||
|
||||
boolean isSteamIdUsedByOtherUser(long userId, long steamId);
|
||||
Optional<SteamIdHolder> findSteamIdHolder(long steamId);
|
||||
|
||||
/**
|
||||
* Also sets the ID unverified.
|
||||
*/
|
||||
@Transactional
|
||||
void clearSteamId(long userId);
|
||||
|
||||
@Transactional
|
||||
void setVerifiedSteamId(long userId, long steamId);
|
||||
|
||||
boolean isDisplayNameUsedByOtherUser(long userId, String displayName);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package auth_service.modules.user.persistence;
|
||||
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
import auth_service.modules.user.model.Appearance;
|
||||
import auth_service.modules.user.model.SteamIdHolder;
|
||||
import auth_service.modules.user.model.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
import auth_service.modules.user.persistence.entities.UserEntity;
|
||||
@@ -14,7 +15,6 @@ import jakarta.transaction.Transactional;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -63,7 +63,7 @@ public class UserRepositoryImpl implements UserRepository {
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public boolean updateSettings(long userId, Long steamId, String displayName, UserSettings settings) {
|
||||
public void updateSettings(long userId, String displayName, UserSettings settings) {
|
||||
var userEntity = entityManager.find(UserEntity.class, userId);
|
||||
if (userEntity == null) {
|
||||
throw new UserNotFoundException("Could not find user with id " + userId);
|
||||
@@ -80,11 +80,7 @@ public class UserRepositoryImpl implements UserRepository {
|
||||
settingsEntity.setTheme(settings.getTheme());
|
||||
settingsEntity.setPublicProfile(settings.isPublicProfile());
|
||||
|
||||
boolean steamIdChanged = !Objects.equals(userEntity.getSteamId(), steamId);
|
||||
userEntity.setSteamId(steamId);
|
||||
userEntity.setDisplayName(displayName);
|
||||
|
||||
return steamIdChanged;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -114,13 +110,38 @@ public class UserRepositoryImpl implements UserRepository {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSteamIdUsedByOtherUser(long userId, long steamId) {
|
||||
var count = entityManager.createQuery(
|
||||
"SELECT COUNT(u) FROM UserEntity u WHERE u.steamId = :steamId AND u.id <> :userId", Long.class)
|
||||
public Optional<SteamIdHolder> findSteamIdHolder(long steamId) {
|
||||
// A projection, not the entity: the bulk updates that follow would leave a loaded entity stale.
|
||||
return entityManager.createQuery(
|
||||
"SELECT new auth_service.modules.user.model.SteamIdHolder(u.id, u.steamIdVerified) "
|
||||
+ "FROM UserEntity u WHERE u.steamId = :steamId", SteamIdHolder.class)
|
||||
.setParameter("steamId", steamId)
|
||||
.getResultStream()
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
// Bulk updates, not entity changes: the clear must reach the database before the new holder is set, or the
|
||||
// flush order could break uc_steam_id.
|
||||
@Transactional
|
||||
@Override
|
||||
public void clearSteamId(long userId) {
|
||||
entityManager.createQuery(
|
||||
"UPDATE UserEntity u SET u.steamId = NULL, u.steamIdVerified = FALSE WHERE u.id = :userId")
|
||||
.setParameter("userId", userId)
|
||||
.executeUpdate();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public void setVerifiedSteamId(long userId, long steamId) {
|
||||
int updated = entityManager.createQuery(
|
||||
"UPDATE UserEntity u SET u.steamId = :steamId, u.steamIdVerified = TRUE WHERE u.id = :userId")
|
||||
.setParameter("steamId", steamId)
|
||||
.setParameter("userId", userId)
|
||||
.getSingleResult();
|
||||
return count > 0;
|
||||
.executeUpdate();
|
||||
if (updated == 0) {
|
||||
throw new UserNotFoundException("Could not find user with id " + userId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -33,6 +33,9 @@ public class UserEntity {
|
||||
@Column(name = "steam_id", unique = true)
|
||||
private Long steamId;
|
||||
|
||||
@Column(name = "steam_id_verified", nullable = false)
|
||||
private boolean steamIdVerified;
|
||||
|
||||
@Column(name = "display_name", length = 24)
|
||||
private String displayName;
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ public abstract class EntityMapper {
|
||||
.lastLogin(entity.getLastLoginDate())
|
||||
.createdDate(entity.getRegistrationDate())
|
||||
.steamId(entity.getSteamId())
|
||||
.steamIdVerified(entity.isSteamIdVerified())
|
||||
.displayName(entity.getDisplayName())
|
||||
.settings(settingsModel)
|
||||
.build();
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package auth_service.modules.user.service;
|
||||
|
||||
import auth_service.modules.user.model.SteamLinkOutcome;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Links a Steam ID to a logged-in account after Steam proved it. Each link attempt has a one-time state, and the
|
||||
* callback must come from the browser that started it (the binding cookie holds the same state). Without that, an
|
||||
* attacker could send a victim a Steam login link with the attacker's state and so take the victim's Steam ID.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SteamLinkService {
|
||||
|
||||
public static final Duration STATE_TTL = Duration.ofMinutes(10);
|
||||
public static final String CALLBACK_PATH = "/api/steam-link/callback";
|
||||
|
||||
public record Start(String state, String redirectUrl) {}
|
||||
|
||||
private final SteamOpenIdService steamOpenIdService;
|
||||
private final UserService userService;
|
||||
private final String publicUrl;
|
||||
private final String frontendUrl;
|
||||
// In memory: a restart only means the user clicks again.
|
||||
private final Cache<String, Long> pendingUserByState = Caffeine.newBuilder()
|
||||
.expireAfterWrite(STATE_TTL)
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
// One pending link per user, so one user cannot push the states of others out of the store.
|
||||
private final Cache<Long, String> pendingStateByUser = Caffeine.newBuilder()
|
||||
.expireAfterWrite(STATE_TTL)
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
public SteamLinkService(SteamOpenIdService steamOpenIdService, UserService userService,
|
||||
@Value("${app.public-url}") String publicUrl,
|
||||
@Value("${app.frontend-url}") String frontendUrl) {
|
||||
this.steamOpenIdService = steamOpenIdService;
|
||||
this.userService = userService;
|
||||
this.publicUrl = withoutTrailingSlash(publicUrl);
|
||||
this.frontendUrl = withoutTrailingSlash(frontendUrl);
|
||||
}
|
||||
|
||||
public Start start(long userId) {
|
||||
byte[] bytes = new byte[32];
|
||||
random.nextBytes(bytes);
|
||||
String state = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
String previous = pendingStateByUser.asMap().put(userId, state);
|
||||
if (previous != null) {
|
||||
pendingUserByState.invalidate(previous);
|
||||
}
|
||||
pendingUserByState.put(state, userId);
|
||||
return new Start(state, steamOpenIdService.loginUrl(returnTo(state), publicUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param state from the callback URL
|
||||
* @param cookieState from the binding cookie; null when the browser sent none
|
||||
* @param params all query parameters of Steam's redirect
|
||||
*/
|
||||
public SteamLinkOutcome complete(String state, String cookieState, Map<String, String> params) {
|
||||
if ("cancel".equals(params.get("openid.mode"))) {
|
||||
return SteamLinkOutcome.CANCELLED;
|
||||
}
|
||||
if (!sameState(state, cookieState)) {
|
||||
log.warn("Steam link rejected: the state does not match the binding cookie");
|
||||
return SteamLinkOutcome.FAILED;
|
||||
}
|
||||
// One-time: removed before Steam is asked, so a replayed callback finds nothing.
|
||||
Long userId = pendingUserByState.asMap().remove(state);
|
||||
if (userId == null) {
|
||||
log.warn("Steam link rejected: unknown or expired state");
|
||||
return SteamLinkOutcome.FAILED;
|
||||
}
|
||||
pendingStateByUser.asMap().remove(userId, state);
|
||||
Optional<Long> steamId = steamOpenIdService.verify(params, returnTo(state));
|
||||
if (steamId.isEmpty()) {
|
||||
return SteamLinkOutcome.FAILED;
|
||||
}
|
||||
try {
|
||||
return userService.linkVerifiedSteamId(userId, steamId.get());
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// Another account proved the same ID at the same moment.
|
||||
return SteamLinkOutcome.IN_USE;
|
||||
} catch (RuntimeException e) {
|
||||
// The callback always sends the browser back to the frontend, never to an error page on Auth.
|
||||
log.error("Steam link of user {} not stored", userId, e);
|
||||
return SteamLinkOutcome.FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant time: the state is a secret of the browser that started the link.
|
||||
*/
|
||||
public static boolean sameState(String state, String cookieState) {
|
||||
return state != null && cookieState != null
|
||||
&& MessageDigest.isEqual(state.getBytes(StandardCharsets.UTF_8), cookieState.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public String frontendRedirect(SteamLinkOutcome outcome) {
|
||||
return frontendUrl + "/settings?steam=" + outcome.queryValue();
|
||||
}
|
||||
|
||||
private static String withoutTrailingSlash(String url) {
|
||||
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
|
||||
}
|
||||
|
||||
private String returnTo(String state) {
|
||||
return publicUrl + CALLBACK_PATH + "?state=" + state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package auth_service.modules.user.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* "Sign in through Steam" (OpenID 2.0, no API key). Steam proves the Steam ID; this class only asks Steam whether
|
||||
* its answer is genuine.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SteamOpenIdService {
|
||||
|
||||
static final String STEAM_LOGIN = "https://steamcommunity.com/openid/login";
|
||||
private static final String OPENID_NS = "http://specs.openid.net/auth/2.0";
|
||||
private static final String IDENTIFIER_SELECT = OPENID_NS + "/identifier_select";
|
||||
private static final Pattern CLAIMED_ID = Pattern.compile("^https://steamcommunity\\.com/openid/id/(\\d{17})$");
|
||||
// OpenID 2.0 section 10.1; a field outside the signature could be changed on the way back.
|
||||
private static final Set<String> MUST_BE_SIGNED =
|
||||
Set.of("op_endpoint", "claimed_id", "identity", "return_to", "response_nonce", "assoc_handle");
|
||||
|
||||
private final RestClient restClient;
|
||||
|
||||
public SteamOpenIdService() {
|
||||
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||
requestFactory.setConnectTimeout(Duration.ofSeconds(3));
|
||||
requestFactory.setReadTimeout(Duration.ofSeconds(10));
|
||||
this.restClient = RestClient.builder().requestFactory(requestFactory).build();
|
||||
}
|
||||
|
||||
SteamOpenIdService(RestClient restClient) {
|
||||
this.restClient = restClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param returnTo where Steam sends the user back; must lie under the realm
|
||||
*/
|
||||
public String loginUrl(String returnTo, String realm) {
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
params.put("openid.ns", OPENID_NS);
|
||||
params.put("openid.mode", "checkid_setup");
|
||||
params.put("openid.claimed_id", IDENTIFIER_SELECT);
|
||||
params.put("openid.identity", IDENTIFIER_SELECT);
|
||||
params.put("openid.return_to", returnTo);
|
||||
params.put("openid.realm", realm);
|
||||
return STEAM_LOGIN + "?" + params.entrySet().stream()
|
||||
.map(param -> param.getKey() + "=" + URLEncoder.encode(param.getValue(), StandardCharsets.UTF_8))
|
||||
.collect(Collectors.joining("&"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param params the query parameters of Steam's redirect
|
||||
* @param expectedReturnTo the return_to of the login URL this answer belongs to
|
||||
* @return the proved SteamID64; empty when the answer is not for us, not signed as needed, or Steam does not
|
||||
* confirm it
|
||||
*/
|
||||
public Optional<Long> verify(Map<String, String> params, String expectedReturnTo) {
|
||||
Optional<Long> steamId = checkFields(params, expectedReturnTo);
|
||||
if (steamId.isEmpty() || !confirmedBySteam(params)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return steamId;
|
||||
}
|
||||
|
||||
private static Optional<Long> checkFields(Map<String, String> params, String expectedReturnTo) {
|
||||
String claimedId = params.get("openid.claimed_id");
|
||||
if (!"id_res".equals(params.get("openid.mode"))
|
||||
|| !STEAM_LOGIN.equals(params.get("openid.op_endpoint"))
|
||||
|| !expectedReturnTo.equals(params.get("openid.return_to"))
|
||||
|| claimedId == null || !claimedId.equals(params.get("openid.identity"))) {
|
||||
log.warn("Steam OpenID answer rejected: wrong mode, endpoint, return_to or identity");
|
||||
return Optional.empty();
|
||||
}
|
||||
String signed = params.getOrDefault("openid.signed", "");
|
||||
if (!Arrays.asList(signed.split(",")).containsAll(MUST_BE_SIGNED)) {
|
||||
log.warn("Steam OpenID answer rejected: signed fields {}", signed);
|
||||
return Optional.empty();
|
||||
}
|
||||
Matcher matcher = CLAIMED_ID.matcher(claimedId);
|
||||
if (!matcher.matches()) {
|
||||
log.warn("Steam OpenID answer rejected: claimed_id is no Steam ID");
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(Long.parseLong(matcher.group(1)));
|
||||
}
|
||||
|
||||
private boolean confirmedBySteam(Map<String, String> params) {
|
||||
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
||||
params.forEach((key, value) -> {
|
||||
if (key.startsWith("openid.")) form.add(key, value);
|
||||
});
|
||||
form.set("openid.mode", "check_authentication");
|
||||
try {
|
||||
String answer = restClient.post()
|
||||
.uri(STEAM_LOGIN)
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(form)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
boolean valid = answer != null && answer.lines().map(String::trim).anyMatch("is_valid:true"::equals);
|
||||
if (!valid) {
|
||||
log.warn("Steam did not confirm the OpenID answer");
|
||||
}
|
||||
return valid;
|
||||
} catch (Exception e) {
|
||||
log.warn("Steam OpenID check failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package auth_service.modules.user.service;
|
||||
|
||||
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
import auth_service.modules.user.model.Appearance;
|
||||
import auth_service.modules.user.model.SteamIdHolder;
|
||||
import auth_service.modules.user.model.SteamLinkOutcome;
|
||||
import auth_service.modules.user.model.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
import auth_service.modules.user.persistence.UserRepository;
|
||||
@@ -15,6 +16,8 @@ import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -34,21 +37,49 @@ public class UserService {
|
||||
return userRepository.findByEmail(email).orElseThrow(() -> new UserNotFoundException("Could not find user with email " + email));
|
||||
}
|
||||
|
||||
/**
|
||||
* The Steam ID is not part of the settings: it changes only through {@link SteamLinkService}.
|
||||
*/
|
||||
@Transactional
|
||||
@CacheEvict(value = "usersByEmail", allEntries = true)
|
||||
public String updateUserSettings(long userId, String email, Long steamId, String displayName, UserSettings userSettings) {
|
||||
if (steamId != null && userRepository.isSteamIdUsedByOtherUser(userId, steamId)) {
|
||||
throw new SteamIdAlreadyInUseException("The steam id is already in use.");
|
||||
}
|
||||
public void updateUserSettings(long userId, String displayName, UserSettings userSettings) {
|
||||
if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) {
|
||||
throw new DisplayNameAlreadyInUseException("The display name is already in use.");
|
||||
}
|
||||
var updated = userRepository.updateSettings(userId, steamId, displayName, userSettings);
|
||||
if (updated) {
|
||||
log.info("Steam id has changed for user {}", userId);
|
||||
}
|
||||
userRepository.updateSettings(userId, displayName, userSettings);
|
||||
log.info("User settings updated for user {}", userId);
|
||||
return tokenGenerator.generateToken(String.valueOf(userId), email, steamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a Steam ID that Steam proved. An account that only typed the same ID in loses it; an account that
|
||||
* proved it keeps it.
|
||||
*/
|
||||
@Transactional
|
||||
@CacheEvict(value = "usersByEmail", allEntries = true)
|
||||
public SteamLinkOutcome linkVerifiedSteamId(long userId, long steamId) {
|
||||
Optional<SteamIdHolder> holder = userRepository.findSteamIdHolder(steamId);
|
||||
if (holder.isPresent() && holder.get().userId() != userId) {
|
||||
if (holder.get().verified()) {
|
||||
log.info("Steam link of user {} refused: the Steam ID is verified by user {}", userId, holder.get().userId());
|
||||
return SteamLinkOutcome.IN_USE;
|
||||
}
|
||||
log.info("Steam ID taken from user {}, who had typed it in, for user {}", holder.get().userId(), userId);
|
||||
userRepository.clearSteamId(holder.get().userId());
|
||||
}
|
||||
userRepository.setVerifiedSteamId(userId, steamId);
|
||||
log.info("Steam ID verified for user {}", userId);
|
||||
return SteamLinkOutcome.LINKED;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a new access token without the steamId claim
|
||||
*/
|
||||
@Transactional
|
||||
@CacheEvict(value = "usersByEmail", allEntries = true)
|
||||
public String unlinkSteamId(long userId, String email) {
|
||||
userRepository.clearSteamId(userId);
|
||||
log.info("Steam ID unlinked for user {}", userId);
|
||||
return tokenGenerator.generateToken(String.valueOf(userId), email, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -18,6 +18,9 @@ public class AuthCookies {
|
||||
|
||||
public static final String ACCESS_COOKIE = "tbp_access";
|
||||
public static final String REFRESH_COOKIE = "tbp_refresh";
|
||||
public static final String STEAM_LINK_COOKIE = "tbp_steam_link";
|
||||
// Only the Steam callback needs it.
|
||||
private static final String STEAM_LINK_PATH = "/api/steam-link";
|
||||
// Login, refresh and logout live here; logout needs the refresh cookie to revoke its family.
|
||||
private static final String REFRESH_PATH = "/api/auth";
|
||||
|
||||
@@ -31,6 +34,17 @@ public class AuthCookies {
|
||||
return refreshBuilder(token).maxAge(properties.getRefreshTokenTtl()).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a Steam link attempt to this browser. Lax, because Steam sends the user back with a cross-site GET.
|
||||
*/
|
||||
public ResponseCookie steamLink(String state, Duration ttl) {
|
||||
return steamLinkBuilder(state).maxAge(ttl).build();
|
||||
}
|
||||
|
||||
public ResponseCookie steamLinkCleared() {
|
||||
return steamLinkBuilder("").maxAge(Duration.ZERO).build();
|
||||
}
|
||||
|
||||
public List<ResponseCookie> cleared() {
|
||||
return List.of(accessBuilder("").maxAge(Duration.ZERO).build(), refreshBuilder("").maxAge(Duration.ZERO).build());
|
||||
}
|
||||
@@ -56,6 +70,14 @@ public class AuthCookies {
|
||||
return domain == null || domain.isBlank() ? builder : builder.domain(domain);
|
||||
}
|
||||
|
||||
private ResponseCookie.ResponseCookieBuilder steamLinkBuilder(String state) {
|
||||
return ResponseCookie.from(STEAM_LINK_COOKIE, state)
|
||||
.httpOnly(true)
|
||||
.secure(properties.isCookieSecure())
|
||||
.sameSite("Lax")
|
||||
.path(STEAM_LINK_PATH);
|
||||
}
|
||||
|
||||
// Host-only on Auth: no other service needs it. Strict is safe because the frontend is same-site.
|
||||
private ResponseCookie.ResponseCookieBuilder refreshBuilder(String token) {
|
||||
return ResponseCookie.from(REFRESH_COOKIE, token)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package auth_service.shared.api.validation;
|
||||
|
||||
import tools.jackson.core.JsonParser;
|
||||
import tools.jackson.core.JsonToken;
|
||||
import tools.jackson.databind.DeserializationContext;
|
||||
import tools.jackson.databind.ValueDeserializer;
|
||||
|
||||
/**
|
||||
* Rejects a JSON number: an old client sends the Steam ID already rounded by JavaScript.
|
||||
*/
|
||||
public class SteamIdDeserializer extends ValueDeserializer<String> {
|
||||
|
||||
@Override
|
||||
public String deserialize(JsonParser parser, DeserializationContext context) {
|
||||
if (!parser.hasToken(JsonToken.VALUE_STRING)) {
|
||||
return (String) context.handleUnexpectedToken(String.class, parser);
|
||||
}
|
||||
return parser.getString();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package auth_service.shared.api.validation;
|
||||
|
||||
/**
|
||||
* A SteamID64 has 17 digits. It is sent as a JSON string because JavaScript numbers lose precision above 2^53.
|
||||
*/
|
||||
public final class SteamIdFormat {
|
||||
public static final String PATTERN = "\\d{17}";
|
||||
|
||||
private SteamIdFormat() {
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,10 @@ app:
|
||||
auth:
|
||||
cookie-domain: ${APP_AUTH_COOKIE_DOMAIN:}
|
||||
cookie-secure: true
|
||||
# Auth's own public address: Steam sends the user back here after "Sign in through Steam".
|
||||
public-url: ${APP_PUBLIC_URL:http://localhost:8080}
|
||||
# Where the user lands after the Steam link.
|
||||
frontend-url: ${APP_FRONTEND_URL:http://localhost:5173}
|
||||
|
||||
# Registration settings
|
||||
auth:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- True only when the user proved the Steam ID with "Sign in through Steam". Typed-in IDs from before stay, but
|
||||
-- unverified: a verified link from another account takes such an ID away.
|
||||
ALTER TABLE users ADD COLUMN steam_id_verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,154 @@
|
||||
package auth_service.configuration.security;
|
||||
|
||||
import auth_service.modules.user.service.SteamOpenIdService;
|
||||
import auth_service.shared.api.AuthCookies;
|
||||
import auth_service.shared.test.AbstractIntegrationTest;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* "Sign in through Steam" through the whole filter chain and a real database; only Steam itself is mocked.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class SteamLinkFlowTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final String EMAIL = "steam-link@example.com";
|
||||
private static final String TYPIST = "steam-typist@example.com";
|
||||
private static final String PASSWORD = "correct horse battery";
|
||||
private static final long STEAM_ID = 76561198000000042L;
|
||||
private static final String CALLBACK = "http://localhost:8080/api/steam-link/callback?state=";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
@Autowired
|
||||
private JwtService jwtService;
|
||||
@MockitoBean
|
||||
private SteamOpenIdService steamOpenIdService;
|
||||
|
||||
@BeforeEach
|
||||
void seedUsers() {
|
||||
jdbc.update("DELETE FROM users WHERE email IN (?, ?)", EMAIL, TYPIST);
|
||||
jdbc.update("INSERT INTO users (email, password, enabled) VALUES (?, ?, true)", EMAIL, passwordEncoder.encode(PASSWORD));
|
||||
// Someone who typed the same ID in before Steam linking existed.
|
||||
jdbc.update("INSERT INTO users (email, password, enabled, steam_id) VALUES (?, 'x', true, ?)", TYPIST, STEAM_ID);
|
||||
when(steamOpenIdService.loginUrl(anyString(), anyString())).thenReturn("https://steamcommunity.com/openid/login?test");
|
||||
}
|
||||
|
||||
private MockHttpServletResponse login() throws Exception {
|
||||
return mockMvc.perform(post("/api/auth/login")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse();
|
||||
}
|
||||
|
||||
private MockHttpServletResponse startLink(MockHttpServletResponse session) throws Exception {
|
||||
return mockMvc.perform(post("/api/users/steam-link")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(session.getCookie(AuthCookies.ACCESS_COOKIE)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.redirectUrl").value("https://steamcommunity.com/openid/login?test"))
|
||||
.andReturn().getResponse();
|
||||
}
|
||||
|
||||
private Map<String, Object> row(String email) {
|
||||
return jdbc.queryForMap("SELECT steam_id, steam_id_verified FROM users WHERE email = ?", email);
|
||||
}
|
||||
|
||||
@Test
|
||||
void link_ProvedIdIsStoredVerified_TakenFromTheTypist_AndInTheNextToken() throws Exception {
|
||||
MockHttpServletResponse session = login();
|
||||
MockHttpServletResponse started = startLink(session);
|
||||
// Lax: Steam sends the user back with a cross-site GET, Strict would drop the cookie.
|
||||
assertThat(started.getHeader("Set-Cookie")).contains("SameSite=Lax");
|
||||
Cookie binding = started.getCookie(AuthCookies.STEAM_LINK_COOKIE);
|
||||
assertThat(binding).isNotNull();
|
||||
assertThat(binding.isHttpOnly()).isTrue();
|
||||
assertThat(binding.getPath()).isEqualTo("/api/steam-link");
|
||||
String state = binding.getValue();
|
||||
when(steamOpenIdService.verify(anyMap(), eq(CALLBACK + state))).thenReturn(Optional.of(STEAM_ID));
|
||||
|
||||
// Steam's redirect carries no login cookie here: the binding cookie alone ties it to the user.
|
||||
mockMvc.perform(get("/api/steam-link/callback").param("state", state).param("openid.mode", "id_res").cookie(binding))
|
||||
.andExpect(status().isFound())
|
||||
.andExpect(header().string("Location", "http://localhost:5173/settings?steam=linked"))
|
||||
.andExpect(header().string("Set-Cookie", org.hamcrest.Matchers.containsString("Max-Age=0")));
|
||||
|
||||
assertThat(row(EMAIL)).containsEntry("steam_id", STEAM_ID).containsEntry("steam_id_verified", true);
|
||||
assertThat(row(TYPIST)).containsEntry("steam_id", null).containsEntry("steam_id_verified", false);
|
||||
MockHttpServletResponse refreshed = mockMvc.perform(post("/api/auth/refresh")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(session.getCookie(AuthCookies.REFRESH_COOKIE)))
|
||||
.andExpect(status().isNoContent())
|
||||
.andReturn().getResponse();
|
||||
assertThat(jwtService.parseLoginToken(refreshed.getCookie(AuthCookies.ACCESS_COOKIE).getValue()).orElseThrow().steamId())
|
||||
.isEqualTo(STEAM_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void callback_WithoutTheBindingCookie_FailsAndAsksSteamNothing() throws Exception {
|
||||
String state = startLink(login()).getCookie(AuthCookies.STEAM_LINK_COOKIE).getValue();
|
||||
|
||||
mockMvc.perform(get("/api/steam-link/callback").param("state", state).param("openid.mode", "id_res"))
|
||||
.andExpect(status().isFound())
|
||||
.andExpect(header().string("Location", "http://localhost:5173/settings?steam=failed"))
|
||||
// A forged callback must not clear the cookie of the real link.
|
||||
.andExpect(header().doesNotExist("Set-Cookie"));
|
||||
|
||||
verify(steamOpenIdService, never()).verify(anyMap(), anyString());
|
||||
assertThat(row(EMAIL)).containsEntry("steam_id", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startLink_NeedsALogin() throws Exception {
|
||||
mockMvc.perform(post("/api/users/steam-link").header(ClientHeaderFilter.CLIENT_HEADER, "web"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unlink_ClearsTheIdAndTheNewCookieHasNoSteamId() throws Exception {
|
||||
jdbc.update("UPDATE users SET steam_id = NULL WHERE email = ?", TYPIST);
|
||||
jdbc.update("UPDATE users SET steam_id = ?, steam_id_verified = TRUE WHERE email = ?", STEAM_ID, EMAIL);
|
||||
|
||||
MockHttpServletResponse unlinked = mockMvc.perform(delete("/api/users/steam-link")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(login().getCookie(AuthCookies.ACCESS_COOKIE)))
|
||||
.andExpect(status().isNoContent())
|
||||
.andReturn().getResponse();
|
||||
|
||||
assertThat(row(EMAIL)).containsEntry("steam_id", null).containsEntry("steam_id_verified", false);
|
||||
assertThat(jwtService.parseLoginToken(unlinked.getCookie(AuthCookies.ACCESS_COOKIE).getValue()).orElseThrow().steamId())
|
||||
.isNull();
|
||||
}
|
||||
}
|
||||
+4
-48
@@ -64,7 +64,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
.email("test@example.com")
|
||||
.password("SuperSecret123!")
|
||||
.inviteCode("test-invite-code")
|
||||
.steamId("76561198000000001")
|
||||
.build();
|
||||
|
||||
long expectedUserId = 42L;
|
||||
@@ -87,7 +86,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
.email("test@example.com")
|
||||
.password("SuperSecret123!")
|
||||
.inviteCode("test-invite-code")
|
||||
.steamId("76561198000000001")
|
||||
.build();
|
||||
|
||||
when(registrationService.register(any(Registration.class), any()))
|
||||
@@ -103,60 +101,18 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_SteamIdIsParsedExactly() throws Exception {
|
||||
// Arrange
|
||||
var request = RegistrationRequest.builder()
|
||||
.email("test@example.com")
|
||||
.password("SuperSecret123!")
|
||||
.inviteCode("code")
|
||||
.steamId("76561198000000001")
|
||||
.build();
|
||||
void register_OldClientSendingASteamId_IsAcceptedAndTheIdIgnored() throws Exception {
|
||||
when(registrationService.register(any(Registration.class), any())).thenReturn(42L);
|
||||
|
||||
// Act
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(registrationRequestJson.write(request).getJson()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
// Assert
|
||||
var captor = ArgumentCaptor.forClass(Registration.class);
|
||||
verify(registrationService).register(captor.capture(), any());
|
||||
assertEquals(76561198000000001L, captor.getValue().getSteamId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_InvalidSteamId() throws Exception {
|
||||
// Arrange
|
||||
var request = RegistrationRequest.builder()
|
||||
.email("test@example.com")
|
||||
.password("SuperSecret123!")
|
||||
.inviteCode("code")
|
||||
.steamId("12345")
|
||||
.build();
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(registrationRequestJson.write(request).getJson()))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(registrationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_NumericSteamIdIsRejected() throws Exception {
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","steamId":76561198005679790}"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(registrationService);
|
||||
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","steamId":"76561198000000001"}"""))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_DuplicateEmailRaceIsNotReportedAsSteamIdConflict() throws Exception {
|
||||
void register_DuplicateEmailRaceIsNotReportedAsConflict() throws Exception {
|
||||
// Arrange
|
||||
var request = RegistrationRequest.builder()
|
||||
.email("test@example.com")
|
||||
|
||||
-21
@@ -27,7 +27,6 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
|
||||
.email(email)
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.steamId(111L)
|
||||
.registrationDate(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
entityManager.persist(entity);
|
||||
@@ -38,32 +37,12 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
|
||||
assertFalse(registeredUserRepository.existsByEmail("other@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsBySteamId_Success() {
|
||||
// Arrange
|
||||
var steamId = 222L;
|
||||
var entity = RegisteredUserEntity.builder()
|
||||
.email("steam@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.steamId(steamId)
|
||||
.registrationDate(java.time.LocalDateTime.now())
|
||||
.build();
|
||||
entityManager.persist(entity);
|
||||
entityManager.flush();
|
||||
|
||||
// Act & Assert
|
||||
assertTrue(registeredUserRepository.existsBySteamId(steamId));
|
||||
assertFalse(registeredUserRepository.existsBySteamId(333L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_Success() {
|
||||
// Arrange
|
||||
var registration = Registration.builder()
|
||||
.email("save@example.com")
|
||||
.encryptedPassword("hashed")
|
||||
.steamId(444L)
|
||||
.build();
|
||||
|
||||
// Act
|
||||
|
||||
@@ -3,7 +3,6 @@ package auth_service.modules.registration.service;
|
||||
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
|
||||
import auth_service.modules.registration.exception.InvalidInvitationCodeException;
|
||||
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
|
||||
import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException;
|
||||
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
|
||||
import auth_service.modules.registration.model.Registration;
|
||||
import auth_service.modules.registration.persistence.RegisteredUserRepository;
|
||||
@@ -49,7 +48,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
||||
.email("test@example.com")
|
||||
.rawPassword("SuperSecret123!")
|
||||
.inviteCode("test-invite-code")
|
||||
.steamId(76561198000000001L)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -59,7 +57,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
||||
long expectedUserId = 42L;
|
||||
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
|
||||
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
|
||||
when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(false);
|
||||
when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ");
|
||||
when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId);
|
||||
|
||||
@@ -97,49 +94,16 @@ class RegistrationServiceTest extends AbstractUnitTest {
|
||||
// Act & Assert
|
||||
assertThrows(EmailAlreadyInUseException.class, () -> registrationService.register(validRegistration, IP));
|
||||
|
||||
verify(registeredUserRepository, never()).existsBySteamId(anyLong());
|
||||
verify(registeredUserRepository, never()).save(any());
|
||||
verify(registrationAttemptService, never()).release(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_SteamIdAlreadyRegistered() {
|
||||
// Arrange
|
||||
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
|
||||
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
|
||||
when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(true);
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(SteamIdAlreadyRegisteredException.class, () -> registrationService.register(validRegistration, IP));
|
||||
|
||||
verify(registeredUserRepository, never()).save(any());
|
||||
verify(registrationAttemptService, never()).release(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_WithoutSteamId() {
|
||||
// Arrange
|
||||
validRegistration.setSteamId(null);
|
||||
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
|
||||
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
|
||||
when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ");
|
||||
when(registeredUserRepository.save(any(Registration.class))).thenReturn(42L);
|
||||
|
||||
// Act
|
||||
long result = registrationService.register(validRegistration, IP);
|
||||
|
||||
// Assert
|
||||
assertEquals(42L, result);
|
||||
verify(registeredUserRepository, never()).existsBySteamId(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_DisplayNameTaken() {
|
||||
// Arrange
|
||||
validRegistration.setDisplayName("Lobo");
|
||||
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
|
||||
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
|
||||
when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(false);
|
||||
when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true);
|
||||
|
||||
// Act & Assert
|
||||
|
||||
@@ -4,7 +4,6 @@ import auth_service.configuration.security.JwtAuthenticationFilter;
|
||||
import auth_service.configuration.security.JwtService;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
import auth_service.modules.user.model.Appearance;
|
||||
import auth_service.modules.user.model.User;
|
||||
@@ -33,6 +32,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -89,7 +89,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var settings = UserSettings.builder().language("en").theme("dark").build();
|
||||
var user = User.builder().email(email).steamId(76561198000000001L).settings(settings).build();
|
||||
var user = User.builder().email(email).steamId(76561198000000001L).steamIdVerified(true).settings(settings).build();
|
||||
var principal = new UserPrincipal("1", email, null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
@@ -100,7 +100,8 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
mockMvc.perform(get("/api/users/me")
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.steamId").value("76561198000000001"));
|
||||
.andExpect(jsonPath("$.steamId").value("76561198000000001"))
|
||||
.andExpect(jsonPath("$.steamIdVerified").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -139,130 +140,31 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_Success() throws Exception {
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var principal = new UserPrincipal("1", email, null);
|
||||
void updateUserSettings_OldClientSendingASteamId_SavesTheRestAndIssuesNoCookie() throws Exception {
|
||||
var principal = new UserPrincipal("1", "test@example.com", null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), any(), any())).thenReturn("new-token");
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}"""))
|
||||
.andExpect(status().isNoContent())
|
||||
.andExpect(cookie().value(AuthCookies.ACCESS_COOKIE, "new-token"))
|
||||
.andExpect(cookie().httpOnly(AuthCookies.ACCESS_COOKIE, true));
|
||||
.andExpect(cookie().doesNotExist(AuthCookies.ACCESS_COOKIE));
|
||||
|
||||
verify(userService).updateUserSettings(eq(1L), isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_NoSteamId() throws Exception {
|
||||
void updateUserSettings_OtherConstraintIsNotAConflict() throws Exception {
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var principal = new UserPrincipal("1", email, null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"useDarkTheme":true,"language":"en","steamId":null}"""))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(userService).updateUserSettings(eq(1L), eq(email), isNull(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_InvalidSteamId() throws Exception {
|
||||
// Arrange
|
||||
var principal = new UserPrincipal("1", "test@example.com", null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"useDarkTheme":true,"language":"en","steamId":"7656119800567978x"}"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(userService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_UniqueConstraintRaceIsConflict() throws Exception {
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var principal = new UserPrincipal("1", email, null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), any(), any()))
|
||||
.thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uc_steam_id\""));
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("STEAM_ID_ALREADY_IN_USE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_NumericSteamIdIsRejected() throws Exception {
|
||||
// Arrange
|
||||
var principal = new UserPrincipal("1", "test@example.com", null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"useDarkTheme":true,"language":"en","steamId":76561198005679790}"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(userService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_SteamIdOfOtherUser() throws Exception {
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var principal = new UserPrincipal("1", email, null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), any(), any()))
|
||||
.thenThrow(new SteamIdAlreadyInUseException("taken"));
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("STEAM_ID_ALREADY_IN_USE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_OtherConstraintIsNotSteamIdConflict() throws Exception {
|
||||
// Arrange
|
||||
var email = "test@example.com";
|
||||
var principal = new UserPrincipal("1", email, null);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), isNull(), any(), any()))
|
||||
.thenThrow(new DataIntegrityViolationException("value too long for type character varying(10)"));
|
||||
doThrow(new DataIntegrityViolationException("value too long for type character varying(10)")).when(userService).updateUserSettings(eq(1L), any(), any());
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
@@ -299,7 +201,6 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), isNull(), eq("Lobo Corp_1"), any())).thenReturn("t");
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
@@ -335,8 +236,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), any(), eq("Lobo"), any()))
|
||||
.thenThrow(new DisplayNameAlreadyInUseException("taken"));
|
||||
doThrow(new DisplayNameAlreadyInUseException("taken")).when(userService).updateUserSettings(eq(1L), eq("Lobo"), any());
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
@@ -355,8 +255,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
|
||||
);
|
||||
when(userService.updateUserSettings(eq(1L), eq(email), any(), eq("Lobo"), any()))
|
||||
.thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uq_users_display_name_lower\""));
|
||||
doThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uq_users_display_name_lower\"")).when(userService).updateUserSettings(eq(1L), eq("Lobo"), any());
|
||||
|
||||
// Act & Assert
|
||||
mockMvc.perform(put("/api/users/settings")
|
||||
@@ -380,7 +279,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
.content("{\"useDarkTheme\":true,\"language\":\"en\",\"steamId\":null,\"displayName\":\"Lobo\"" + field + "}"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(userService).updateUserSettings(eq(1L), eq("test@example.com"), isNull(), eq("Lobo"),
|
||||
verify(userService).updateUserSettings(eq(1L), eq("Lobo"),
|
||||
argThat(settings -> settings.isPublicProfile() == expected));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth_service.modules.user.persistence;
|
||||
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
import auth_service.modules.user.model.Appearance;
|
||||
import auth_service.modules.user.model.SteamIdHolder;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
import auth_service.modules.user.persistence.entities.UserEntity;
|
||||
import auth_service.shared.test.AbstractPersistenceTest;
|
||||
@@ -11,6 +12,7 @@ import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@@ -65,13 +67,12 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
.build();
|
||||
|
||||
// Act
|
||||
var updated = userRepository.updateSettings(userEntity.getId(), 654321L, null, settings);
|
||||
userRepository.updateSettings(userEntity.getId(), null, settings);
|
||||
|
||||
// Assert
|
||||
assertTrue(updated);
|
||||
// Assert: the Steam ID changes only through the Steam link
|
||||
var updatedUser = entityManager.find(UserEntity.class, userEntity.getId());
|
||||
assertNotNull(updatedUser);
|
||||
assertEquals(654321L, updatedUser.getSteamId());
|
||||
assertEquals(123456L, updatedUser.getSteamId());
|
||||
assertNotNull(updatedUser.getSettings());
|
||||
assertEquals("de", updatedUser.getSettings().getLanguage());
|
||||
}
|
||||
@@ -96,55 +97,54 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
assertNull(result.get().getSteamId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateSettings_ClearsSteamId() {
|
||||
// Arrange
|
||||
var userEntity = entityManager.persist(UserEntity.builder()
|
||||
.email("clear@example.com")
|
||||
private UserEntity user(String email, Long steamId, boolean verified) {
|
||||
UserEntity user = entityManager.persist(UserEntity.builder()
|
||||
.email(email)
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.steamId(123456L)
|
||||
.steamId(steamId)
|
||||
.steamIdVerified(verified)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
entityManager.flush();
|
||||
var settings = UserSettings.builder().language("en").theme("light").build();
|
||||
|
||||
// Act
|
||||
userRepository.updateSettings(userEntity.getId(), null, null, settings);
|
||||
|
||||
// Assert
|
||||
assertNull(entityManager.find(UserEntity.class, userEntity.getId()).getSteamId());
|
||||
return user;
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSteamIdUsedByOtherUser() {
|
||||
// Arrange
|
||||
var owner = entityManager.persist(UserEntity.builder()
|
||||
.email("owner@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.steamId(76561198000000001L)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
var other = entityManager.persist(UserEntity.builder()
|
||||
.email("other@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
entityManager.flush();
|
||||
void findSteamIdHolder_ReturnsTheAccountAndWhetherItIsVerified() {
|
||||
UserEntity owner = user("owner@example.com", 76561198000000001L, true);
|
||||
|
||||
// Act & Assert
|
||||
assertFalse(userRepository.isSteamIdUsedByOtherUser(owner.getId(), 76561198000000001L));
|
||||
assertTrue(userRepository.isSteamIdUsedByOtherUser(other.getId(), 76561198000000001L));
|
||||
assertFalse(userRepository.isSteamIdUsedByOtherUser(other.getId(), 76561198000000000L));
|
||||
assertEquals(Optional.of(new SteamIdHolder(owner.getId(), true)), userRepository.findSteamIdHolder(76561198000000001L));
|
||||
assertTrue(userRepository.findSteamIdHolder(76561198000000000L).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearThenSet_MovesTheIdWithoutBreakingTheUniqueConstraint() {
|
||||
UserEntity typist = user("typist@example.com", 76561198000000001L, false);
|
||||
UserEntity owner = user("owner@example.com", null, false);
|
||||
|
||||
userRepository.clearSteamId(typist.getId());
|
||||
userRepository.setVerifiedSteamId(owner.getId(), 76561198000000001L);
|
||||
entityManager.clear();
|
||||
|
||||
UserEntity cleared = entityManager.find(UserEntity.class, typist.getId());
|
||||
assertNull(cleared.getSteamId());
|
||||
assertFalse(cleared.isSteamIdVerified());
|
||||
UserEntity linked = entityManager.find(UserEntity.class, owner.getId());
|
||||
assertEquals(76561198000000001L, linked.getSteamId());
|
||||
assertTrue(linked.isSteamIdVerified());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setVerifiedSteamId_UnknownUser() {
|
||||
assertThrows(UserNotFoundException.class, () -> userRepository.setVerifiedSteamId(999_999L, 76561198000000001L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateSettings_UnknownUser() {
|
||||
var settings = UserSettings.builder().language("en").theme("light").build();
|
||||
|
||||
assertThrows(UserNotFoundException.class, () -> userRepository.updateSettings(-1L, null, null, settings));
|
||||
assertThrows(UserNotFoundException.class, () -> userRepository.updateSettings(-1L, null, settings));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -172,7 +172,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
var settings = UserSettings.builder().language("en").theme("light").build();
|
||||
|
||||
// Act
|
||||
userRepository.updateSettings(owner.getId(), null, "Lobo", settings);
|
||||
userRepository.updateSettings(owner.getId(), "Lobo", settings);
|
||||
entityManager.flush();
|
||||
|
||||
// Assert
|
||||
@@ -192,7 +192,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
userRepository.updateSettings(userEntity.getId(), null, "Lobo",
|
||||
userRepository.updateSettings(userEntity.getId(), "Lobo",
|
||||
UserSettings.builder().language("de").theme("dark").publicProfile(true).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
@@ -230,7 +230,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
userRepository.updateAppearance(userEntity.getId(), new Appearance("pink", "sharp", false, 220L, null, true));
|
||||
entityManager.flush();
|
||||
|
||||
userRepository.updateSettings(userEntity.getId(), null, null,
|
||||
userRepository.updateSettings(userEntity.getId(), null,
|
||||
UserSettings.builder().language("de").theme("dark").build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package auth_service.modules.user.service;
|
||||
|
||||
import auth_service.modules.user.model.SteamLinkOutcome;
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class SteamLinkServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final String PUBLIC_URL = "https://auth.example";
|
||||
private static final Map<String, String> ANSWER = Map.of("openid.mode", "id_res");
|
||||
|
||||
@Mock
|
||||
private SteamOpenIdService steamOpenIdService;
|
||||
@Mock
|
||||
private UserService userService;
|
||||
|
||||
private SteamLinkService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new SteamLinkService(steamOpenIdService, userService, PUBLIC_URL, "https://tbp.example");
|
||||
}
|
||||
|
||||
private String start() {
|
||||
when(steamOpenIdService.loginUrl(anyString(), eq(PUBLIC_URL))).thenReturn("https://steamcommunity.com/openid/login?x");
|
||||
return service.start(7L).state();
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_ReturnsSteamsUrlWithOurCallbackAndAFreshState() {
|
||||
String first = start();
|
||||
String second = service.start(7L).state();
|
||||
|
||||
assertThat(first).hasSizeGreaterThanOrEqualTo(43).isNotEqualTo(second);
|
||||
verify(steamOpenIdService).loginUrl(PUBLIC_URL + "/api/steam-link/callback?state=" + first, PUBLIC_URL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_ProvedId_IsLinkedToTheUserWhoStarted() {
|
||||
String state = start();
|
||||
when(steamOpenIdService.verify(ANSWER, PUBLIC_URL + "/api/steam-link/callback?state=" + state))
|
||||
.thenReturn(Optional.of(76561198000000001L));
|
||||
when(userService.linkVerifiedSteamId(7L, 76561198000000001L)).thenReturn(SteamLinkOutcome.LINKED);
|
||||
|
||||
assertThat(service.complete(state, state, ANSWER)).isEqualTo(SteamLinkOutcome.LINKED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_StateIsOneTime() {
|
||||
String state = start();
|
||||
when(steamOpenIdService.verify(anyMap(), anyString())).thenReturn(Optional.of(76561198000000001L));
|
||||
when(userService.linkVerifiedSteamId(7L, 76561198000000001L)).thenReturn(SteamLinkOutcome.LINKED);
|
||||
service.complete(state, state, ANSWER);
|
||||
|
||||
assertThat(service.complete(state, state, ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_CookieOfAnotherBrowser_FailsWithoutAskingSteam() {
|
||||
String state = start();
|
||||
|
||||
assertThat(service.complete(state, "other", ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
assertThat(service.complete(state, null, ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
verify(steamOpenIdService, never()).verify(anyMap(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_UnknownState_FailsWithoutAskingSteam() {
|
||||
assertThat(service.complete("made-up", "made-up", ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
verify(steamOpenIdService, never()).verify(anyMap(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_CookieMismatch_KeepsTheStateForTheRightBrowser() {
|
||||
String state = start();
|
||||
service.complete(state, "attacker", ANSWER);
|
||||
when(steamOpenIdService.verify(anyMap(), anyString())).thenReturn(Optional.of(76561198000000001L));
|
||||
when(userService.linkVerifiedSteamId(7L, 76561198000000001L)).thenReturn(SteamLinkOutcome.LINKED);
|
||||
|
||||
assertThat(service.complete(state, state, ANSWER)).isEqualTo(SteamLinkOutcome.LINKED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_UserCancelledOnSteam() {
|
||||
assertThat(service.complete(null, null, Map.of("openid.mode", "cancel"))).isEqualTo(SteamLinkOutcome.CANCELLED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_SteamDoesNotConfirm_Fails() {
|
||||
String state = start();
|
||||
when(steamOpenIdService.verify(anyMap(), anyString())).thenReturn(Optional.empty());
|
||||
|
||||
assertThat(service.complete(state, state, ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
verify(userService, never()).linkVerifiedSteamId(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_SameIdProvedByAnotherAccountAtOnce_IsInUse() {
|
||||
String state = start();
|
||||
when(steamOpenIdService.verify(anyMap(), anyString())).thenReturn(Optional.of(76561198000000001L));
|
||||
when(userService.linkVerifiedSteamId(any(Long.class), any(Long.class)))
|
||||
.thenThrow(new DataIntegrityViolationException("uc_steam_id"));
|
||||
|
||||
assertThat(service.complete(state, state, ANSWER)).isEqualTo(SteamLinkOutcome.IN_USE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void complete_StoreFails_IsFailedNotAnException() {
|
||||
String state = start();
|
||||
when(steamOpenIdService.verify(anyMap(), anyString())).thenReturn(Optional.of(76561198000000001L));
|
||||
when(userService.linkVerifiedSteamId(7L, 76561198000000001L))
|
||||
.thenThrow(new org.springframework.dao.DataAccessResourceFailureException("connection lost"));
|
||||
|
||||
assertThat(service.complete(state, state, ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void start_Again_ReplacesTheUsersEarlierState() {
|
||||
String first = start();
|
||||
String second = service.start(7L).state();
|
||||
when(steamOpenIdService.verify(anyMap(), anyString())).thenReturn(Optional.of(76561198000000001L));
|
||||
when(userService.linkVerifiedSteamId(7L, 76561198000000001L)).thenReturn(SteamLinkOutcome.LINKED);
|
||||
|
||||
assertThat(service.complete(first, first, ANSWER)).isEqualTo(SteamLinkOutcome.FAILED);
|
||||
assertThat(service.complete(second, second, ANSWER)).isEqualTo(SteamLinkOutcome.LINKED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void urlsWithATrailingSlash_GiveNoDoubleSlash() {
|
||||
service = new SteamLinkService(steamOpenIdService, userService, PUBLIC_URL + "/", "https://tbp.example/");
|
||||
String state = start();
|
||||
|
||||
verify(steamOpenIdService).loginUrl(PUBLIC_URL + "/api/steam-link/callback?state=" + state, PUBLIC_URL);
|
||||
assertThat(service.frontendRedirect(SteamLinkOutcome.LINKED)).isEqualTo("https://tbp.example/settings?steam=linked");
|
||||
}
|
||||
|
||||
@Test
|
||||
void frontendRedirect_GoesToTheSettingsWithTheOutcome() {
|
||||
assertThat(service.frontendRedirect(SteamLinkOutcome.IN_USE)).isEqualTo("https://tbp.example/settings?steam=in-use");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package auth_service.modules.user.service;
|
||||
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
class SteamOpenIdServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final String RETURN_TO = "https://auth.example/api/steam-link/callback?state=abc";
|
||||
private static final String CLAIMED = "https://steamcommunity.com/openid/id/76561198000000001";
|
||||
|
||||
private MockRestServiceServer steam;
|
||||
private SteamOpenIdService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
steam = MockRestServiceServer.bindTo(builder).build();
|
||||
service = new SteamOpenIdService(builder.build());
|
||||
}
|
||||
|
||||
private static Map<String, String> answer() {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("state", "abc");
|
||||
params.put("openid.ns", "http://specs.openid.net/auth/2.0");
|
||||
params.put("openid.mode", "id_res");
|
||||
params.put("openid.op_endpoint", "https://steamcommunity.com/openid/login");
|
||||
params.put("openid.claimed_id", CLAIMED);
|
||||
params.put("openid.identity", CLAIMED);
|
||||
params.put("openid.return_to", RETURN_TO);
|
||||
params.put("openid.response_nonce", "2026-09-26T10:00:00Zabc");
|
||||
params.put("openid.assoc_handle", "1234567890");
|
||||
params.put("openid.signed", "signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle");
|
||||
params.put("openid.sig", "c2lnbmF0dXJl");
|
||||
return params;
|
||||
}
|
||||
|
||||
private void steamAnswers(String body) {
|
||||
steam.expect(requestTo("https://steamcommunity.com/openid/login"))
|
||||
.andExpect(method(HttpMethod.POST))
|
||||
.andExpect(content().string(containsString("openid.mode=check_authentication")))
|
||||
.andExpect(content().string(containsString("openid.sig=c2lnbmF0dXJl")))
|
||||
.andRespond(withSuccess(body, MediaType.TEXT_PLAIN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginUrl_AsksSteamToPickTheAccountAndComeBackToUs() {
|
||||
String url = service.loginUrl(RETURN_TO, "https://auth.example");
|
||||
|
||||
Map<String, String> query = UriComponentsBuilder.fromUriString(url).build().getQueryParams().toSingleValueMap();
|
||||
assertThat(url).startsWith("https://steamcommunity.com/openid/login?");
|
||||
assertThat(query).containsEntry("openid.mode", "checkid_setup")
|
||||
.containsEntry("openid.claimed_id", "http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select");
|
||||
assertThat(URLDecoder.decode(query.get("openid.return_to"), StandardCharsets.UTF_8)).isEqualTo(RETURN_TO);
|
||||
assertThat(URLDecoder.decode(query.get("openid.realm"), StandardCharsets.UTF_8)).isEqualTo("https://auth.example");
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_SteamConfirms_ReturnsTheSteamId() {
|
||||
steamAnswers("ns:http://specs.openid.net/auth/2.0\nis_valid:true\n");
|
||||
|
||||
assertThat(service.verify(answer(), RETURN_TO)).isEqualTo(Optional.of(76561198000000001L));
|
||||
steam.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_SteamSaysInvalid_IsEmpty() {
|
||||
steamAnswers("ns:http://specs.openid.net/auth/2.0\nis_valid:false\n");
|
||||
|
||||
assertThat(service.verify(answer(), RETURN_TO)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_SteamError_IsEmpty() {
|
||||
steam.expect(requestTo("https://steamcommunity.com/openid/login")).andRespond(withServerError());
|
||||
|
||||
assertThat(service.verify(answer(), RETURN_TO)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void verify_AnswerNotForUs_IsEmptyWithoutAskingSteam() {
|
||||
Map<String, Map<String, String>> wrong = new HashMap<>();
|
||||
wrong.put("other state", with("openid.return_to", "https://auth.example/api/steam-link/callback?state=xyz"));
|
||||
wrong.put("other endpoint", with("openid.op_endpoint", "https://evil.example/openid/login"));
|
||||
wrong.put("claimed differs", with("openid.identity", "https://steamcommunity.com/openid/id/76561198000000002"));
|
||||
wrong.put("http claimed", withClaimedId("http://steamcommunity.com/openid/id/76561198000000001"));
|
||||
wrong.put("extra path", withClaimedId(CLAIMED + "/x"));
|
||||
wrong.put("unsigned claimed_id", with("openid.signed", "signed,op_endpoint,identity,return_to,response_nonce"));
|
||||
wrong.put("mode", with("openid.mode", "setup_needed"));
|
||||
|
||||
wrong.forEach((reason, params) -> assertThat(service.verify(params, RETURN_TO)).as(reason).isEmpty());
|
||||
steam.verify();
|
||||
}
|
||||
|
||||
private static Map<String, String> with(String key, String value) {
|
||||
Map<String, String> params = answer();
|
||||
params.put(key, value);
|
||||
return params;
|
||||
}
|
||||
|
||||
private static Map<String, String> withClaimedId(String claimedId) {
|
||||
Map<String, String> params = answer();
|
||||
params.put("openid.claimed_id", claimedId);
|
||||
params.put("openid.identity", claimedId);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
package auth_service.modules.user.service;
|
||||
|
||||
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
import auth_service.modules.user.model.Appearance;
|
||||
import auth_service.modules.user.model.SteamIdHolder;
|
||||
import auth_service.modules.user.model.SteamLinkOutcome;
|
||||
import auth_service.modules.user.model.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
import auth_service.modules.user.persistence.UserRepository;
|
||||
import auth_service.shared.security.TokenGenerator;
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
@@ -60,52 +62,15 @@ class UserServiceTest extends AbstractUnitTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_ReturnsTokenWithNewSteamId() {
|
||||
// Arrange
|
||||
long userId = 1L;
|
||||
var email = "test@example.com";
|
||||
Long steamId = 76561198000000001L;
|
||||
void updateUserSettings_NeverTouchesTheSteamIdAndIssuesNoToken() {
|
||||
var userSettings = UserSettings.builder().language("en").build();
|
||||
when(userRepository.isSteamIdUsedByOtherUser(userId, steamId)).thenReturn(false);
|
||||
when(tokenGenerator.generateToken("1", email, steamId)).thenReturn("new-token");
|
||||
|
||||
// Act
|
||||
var token = userService.updateUserSettings(userId, email, steamId, null, userSettings);
|
||||
userService.updateUserSettings(1L, null, userSettings);
|
||||
|
||||
// Assert
|
||||
assertEquals("new-token", token);
|
||||
verify(userRepository).updateSettings(userId, steamId, null, userSettings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_WithoutSteamId() {
|
||||
// Arrange
|
||||
long userId = 1L;
|
||||
var email = "test@example.com";
|
||||
var userSettings = UserSettings.builder().language("en").build();
|
||||
when(tokenGenerator.generateToken("1", email, null)).thenReturn("new-token");
|
||||
|
||||
// Act
|
||||
var token = userService.updateUserSettings(userId, email, null, null, userSettings);
|
||||
|
||||
// Assert
|
||||
assertEquals("new-token", token);
|
||||
verify(userRepository).updateSettings(userId, null, null, userSettings);
|
||||
verify(userRepository, never()).isSteamIdUsedByOtherUser(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserSettings_SteamIdOfOtherUser() {
|
||||
// Arrange
|
||||
long userId = 1L;
|
||||
Long steamId = 76561198000000001L;
|
||||
var userSettings = UserSettings.builder().language("en").build();
|
||||
when(userRepository.isSteamIdUsedByOtherUser(userId, steamId)).thenReturn(true);
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(SteamIdAlreadyInUseException.class,
|
||||
() -> userService.updateUserSettings(userId, "test@example.com", steamId, null, userSettings));
|
||||
verify(userRepository, never()).updateSettings(anyLong(), any(), any(), any());
|
||||
verify(userRepository).updateSettings(1L, null, userSettings);
|
||||
verify(userRepository, never()).clearSteamId(anyLong());
|
||||
verify(userRepository, never()).setVerifiedSteamId(anyLong(), anyLong());
|
||||
verifyNoInteractions(tokenGenerator);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -116,8 +81,8 @@ class UserServiceTest extends AbstractUnitTest {
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(DisplayNameAlreadyInUseException.class,
|
||||
() -> userService.updateUserSettings(1L, "test@example.com", null, "Lobo", userSettings));
|
||||
verify(userRepository, never()).updateSettings(anyLong(), any(), any(), any());
|
||||
() -> userService.updateUserSettings(1L, "Lobo", userSettings));
|
||||
verify(userRepository, never()).updateSettings(anyLong(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -127,10 +92,61 @@ class UserServiceTest extends AbstractUnitTest {
|
||||
when(userRepository.isDisplayNameUsedByOtherUser(1L, "Lobo")).thenReturn(false);
|
||||
|
||||
// Act
|
||||
userService.updateUserSettings(1L, "test@example.com", null, "Lobo", userSettings);
|
||||
userService.updateUserSettings(1L, "Lobo", userSettings);
|
||||
|
||||
// Assert
|
||||
verify(userRepository).updateSettings(1L, null, "Lobo", userSettings);
|
||||
verify(userRepository).updateSettings(1L, "Lobo", userSettings);
|
||||
}
|
||||
|
||||
private static final long STEAM_ID = 76561198000000001L;
|
||||
|
||||
@Test
|
||||
void linkVerifiedSteamId_FreeId_IsStoredVerified() {
|
||||
when(userRepository.findSteamIdHolder(STEAM_ID)).thenReturn(Optional.empty());
|
||||
|
||||
assertEquals(SteamLinkOutcome.LINKED, userService.linkVerifiedSteamId(1L, STEAM_ID));
|
||||
|
||||
verify(userRepository).setVerifiedSteamId(1L, STEAM_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkVerifiedSteamId_OwnTypedId_BecomesVerified() {
|
||||
when(userRepository.findSteamIdHolder(STEAM_ID)).thenReturn(Optional.of(new SteamIdHolder(1L, false)));
|
||||
|
||||
assertEquals(SteamLinkOutcome.LINKED, userService.linkVerifiedSteamId(1L, STEAM_ID));
|
||||
|
||||
verify(userRepository, never()).clearSteamId(anyLong());
|
||||
verify(userRepository).setVerifiedSteamId(1L, STEAM_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkVerifiedSteamId_TypedInByAnotherAccount_IsTakenFromIt() {
|
||||
when(userRepository.findSteamIdHolder(STEAM_ID)).thenReturn(Optional.of(new SteamIdHolder(2L, false)));
|
||||
|
||||
assertEquals(SteamLinkOutcome.LINKED, userService.linkVerifiedSteamId(1L, STEAM_ID));
|
||||
|
||||
InOrder order = inOrder(userRepository);
|
||||
order.verify(userRepository).clearSteamId(2L);
|
||||
order.verify(userRepository).setVerifiedSteamId(1L, STEAM_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkVerifiedSteamId_ProvedByAnotherAccount_IsInUse() {
|
||||
when(userRepository.findSteamIdHolder(STEAM_ID)).thenReturn(Optional.of(new SteamIdHolder(2L, true)));
|
||||
|
||||
assertEquals(SteamLinkOutcome.IN_USE, userService.linkVerifiedSteamId(1L, STEAM_ID));
|
||||
|
||||
verify(userRepository, never()).clearSteamId(anyLong());
|
||||
verify(userRepository, never()).setVerifiedSteamId(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unlinkSteamId_ClearsItAndReturnsATokenWithoutIt() {
|
||||
when(tokenGenerator.generateToken("1", "test@example.com", null)).thenReturn("token-without-steam");
|
||||
|
||||
assertEquals("token-without-steam", userService.unlinkSteamId(1L, "test@example.com"));
|
||||
|
||||
verify(userRepository).clearSteamId(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user