From 4b2acef38140240b164bf320fb7964c548f492c7 Mon Sep 17 00:00:00 2001 From: lobo Date: Sat, 26 Sep 2026 11:20:04 +0200 Subject: [PATCH] feat(steam): verify the Steam ID with Sign in through Steam 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. --- readme.md | 36 +++- .../security/SecurityConfig.java | 1 + .../api/RegistrationExceptionController.java | 6 - .../api/dto/mapper/ApiMapper.java | 1 - .../api/dto/request/RegistrationRequest.java | 8 +- .../SteamIdAlreadyRegisteredException.java | 7 - .../registration/model/Registration.java | 1 - .../persistence/RegisteredUserRepository.java | 2 - .../RegisteredUserRepositoryImpl.java | 10 -- .../entities/RegisteredUserEntity.java | 3 - .../persistence/mapper/EntityMapper.java | 1 - .../service/RegistrationService.java | 5 - .../modules/user/api/SteamLinkController.java | 70 ++++++++ .../modules/user/api/UserController.java | 11 +- .../user/api/UserExceptionController.java | 17 +- .../api/dto/request/UserSettingsRequest.java | 8 +- .../api/dto/response/SteamLinkResponse.java | 7 + .../api/dto/response/UserProfileResponse.java | 2 +- .../modules/user/api/mapper/ApiMapper.java | 5 +- .../SteamIdAlreadyInUseException.java | 7 - .../modules/user/model/SteamIdHolder.java | 4 + .../modules/user/model/SteamLinkOutcome.java | 22 +++ .../auth_service/modules/user/model/User.java | 1 + .../user/persistence/UserRepository.java | 14 +- .../user/persistence/UserRepositoryImpl.java | 43 +++-- .../user/persistence/entities/UserEntity.java | 3 + .../user/persistence/mapper/EntityMapper.java | 1 + .../user/service/SteamLinkService.java | 125 ++++++++++++++ .../user/service/SteamOpenIdService.java | 127 ++++++++++++++ .../modules/user/service/UserService.java | 51 ++++-- .../auth_service/shared/api/AuthCookies.java | 22 +++ .../api/validation/SteamIdDeserializer.java | 20 --- .../shared/api/validation/SteamIdFormat.java | 11 -- src/main/resources/application.yml | 4 + .../db/migration/V11__Steam_Id_Verified.sql | 3 + .../security/SteamLinkFlowTest.java | 154 +++++++++++++++++ .../api/RegistrationControllerTest.java | 52 +----- .../RegisteredUserRepositoryTest.java | 21 --- .../service/RegistrationServiceTest.java | 36 ---- .../modules/user/api/UserControllerTest.java | 129 ++------------ .../user/persistence/UserRepositoryTest.java | 82 ++++----- .../user/service/SteamLinkServiceTest.java | 157 ++++++++++++++++++ .../user/service/SteamOpenIdServiceTest.java | 126 ++++++++++++++ .../modules/user/service/UserServiceTest.java | 112 +++++++------ 44 files changed, 1079 insertions(+), 449 deletions(-) delete mode 100644 src/main/java/auth_service/modules/registration/exception/SteamIdAlreadyRegisteredException.java create mode 100644 src/main/java/auth_service/modules/user/api/SteamLinkController.java create mode 100644 src/main/java/auth_service/modules/user/api/dto/response/SteamLinkResponse.java delete mode 100644 src/main/java/auth_service/modules/user/exception/SteamIdAlreadyInUseException.java create mode 100644 src/main/java/auth_service/modules/user/model/SteamIdHolder.java create mode 100644 src/main/java/auth_service/modules/user/model/SteamLinkOutcome.java create mode 100644 src/main/java/auth_service/modules/user/service/SteamLinkService.java create mode 100644 src/main/java/auth_service/modules/user/service/SteamOpenIdService.java delete mode 100644 src/main/java/auth_service/shared/api/validation/SteamIdDeserializer.java delete mode 100644 src/main/java/auth_service/shared/api/validation/SteamIdFormat.java create mode 100644 src/main/resources/db/migration/V11__Steam_Id_Verified.sql create mode 100644 src/test/java/auth_service/configuration/security/SteamLinkFlowTest.java create mode 100644 src/test/java/auth_service/modules/user/service/SteamLinkServiceTest.java create mode 100644 src/test/java/auth_service/modules/user/service/SteamOpenIdServiceTest.java diff --git a/readme.md b/readme.md index 1880881..da89b6a 100644 --- a/readme.md +++ b/readme.md @@ -26,7 +26,12 @@ To Be Played is split into three repositories: token that comes back revokes all tokens of that login. Logout revokes them too. - CSRF guard: `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header, and CORS allows only the frontend origin. A `Bearer` header still works for API clients and tests. -- User settings: display name (unique, case-insensitive), Steam ID, language, dark mode. +- User settings: display name (unique, case-insensitive), language, dark mode. +- Steam account linked through **"Sign in through Steam"** (Steam OpenID 2.0, no API key), never typed in. Steam + proves the SteamID64, and Auth asks Steam to confirm the answer (`check_authentication`) before it stores the ID + as verified. A one-time state plus a binding cookie tie the answer to the browser that started the link, so a + foreign Steam login link cannot move someone's Steam ID into another account. IDs typed in before this stay, but + unverified: a verified link from another account takes them away. Unlinking is possible. - Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs. - Opt-in public profile at `/u/`. Auth answers with a short-lived **profile ticket** (EdDSA JWT, @@ -42,7 +47,10 @@ To Be Played is split into three repositories: | `POST` | `/api/auth/refresh` | refresh cookie, replaces both cookies | | `POST` | `/api/auth/logout` | refresh cookie, clears both cookies | | `GET` | `/api/users/me` | access token | -| `PUT` | `/api/users/settings` | access token, sets a new access cookie | +| `PUT` | `/api/users/settings` | access token | +| `POST` | `/api/users/steam-link` | access token, returns Steam's login URL and sets the binding cookie | +| `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie | +| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings | | `PUT` | `/api/users/settings/appearance` | access token | | `GET` | `/api/public/profiles/{displayName}` | public | | `GET` | `/api/stats/public` | public | @@ -101,6 +109,28 @@ sequenceDiagram 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 Needs Java 25 and Docker. @@ -125,6 +155,8 @@ Configuration comes from environment variables: | `JWT_KID` | Key id written into the token header | | `AUTH_INVITATION_CODE` | Code required for registration | | `DB_PASSWORD` | Local PostgreSQL password | +| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) | +| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) | Generate a keypair: diff --git a/src/main/java/auth_service/configuration/security/SecurityConfig.java b/src/main/java/auth_service/configuration/security/SecurityConfig.java index 29f0e40..14c22c6 100644 --- a/src/main/java/auth_service/configuration/security/SecurityConfig.java +++ b/src/main/java/auth_service/configuration/security/SecurityConfig.java @@ -41,6 +41,7 @@ public class SecurityConfig { "/api/registration/**", // Allow new user registration "/api/stats/public", // Public landing-page counters (cached, no PII) "/api/public/**", // Opt-in public profiles, see PublicProfileController + "/api/steam-link/callback", // Steam's redirect back; bound to the browser by a cookie, see SteamLinkService "/actuator/health", // Docker health check; details are off "/.well-known/jwks.json", // Public verify keys for Core "/error" // Default Spring Boot error page routing diff --git a/src/main/java/auth_service/modules/registration/api/RegistrationExceptionController.java b/src/main/java/auth_service/modules/registration/api/RegistrationExceptionController.java index be7031c..0eba0ec 100644 --- a/src/main/java/auth_service/modules/registration/api/RegistrationExceptionController.java +++ b/src/main/java/auth_service/modules/registration/api/RegistrationExceptionController.java @@ -4,7 +4,6 @@ import auth_service.shared.api.dto.response.ErrorResponse; import auth_service.modules.registration.exception.EmailAlreadyInUseException; import auth_service.modules.registration.exception.InvalidInvitationCodeException; import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; -import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException; import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; @@ -53,11 +52,6 @@ public class RegistrationExceptionController { return response(body, HttpStatus.BAD_REQUEST); } - @ExceptionHandler(SteamIdAlreadyRegisteredException.class) - public ResponseEntity clientError(SteamIdAlreadyRegisteredException ex) { - var body = new ErrorResponse("STEAM_ID_ALREADY_IN_USE", ex.getMessage()); - return response(body, HttpStatus.BAD_REQUEST); - } } diff --git a/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java b/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java index a490269..a69c861 100644 --- a/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java @@ -13,7 +13,6 @@ public abstract class ApiMapper { rawPassword(registrationRequest.password()). inviteCode(registrationRequest.inviteCode()). displayName(registrationRequest.displayName()). - steamId(registrationRequest.steamId() == null ? null : Long.valueOf(registrationRequest.steamId())). build(); } diff --git a/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java b/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java index 00f5b3b..cd731b8 100644 --- a/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java +++ b/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java @@ -1,15 +1,13 @@ package auth_service.modules.registration.api.dto.request; import auth_service.shared.api.validation.DisplayNameFormat; -import auth_service.shared.api.validation.SteamIdDeserializer; -import auth_service.shared.api.validation.SteamIdFormat; import jakarta.validation.constraints.Pattern; import lombok.Builder; -import tools.jackson.databind.annotation.JsonDeserialize; +/** + * No Steam ID: it comes only from "Sign in through Steam", later in the settings. + */ @Builder public record RegistrationRequest(String email, String password, String inviteCode, - @Pattern(regexp = SteamIdFormat.PATTERN) - @JsonDeserialize(using = SteamIdDeserializer.class) String steamId, @Pattern(regexp = DisplayNameFormat.PATTERN) String displayName) { } diff --git a/src/main/java/auth_service/modules/registration/exception/SteamIdAlreadyRegisteredException.java b/src/main/java/auth_service/modules/registration/exception/SteamIdAlreadyRegisteredException.java deleted file mode 100644 index 4a8339b..0000000 --- a/src/main/java/auth_service/modules/registration/exception/SteamIdAlreadyRegisteredException.java +++ /dev/null @@ -1,7 +0,0 @@ -package auth_service.modules.registration.exception; - -public class SteamIdAlreadyRegisteredException extends RuntimeException { - public SteamIdAlreadyRegisteredException(String message) { - super(message); - } -} diff --git a/src/main/java/auth_service/modules/registration/model/Registration.java b/src/main/java/auth_service/modules/registration/model/Registration.java index b35d8d0..4c5d328 100644 --- a/src/main/java/auth_service/modules/registration/model/Registration.java +++ b/src/main/java/auth_service/modules/registration/model/Registration.java @@ -10,6 +10,5 @@ public class Registration { private String rawPassword; private String encryptedPassword; private String inviteCode; - private Long steamId; private String displayName; } diff --git a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepository.java b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepository.java index 1b2ec7d..8df9e5a 100644 --- a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepository.java +++ b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepository.java @@ -5,8 +5,6 @@ import auth_service.modules.registration.model.Registration; public interface RegisteredUserRepository { boolean existsByEmail(String email); - boolean existsBySteamId(long steamId); - boolean existsByDisplayName(String displayName); long save(Registration registration); diff --git a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java index 2ccc905..0f80e9e 100644 --- a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java @@ -24,16 +24,6 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository { return count != null && count > 0; } - @Override - public boolean existsBySteamId(long steamId) { - var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE u.steamId = :steamId"; - var count = entityManager.createQuery(jpql, Long.class) - .setParameter("steamId", steamId) - .getSingleResult(); - - return count != null && count > 0; - } - @Override public boolean existsByDisplayName(String displayName) { var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE LOWER(u.displayName) = LOWER(:displayName)"; diff --git a/src/main/java/auth_service/modules/registration/persistence/entities/RegisteredUserEntity.java b/src/main/java/auth_service/modules/registration/persistence/entities/RegisteredUserEntity.java index 12f47c8..1556499 100644 --- a/src/main/java/auth_service/modules/registration/persistence/entities/RegisteredUserEntity.java +++ b/src/main/java/auth_service/modules/registration/persistence/entities/RegisteredUserEntity.java @@ -27,9 +27,6 @@ public class RegisteredUserEntity { @Column(nullable = false) private boolean enabled; - @Column(name = "steam_id", unique = true) - private Long steamId; - @Column(name = "display_name", length = 24) private String displayName; diff --git a/src/main/java/auth_service/modules/registration/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/registration/persistence/mapper/EntityMapper.java index e458269..e371e05 100644 --- a/src/main/java/auth_service/modules/registration/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/registration/persistence/mapper/EntityMapper.java @@ -17,7 +17,6 @@ public abstract class EntityMapper { registrationDate(LocalDateTime.now(Clock.systemUTC())). password(userModel.getEncryptedPassword()). enabled(true). - steamId(userModel.getSteamId()). displayName(userModel.getDisplayName()). lastLoginDate(null). build(); diff --git a/src/main/java/auth_service/modules/registration/service/RegistrationService.java b/src/main/java/auth_service/modules/registration/service/RegistrationService.java index 87f3a0a..fd39483 100644 --- a/src/main/java/auth_service/modules/registration/service/RegistrationService.java +++ b/src/main/java/auth_service/modules/registration/service/RegistrationService.java @@ -3,7 +3,6 @@ package auth_service.modules.registration.service; import auth_service.modules.registration.exception.EmailAlreadyInUseException; import auth_service.modules.registration.exception.InvalidInvitationCodeException; import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; -import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException; import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.persistence.RegisteredUserRepository; import lombok.RequiredArgsConstructor; @@ -39,10 +38,6 @@ public class RegistrationService { throw new EmailAlreadyInUseException("The provided email address is already in use."); } - if (registration.getSteamId() != null && registeredUserRepository.existsBySteamId(registration.getSteamId())) { - throw new SteamIdAlreadyRegisteredException("The steam id is already in use."); - } - if (registration.getDisplayName() != null && registeredUserRepository.existsByDisplayName(registration.getDisplayName())) { throw new DisplayNameAlreadyRegisteredException("The display name is already in use."); } diff --git a/src/main/java/auth_service/modules/user/api/SteamLinkController.java b/src/main/java/auth_service/modules/user/api/SteamLinkController.java new file mode 100644 index 0000000..fc93e5e --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/SteamLinkController.java @@ -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 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 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 callback(@RequestParam(required = false) String state, + @RequestParam Map 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(); + } +} diff --git a/src/main/java/auth_service/modules/user/api/UserController.java b/src/main/java/auth_service/modules/user/api/UserController.java index e54310d..ea8106b 100644 --- a/src/main/java/auth_service/modules/user/api/UserController.java +++ b/src/main/java/auth_service/modules/user/api/UserController.java @@ -1,6 +1,5 @@ package auth_service.modules.user.api; -import auth_service.shared.api.AuthCookies; import auth_service.shared.security.UserPrincipal; import auth_service.modules.user.api.dto.request.AppearanceRequest; import auth_service.modules.user.api.dto.request.UserSettingsRequest; @@ -10,7 +9,6 @@ import auth_service.modules.user.service.UserService; import io.micrometer.core.annotation.Timed; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -24,7 +22,6 @@ import org.springframework.web.bind.annotation.*; public class UserController { private final UserService userService; - private final AuthCookies authCookies; /** * Version 1 of the 'me' endpoint, accessed via /api/users/me?v=1 @@ -44,12 +41,8 @@ public class UserController { @Timed(value = "user.me.v1.requests.time", description = "Time taken to process v1 user profile requests") public ResponseEntity updateUserSettings(@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody UserSettingsRequest request) { - var token = this.userService.updateUserSettings(principal.getIdAsLong(), principal.email(), - ApiMapper.toSteamId(request), request.displayName(), ApiMapper.toDomain(request)); - // A new access cookie, so the steamId claim that Core reads matches the saved settings. - return ResponseEntity.noContent() - .header(HttpHeaders.SET_COOKIE, authCookies.access(token).toString()) - .build(); + this.userService.updateUserSettings(principal.getIdAsLong(), request.displayName(), ApiMapper.toDomain(request)); + return ResponseEntity.noContent().build(); } @PutMapping(value = "/settings/appearance", version = "1.0") diff --git a/src/main/java/auth_service/modules/user/api/UserExceptionController.java b/src/main/java/auth_service/modules/user/api/UserExceptionController.java index dd47983..393dd5b 100644 --- a/src/main/java/auth_service/modules/user/api/UserExceptionController.java +++ b/src/main/java/auth_service/modules/user/api/UserExceptionController.java @@ -2,7 +2,6 @@ package auth_service.modules.user.api; import auth_service.shared.api.dto.response.ErrorResponse; import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; -import auth_service.modules.user.exception.SteamIdAlreadyInUseException; import auth_service.modules.user.exception.UserNotFoundException; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; @@ -17,7 +16,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @Order(value = 1) @Slf4j public class UserExceptionController { - private static final String STEAM_ID_CONSTRAINT = "uc_steam_id"; private static final String DISPLAY_NAME_CONSTRAINT = "uq_users_display_name_lower"; private static @NonNull ResponseEntity response(ErrorResponse body, HttpStatus status) { @@ -39,18 +37,10 @@ public class UserExceptionController { return response(body, HttpStatus.NOT_FOUND); } - @ExceptionHandler(SteamIdAlreadyInUseException.class) - public ResponseEntity clientError(SteamIdAlreadyInUseException ex) { - return steamIdInUse(); - } - - // A parallel request can save the same Steam ID or display name after the service check. Then the DB constraint fails. + // A parallel request can save the same display name after the service check. Then the DB constraint fails. @ExceptionHandler(DataIntegrityViolationException.class) public ResponseEntity dataIntegrityError(DataIntegrityViolationException ex) { var cause = ex.getMostSpecificCause().getMessage(); - if (cause != null && cause.contains(STEAM_ID_CONSTRAINT)) { - return steamIdInUse(); - } if (cause != null && cause.contains(DISPLAY_NAME_CONSTRAINT)) { return displayNameInUse(); } @@ -68,9 +58,4 @@ public class UserExceptionController { return response(body, HttpStatus.CONFLICT); } - private static ResponseEntity steamIdInUse() { - var body = new ErrorResponse("STEAM_ID_ALREADY_IN_USE", "The steam id is already in use."); - return response(body, HttpStatus.CONFLICT); - } - } diff --git a/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java index e2076a1..ccb3379 100644 --- a/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java +++ b/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java @@ -1,21 +1,17 @@ package auth_service.modules.user.api.dto.request; import auth_service.shared.api.validation.DisplayNameFormat; -import auth_service.shared.api.validation.SteamIdDeserializer; -import auth_service.shared.api.validation.SteamIdFormat; import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Pattern; -import tools.jackson.databind.annotation.JsonDeserialize; /** - * @param steamId null removes the Steam ID + * No Steam ID: it comes only from "Sign in through Steam". + * * @param displayName null removes the display name * @param publicProfile missing counts as false, so an old client can only make a profile private */ public record UserSettingsRequest(boolean useDarkTheme, String language, - @Pattern(regexp = SteamIdFormat.PATTERN) - @JsonDeserialize(using = SteamIdDeserializer.class) String steamId, @Pattern(regexp = DisplayNameFormat.PATTERN) String displayName, Boolean publicProfile) { diff --git a/src/main/java/auth_service/modules/user/api/dto/response/SteamLinkResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/SteamLinkResponse.java new file mode 100644 index 0000000..ee58ae8 --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/dto/response/SteamLinkResponse.java @@ -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) { +} diff --git a/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java index 408e41e..e63034a 100644 --- a/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java +++ b/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java @@ -5,5 +5,5 @@ import lombok.Builder; import java.time.LocalDateTime; @Builder -public record UserProfileResponse(long id, String email, String role, LocalDateTime lastLogin, LocalDateTime createdDate, String steamId, String displayName, SettingsResponse settings) { +public record UserProfileResponse(long id, String email, String role, LocalDateTime lastLogin, LocalDateTime createdDate, String steamId, boolean steamIdVerified, String displayName, SettingsResponse settings) { } diff --git a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java index 4650567..2723124 100644 --- a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java @@ -21,6 +21,7 @@ public abstract class ApiMapper { .createdDate(user.getCreatedDate()) .lastLogin(user.getLastLogin()) .steamId(user.getSteamId() == null ? null : String.valueOf(user.getSteamId())) + .steamIdVerified(user.isSteamIdVerified()) .displayName(user.getDisplayName()) .role("USER") .settings(SettingsResponse.builder() @@ -44,10 +45,6 @@ public abstract class ApiMapper { request.backgroundAppid(), request.bannerAppid(), !Boolean.FALSE.equals(request.showBanner())); } - public static Long toSteamId(UserSettingsRequest request) { - return request.steamId() == null ? null : Long.valueOf(request.steamId()); - } - public static UserSettings toDomain(UserSettingsRequest request) { return UserSettings.builder() .theme(request.useDarkTheme() ? "dark" : "light") diff --git a/src/main/java/auth_service/modules/user/exception/SteamIdAlreadyInUseException.java b/src/main/java/auth_service/modules/user/exception/SteamIdAlreadyInUseException.java deleted file mode 100644 index 5505d0b..0000000 --- a/src/main/java/auth_service/modules/user/exception/SteamIdAlreadyInUseException.java +++ /dev/null @@ -1,7 +0,0 @@ -package auth_service.modules.user.exception; - -public class SteamIdAlreadyInUseException extends RuntimeException { - public SteamIdAlreadyInUseException(String message) { - super(message); - } -} diff --git a/src/main/java/auth_service/modules/user/model/SteamIdHolder.java b/src/main/java/auth_service/modules/user/model/SteamIdHolder.java new file mode 100644 index 0000000..366e3e2 --- /dev/null +++ b/src/main/java/auth_service/modules/user/model/SteamIdHolder.java @@ -0,0 +1,4 @@ +package auth_service.modules.user.model; + +public record SteamIdHolder(long userId, boolean verified) { +} diff --git a/src/main/java/auth_service/modules/user/model/SteamLinkOutcome.java b/src/main/java/auth_service/modules/user/model/SteamLinkOutcome.java new file mode 100644 index 0000000..a10c1c8 --- /dev/null +++ b/src/main/java/auth_service/modules/user/model/SteamLinkOutcome.java @@ -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; + } +} diff --git a/src/main/java/auth_service/modules/user/model/User.java b/src/main/java/auth_service/modules/user/model/User.java index 6df10d9..c46e324 100644 --- a/src/main/java/auth_service/modules/user/model/User.java +++ b/src/main/java/auth_service/modules/user/model/User.java @@ -14,6 +14,7 @@ public class User { private LocalDateTime lastLogin; private LocalDateTime createdDate; private Long steamId; + private boolean steamIdVerified; private String displayName; private UserSettings settings; diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepository.java b/src/main/java/auth_service/modules/user/persistence/UserRepository.java index 8e00d11..3953c14 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepository.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepository.java @@ -2,6 +2,7 @@ package auth_service.modules.user.persistence; import auth_service.modules.user.model.Appearance; +import auth_service.modules.user.model.SteamIdHolder; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; import jakarta.transaction.Transactional; @@ -25,12 +26,21 @@ public interface UserRepository { UserSettings findSettingsByUserId(String userId); @Transactional - boolean updateSettings(long userId, Long steamId, String displayName, UserSettings settings); + void updateSettings(long userId, String displayName, UserSettings settings); @Transactional void updateAppearance(long userId, Appearance appearance); - boolean isSteamIdUsedByOtherUser(long userId, long steamId); + Optional 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); } diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java index 4931e8f..cf938cd 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java @@ -2,6 +2,7 @@ package auth_service.modules.user.persistence; import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.model.Appearance; +import auth_service.modules.user.model.SteamIdHolder; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.persistence.entities.UserEntity; @@ -14,7 +15,6 @@ import jakarta.transaction.Transactional; import jakarta.validation.constraints.NotNull; import org.springframework.stereotype.Repository; -import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -63,7 +63,7 @@ public class UserRepositoryImpl implements UserRepository { @Transactional @Override - public boolean updateSettings(long userId, Long steamId, String displayName, UserSettings settings) { + public void updateSettings(long userId, String displayName, UserSettings settings) { var userEntity = entityManager.find(UserEntity.class, userId); if (userEntity == null) { throw new UserNotFoundException("Could not find user with id " + userId); @@ -80,11 +80,7 @@ public class UserRepositoryImpl implements UserRepository { settingsEntity.setTheme(settings.getTheme()); settingsEntity.setPublicProfile(settings.isPublicProfile()); - boolean steamIdChanged = !Objects.equals(userEntity.getSteamId(), steamId); - userEntity.setSteamId(steamId); userEntity.setDisplayName(displayName); - - return steamIdChanged; } @Transactional @@ -114,13 +110,38 @@ public class UserRepositoryImpl implements UserRepository { } @Override - public boolean isSteamIdUsedByOtherUser(long userId, long steamId) { - var count = entityManager.createQuery( - "SELECT COUNT(u) FROM UserEntity u WHERE u.steamId = :steamId AND u.id <> :userId", Long.class) + public Optional findSteamIdHolder(long steamId) { + // A projection, not the entity: the bulk updates that follow would leave a loaded entity stale. + return entityManager.createQuery( + "SELECT new auth_service.modules.user.model.SteamIdHolder(u.id, u.steamIdVerified) " + + "FROM UserEntity u WHERE u.steamId = :steamId", SteamIdHolder.class) + .setParameter("steamId", steamId) + .getResultStream() + .findFirst(); + } + + // Bulk updates, not entity changes: the clear must reach the database before the new holder is set, or the + // flush order could break uc_steam_id. + @Transactional + @Override + public void clearSteamId(long userId) { + entityManager.createQuery( + "UPDATE UserEntity u SET u.steamId = NULL, u.steamIdVerified = FALSE WHERE u.id = :userId") + .setParameter("userId", userId) + .executeUpdate(); + } + + @Transactional + @Override + public void setVerifiedSteamId(long userId, long steamId) { + int updated = entityManager.createQuery( + "UPDATE UserEntity u SET u.steamId = :steamId, u.steamIdVerified = TRUE WHERE u.id = :userId") .setParameter("steamId", steamId) .setParameter("userId", userId) - .getSingleResult(); - return count > 0; + .executeUpdate(); + if (updated == 0) { + throw new UserNotFoundException("Could not find user with id " + userId); + } } @Override diff --git a/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java b/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java index 5be5a78..f692ea7 100644 --- a/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java +++ b/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java @@ -33,6 +33,9 @@ public class UserEntity { @Column(name = "steam_id", unique = true) private Long steamId; + @Column(name = "steam_id_verified", nullable = false) + private boolean steamIdVerified; + @Column(name = "display_name", length = 24) private String displayName; diff --git a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java index 2e939c5..1d8fbb7 100644 --- a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java @@ -44,6 +44,7 @@ public abstract class EntityMapper { .lastLogin(entity.getLastLoginDate()) .createdDate(entity.getRegistrationDate()) .steamId(entity.getSteamId()) + .steamIdVerified(entity.isSteamIdVerified()) .displayName(entity.getDisplayName()) .settings(settingsModel) .build(); diff --git a/src/main/java/auth_service/modules/user/service/SteamLinkService.java b/src/main/java/auth_service/modules/user/service/SteamLinkService.java new file mode 100644 index 0000000..2f9006d --- /dev/null +++ b/src/main/java/auth_service/modules/user/service/SteamLinkService.java @@ -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 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 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 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 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; + } +} diff --git a/src/main/java/auth_service/modules/user/service/SteamOpenIdService.java b/src/main/java/auth_service/modules/user/service/SteamOpenIdService.java new file mode 100644 index 0000000..7762586 --- /dev/null +++ b/src/main/java/auth_service/modules/user/service/SteamOpenIdService.java @@ -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 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 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 verify(Map params, String expectedReturnTo) { + Optional steamId = checkFields(params, expectedReturnTo); + if (steamId.isEmpty() || !confirmedBySteam(params)) { + return Optional.empty(); + } + return steamId; + } + + private static Optional checkFields(Map 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 params) { + MultiValueMap 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; + } + } +} diff --git a/src/main/java/auth_service/modules/user/service/UserService.java b/src/main/java/auth_service/modules/user/service/UserService.java index ec31e7b..eef85a7 100644 --- a/src/main/java/auth_service/modules/user/service/UserService.java +++ b/src/main/java/auth_service/modules/user/service/UserService.java @@ -1,9 +1,10 @@ package auth_service.modules.user.service; import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; -import auth_service.modules.user.exception.SteamIdAlreadyInUseException; import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.model.Appearance; +import auth_service.modules.user.model.SteamIdHolder; +import auth_service.modules.user.model.SteamLinkOutcome; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.persistence.UserRepository; @@ -15,6 +16,8 @@ import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Slf4j @Service @RequiredArgsConstructor @@ -34,21 +37,49 @@ public class UserService { return userRepository.findByEmail(email).orElseThrow(() -> new UserNotFoundException("Could not find user with email " + email)); } + /** + * The Steam ID is not part of the settings: it changes only through {@link SteamLinkService}. + */ @Transactional @CacheEvict(value = "usersByEmail", allEntries = true) - public String updateUserSettings(long userId, String email, Long steamId, String displayName, UserSettings userSettings) { - if (steamId != null && userRepository.isSteamIdUsedByOtherUser(userId, steamId)) { - throw new SteamIdAlreadyInUseException("The steam id is already in use."); - } + public void updateUserSettings(long userId, String displayName, UserSettings userSettings) { if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) { throw new DisplayNameAlreadyInUseException("The display name is already in use."); } - var updated = userRepository.updateSettings(userId, steamId, displayName, userSettings); - if (updated) { - log.info("Steam id has changed for user {}", userId); - } + userRepository.updateSettings(userId, displayName, userSettings); log.info("User settings updated for user {}", userId); - return tokenGenerator.generateToken(String.valueOf(userId), email, steamId); + } + + /** + * Stores a Steam ID that Steam proved. An account that only typed the same ID in loses it; an account that + * proved it keeps it. + */ + @Transactional + @CacheEvict(value = "usersByEmail", allEntries = true) + public SteamLinkOutcome linkVerifiedSteamId(long userId, long steamId) { + Optional 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 diff --git a/src/main/java/auth_service/shared/api/AuthCookies.java b/src/main/java/auth_service/shared/api/AuthCookies.java index a8382cd..7c4a481 100644 --- a/src/main/java/auth_service/shared/api/AuthCookies.java +++ b/src/main/java/auth_service/shared/api/AuthCookies.java @@ -18,6 +18,9 @@ public class AuthCookies { public static final String ACCESS_COOKIE = "tbp_access"; public static final String REFRESH_COOKIE = "tbp_refresh"; + public static final String STEAM_LINK_COOKIE = "tbp_steam_link"; + // Only the Steam callback needs it. + private static final String STEAM_LINK_PATH = "/api/steam-link"; // Login, refresh and logout live here; logout needs the refresh cookie to revoke its family. private static final String REFRESH_PATH = "/api/auth"; @@ -31,6 +34,17 @@ public class AuthCookies { return refreshBuilder(token).maxAge(properties.getRefreshTokenTtl()).build(); } + /** + * Binds a Steam link attempt to this browser. Lax, because Steam sends the user back with a cross-site GET. + */ + public ResponseCookie steamLink(String state, Duration ttl) { + return steamLinkBuilder(state).maxAge(ttl).build(); + } + + public ResponseCookie steamLinkCleared() { + return steamLinkBuilder("").maxAge(Duration.ZERO).build(); + } + public List cleared() { return List.of(accessBuilder("").maxAge(Duration.ZERO).build(), refreshBuilder("").maxAge(Duration.ZERO).build()); } @@ -56,6 +70,14 @@ public class AuthCookies { return domain == null || domain.isBlank() ? builder : builder.domain(domain); } + private ResponseCookie.ResponseCookieBuilder steamLinkBuilder(String state) { + return ResponseCookie.from(STEAM_LINK_COOKIE, state) + .httpOnly(true) + .secure(properties.isCookieSecure()) + .sameSite("Lax") + .path(STEAM_LINK_PATH); + } + // Host-only on Auth: no other service needs it. Strict is safe because the frontend is same-site. private ResponseCookie.ResponseCookieBuilder refreshBuilder(String token) { return ResponseCookie.from(REFRESH_COOKIE, token) diff --git a/src/main/java/auth_service/shared/api/validation/SteamIdDeserializer.java b/src/main/java/auth_service/shared/api/validation/SteamIdDeserializer.java deleted file mode 100644 index b930193..0000000 --- a/src/main/java/auth_service/shared/api/validation/SteamIdDeserializer.java +++ /dev/null @@ -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 { - - @Override - public String deserialize(JsonParser parser, DeserializationContext context) { - if (!parser.hasToken(JsonToken.VALUE_STRING)) { - return (String) context.handleUnexpectedToken(String.class, parser); - } - return parser.getString(); - } -} diff --git a/src/main/java/auth_service/shared/api/validation/SteamIdFormat.java b/src/main/java/auth_service/shared/api/validation/SteamIdFormat.java deleted file mode 100644 index e50149a..0000000 --- a/src/main/java/auth_service/shared/api/validation/SteamIdFormat.java +++ /dev/null @@ -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() { - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1ed4186..534fcf0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -51,6 +51,10 @@ app: auth: cookie-domain: ${APP_AUTH_COOKIE_DOMAIN:} cookie-secure: true + # Auth's own public address: Steam sends the user back here after "Sign in through Steam". + public-url: ${APP_PUBLIC_URL:http://localhost:8080} + # Where the user lands after the Steam link. + frontend-url: ${APP_FRONTEND_URL:http://localhost:5173} # Registration settings auth: diff --git a/src/main/resources/db/migration/V11__Steam_Id_Verified.sql b/src/main/resources/db/migration/V11__Steam_Id_Verified.sql new file mode 100644 index 0000000..1354443 --- /dev/null +++ b/src/main/resources/db/migration/V11__Steam_Id_Verified.sql @@ -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; diff --git a/src/test/java/auth_service/configuration/security/SteamLinkFlowTest.java b/src/test/java/auth_service/configuration/security/SteamLinkFlowTest.java new file mode 100644 index 0000000..37dbd23 --- /dev/null +++ b/src/test/java/auth_service/configuration/security/SteamLinkFlowTest.java @@ -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 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(); + } +} diff --git a/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java b/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java index 0795200..fda694e 100644 --- a/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java +++ b/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java @@ -64,7 +64,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest { .email("test@example.com") .password("SuperSecret123!") .inviteCode("test-invite-code") - .steamId("76561198000000001") .build(); long expectedUserId = 42L; @@ -87,7 +86,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest { .email("test@example.com") .password("SuperSecret123!") .inviteCode("test-invite-code") - .steamId("76561198000000001") .build(); when(registrationService.register(any(Registration.class), any())) @@ -103,60 +101,18 @@ class RegistrationControllerTest extends AbstractWebMvcTest { } @Test - void register_SteamIdIsParsedExactly() throws Exception { - // Arrange - var request = RegistrationRequest.builder() - .email("test@example.com") - .password("SuperSecret123!") - .inviteCode("code") - .steamId("76561198000000001") - .build(); + void register_OldClientSendingASteamId_IsAcceptedAndTheIdIgnored() throws Exception { when(registrationService.register(any(Registration.class), any())).thenReturn(42L); - // Act - mockMvc.perform(post("/api/registration/new") - .contentType(MediaType.APPLICATION_JSON) - .content(registrationRequestJson.write(request).getJson())) - .andExpect(status().isOk()); - - // Assert - var captor = ArgumentCaptor.forClass(Registration.class); - verify(registrationService).register(captor.capture(), any()); - assertEquals(76561198000000001L, captor.getValue().getSteamId()); - } - - @Test - void register_InvalidSteamId() throws Exception { - // Arrange - var request = RegistrationRequest.builder() - .email("test@example.com") - .password("SuperSecret123!") - .inviteCode("code") - .steamId("12345") - .build(); - - // Act & Assert - mockMvc.perform(post("/api/registration/new") - .contentType(MediaType.APPLICATION_JSON) - .content(registrationRequestJson.write(request).getJson())) - .andExpect(status().isBadRequest()); - - verifyNoInteractions(registrationService); - } - - @Test - void register_NumericSteamIdIsRejected() throws Exception { mockMvc.perform(post("/api/registration/new") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","steamId":76561198005679790}""")) - .andExpect(status().isBadRequest()); - - verifyNoInteractions(registrationService); + {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","steamId":"76561198000000001"}""")) + .andExpect(status().isOk()); } @Test - void register_DuplicateEmailRaceIsNotReportedAsSteamIdConflict() throws Exception { + void register_DuplicateEmailRaceIsNotReportedAsConflict() throws Exception { // Arrange var request = RegistrationRequest.builder() .email("test@example.com") diff --git a/src/test/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryTest.java b/src/test/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryTest.java index 67b20a5..75952d6 100644 --- a/src/test/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryTest.java +++ b/src/test/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryTest.java @@ -27,7 +27,6 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest { .email(email) .password("password") .enabled(true) - .steamId(111L) .registrationDate(java.time.LocalDateTime.now()) .build(); entityManager.persist(entity); @@ -38,32 +37,12 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest { assertFalse(registeredUserRepository.existsByEmail("other@example.com")); } - @Test - void existsBySteamId_Success() { - // Arrange - var steamId = 222L; - var entity = RegisteredUserEntity.builder() - .email("steam@example.com") - .password("password") - .enabled(true) - .steamId(steamId) - .registrationDate(java.time.LocalDateTime.now()) - .build(); - entityManager.persist(entity); - entityManager.flush(); - - // Act & Assert - assertTrue(registeredUserRepository.existsBySteamId(steamId)); - assertFalse(registeredUserRepository.existsBySteamId(333L)); - } - @Test void save_Success() { // Arrange var registration = Registration.builder() .email("save@example.com") .encryptedPassword("hashed") - .steamId(444L) .build(); // Act diff --git a/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java b/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java index 3b6702d..c9fc9e6 100644 --- a/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java +++ b/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java @@ -3,7 +3,6 @@ package auth_service.modules.registration.service; import auth_service.modules.registration.exception.EmailAlreadyInUseException; import auth_service.modules.registration.exception.InvalidInvitationCodeException; import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; -import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException; import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException; import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.persistence.RegisteredUserRepository; @@ -49,7 +48,6 @@ class RegistrationServiceTest extends AbstractUnitTest { .email("test@example.com") .rawPassword("SuperSecret123!") .inviteCode("test-invite-code") - .steamId(76561198000000001L) .build(); } @@ -59,7 +57,6 @@ class RegistrationServiceTest extends AbstractUnitTest { long expectedUserId = 42L; when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true); when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false); - when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(false); when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ"); when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId); @@ -97,49 +94,16 @@ class RegistrationServiceTest extends AbstractUnitTest { // Act & Assert assertThrows(EmailAlreadyInUseException.class, () -> registrationService.register(validRegistration, IP)); - verify(registeredUserRepository, never()).existsBySteamId(anyLong()); verify(registeredUserRepository, never()).save(any()); verify(registrationAttemptService, never()).release(anyString()); } - @Test - void register_SteamIdAlreadyRegistered() { - // Arrange - when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true); - when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false); - when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(true); - - // Act & Assert - assertThrows(SteamIdAlreadyRegisteredException.class, () -> registrationService.register(validRegistration, IP)); - - verify(registeredUserRepository, never()).save(any()); - verify(registrationAttemptService, never()).release(anyString()); - } - - @Test - void register_WithoutSteamId() { - // Arrange - validRegistration.setSteamId(null); - when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true); - when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false); - when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ"); - when(registeredUserRepository.save(any(Registration.class))).thenReturn(42L); - - // Act - long result = registrationService.register(validRegistration, IP); - - // Assert - assertEquals(42L, result); - verify(registeredUserRepository, never()).existsBySteamId(anyLong()); - } - @Test void register_DisplayNameTaken() { // Arrange validRegistration.setDisplayName("Lobo"); when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true); when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false); - when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(false); when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true); // Act & Assert diff --git a/src/test/java/auth_service/modules/user/api/UserControllerTest.java b/src/test/java/auth_service/modules/user/api/UserControllerTest.java index 20e991f..f0c9ca4 100644 --- a/src/test/java/auth_service/modules/user/api/UserControllerTest.java +++ b/src/test/java/auth_service/modules/user/api/UserControllerTest.java @@ -4,7 +4,6 @@ import auth_service.configuration.security.JwtAuthenticationFilter; import auth_service.configuration.security.JwtService; import auth_service.shared.security.UserPrincipal; import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; -import auth_service.modules.user.exception.SteamIdAlreadyInUseException; import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.User; @@ -33,6 +32,7 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.Collections; import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -89,7 +89,7 @@ class UserControllerTest extends AbstractWebMvcTest { // Arrange var email = "test@example.com"; var settings = UserSettings.builder().language("en").theme("dark").build(); - var user = User.builder().email(email).steamId(76561198000000001L).settings(settings).build(); + var user = User.builder().email(email).steamId(76561198000000001L).steamIdVerified(true).settings(settings).build(); var principal = new UserPrincipal("1", email, null); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) @@ -100,7 +100,8 @@ class UserControllerTest extends AbstractWebMvcTest { mockMvc.perform(get("/api/users/me") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.steamId").value("76561198000000001")); + .andExpect(jsonPath("$.steamId").value("76561198000000001")) + .andExpect(jsonPath("$.steamIdVerified").value(true)); } @Test @@ -139,130 +140,31 @@ class UserControllerTest extends AbstractWebMvcTest { } @Test - void updateUserSettings_Success() throws Exception { - // Arrange - var email = "test@example.com"; - var principal = new UserPrincipal("1", email, null); + void updateUserSettings_OldClientSendingASteamId_SavesTheRestAndIssuesNoCookie() throws Exception { + var principal = new UserPrincipal("1", "test@example.com", null); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) ); - when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), any(), any())).thenReturn("new-token"); - // Act & Assert mockMvc.perform(put("/api/users/settings") .contentType(MediaType.APPLICATION_JSON) .content(""" {"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}""")) .andExpect(status().isNoContent()) - .andExpect(cookie().value(AuthCookies.ACCESS_COOKIE, "new-token")) - .andExpect(cookie().httpOnly(AuthCookies.ACCESS_COOKIE, true)); + .andExpect(cookie().doesNotExist(AuthCookies.ACCESS_COOKIE)); + + verify(userService).updateUserSettings(eq(1L), isNull(), any()); } @Test - void updateUserSettings_NoSteamId() throws Exception { + void updateUserSettings_OtherConstraintIsNotAConflict() throws Exception { // Arrange var email = "test@example.com"; var principal = new UserPrincipal("1", email, null); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) ); - - // Act & Assert - mockMvc.perform(put("/api/users/settings") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"useDarkTheme":true,"language":"en","steamId":null}""")) - .andExpect(status().isNoContent()); - - verify(userService).updateUserSettings(eq(1L), eq(email), isNull(), any(), any()); - } - - @Test - void updateUserSettings_InvalidSteamId() throws Exception { - // Arrange - var principal = new UserPrincipal("1", "test@example.com", null); - SecurityContextHolder.getContext().setAuthentication( - new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) - ); - - // Act & Assert - mockMvc.perform(put("/api/users/settings") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"useDarkTheme":true,"language":"en","steamId":"7656119800567978x"}""")) - .andExpect(status().isBadRequest()); - - verifyNoInteractions(userService); - } - - @Test - void updateUserSettings_UniqueConstraintRaceIsConflict() throws Exception { - // Arrange - var email = "test@example.com"; - var principal = new UserPrincipal("1", email, null); - SecurityContextHolder.getContext().setAuthentication( - new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) - ); - when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), any(), any())) - .thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uc_steam_id\"")); - - // Act & Assert - mockMvc.perform(put("/api/users/settings") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}""")) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("STEAM_ID_ALREADY_IN_USE")); - } - - @Test - void updateUserSettings_NumericSteamIdIsRejected() throws Exception { - // Arrange - var principal = new UserPrincipal("1", "test@example.com", null); - SecurityContextHolder.getContext().setAuthentication( - new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) - ); - - // Act & Assert - mockMvc.perform(put("/api/users/settings") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"useDarkTheme":true,"language":"en","steamId":76561198005679790}""")) - .andExpect(status().isBadRequest()); - - verifyNoInteractions(userService); - } - - @Test - void updateUserSettings_SteamIdOfOtherUser() throws Exception { - // Arrange - var email = "test@example.com"; - var principal = new UserPrincipal("1", email, null); - SecurityContextHolder.getContext().setAuthentication( - new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) - ); - when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), any(), any())) - .thenThrow(new SteamIdAlreadyInUseException("taken")); - - // Act & Assert - mockMvc.perform(put("/api/users/settings") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}""")) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("STEAM_ID_ALREADY_IN_USE")); - } - - @Test - void updateUserSettings_OtherConstraintIsNotSteamIdConflict() throws Exception { - // Arrange - var email = "test@example.com"; - var principal = new UserPrincipal("1", email, null); - SecurityContextHolder.getContext().setAuthentication( - new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) - ); - when(userService.updateUserSettings(eq(1L), eq(email), isNull(), any(), any())) - .thenThrow(new DataIntegrityViolationException("value too long for type character varying(10)")); + doThrow(new DataIntegrityViolationException("value too long for type character varying(10)")).when(userService).updateUserSettings(eq(1L), any(), any()); // Act & Assert mockMvc.perform(put("/api/users/settings") @@ -299,7 +201,6 @@ class UserControllerTest extends AbstractWebMvcTest { SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) ); - when(userService.updateUserSettings(eq(1L), eq(email), isNull(), eq("Lobo Corp_1"), any())).thenReturn("t"); // Act & Assert mockMvc.perform(put("/api/users/settings") @@ -335,8 +236,7 @@ class UserControllerTest extends AbstractWebMvcTest { SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) ); - when(userService.updateUserSettings(eq(1L), eq(email), any(), eq("Lobo"), any())) - .thenThrow(new DisplayNameAlreadyInUseException("taken")); + doThrow(new DisplayNameAlreadyInUseException("taken")).when(userService).updateUserSettings(eq(1L), eq("Lobo"), any()); // Act & Assert mockMvc.perform(put("/api/users/settings") @@ -355,8 +255,7 @@ class UserControllerTest extends AbstractWebMvcTest { SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) ); - when(userService.updateUserSettings(eq(1L), eq(email), any(), eq("Lobo"), any())) - .thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uq_users_display_name_lower\"")); + doThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uq_users_display_name_lower\"")).when(userService).updateUserSettings(eq(1L), eq("Lobo"), any()); // Act & Assert mockMvc.perform(put("/api/users/settings") @@ -380,7 +279,7 @@ class UserControllerTest extends AbstractWebMvcTest { .content("{\"useDarkTheme\":true,\"language\":\"en\",\"steamId\":null,\"displayName\":\"Lobo\"" + field + "}")) .andExpect(status().isNoContent()); - verify(userService).updateUserSettings(eq(1L), eq("test@example.com"), isNull(), eq("Lobo"), + verify(userService).updateUserSettings(eq(1L), eq("Lobo"), argThat(settings -> settings.isPublicProfile() == expected)); } diff --git a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java index 08d2db5..ff4fa35 100644 --- a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java +++ b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java @@ -2,6 +2,7 @@ package auth_service.modules.user.persistence; import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.model.Appearance; +import auth_service.modules.user.model.SteamIdHolder; import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.shared.test.AbstractPersistenceTest; @@ -11,6 +12,7 @@ import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; import org.springframework.context.annotation.Import; import java.time.LocalDateTime; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -65,13 +67,12 @@ class UserRepositoryTest extends AbstractPersistenceTest { .build(); // Act - var updated = userRepository.updateSettings(userEntity.getId(), 654321L, null, settings); + userRepository.updateSettings(userEntity.getId(), null, settings); - // Assert - assertTrue(updated); + // Assert: the Steam ID changes only through the Steam link var updatedUser = entityManager.find(UserEntity.class, userEntity.getId()); assertNotNull(updatedUser); - assertEquals(654321L, updatedUser.getSteamId()); + assertEquals(123456L, updatedUser.getSteamId()); assertNotNull(updatedUser.getSettings()); assertEquals("de", updatedUser.getSettings().getLanguage()); } @@ -96,55 +97,54 @@ class UserRepositoryTest extends AbstractPersistenceTest { assertNull(result.get().getSteamId()); } - @Test - void updateSettings_ClearsSteamId() { - // Arrange - var userEntity = entityManager.persist(UserEntity.builder() - .email("clear@example.com") + private UserEntity user(String email, Long steamId, boolean verified) { + UserEntity user = entityManager.persist(UserEntity.builder() + .email(email) .password("password") .enabled(true) - .steamId(123456L) + .steamId(steamId) + .steamIdVerified(verified) .registrationDate(LocalDateTime.now()) .build()); entityManager.flush(); - var settings = UserSettings.builder().language("en").theme("light").build(); - - // Act - userRepository.updateSettings(userEntity.getId(), null, null, settings); - - // Assert - assertNull(entityManager.find(UserEntity.class, userEntity.getId()).getSteamId()); + return user; } @Test - void isSteamIdUsedByOtherUser() { - // Arrange - var owner = entityManager.persist(UserEntity.builder() - .email("owner@example.com") - .password("password") - .enabled(true) - .steamId(76561198000000001L) - .registrationDate(LocalDateTime.now()) - .build()); - var other = entityManager.persist(UserEntity.builder() - .email("other@example.com") - .password("password") - .enabled(true) - .registrationDate(LocalDateTime.now()) - .build()); - entityManager.flush(); + void findSteamIdHolder_ReturnsTheAccountAndWhetherItIsVerified() { + UserEntity owner = user("owner@example.com", 76561198000000001L, true); - // Act & Assert - assertFalse(userRepository.isSteamIdUsedByOtherUser(owner.getId(), 76561198000000001L)); - assertTrue(userRepository.isSteamIdUsedByOtherUser(other.getId(), 76561198000000001L)); - assertFalse(userRepository.isSteamIdUsedByOtherUser(other.getId(), 76561198000000000L)); + assertEquals(Optional.of(new SteamIdHolder(owner.getId(), true)), userRepository.findSteamIdHolder(76561198000000001L)); + assertTrue(userRepository.findSteamIdHolder(76561198000000000L).isEmpty()); + } + + @Test + void clearThenSet_MovesTheIdWithoutBreakingTheUniqueConstraint() { + UserEntity typist = user("typist@example.com", 76561198000000001L, false); + UserEntity owner = user("owner@example.com", null, false); + + userRepository.clearSteamId(typist.getId()); + userRepository.setVerifiedSteamId(owner.getId(), 76561198000000001L); + entityManager.clear(); + + UserEntity cleared = entityManager.find(UserEntity.class, typist.getId()); + assertNull(cleared.getSteamId()); + assertFalse(cleared.isSteamIdVerified()); + UserEntity linked = entityManager.find(UserEntity.class, owner.getId()); + assertEquals(76561198000000001L, linked.getSteamId()); + assertTrue(linked.isSteamIdVerified()); + } + + @Test + void setVerifiedSteamId_UnknownUser() { + assertThrows(UserNotFoundException.class, () -> userRepository.setVerifiedSteamId(999_999L, 76561198000000001L)); } @Test void updateSettings_UnknownUser() { var settings = UserSettings.builder().language("en").theme("light").build(); - assertThrows(UserNotFoundException.class, () -> userRepository.updateSettings(-1L, null, null, settings)); + assertThrows(UserNotFoundException.class, () -> userRepository.updateSettings(-1L, null, settings)); } @Test @@ -172,7 +172,7 @@ class UserRepositoryTest extends AbstractPersistenceTest { var settings = UserSettings.builder().language("en").theme("light").build(); // Act - userRepository.updateSettings(owner.getId(), null, "Lobo", settings); + userRepository.updateSettings(owner.getId(), "Lobo", settings); entityManager.flush(); // Assert @@ -192,7 +192,7 @@ class UserRepositoryTest extends AbstractPersistenceTest { .build()); entityManager.flush(); - userRepository.updateSettings(userEntity.getId(), null, "Lobo", + userRepository.updateSettings(userEntity.getId(), "Lobo", UserSettings.builder().language("de").theme("dark").publicProfile(true).build()); entityManager.flush(); entityManager.clear(); @@ -230,7 +230,7 @@ class UserRepositoryTest extends AbstractPersistenceTest { userRepository.updateAppearance(userEntity.getId(), new Appearance("pink", "sharp", false, 220L, null, true)); entityManager.flush(); - userRepository.updateSettings(userEntity.getId(), null, null, + userRepository.updateSettings(userEntity.getId(), null, UserSettings.builder().language("de").theme("dark").build()); entityManager.flush(); entityManager.clear(); diff --git a/src/test/java/auth_service/modules/user/service/SteamLinkServiceTest.java b/src/test/java/auth_service/modules/user/service/SteamLinkServiceTest.java new file mode 100644 index 0000000..87bb793 --- /dev/null +++ b/src/test/java/auth_service/modules/user/service/SteamLinkServiceTest.java @@ -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 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"); + } +} diff --git a/src/test/java/auth_service/modules/user/service/SteamOpenIdServiceTest.java b/src/test/java/auth_service/modules/user/service/SteamOpenIdServiceTest.java new file mode 100644 index 0000000..52dff85 --- /dev/null +++ b/src/test/java/auth_service/modules/user/service/SteamOpenIdServiceTest.java @@ -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 answer() { + Map 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 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> 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 with(String key, String value) { + Map params = answer(); + params.put(key, value); + return params; + } + + private static Map withClaimedId(String claimedId) { + Map params = answer(); + params.put("openid.claimed_id", claimedId); + params.put("openid.identity", claimedId); + return params; + } +} diff --git a/src/test/java/auth_service/modules/user/service/UserServiceTest.java b/src/test/java/auth_service/modules/user/service/UserServiceTest.java index f6e89fb..9cfc277 100644 --- a/src/test/java/auth_service/modules/user/service/UserServiceTest.java +++ b/src/test/java/auth_service/modules/user/service/UserServiceTest.java @@ -1,15 +1,17 @@ package auth_service.modules.user.service; import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; -import auth_service.modules.user.exception.SteamIdAlreadyInUseException; import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.model.Appearance; +import auth_service.modules.user.model.SteamIdHolder; +import auth_service.modules.user.model.SteamLinkOutcome; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.persistence.UserRepository; import auth_service.shared.security.TokenGenerator; import auth_service.shared.test.AbstractUnitTest; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -60,52 +62,15 @@ class UserServiceTest extends AbstractUnitTest { } @Test - void updateUserSettings_ReturnsTokenWithNewSteamId() { - // Arrange - long userId = 1L; - var email = "test@example.com"; - Long steamId = 76561198000000001L; + void updateUserSettings_NeverTouchesTheSteamIdAndIssuesNoToken() { var userSettings = UserSettings.builder().language("en").build(); - when(userRepository.isSteamIdUsedByOtherUser(userId, steamId)).thenReturn(false); - when(tokenGenerator.generateToken("1", email, steamId)).thenReturn("new-token"); - // Act - var token = userService.updateUserSettings(userId, email, steamId, null, userSettings); + userService.updateUserSettings(1L, null, userSettings); - // Assert - assertEquals("new-token", token); - verify(userRepository).updateSettings(userId, steamId, null, userSettings); - } - - @Test - void updateUserSettings_WithoutSteamId() { - // Arrange - long userId = 1L; - var email = "test@example.com"; - var userSettings = UserSettings.builder().language("en").build(); - when(tokenGenerator.generateToken("1", email, null)).thenReturn("new-token"); - - // Act - var token = userService.updateUserSettings(userId, email, null, null, userSettings); - - // Assert - assertEquals("new-token", token); - verify(userRepository).updateSettings(userId, null, null, userSettings); - verify(userRepository, never()).isSteamIdUsedByOtherUser(anyLong(), anyLong()); - } - - @Test - void updateUserSettings_SteamIdOfOtherUser() { - // Arrange - long userId = 1L; - Long steamId = 76561198000000001L; - var userSettings = UserSettings.builder().language("en").build(); - when(userRepository.isSteamIdUsedByOtherUser(userId, steamId)).thenReturn(true); - - // Act & Assert - assertThrows(SteamIdAlreadyInUseException.class, - () -> userService.updateUserSettings(userId, "test@example.com", steamId, null, userSettings)); - verify(userRepository, never()).updateSettings(anyLong(), any(), any(), any()); + verify(userRepository).updateSettings(1L, null, userSettings); + verify(userRepository, never()).clearSteamId(anyLong()); + verify(userRepository, never()).setVerifiedSteamId(anyLong(), anyLong()); + verifyNoInteractions(tokenGenerator); } @Test @@ -116,8 +81,8 @@ class UserServiceTest extends AbstractUnitTest { // Act & Assert assertThrows(DisplayNameAlreadyInUseException.class, - () -> userService.updateUserSettings(1L, "test@example.com", null, "Lobo", userSettings)); - verify(userRepository, never()).updateSettings(anyLong(), any(), any(), any()); + () -> userService.updateUserSettings(1L, "Lobo", userSettings)); + verify(userRepository, never()).updateSettings(anyLong(), any(), any()); } @Test @@ -127,10 +92,61 @@ class UserServiceTest extends AbstractUnitTest { when(userRepository.isDisplayNameUsedByOtherUser(1L, "Lobo")).thenReturn(false); // Act - userService.updateUserSettings(1L, "test@example.com", null, "Lobo", userSettings); + userService.updateUserSettings(1L, "Lobo", userSettings); // Assert - verify(userRepository).updateSettings(1L, null, "Lobo", userSettings); + verify(userRepository).updateSettings(1L, "Lobo", userSettings); + } + + private static final long STEAM_ID = 76561198000000001L; + + @Test + void linkVerifiedSteamId_FreeId_IsStoredVerified() { + when(userRepository.findSteamIdHolder(STEAM_ID)).thenReturn(Optional.empty()); + + assertEquals(SteamLinkOutcome.LINKED, userService.linkVerifiedSteamId(1L, STEAM_ID)); + + verify(userRepository).setVerifiedSteamId(1L, STEAM_ID); + } + + @Test + void linkVerifiedSteamId_OwnTypedId_BecomesVerified() { + when(userRepository.findSteamIdHolder(STEAM_ID)).thenReturn(Optional.of(new SteamIdHolder(1L, false))); + + assertEquals(SteamLinkOutcome.LINKED, userService.linkVerifiedSteamId(1L, STEAM_ID)); + + verify(userRepository, never()).clearSteamId(anyLong()); + verify(userRepository).setVerifiedSteamId(1L, STEAM_ID); + } + + @Test + void linkVerifiedSteamId_TypedInByAnotherAccount_IsTakenFromIt() { + when(userRepository.findSteamIdHolder(STEAM_ID)).thenReturn(Optional.of(new SteamIdHolder(2L, false))); + + assertEquals(SteamLinkOutcome.LINKED, userService.linkVerifiedSteamId(1L, STEAM_ID)); + + InOrder order = inOrder(userRepository); + order.verify(userRepository).clearSteamId(2L); + order.verify(userRepository).setVerifiedSteamId(1L, STEAM_ID); + } + + @Test + void linkVerifiedSteamId_ProvedByAnotherAccount_IsInUse() { + when(userRepository.findSteamIdHolder(STEAM_ID)).thenReturn(Optional.of(new SteamIdHolder(2L, true))); + + assertEquals(SteamLinkOutcome.IN_USE, userService.linkVerifiedSteamId(1L, STEAM_ID)); + + verify(userRepository, never()).clearSteamId(anyLong()); + verify(userRepository, never()).setVerifiedSteamId(anyLong(), anyLong()); + } + + @Test + void unlinkSteamId_ClearsItAndReturnsATokenWithoutIt() { + when(tokenGenerator.generateToken("1", "test@example.com", null)).thenReturn("token-without-steam"); + + assertEquals("token-without-steam", userService.unlinkSteamId(1L, "test@example.com")); + + verify(userRepository).clearSteamId(1L); } @Test