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. 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 - 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. 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 - 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. 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, - 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/refresh` | refresh cookie, replaces both cookies |
| `POST` | `/api/auth/logout` | refresh cookie, clears both cookies | | `POST` | `/api/auth/logout` | refresh cookie, clears both cookies |
| `GET` | `/api/users/me` | access token | | `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 | | `PUT` | `/api/users/settings/appearance` | access token |
| `GET` | `/api/public/profiles/{displayName}` | public | | `GET` | `/api/public/profiles/{displayName}` | public |
| `GET` | `/api/stats/public` | public | | `GET` | `/api/stats/public` | public |
@@ -66,12 +74,62 @@ layers:
| Layer | Package | Rule | | Layer | Package | Rule |
|---|---|---| |---|---|---|
| API | `api` | REST controllers, request/response records, `ApiMapper`. Calls services, never repositories. | | 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. | | 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 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`, violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
`AbstractIntegrationTest`) live in `auth_service.shared.test`; all integration tests share one PostgreSQL container. 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 ## Run locally
@@ -97,6 +155,8 @@ Configuration comes from environment variables:
| `JWT_KID` | Key id written into the token header | | `JWT_KID` | Key id written into the token header |
| `AUTH_INVITATION_CODE` | Code required for registration | | `AUTH_INVITATION_CODE` | Code required for registration |
| `DB_PASSWORD` | Local PostgreSQL password | | `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: Generate a keypair:
@@ -41,6 +41,7 @@ public class SecurityConfig {
"/api/registration/**", // Allow new user registration "/api/registration/**", // Allow new user registration
"/api/stats/public", // Public landing-page counters (cached, no PII) "/api/stats/public", // Public landing-page counters (cached, no PII)
"/api/public/**", // Opt-in public profiles, see PublicProfileController "/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 "/actuator/health", // Docker health check; details are off
"/.well-known/jwks.json", // Public verify keys for Core "/.well-known/jwks.json", // Public verify keys for Core
"/error" // Default Spring Boot error page routing "/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.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.InvalidInvitationCodeException; import auth_service.modules.registration.exception.InvalidInvitationCodeException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; 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.exception.TooManyRegistrationAttemptsException;
import lombok.NonNull; import lombok.NonNull;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -53,11 +52,6 @@ public class RegistrationExceptionController {
return response(body, HttpStatus.BAD_REQUEST); 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()). rawPassword(registrationRequest.password()).
inviteCode(registrationRequest.inviteCode()). inviteCode(registrationRequest.inviteCode()).
displayName(registrationRequest.displayName()). displayName(registrationRequest.displayName()).
steamId(registrationRequest.steamId() == null ? null : Long.valueOf(registrationRequest.steamId())).
build(); build();
} }
@@ -1,15 +1,13 @@
package auth_service.modules.registration.api.dto.request; package auth_service.modules.registration.api.dto.request;
import auth_service.shared.api.validation.DisplayNameFormat; 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 jakarta.validation.constraints.Pattern;
import lombok.Builder; 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 @Builder
public record RegistrationRequest(String email, String password, String inviteCode, 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) { @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 rawPassword;
private String encryptedPassword; private String encryptedPassword;
private String inviteCode; private String inviteCode;
private Long steamId;
private String displayName; private String displayName;
} }
@@ -5,8 +5,6 @@ import auth_service.modules.registration.model.Registration;
public interface RegisteredUserRepository { public interface RegisteredUserRepository {
boolean existsByEmail(String email); boolean existsByEmail(String email);
boolean existsBySteamId(long steamId);
boolean existsByDisplayName(String displayName); boolean existsByDisplayName(String displayName);
long save(Registration registration); long save(Registration registration);
@@ -24,16 +24,6 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
return count != null && count > 0; 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 @Override
public boolean existsByDisplayName(String displayName) { public boolean existsByDisplayName(String displayName) {
var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE LOWER(u.displayName) = LOWER(: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) @Column(nullable = false)
private boolean enabled; private boolean enabled;
@Column(name = "steam_id", unique = true)
private Long steamId;
@Column(name = "display_name", length = 24) @Column(name = "display_name", length = 24)
private String displayName; private String displayName;
@@ -17,7 +17,6 @@ public abstract class EntityMapper {
registrationDate(LocalDateTime.now(Clock.systemUTC())). registrationDate(LocalDateTime.now(Clock.systemUTC())).
password(userModel.getEncryptedPassword()). password(userModel.getEncryptedPassword()).
enabled(true). enabled(true).
steamId(userModel.getSteamId()).
displayName(userModel.getDisplayName()). displayName(userModel.getDisplayName()).
lastLoginDate(null). lastLoginDate(null).
build(); build();
@@ -3,7 +3,6 @@ package auth_service.modules.registration.service;
import auth_service.modules.registration.exception.EmailAlreadyInUseException; import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.InvalidInvitationCodeException; import auth_service.modules.registration.exception.InvalidInvitationCodeException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; 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.model.Registration;
import auth_service.modules.registration.persistence.RegisteredUserRepository; import auth_service.modules.registration.persistence.RegisteredUserRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -39,10 +38,6 @@ public class RegistrationService {
throw new EmailAlreadyInUseException("The provided email address is already in use."); 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())) { if (registration.getDisplayName() != null && registeredUserRepository.existsByDisplayName(registration.getDisplayName())) {
throw new DisplayNameAlreadyRegisteredException("The display name is already in use."); 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; package auth_service.modules.user.api;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.api.dto.request.AppearanceRequest; import auth_service.modules.user.api.dto.request.AppearanceRequest;
import auth_service.modules.user.api.dto.request.UserSettingsRequest; 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 io.micrometer.core.annotation.Timed;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -24,7 +22,6 @@ import org.springframework.web.bind.annotation.*;
public class UserController { public class UserController {
private final UserService userService; private final UserService userService;
private final AuthCookies authCookies;
/** /**
* Version 1 of the 'me' endpoint, accessed via /api/users/me?v=1 * 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") @Timed(value = "user.me.v1.requests.time", description = "Time taken to process v1 user profile requests")
public ResponseEntity<Void> updateUserSettings(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<Void> updateUserSettings(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody UserSettingsRequest request) { @Valid @RequestBody UserSettingsRequest request) {
var token = this.userService.updateUserSettings(principal.getIdAsLong(), principal.email(), this.userService.updateUserSettings(principal.getIdAsLong(), request.displayName(), ApiMapper.toDomain(request));
ApiMapper.toSteamId(request), request.displayName(), ApiMapper.toDomain(request)); return ResponseEntity.noContent().build();
// 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();
} }
@PutMapping(value = "/settings/appearance", version = "1.0") @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.shared.api.dto.response.ErrorResponse;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; 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.exception.UserNotFoundException;
import lombok.NonNull; import lombok.NonNull;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -17,7 +16,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
@Order(value = 1) @Order(value = 1)
@Slf4j @Slf4j
public class UserExceptionController { 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 final String DISPLAY_NAME_CONSTRAINT = "uq_users_display_name_lower";
private static @NonNull ResponseEntity<ErrorResponse> response(ErrorResponse body, HttpStatus status) { private static @NonNull ResponseEntity<ErrorResponse> response(ErrorResponse body, HttpStatus status) {
@@ -39,18 +37,10 @@ public class UserExceptionController {
return response(body, HttpStatus.NOT_FOUND); return response(body, HttpStatus.NOT_FOUND);
} }
@ExceptionHandler(SteamIdAlreadyInUseException.class) // A parallel request can save the same display name after the service check. Then the DB constraint fails.
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.
@ExceptionHandler(DataIntegrityViolationException.class) @ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> dataIntegrityError(DataIntegrityViolationException ex) { public ResponseEntity<ErrorResponse> dataIntegrityError(DataIntegrityViolationException ex) {
var cause = ex.getMostSpecificCause().getMessage(); var cause = ex.getMostSpecificCause().getMessage();
if (cause != null && cause.contains(STEAM_ID_CONSTRAINT)) {
return steamIdInUse();
}
if (cause != null && cause.contains(DISPLAY_NAME_CONSTRAINT)) { if (cause != null && cause.contains(DISPLAY_NAME_CONSTRAINT)) {
return displayNameInUse(); return displayNameInUse();
} }
@@ -68,9 +58,4 @@ public class UserExceptionController {
return response(body, HttpStatus.CONFLICT); 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; package auth_service.modules.user.api.dto.request;
import auth_service.shared.api.validation.DisplayNameFormat; 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 com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Pattern; 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 displayName null removes the display name
* @param publicProfile missing counts as false, so an old client can only make a profile private * @param publicProfile missing counts as false, so an old client can only make a profile private
*/ */
public record UserSettingsRequest(boolean useDarkTheme, String language, public record UserSettingsRequest(boolean useDarkTheme, String language,
@Pattern(regexp = SteamIdFormat.PATTERN)
@JsonDeserialize(using = SteamIdDeserializer.class) String steamId,
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName, @Pattern(regexp = DisplayNameFormat.PATTERN) String displayName,
Boolean publicProfile) { 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; import java.time.LocalDateTime;
@Builder @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()) .createdDate(user.getCreatedDate())
.lastLogin(user.getLastLogin()) .lastLogin(user.getLastLogin())
.steamId(user.getSteamId() == null ? null : String.valueOf(user.getSteamId())) .steamId(user.getSteamId() == null ? null : String.valueOf(user.getSteamId()))
.steamIdVerified(user.isSteamIdVerified())
.displayName(user.getDisplayName()) .displayName(user.getDisplayName())
.role("USER") .role("USER")
.settings(SettingsResponse.builder() .settings(SettingsResponse.builder()
@@ -44,10 +45,6 @@ public abstract class ApiMapper {
request.backgroundAppid(), request.bannerAppid(), !Boolean.FALSE.equals(request.showBanner())); 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) { public static UserSettings toDomain(UserSettingsRequest request) {
return UserSettings.builder() return UserSettings.builder()
.theme(request.useDarkTheme() ? "dark" : "light") .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 lastLogin;
private LocalDateTime createdDate; private LocalDateTime createdDate;
private Long steamId; private Long steamId;
private boolean steamIdVerified;
private String displayName; private String displayName;
private UserSettings settings; 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.Appearance;
import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
@@ -25,12 +26,21 @@ public interface UserRepository {
UserSettings findSettingsByUserId(String userId); UserSettings findSettingsByUserId(String userId);
@Transactional @Transactional
boolean updateSettings(long userId, Long steamId, String displayName, UserSettings settings); void updateSettings(long userId, String displayName, UserSettings settings);
@Transactional @Transactional
void updateAppearance(long userId, Appearance appearance); 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); 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.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance; 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.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.modules.user.persistence.entities.UserEntity;
@@ -14,7 +15,6 @@ import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -63,7 +63,7 @@ public class UserRepositoryImpl implements UserRepository {
@Transactional @Transactional
@Override @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); var userEntity = entityManager.find(UserEntity.class, userId);
if (userEntity == null) { if (userEntity == null) {
throw new UserNotFoundException("Could not find user with id " + userId); throw new UserNotFoundException("Could not find user with id " + userId);
@@ -80,11 +80,7 @@ public class UserRepositoryImpl implements UserRepository {
settingsEntity.setTheme(settings.getTheme()); settingsEntity.setTheme(settings.getTheme());
settingsEntity.setPublicProfile(settings.isPublicProfile()); settingsEntity.setPublicProfile(settings.isPublicProfile());
boolean steamIdChanged = !Objects.equals(userEntity.getSteamId(), steamId);
userEntity.setSteamId(steamId);
userEntity.setDisplayName(displayName); userEntity.setDisplayName(displayName);
return steamIdChanged;
} }
@Transactional @Transactional
@@ -114,13 +110,38 @@ public class UserRepositoryImpl implements UserRepository {
} }
@Override @Override
public boolean isSteamIdUsedByOtherUser(long userId, long steamId) { public Optional<SteamIdHolder> findSteamIdHolder(long steamId) {
var count = entityManager.createQuery( // A projection, not the entity: the bulk updates that follow would leave a loaded entity stale.
"SELECT COUNT(u) FROM UserEntity u WHERE u.steamId = :steamId AND u.id <> :userId", Long.class) 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("steamId", steamId)
.setParameter("userId", userId) .setParameter("userId", userId)
.getSingleResult(); .executeUpdate();
return count > 0; if (updated == 0) {
throw new UserNotFoundException("Could not find user with id " + userId);
}
} }
@Override @Override
@@ -33,6 +33,9 @@ public class UserEntity {
@Column(name = "steam_id", unique = true) @Column(name = "steam_id", unique = true)
private Long steamId; private Long steamId;
@Column(name = "steam_id_verified", nullable = false)
private boolean steamIdVerified;
@Column(name = "display_name", length = 24) @Column(name = "display_name", length = 24)
private String displayName; private String displayName;
@@ -44,6 +44,7 @@ public abstract class EntityMapper {
.lastLogin(entity.getLastLoginDate()) .lastLogin(entity.getLastLoginDate())
.createdDate(entity.getRegistrationDate()) .createdDate(entity.getRegistrationDate())
.steamId(entity.getSteamId()) .steamId(entity.getSteamId())
.steamIdVerified(entity.isSteamIdVerified())
.displayName(entity.getDisplayName()) .displayName(entity.getDisplayName())
.settings(settingsModel) .settings(settingsModel)
.build(); .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; package auth_service.modules.user.service;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; 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.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance; 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.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.UserRepository; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -34,21 +37,49 @@ public class UserService {
return userRepository.findByEmail(email).orElseThrow(() -> new UserNotFoundException("Could not find user with email " + email)); 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 @Transactional
@CacheEvict(value = "usersByEmail", allEntries = true) @CacheEvict(value = "usersByEmail", allEntries = true)
public String updateUserSettings(long userId, String email, Long steamId, String displayName, UserSettings userSettings) { public void updateUserSettings(long userId, String displayName, UserSettings userSettings) {
if (steamId != null && userRepository.isSteamIdUsedByOtherUser(userId, steamId)) {
throw new SteamIdAlreadyInUseException("The steam id is already in use.");
}
if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) { if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) {
throw new DisplayNameAlreadyInUseException("The display name is already in use."); throw new DisplayNameAlreadyInUseException("The display name is already in use.");
} }
var updated = userRepository.updateSettings(userId, steamId, displayName, userSettings); userRepository.updateSettings(userId, displayName, userSettings);
if (updated) {
log.info("Steam id has changed for user {}", userId);
}
log.info("User settings updated for user {}", userId); 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 @Transactional
@@ -18,6 +18,9 @@ public class AuthCookies {
public static final String ACCESS_COOKIE = "tbp_access"; public static final String ACCESS_COOKIE = "tbp_access";
public static final String REFRESH_COOKIE = "tbp_refresh"; 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. // Login, refresh and logout live here; logout needs the refresh cookie to revoke its family.
private static final String REFRESH_PATH = "/api/auth"; private static final String REFRESH_PATH = "/api/auth";
@@ -31,6 +34,17 @@ public class AuthCookies {
return refreshBuilder(token).maxAge(properties.getRefreshTokenTtl()).build(); 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() { public List<ResponseCookie> cleared() {
return List.of(accessBuilder("").maxAge(Duration.ZERO).build(), refreshBuilder("").maxAge(Duration.ZERO).build()); 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); 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. // Host-only on Auth: no other service needs it. Strict is safe because the frontend is same-site.
private ResponseCookie.ResponseCookieBuilder refreshBuilder(String token) { private ResponseCookie.ResponseCookieBuilder refreshBuilder(String token) {
return ResponseCookie.from(REFRESH_COOKIE, 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: auth:
cookie-domain: ${APP_AUTH_COOKIE_DOMAIN:} cookie-domain: ${APP_AUTH_COOKIE_DOMAIN:}
cookie-secure: true 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 # Registration settings
auth: 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") .email("test@example.com")
.password("SuperSecret123!") .password("SuperSecret123!")
.inviteCode("test-invite-code") .inviteCode("test-invite-code")
.steamId("76561198000000001")
.build(); .build();
long expectedUserId = 42L; long expectedUserId = 42L;
@@ -87,7 +86,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.email("test@example.com") .email("test@example.com")
.password("SuperSecret123!") .password("SuperSecret123!")
.inviteCode("test-invite-code") .inviteCode("test-invite-code")
.steamId("76561198000000001")
.build(); .build();
when(registrationService.register(any(Registration.class), any())) when(registrationService.register(any(Registration.class), any()))
@@ -103,60 +101,18 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
} }
@Test @Test
void register_SteamIdIsParsedExactly() throws Exception { void register_OldClientSendingASteamId_IsAcceptedAndTheIdIgnored() throws Exception {
// Arrange
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.inviteCode("code")
.steamId("76561198000000001")
.build();
when(registrationService.register(any(Registration.class), any())).thenReturn(42L); 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") mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","steamId":76561198005679790}""")) {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","steamId":"76561198000000001"}"""))
.andExpect(status().isBadRequest()); .andExpect(status().isOk());
verifyNoInteractions(registrationService);
} }
@Test @Test
void register_DuplicateEmailRaceIsNotReportedAsSteamIdConflict() throws Exception { void register_DuplicateEmailRaceIsNotReportedAsConflict() throws Exception {
// Arrange // Arrange
var request = RegistrationRequest.builder() var request = RegistrationRequest.builder()
.email("test@example.com") .email("test@example.com")
@@ -27,7 +27,6 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
.email(email) .email(email)
.password("password") .password("password")
.enabled(true) .enabled(true)
.steamId(111L)
.registrationDate(java.time.LocalDateTime.now()) .registrationDate(java.time.LocalDateTime.now())
.build(); .build();
entityManager.persist(entity); entityManager.persist(entity);
@@ -38,32 +37,12 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
assertFalse(registeredUserRepository.existsByEmail("other@example.com")); 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 @Test
void save_Success() { void save_Success() {
// Arrange // Arrange
var registration = Registration.builder() var registration = Registration.builder()
.email("save@example.com") .email("save@example.com")
.encryptedPassword("hashed") .encryptedPassword("hashed")
.steamId(444L)
.build(); .build();
// Act // Act
@@ -3,7 +3,6 @@ package auth_service.modules.registration.service;
import auth_service.modules.registration.exception.EmailAlreadyInUseException; import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.InvalidInvitationCodeException; import auth_service.modules.registration.exception.InvalidInvitationCodeException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; 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.exception.TooManyRegistrationAttemptsException;
import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.RegisteredUserRepository; import auth_service.modules.registration.persistence.RegisteredUserRepository;
@@ -49,7 +48,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
.email("test@example.com") .email("test@example.com")
.rawPassword("SuperSecret123!") .rawPassword("SuperSecret123!")
.inviteCode("test-invite-code") .inviteCode("test-invite-code")
.steamId(76561198000000001L)
.build(); .build();
} }
@@ -59,7 +57,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
long expectedUserId = 42L; long expectedUserId = 42L;
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true); when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false); when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(false);
when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ"); when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ");
when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId); when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId);
@@ -97,49 +94,16 @@ class RegistrationServiceTest extends AbstractUnitTest {
// Act & Assert // Act & Assert
assertThrows(EmailAlreadyInUseException.class, () -> registrationService.register(validRegistration, IP)); assertThrows(EmailAlreadyInUseException.class, () -> registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).existsBySteamId(anyLong());
verify(registeredUserRepository, never()).save(any()); verify(registeredUserRepository, never()).save(any());
verify(registrationAttemptService, never()).release(anyString()); 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 @Test
void register_DisplayNameTaken() { void register_DisplayNameTaken() {
// Arrange // Arrange
validRegistration.setDisplayName("Lobo"); validRegistration.setDisplayName("Lobo");
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true); when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false); when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(false);
when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true); when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true);
// Act & Assert // Act & Assert
@@ -4,7 +4,6 @@ import auth_service.configuration.security.JwtAuthenticationFilter;
import auth_service.configuration.security.JwtService; import auth_service.configuration.security.JwtService;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; 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.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
@@ -33,6 +32,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.Collections; import java.util.Collections;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -89,7 +89,7 @@ class UserControllerTest extends AbstractWebMvcTest {
// Arrange // Arrange
var email = "test@example.com"; var email = "test@example.com";
var settings = UserSettings.builder().language("en").theme("dark").build(); 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); var principal = new UserPrincipal("1", email, null);
SecurityContextHolder.getContext().setAuthentication( SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
@@ -100,7 +100,8 @@ class UserControllerTest extends AbstractWebMvcTest {
mockMvc.perform(get("/api/users/me") mockMvc.perform(get("/api/users/me")
.accept(MediaType.APPLICATION_JSON)) .accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.steamId").value("76561198000000001")); .andExpect(jsonPath("$.steamId").value("76561198000000001"))
.andExpect(jsonPath("$.steamIdVerified").value(true));
} }
@Test @Test
@@ -139,130 +140,31 @@ class UserControllerTest extends AbstractWebMvcTest {
} }
@Test @Test
void updateUserSettings_Success() throws Exception { void updateUserSettings_OldClientSendingASteamId_SavesTheRestAndIssuesNoCookie() throws Exception {
// Arrange var principal = new UserPrincipal("1", "test@example.com", null);
var email = "test@example.com";
var principal = new UserPrincipal("1", email, null);
SecurityContextHolder.getContext().setAuthentication( SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) 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") mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}""")) {"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}"""))
.andExpect(status().isNoContent()) .andExpect(status().isNoContent())
.andExpect(cookie().value(AuthCookies.ACCESS_COOKIE, "new-token")) .andExpect(cookie().doesNotExist(AuthCookies.ACCESS_COOKIE));
.andExpect(cookie().httpOnly(AuthCookies.ACCESS_COOKIE, true));
verify(userService).updateUserSettings(eq(1L), isNull(), any());
} }
@Test @Test
void updateUserSettings_NoSteamId() throws Exception { void updateUserSettings_OtherConstraintIsNotAConflict() throws Exception {
// Arrange // Arrange
var email = "test@example.com"; var email = "test@example.com";
var principal = new UserPrincipal("1", email, null); var principal = new UserPrincipal("1", email, null);
SecurityContextHolder.getContext().setAuthentication( SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
); );
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")
.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)"));
// Act & Assert // Act & Assert
mockMvc.perform(put("/api/users/settings") mockMvc.perform(put("/api/users/settings")
@@ -299,7 +201,6 @@ class UserControllerTest extends AbstractWebMvcTest {
SecurityContextHolder.getContext().setAuthentication( SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
); );
when(userService.updateUserSettings(eq(1L), eq(email), isNull(), eq("Lobo Corp_1"), any())).thenReturn("t");
// Act & Assert // Act & Assert
mockMvc.perform(put("/api/users/settings") mockMvc.perform(put("/api/users/settings")
@@ -335,8 +236,7 @@ class UserControllerTest extends AbstractWebMvcTest {
SecurityContextHolder.getContext().setAuthentication( SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
); );
when(userService.updateUserSettings(eq(1L), eq(email), any(), eq("Lobo"), any())) doThrow(new DisplayNameAlreadyInUseException("taken")).when(userService).updateUserSettings(eq(1L), eq("Lobo"), any());
.thenThrow(new DisplayNameAlreadyInUseException("taken"));
// Act & Assert // Act & Assert
mockMvc.perform(put("/api/users/settings") mockMvc.perform(put("/api/users/settings")
@@ -355,8 +255,7 @@ class UserControllerTest extends AbstractWebMvcTest {
SecurityContextHolder.getContext().setAuthentication( SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
); );
when(userService.updateUserSettings(eq(1L), eq(email), any(), eq("Lobo"), any())) doThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uq_users_display_name_lower\"")).when(userService).updateUserSettings(eq(1L), eq("Lobo"), any());
.thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uq_users_display_name_lower\""));
// Act & Assert // Act & Assert
mockMvc.perform(put("/api/users/settings") mockMvc.perform(put("/api/users/settings")
@@ -380,7 +279,7 @@ class UserControllerTest extends AbstractWebMvcTest {
.content("{\"useDarkTheme\":true,\"language\":\"en\",\"steamId\":null,\"displayName\":\"Lobo\"" + field + "}")) .content("{\"useDarkTheme\":true,\"language\":\"en\",\"steamId\":null,\"displayName\":\"Lobo\"" + field + "}"))
.andExpect(status().isNoContent()); .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)); 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.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance; 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.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.modules.user.persistence.entities.UserEntity;
import auth_service.shared.test.AbstractPersistenceTest; 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 org.springframework.context.annotation.Import;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -65,13 +67,12 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.build(); .build();
// Act // Act
var updated = userRepository.updateSettings(userEntity.getId(), 654321L, null, settings); userRepository.updateSettings(userEntity.getId(), null, settings);
// Assert // Assert: the Steam ID changes only through the Steam link
assertTrue(updated);
var updatedUser = entityManager.find(UserEntity.class, userEntity.getId()); var updatedUser = entityManager.find(UserEntity.class, userEntity.getId());
assertNotNull(updatedUser); assertNotNull(updatedUser);
assertEquals(654321L, updatedUser.getSteamId()); assertEquals(123456L, updatedUser.getSteamId());
assertNotNull(updatedUser.getSettings()); assertNotNull(updatedUser.getSettings());
assertEquals("de", updatedUser.getSettings().getLanguage()); assertEquals("de", updatedUser.getSettings().getLanguage());
} }
@@ -96,55 +97,54 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertNull(result.get().getSteamId()); assertNull(result.get().getSteamId());
} }
@Test private UserEntity user(String email, Long steamId, boolean verified) {
void updateSettings_ClearsSteamId() { UserEntity user = entityManager.persist(UserEntity.builder()
// Arrange .email(email)
var userEntity = entityManager.persist(UserEntity.builder()
.email("clear@example.com")
.password("password") .password("password")
.enabled(true) .enabled(true)
.steamId(123456L) .steamId(steamId)
.steamIdVerified(verified)
.registrationDate(LocalDateTime.now()) .registrationDate(LocalDateTime.now())
.build()); .build());
entityManager.flush(); entityManager.flush();
var settings = UserSettings.builder().language("en").theme("light").build(); return user;
// Act
userRepository.updateSettings(userEntity.getId(), null, null, settings);
// Assert
assertNull(entityManager.find(UserEntity.class, userEntity.getId()).getSteamId());
} }
@Test @Test
void isSteamIdUsedByOtherUser() { void findSteamIdHolder_ReturnsTheAccountAndWhetherItIsVerified() {
// Arrange UserEntity owner = user("owner@example.com", 76561198000000001L, true);
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();
// Act & Assert assertEquals(Optional.of(new SteamIdHolder(owner.getId(), true)), userRepository.findSteamIdHolder(76561198000000001L));
assertFalse(userRepository.isSteamIdUsedByOtherUser(owner.getId(), 76561198000000001L)); assertTrue(userRepository.findSteamIdHolder(76561198000000000L).isEmpty());
assertTrue(userRepository.isSteamIdUsedByOtherUser(other.getId(), 76561198000000001L)); }
assertFalse(userRepository.isSteamIdUsedByOtherUser(other.getId(), 76561198000000000L));
@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 @Test
void updateSettings_UnknownUser() { void updateSettings_UnknownUser() {
var settings = UserSettings.builder().language("en").theme("light").build(); 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 @Test
@@ -172,7 +172,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
var settings = UserSettings.builder().language("en").theme("light").build(); var settings = UserSettings.builder().language("en").theme("light").build();
// Act // Act
userRepository.updateSettings(owner.getId(), null, "Lobo", settings); userRepository.updateSettings(owner.getId(), "Lobo", settings);
entityManager.flush(); entityManager.flush();
// Assert // Assert
@@ -192,7 +192,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.build()); .build());
entityManager.flush(); entityManager.flush();
userRepository.updateSettings(userEntity.getId(), null, "Lobo", userRepository.updateSettings(userEntity.getId(), "Lobo",
UserSettings.builder().language("de").theme("dark").publicProfile(true).build()); UserSettings.builder().language("de").theme("dark").publicProfile(true).build());
entityManager.flush(); entityManager.flush();
entityManager.clear(); entityManager.clear();
@@ -230,7 +230,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
userRepository.updateAppearance(userEntity.getId(), new Appearance("pink", "sharp", false, 220L, null, true)); userRepository.updateAppearance(userEntity.getId(), new Appearance("pink", "sharp", false, 220L, null, true));
entityManager.flush(); entityManager.flush();
userRepository.updateSettings(userEntity.getId(), null, null, userRepository.updateSettings(userEntity.getId(), null,
UserSettings.builder().language("de").theme("dark").build()); UserSettings.builder().language("de").theme("dark").build());
entityManager.flush(); entityManager.flush();
entityManager.clear(); 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; package auth_service.modules.user.service;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; 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.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance; 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.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.UserRepository; import auth_service.modules.user.persistence.UserRepository;
import auth_service.shared.security.TokenGenerator; import auth_service.shared.security.TokenGenerator;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
@@ -60,52 +62,15 @@ class UserServiceTest extends AbstractUnitTest {
} }
@Test @Test
void updateUserSettings_ReturnsTokenWithNewSteamId() { void updateUserSettings_NeverTouchesTheSteamIdAndIssuesNoToken() {
// Arrange
long userId = 1L;
var email = "test@example.com";
Long steamId = 76561198000000001L;
var userSettings = UserSettings.builder().language("en").build(); var userSettings = UserSettings.builder().language("en").build();
when(userRepository.isSteamIdUsedByOtherUser(userId, steamId)).thenReturn(false);
when(tokenGenerator.generateToken("1", email, steamId)).thenReturn("new-token");
// Act userService.updateUserSettings(1L, null, userSettings);
var token = userService.updateUserSettings(userId, email, steamId, null, userSettings);
// Assert verify(userRepository).updateSettings(1L, null, userSettings);
assertEquals("new-token", token); verify(userRepository, never()).clearSteamId(anyLong());
verify(userRepository).updateSettings(userId, steamId, null, userSettings); verify(userRepository, never()).setVerifiedSteamId(anyLong(), anyLong());
} verifyNoInteractions(tokenGenerator);
@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());
} }
@Test @Test
@@ -116,8 +81,8 @@ class UserServiceTest extends AbstractUnitTest {
// Act & Assert // Act & Assert
assertThrows(DisplayNameAlreadyInUseException.class, assertThrows(DisplayNameAlreadyInUseException.class,
() -> userService.updateUserSettings(1L, "test@example.com", null, "Lobo", userSettings)); () -> userService.updateUserSettings(1L, "Lobo", userSettings));
verify(userRepository, never()).updateSettings(anyLong(), any(), any(), any()); verify(userRepository, never()).updateSettings(anyLong(), any(), any());
} }
@Test @Test
@@ -127,10 +92,61 @@ class UserServiceTest extends AbstractUnitTest {
when(userRepository.isDisplayNameUsedByOtherUser(1L, "Lobo")).thenReturn(false); when(userRepository.isDisplayNameUsedByOtherUser(1L, "Lobo")).thenReturn(false);
// Act // Act
userService.updateUserSettings(1L, "test@example.com", null, "Lobo", userSettings); userService.updateUserSettings(1L, "Lobo", userSettings);
// Assert // 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 @Test