Compare commits

..
2 Commits
Author SHA1 Message Date
lobo 4b2acef381 feat(steam): verify the Steam ID with Sign in through Steam
Build, Test & Push Auth Service / docker (push) Successful in 1m31s
Build, Test & Push Auth Service / quality-check (push) Successful in 34s
The Steam ID is no longer typed in at registration or in the settings.
A logged-in user links it through Steam OpenID 2.0: Auth checks the
answer with Steam (check_authentication) and stores the ID as verified
(V11 steam_id_verified). A one-time state plus a binding cookie tie the
callback to the browser that started the link, so a foreign Steam login
link cannot move someone's Steam ID into another account.

Typed-in IDs from before stay unverified; a verified link from another
account takes them away. DELETE /api/users/steam-link unlinks.
2026-09-26 11:20:04 +02:00
lobo e3433799f1 docs(readme): add the architecture overview 2026-09-26 07:37:44 +02:00
44 changed files with 1110 additions and 452 deletions

No files matched your search

+65 -5
View File
@@ -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
@@ -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();
}
@@ -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) {
}
@@ -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;
}
@@ -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);
@@ -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)";
@@ -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() {
}
}
+4
View File
@@ -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();
}
}
@@ -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")
@@ -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