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