Public Access
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd79c63441 | ||
|
|
9f56742ff6 | ||
|
|
89e2fa3df2 | ||
|
|
06f8089f5a |
No files matched your search
@@ -36,5 +36,9 @@ EXPOSE 8080
|
|||||||
# Copy the compiled jar file from the builder stage
|
# Copy the compiled jar file from the builder stage
|
||||||
COPY --from=builder /app/target/*.jar app.jar
|
COPY --from=builder /app/target/*.jar app.jar
|
||||||
|
|
||||||
|
# No root in the container
|
||||||
|
RUN addgroup -S app && adduser -S app -G app
|
||||||
|
USER app
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
@@ -43,8 +43,7 @@ To Be Played is split into three repositories:
|
|||||||
tile is shown), `profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout
|
tile is shown), `profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout
|
||||||
(`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login: every token names
|
(`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login: every token names
|
||||||
its kind in `aud` (`tbp-login`, `tbp-public-profile`, `tbp-internal`, `iss=tbp-auth`), and both services check it.
|
its kind in `aud` (`tbp-login`, `tbp-public-profile`, `tbp-internal`, `iss=tbp-auth`), and both services check it.
|
||||||
A login's `sub` is the user id; the email is in no token. Until item 29 the tokens also carry the claims of before
|
A login's `sub` is the user id; the email is in no token. A token without `aud` is refused. The claims are pinned in
|
||||||
item 26 (`userId`, `typ`), so an older Core still reads them. The claims are pinned in
|
|
||||||
`src/test/resources/contract/auth-core-contract.json`, the same file as in Core
|
`src/test/resources/contract/auth-core-contract.json`, the same file as in Core
|
||||||
(`ToBePlayed-Deployment/scripts/check-contract.sh` compares both).
|
(`ToBePlayed-Deployment/scripts/check-contract.sh` compares both).
|
||||||
- Errors: every error answer of a controller has the body `{code, message}` (a 401 or 403 of the security filters has none); a refused request is `400 VALIDATION_FAILED` with
|
- Errors: every error answer of a controller has the body `{code, message}` (a 401 or 403 of the security filters has none); a refused request is `400 VALIDATION_FAILED` with
|
||||||
@@ -102,9 +101,9 @@ To Be Played is split into three repositories:
|
|||||||
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings |
|
| `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 |
|
||||||
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token |
|
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token |
|
||||||
| `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`) | access token |
|
| `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`). Auth checks only the id shape `[A-Z_]{1,30}`: Core and the Frontend skip ids they do not know, and Core never shares the overview or the activity | access token |
|
||||||
| `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token |
|
| `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token |
|
||||||
| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`: `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
|
| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
|
||||||
| `GET` | `/api/public/profiles/{displayName}` | public |
|
| `GET` | `/api/public/profiles/{displayName}` | public |
|
||||||
| `GET` | `/api/stats/public` | public |
|
| `GET` | `/api/stats/public` | public |
|
||||||
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
||||||
@@ -157,6 +156,12 @@ classes (`AbstractUnitTest`, `AbstractWebMvcTest`, `AbstractPersistenceTest`, `A
|
|||||||
the base classes carry every annotation, so each kind starts one Spring context. A test with its own annotations
|
the base classes carry every annotation, so each kind starts one Spring context. A test with its own annotations
|
||||||
starts another one.
|
starts another one.
|
||||||
|
|
||||||
|
**Mirrored in Core.** These files exist in both services with small differences; a change in one needs the same
|
||||||
|
change in the other: `ClientHeaderFilter`, `RoleAuthorizationManager`, `MethodSecurityConfig`, `Role`, `RequiresRole`,
|
||||||
|
`Public` (Core also has `Internal`), `UserPrincipal`, the login token check in `JwtService.parseLoginToken`, the
|
||||||
|
ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/auth-core-contract.json`
|
||||||
|
(`ToBePlayed-Deployment/scripts/check-contract.sh` compares the two).
|
||||||
|
|
||||||
### Login flow
|
### Login flow
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -221,6 +226,7 @@ sequenceDiagram
|
|||||||
Note over A: an old access token gets 401 within 30 s (activeUsers cache)
|
Note over A: an old access token gets 401 within 30 s (activeUsers cache)
|
||||||
Note over A,C: nightly 04:15
|
Note over A,C: nightly 04:15
|
||||||
A->>C: PUT /internal/accounts/deleted (all flagged users)
|
A->>C: PUT /internal/accounts/deleted (all flagged users)
|
||||||
|
A->>C: PUT /internal/accounts/states (every user)
|
||||||
A->>C: POST /internal/accounts/purge (flagged 30+ days)
|
A->>C: POST /internal/accounts/purge (flagged 30+ days)
|
||||||
A->>A: DELETE users (only when Core answered and no dry run)
|
A->>A: DELETE users (only when Core answered and no dry run)
|
||||||
A->>C: POST /internal/accounts/prune (all user ids)
|
A->>C: POST /internal/accounts/prune (all user ids)
|
||||||
@@ -229,6 +235,24 @@ sequenceDiagram
|
|||||||
A failed call to Core does not fail the delete: the next night sends the whole flagged set again. Undo within 30
|
A failed call to Core does not fail the delete: the next night sends the whole flagged set again. Undo within 30
|
||||||
days is `UPDATE users SET deleted_at = NULL` (see the Deployment README).
|
days is `UPDATE users SET deleted_at = NULL` (see the Deployment README).
|
||||||
|
|
||||||
|
### Steam ID and block sync to Core
|
||||||
|
|
||||||
|
Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that
|
||||||
|
loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit
|
||||||
|
(`AccountStatePublisher`); the nightly cleanup sends every user. The call runs on the task executor, so neither the answer
|
||||||
|
nor a database connection waits for Core; a failed call only warns.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant A as Auth
|
||||||
|
participant C as Core
|
||||||
|
A->>A: link / unlink / block / unblock (commit)
|
||||||
|
A->>C: POST /internal/accounts/states {userId, steamId, blocked}
|
||||||
|
C->>C: blocked_user, Steam ID in user_steam_sync
|
||||||
|
Note over A,C: nightly 04:15
|
||||||
|
A->>C: PUT /internal/accounts/states (every user)
|
||||||
|
```
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|
||||||
### Local development without keys (profile `local`)
|
### Local development without keys (profile `local`)
|
||||||
@@ -261,7 +285,7 @@ Needs Java 25, Docker and your own keys.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
|
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
|
||||||
service listens on `http://localhost:8080`. Health: `/actuator/health`, metrics: `/actuator/prometheus`.
|
service listens on `http://localhost:8080`. Health: `/actuator/health` (the only open actuator endpoint).
|
||||||
|
|
||||||
The cookies are host-only unless `APP_AUTH_COOKIE_DOMAIN` is set; production sets it to the parent domain, so Core
|
The cookies are host-only unless `APP_AUTH_COOKIE_DOMAIN` is set; production sets it to the parent domain, so Core
|
||||||
gets the access cookie too. Browsers accept `Secure` cookies on `http://localhost`, so local development needs no
|
gets the access cookie too. Browsers accept `Secure` cookies on `http://localhost`, so local development needs no
|
||||||
@@ -298,7 +322,7 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- Account deletion and data export.
|
See `ROADMAP.md` in the ToBePlayed-Deployment repo.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
package auth_service.configuration;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@EnableAsync
|
|
||||||
public class AsyncConfig {
|
|
||||||
// This class enables Spring's @Async annotation functionality globally
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ public class ObservabilityConfig {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers the TimedAspect bean to enable @Timed annotations.
|
* Registers the TimedAspect bean to enable @Timed annotations.
|
||||||
* * @param registry The Micrometer MeterRegistry
|
* @param registry The Micrometer MeterRegistry
|
||||||
*
|
*
|
||||||
* @return A new TimedAspect instance
|
* @return A new TimedAspect instance
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import java.util.Base64;
|
|||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,11 +37,8 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
|||||||
static final String PROFILE_TICKET_AUDIENCE = "tbp-public-profile";
|
static final String PROFILE_TICKET_AUDIENCE = "tbp-public-profile";
|
||||||
static final String SERVICE_AUDIENCE = "tbp-internal";
|
static final String SERVICE_AUDIENCE = "tbp-internal";
|
||||||
private static final long PROFILE_TICKET_EXPIRATION_MS = 10 * 60 * 1000;
|
private static final long PROFILE_TICKET_EXPIRATION_MS = 10 * 60 * 1000;
|
||||||
// The kind of a token before item 26; item 29 removes it.
|
private static final String PROFILE_TICKET_SUBJECT = "public-profile";
|
||||||
private static final String TYPE_CLAIM = "typ";
|
|
||||||
private static final String PROFILE_TICKET_TYPE = "public-profile";
|
|
||||||
private static final long SERVICE_TOKEN_EXPIRATION_MS = 60 * 1000;
|
private static final long SERVICE_TOKEN_EXPIRATION_MS = 60 * 1000;
|
||||||
private static final String SERVICE_TOKEN_TYPE = "service";
|
|
||||||
private static final String ED25519 = "Ed25519";
|
private static final String ED25519 = "Ed25519";
|
||||||
private static final String ROLE_CLAIM = "role";
|
private static final String ROLE_CLAIM = "role";
|
||||||
|
|
||||||
@@ -75,8 +71,6 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
|||||||
.issuer(ISSUER)
|
.issuer(ISSUER)
|
||||||
.audience().add(LOGIN_AUDIENCE).and()
|
.audience().add(LOGIN_AUDIENCE).and()
|
||||||
.subject(userId)
|
.subject(userId)
|
||||||
// item 29 removes: a Core from before item 26 reads the user id only from this claim.
|
|
||||||
.claim("userId", userId)
|
|
||||||
.claim(ROLE_CLAIM, role.name())
|
.claim(ROLE_CLAIM, role.name())
|
||||||
.issuedAt(new Date(System.currentTimeMillis()))
|
.issuedAt(new Date(System.currentTimeMillis()))
|
||||||
.expiration(new Date(System.currentTimeMillis() + sessionProperties.getAccessTokenTtl().toMillis()));
|
.expiration(new Date(System.currentTimeMillis() + sessionProperties.getAccessTokenTtl().toMillis()));
|
||||||
@@ -96,8 +90,7 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
|||||||
.header().keyId(jwtConfig.getKid()).and()
|
.header().keyId(jwtConfig.getKid()).and()
|
||||||
.issuer(ISSUER)
|
.issuer(ISSUER)
|
||||||
.audience().add(PROFILE_TICKET_AUDIENCE).and()
|
.audience().add(PROFILE_TICKET_AUDIENCE).and()
|
||||||
.subject(PROFILE_TICKET_TYPE)
|
.subject(PROFILE_TICKET_SUBJECT)
|
||||||
.claim(TYPE_CLAIM, PROFILE_TICKET_TYPE)
|
|
||||||
.claim("profileUserId", String.valueOf(userId))
|
.claim("profileUserId", String.valueOf(userId))
|
||||||
.claim("profileStats", profileStats)
|
.claim("profileStats", profileStats)
|
||||||
.claim("sections", sections)
|
.claim("sections", sections)
|
||||||
@@ -114,7 +107,6 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
|||||||
.issuer(ISSUER)
|
.issuer(ISSUER)
|
||||||
.audience().add(SERVICE_AUDIENCE).and()
|
.audience().add(SERVICE_AUDIENCE).and()
|
||||||
.subject("tbp-auth")
|
.subject("tbp-auth")
|
||||||
.claim(TYPE_CLAIM, SERVICE_TOKEN_TYPE)
|
|
||||||
.issuedAt(new Date(System.currentTimeMillis()))
|
.issuedAt(new Date(System.currentTimeMillis()))
|
||||||
.expiration(new Date(System.currentTimeMillis() + SERVICE_TOKEN_EXPIRATION_MS))
|
.expiration(new Date(System.currentTimeMillis() + SERVICE_TOKEN_EXPIRATION_MS))
|
||||||
.signWith(privateKey, Jwts.SIG.EdDSA)
|
.signWith(privateKey, Jwts.SIG.EdDSA)
|
||||||
@@ -138,12 +130,10 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
|||||||
public Optional<UserPrincipal> parseLoginToken(String token) {
|
public Optional<UserPrincipal> parseLoginToken(String token) {
|
||||||
try {
|
try {
|
||||||
Claims claims = parser.parseSignedClaims(token).getPayload();
|
Claims claims = parser.parseSignedClaims(token).getPayload();
|
||||||
if (!isKind(claims, LOGIN_AUDIENCE, null)) {
|
if (!isKind(claims, LOGIN_AUDIENCE)) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
// item 29 removes: a token without aud comes from before item 26, with the user id in "userId".
|
String userId = claims.getSubject();
|
||||||
String userId = hasAudience(claims) ? claims.getSubject()
|
|
||||||
: claims.getSubject() == null ? null : claims.get("userId", String.class);
|
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
@@ -154,18 +144,9 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean hasAudience(Claims claims) {
|
private static boolean isKind(Claims claims, String audience) {
|
||||||
return claims.getAudience() != null && !claims.getAudience().isEmpty();
|
return claims.getAudience() != null && claims.getAudience().contains(audience)
|
||||||
}
|
&& ISSUER.equals(claims.getIssuer());
|
||||||
|
|
||||||
/**
|
|
||||||
* @param legacyType the {@code typ} of this kind before item 26, null for a login
|
|
||||||
*/
|
|
||||||
private static boolean isKind(Claims claims, String audience, String legacyType) {
|
|
||||||
if (hasAudience(claims)) {
|
|
||||||
return claims.getAudience().contains(audience) && ISSUER.equals(claims.getIssuer());
|
|
||||||
}
|
|
||||||
return Objects.equals(legacyType, claims.get(TYPE_CLAIM, String.class));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PrivateKey decodePrivateKey(String base64Pkcs8Der) {
|
private static PrivateKey decodePrivateKey(String base64Pkcs8Der) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import org.springframework.security.authorization.AuthorizationManager;
|
|||||||
import org.springframework.security.authorization.AuthorizationResult;
|
import org.springframework.security.authorization.AuthorizationResult;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.GrantedAuthority;
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
import org.springframework.web.context.request.RequestContextHolder;
|
|
||||||
|
|
||||||
import java.lang.reflect.AnnotatedElement;
|
import java.lang.reflect.AnnotatedElement;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
@@ -23,9 +22,6 @@ class RoleAuthorizationManager implements AuthorizationManager<MethodInvocation>
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AuthorizationResult authorize(Supplier<? extends Authentication> authentication, MethodInvocation invocation) {
|
public AuthorizationResult authorize(Supplier<? extends Authentication> authentication, MethodInvocation invocation) {
|
||||||
if (RequestContextHolder.getRequestAttributes() == null) {
|
|
||||||
return GRANTED;
|
|
||||||
}
|
|
||||||
Class<?> targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis())
|
Class<?> targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis())
|
||||||
: invocation.getMethod().getDeclaringClass();
|
: invocation.getMethod().getDeclaringClass();
|
||||||
Method method = AopUtils.getMostSpecificMethod(invocation.getMethod(), targetClass);
|
Method method = AopUtils.getMostSpecificMethod(invocation.getMethod(), targetClass);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class AccountController {
|
|||||||
private final AccountService accountService;
|
private final AccountService accountService;
|
||||||
private final AuthCookies authCookies;
|
private final AuthCookies authCookies;
|
||||||
|
|
||||||
@DeleteMapping(value = "/api/users/me", version = "1.0")
|
@DeleteMapping(value = "/api/users/me")
|
||||||
public ResponseEntity<Void> deleteAccount(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<Void> deleteAccount(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@Valid @RequestBody DeleteAccountRequest request) {
|
@Valid @RequestBody DeleteAccountRequest request) {
|
||||||
accountService.deleteAccount(principal.getIdAsLong(), request.password());
|
accountService.deleteAccount(principal.getIdAsLong(), request.password());
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.modules.account.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One user as Core copies it.
|
||||||
|
*/
|
||||||
|
public record CoreAccountState(long userId, Long steamId, boolean blocked) {
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package auth_service.modules.account.repo;
|
package auth_service.modules.account.repo;
|
||||||
|
|
||||||
|
import auth_service.modules.account.model.CoreAccountState;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@@ -56,6 +58,20 @@ public class AccountRepository {
|
|||||||
Timestamp.from(cutoff));
|
Timestamp.from(cutoff));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final RowMapper<CoreAccountState> STATE_ROW = (rs, row) ->
|
||||||
|
new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), !rs.getBoolean("enabled"));
|
||||||
|
|
||||||
|
public Optional<CoreAccountState> findState(long userId) {
|
||||||
|
return jdbc.query("SELECT id, steam_id, enabled FROM users WHERE id = ?", STATE_ROW, userId).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every user, also the flagged ones.
|
||||||
|
*/
|
||||||
|
public List<CoreAccountState> findAllStates() {
|
||||||
|
return jdbc.query("SELECT id, steam_id, enabled FROM users ORDER BY id", STATE_ROW);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Also the flagged users: their rows in Core are no orphans yet.
|
* Also the flagged users: their rows in Core are no orphans yet.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import java.time.Instant;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nightly, after Core's sync: tells Core who is flagged, removes users flagged longer than the retention (Core
|
* Nightly, after Core's sync: tells Core who is flagged and every user's Steam ID and block, removes users flagged
|
||||||
* first, then Auth), and lets Core remove rows of users Auth does not know.
|
* longer than the retention (Core first, then Auth), and lets Core remove rows of users Auth does not know.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -41,10 +41,19 @@ public class AccountCleanupService {
|
|||||||
log.warn("Account cleanup stopped: Core did not take the flagged users: {}", e.getMessage());
|
log.warn("Account cleanup stopped: Core did not take the flagged users: {}", e.getMessage());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
sendStates();
|
||||||
purgeDue();
|
purgeDue();
|
||||||
pruneOrphans();
|
pruneOrphans();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void sendStates() {
|
||||||
|
try {
|
||||||
|
coreAccountService.replaceStates(accountRepository.findAllStates());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("Account cleanup: Core did not take the Steam IDs and blocks: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void purgeDue() {
|
private void purgeDue() {
|
||||||
List<Long> due = accountRepository.findFlaggedBefore(Instant.now().minus(retention));
|
List<Long> due = accountRepository.findFlaggedBefore(Instant.now().minus(retention));
|
||||||
if (due.isEmpty()) {
|
if (due.isEmpty()) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package auth_service.modules.account.service;
|
package auth_service.modules.account.service;
|
||||||
|
|
||||||
|
import auth_service.modules.account.model.CoreAccountState;
|
||||||
import auth_service.modules.account.model.CoreCleanupResult;
|
import auth_service.modules.account.model.CoreCleanupResult;
|
||||||
import auth_service.shared.security.ServiceTokenIssuer;
|
import auth_service.shared.security.ServiceTokenIssuer;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -52,6 +53,19 @@ public class CoreAccountService {
|
|||||||
.body(Map.of("userIds", userIds)).retrieve().toBodilessEntity();
|
.body(Map.of("userIds", userIds)).retrieve().toBodilessEntity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void applyStates(List<CoreAccountState> states) {
|
||||||
|
restClient.post().uri("/internal/accounts/states").headers(this::authorize)
|
||||||
|
.body(Map.of("accounts", states)).retrieve().toBodilessEntity();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After this, Core blocks exactly the blocked users of the list.
|
||||||
|
*/
|
||||||
|
public void replaceStates(List<CoreAccountState> states) {
|
||||||
|
restClient.put().uri("/internal/accounts/states").headers(this::authorize)
|
||||||
|
.body(Map.of("accounts", states)).retrieve().toBodilessEntity();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param dryRun Core only counts; Core also counts when its own dry run is on
|
* @param dryRun Core only counts; Core also counts when its own dry run is on
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package auth_service.modules.account.service;
|
||||||
|
|
||||||
|
import auth_service.modules.account.repo.AccountRepository;
|
||||||
|
import auth_service.shared.account.AccountStatePublisher;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends on the task executor: in afterCommit the transaction still holds its database connection, and a slow Core
|
||||||
|
* (up to 13 s) would keep it from the pool.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class CoreAccountStatePublisher implements AccountStatePublisher {
|
||||||
|
|
||||||
|
private final AccountRepository accountRepository;
|
||||||
|
private final CoreAccountService coreAccountService;
|
||||||
|
private final Executor executor;
|
||||||
|
|
||||||
|
public CoreAccountStatePublisher(AccountRepository accountRepository, CoreAccountService coreAccountService,
|
||||||
|
@Qualifier("applicationTaskExecutor") Executor executor) {
|
||||||
|
this.accountRepository = accountRepository;
|
||||||
|
this.coreAccountService = coreAccountService;
|
||||||
|
this.executor = executor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void publish(long userId) {
|
||||||
|
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
executor.execute(() -> send(userId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
executor.execute(() -> send(userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void send(long userId) {
|
||||||
|
try {
|
||||||
|
accountRepository.findState(userId).ifPresent(state -> coreAccountService.applyStates(List.of(state)));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("Core did not take the state of user {}, the nightly cleanup sends it again: {}", userId,
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ public class AdminUserController {
|
|||||||
|
|
||||||
private final AdminUserService adminUserService;
|
private final AdminUserService adminUserService;
|
||||||
|
|
||||||
@GetMapping(version = "1.0")
|
@GetMapping
|
||||||
public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query,
|
public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
@@ -36,14 +36,14 @@ public class AdminUserController {
|
|||||||
found.page(), found.size(), found.totalElements(), found.totalPages()));
|
found.page(), found.size(), found.totalElements(), found.totalPages()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/{userId}/blocked", version = "1.0")
|
@PutMapping(value = "/{userId}/blocked")
|
||||||
public ResponseEntity<Void> setBlocked(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId,
|
public ResponseEntity<Void> setBlocked(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId,
|
||||||
@Valid @RequestBody BlockRequest request) {
|
@Valid @RequestBody BlockRequest request) {
|
||||||
adminUserService.setBlocked(principal.getIdAsLong(), userId, request.blocked());
|
adminUserService.setBlocked(principal.getIdAsLong(), userId, request.blocked());
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping(value = "/{userId}", version = "1.0")
|
@DeleteMapping(value = "/{userId}")
|
||||||
public ResponseEntity<Void> delete(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId) {
|
public ResponseEntity<Void> delete(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId) {
|
||||||
adminUserService.delete(principal.getIdAsLong(), userId);
|
adminUserService.delete(principal.getIdAsLong(), userId);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import auth_service.modules.admin.model.AdminUserPage;
|
|||||||
import auth_service.modules.admin.model.TargetState;
|
import auth_service.modules.admin.model.TargetState;
|
||||||
import auth_service.modules.admin.repo.AdminUserRepository;
|
import auth_service.modules.admin.repo.AdminUserRepository;
|
||||||
import auth_service.shared.account.AccountDeleter;
|
import auth_service.shared.account.AccountDeleter;
|
||||||
|
import auth_service.shared.account.AccountStatePublisher;
|
||||||
import auth_service.shared.audit.AdminAuditLog;
|
import auth_service.shared.audit.AdminAuditLog;
|
||||||
import auth_service.shared.security.RequiresRole;
|
import auth_service.shared.security.RequiresRole;
|
||||||
import auth_service.shared.security.Role;
|
import auth_service.shared.security.Role;
|
||||||
@@ -29,6 +30,7 @@ public class AdminUserService {
|
|||||||
private final AdminUserRepository adminUserRepository;
|
private final AdminUserRepository adminUserRepository;
|
||||||
private final AdminAuditLog adminAuditLog;
|
private final AdminAuditLog adminAuditLog;
|
||||||
private final AccountDeleter accountDeleter;
|
private final AccountDeleter accountDeleter;
|
||||||
|
private final AccountStatePublisher accountStatePublisher;
|
||||||
|
|
||||||
public AdminUserPage findUsers(String query, int page, int size) {
|
public AdminUserPage findUsers(String query, int page, int size) {
|
||||||
String search = query == null ? "" : query.trim();
|
String search = query == null ? "" : query.trim();
|
||||||
@@ -48,6 +50,7 @@ public class AdminUserService {
|
|||||||
throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted");
|
throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted");
|
||||||
}
|
}
|
||||||
adminUserRepository.setBlocked(targetUserId, blocked, Instant.now());
|
adminUserRepository.setBlocked(targetUserId, blocked, Instant.now());
|
||||||
|
accountStatePublisher.publish(targetUserId);
|
||||||
adminAuditLog.record(actorUserId, blocked ? "BLOCK" : "UNBLOCK", targetUserId, null);
|
adminAuditLog.record(actorUserId, blocked ? "BLOCK" : "UNBLOCK", targetUserId, null);
|
||||||
log.info("Admin {} {} user {}", actorUserId, blocked ? "blocked" : "unblocked", targetUserId);
|
log.info("Admin {} {} user {}", actorUserId, blocked ? "blocked" : "unblocked", targetUserId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ public class AdminHintController {
|
|||||||
|
|
||||||
private final AdminHintService adminHintService;
|
private final AdminHintService adminHintService;
|
||||||
|
|
||||||
@GetMapping(version = "1.0")
|
@GetMapping
|
||||||
public ResponseEntity<AdminHintListResponse> findAll() {
|
public ResponseEntity<AdminHintListResponse> findAll() {
|
||||||
return ResponseEntity.ok(new AdminHintListResponse(adminHintService.findAll().stream()
|
return ResponseEntity.ok(new AdminHintListResponse(adminHintService.findAll().stream()
|
||||||
.map(AdminHintController::toApi).toList()));
|
.map(AdminHintController::toApi).toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(version = "1.0")
|
@PostMapping
|
||||||
public ResponseEntity<CreatedHintResponse> create(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<CreatedHintResponse> create(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@Valid @RequestBody CreateHintRequest request) {
|
@Valid @RequestBody CreateHintRequest request) {
|
||||||
long id = adminHintService.create(principal.getIdAsLong(),
|
long id = adminHintService.create(principal.getIdAsLong(),
|
||||||
@@ -43,7 +43,7 @@ public class AdminHintController {
|
|||||||
return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedHintResponse(id));
|
return ResponseEntity.status(HttpStatus.CREATED).body(new CreatedHintResponse(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/{hintId}/end", version = "1.0")
|
@PutMapping(value = "/{hintId}/end")
|
||||||
public ResponseEntity<Void> end(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) {
|
public ResponseEntity<Void> end(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) {
|
||||||
adminHintService.end(principal.getIdAsLong(), hintId);
|
adminHintService.end(principal.getIdAsLong(), hintId);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ public class HintController {
|
|||||||
|
|
||||||
private final HintService hintService;
|
private final HintService hintService;
|
||||||
|
|
||||||
@GetMapping(version = "1.0")
|
@GetMapping
|
||||||
public ResponseEntity<HintListResponse> activeHints(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<HintListResponse> activeHints(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
return ResponseEntity.ok(new HintListResponse(hintService.activeHints(principal.getIdAsLong()).stream()
|
return ResponseEntity.ok(new HintListResponse(hintService.activeHints(principal.getIdAsLong()).stream()
|
||||||
.map(HintController::toApi).toList()));
|
.map(HintController::toApi).toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/{hintId}/done", version = "1.0")
|
@PutMapping(value = "/{hintId}/done")
|
||||||
public ResponseEntity<Void> markDone(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) {
|
public ResponseEntity<Void> markDone(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long hintId) {
|
||||||
hintService.markDone(principal.getIdAsLong(), hintId);
|
hintService.markDone(principal.getIdAsLong(), hintId);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package auth_service.modules.login.model;
|
|||||||
import auth_service.shared.security.Role;
|
import auth_service.shared.security.Role;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import lombok.ToString;
|
||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
@Data
|
@Data
|
||||||
public class User {
|
public class User {
|
||||||
private long id;
|
private long id;
|
||||||
private String email;
|
private String email;
|
||||||
|
@ToString.Exclude
|
||||||
private String password;
|
private String password;
|
||||||
private Long steamId;
|
private Long steamId;
|
||||||
private boolean enabled;
|
private boolean enabled;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import java.util.Optional;
|
|||||||
public interface MinimalUserRepository {
|
public interface MinimalUserRepository {
|
||||||
/**
|
/**
|
||||||
* Finds a user by their email address.
|
* Finds a user by their email address.
|
||||||
* * @param email The email to search for
|
* @param email The email to search for
|
||||||
*
|
*
|
||||||
* @return An Optional containing the user if found
|
* @return An Optional containing the user if found
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class RegistrationController {
|
|||||||
|
|
||||||
private final RegistrationService registrationService;
|
private final RegistrationService registrationService;
|
||||||
|
|
||||||
@PostMapping(value = "/new", version = "1.0", produces = "application/json", consumes = "application/json")
|
@PostMapping(value = "/new", produces = "application/json", consumes = "application/json")
|
||||||
@Timed(value = "auth.registration.new.requests.time", description = "Time taken to process registration requests")
|
@Timed(value = "auth.registration.new.requests.time", description = "Time taken to process registration requests")
|
||||||
public ResponseEntity<RegistrationResponse> registration(@Valid @RequestBody RegistrationRequest request, HttpServletRequest httpRequest) {
|
public ResponseEntity<RegistrationResponse> registration(@Valid @RequestBody RegistrationRequest request, HttpServletRequest httpRequest) {
|
||||||
var userId = registrationService.register(ApiMapper.mapToModel(request), ClientIp.of(httpRequest));
|
var userId = registrationService.register(ApiMapper.mapToModel(request), ClientIp.of(httpRequest));
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package auth_service.modules.registration.model;
|
|||||||
|
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import lombok.ToString;
|
||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
@Data
|
@Data
|
||||||
public class Registration {
|
public class Registration {
|
||||||
private String email;
|
private String email;
|
||||||
|
@ToString.Exclude
|
||||||
private String rawPassword;
|
private String rawPassword;
|
||||||
|
@ToString.Exclude
|
||||||
private String encryptedPassword;
|
private String encryptedPassword;
|
||||||
private String inviteCode;
|
private String inviteCode;
|
||||||
private String displayName;
|
private String displayName;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ public class RegistrationService {
|
|||||||
|
|
||||||
private void validate(Registration registration) {
|
private void validate(Registration registration) {
|
||||||
if (!codeValidationService.isCodeValid(registration.getInviteCode())) {
|
if (!codeValidationService.isCodeValid(registration.getInviteCode())) {
|
||||||
throw new InvalidInvitationCodeException("The provided invite code is invalid or has already been used.");
|
throw new InvalidInvitationCodeException("The provided invite code is invalid.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (registeredUserRepository.existsByEmail(registration.getEmail())) {
|
if (registeredUserRepository.existsByEmail(registration.getEmail())) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class SteamLinkController {
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final AuthCookies authCookies;
|
private final AuthCookies authCookies;
|
||||||
|
|
||||||
@PostMapping(value = "/api/users/steam-link", version = "1.0", produces = "application/json")
|
@PostMapping(value = "/api/users/steam-link", produces = "application/json")
|
||||||
public ResponseEntity<SteamLinkResponse> start(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<SteamLinkResponse> start(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
SteamLinkService.Start start = steamLinkService.start(principal.getIdAsLong());
|
SteamLinkService.Start start = steamLinkService.start(principal.getIdAsLong());
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
@@ -42,7 +42,7 @@ public class SteamLinkController {
|
|||||||
/**
|
/**
|
||||||
* A new access cookie without the steamId claim, so Core stops syncing at once.
|
* A new access cookie without the steamId claim, so Core stops syncing at once.
|
||||||
*/
|
*/
|
||||||
@DeleteMapping(value = "/api/users/steam-link", version = "1.0")
|
@DeleteMapping(value = "/api/users/steam-link")
|
||||||
public ResponseEntity<Void> unlink(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<Void> unlink(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
String token = userService.unlinkSteamId(principal.getIdAsLong(), principal.role());
|
String token = userService.unlinkSteamId(principal.getIdAsLong(), principal.role());
|
||||||
return ResponseEntity.noContent()
|
return ResponseEntity.noContent()
|
||||||
@@ -53,7 +53,7 @@ public class SteamLinkController {
|
|||||||
/**
|
/**
|
||||||
* Steam's redirect back. Always sends the browser on to the settings page with the outcome.
|
* Steam's redirect back. Always sends the browser on to the settings page with the outcome.
|
||||||
*/
|
*/
|
||||||
@GetMapping(value = SteamLinkService.CALLBACK_PATH, version = "1.0")
|
@GetMapping(value = SteamLinkService.CALLBACK_PATH)
|
||||||
public ResponseEntity<Void> callback(@RequestParam(required = false) String state,
|
public ResponseEntity<Void> callback(@RequestParam(required = false) String state,
|
||||||
@RequestParam Map<String, String> params,
|
@RequestParam Map<String, String> params,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
|
|||||||
@@ -27,55 +27,51 @@ public class UserController {
|
|||||||
|
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
|
||||||
/**
|
@GetMapping(value = "/me", produces = "application/json")
|
||||||
* Version 1 of the 'me' endpoint, accessed via /api/users/me?v=1
|
@Timed(value = "user.me.requests.time", description = "Time taken to answer the user profile")
|
||||||
* * @return The user's dashboard data
|
public ResponseEntity<UserProfileResponse> getCurrentUser(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
*/
|
|
||||||
@GetMapping(value = "/me", version = "1.0", produces = "application/json")
|
|
||||||
@Timed(value = "user.me.v1.requests.time", description = "Time taken to process v1 user profile requests")
|
|
||||||
public ResponseEntity<UserProfileResponse> getCurrentUserV1(@AuthenticationPrincipal UserPrincipal principal) {
|
|
||||||
|
|
||||||
var profileResponse = this.userService.getUser(principal.getIdAsLong());
|
var profileResponse = this.userService.getUser(principal.getIdAsLong());
|
||||||
return ResponseEntity.ok(ApiMapper.toApi(profileResponse, principal.role()));
|
return ResponseEntity.ok(ApiMapper.toApi(profileResponse, principal.role()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/settings", version = "1.0", produces = "application/json")
|
@PutMapping(value = "/settings", produces = "application/json")
|
||||||
@Timed(value = "user.me.v1.requests.time", description = "Time taken to process v1 user profile requests")
|
@Timed(value = "user.settings.requests.time", description = "Time taken to save the user settings")
|
||||||
public ResponseEntity<Void> updateUserSettings(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<Void> updateUserSettings(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@Valid @RequestBody UserSettingsRequest request) {
|
@Valid @RequestBody UserSettingsRequest request) {
|
||||||
this.userService.updateUserSettings(principal.getIdAsLong(), request.displayName(), ApiMapper.toDomain(request));
|
this.userService.updateUserSettings(principal.getIdAsLong(), request.displayName(), ApiMapper.toDomain(request));
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/settings/dashboard-tiles", version = "1.0")
|
@PutMapping(value = "/settings/dashboard-tiles")
|
||||||
public ResponseEntity<Void> updateDashboardTiles(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<Void> updateDashboardTiles(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@Valid @RequestBody DashboardTilesRequest request) {
|
@Valid @RequestBody DashboardTilesRequest request) {
|
||||||
this.userService.updateDashboardTiles(principal.getIdAsLong(), ApiMapper.toDomain(request.tiles()));
|
this.userService.updateDashboardTiles(principal.getIdAsLong(), ApiMapper.toDomain(request.tiles()));
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/settings/profile-tiles", version = "1.0")
|
@PutMapping(value = "/settings/profile-tiles")
|
||||||
public ResponseEntity<Void> updateProfileTiles(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<Void> updateProfileTiles(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@Valid @RequestBody ProfileTilesRequest request) {
|
@Valid @RequestBody ProfileTilesRequest request) {
|
||||||
this.userService.updateProfileTiles(principal.getIdAsLong(), ApiMapper.toDomain(request.tiles()));
|
this.userService.updateProfileTiles(principal.getIdAsLong(), ApiMapper.toDomain(request.tiles()));
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/settings/favorite-platform", version = "1.0")
|
@PutMapping(value = "/settings/favorite-platform")
|
||||||
public ResponseEntity<Void> updateFavoritePlatform(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<Void> updateFavoritePlatform(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@Valid @RequestBody FavoritePlatformRequest request) {
|
@Valid @RequestBody FavoritePlatformRequest request) {
|
||||||
this.userService.updateFavoritePlatform(principal.getIdAsLong(), request.platform());
|
this.userService.updateFavoritePlatform(principal.getIdAsLong(), request.platform());
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/settings/profile-style", version = "1.0")
|
@PutMapping(value = "/settings/profile-style")
|
||||||
public ResponseEntity<Void> updateProfileStyle(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<Void> updateProfileStyle(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@Valid @RequestBody ProfileStyleRequest request) {
|
@Valid @RequestBody ProfileStyleRequest request) {
|
||||||
this.userService.updateProfileStyle(principal.getIdAsLong(), ApiMapper.toDomain(request));
|
this.userService.updateProfileStyle(principal.getIdAsLong(), ApiMapper.toDomain(request));
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping(value = "/settings/appearance", version = "1.0")
|
@PutMapping(value = "/settings/appearance")
|
||||||
public ResponseEntity<Void> updateAppearance(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<Void> updateAppearance(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@Valid @RequestBody AppearanceRequest request) {
|
@Valid @RequestBody AppearanceRequest request) {
|
||||||
this.userService.updateAppearance(principal.getIdAsLong(), ApiMapper.toDomain(request));
|
this.userService.updateAppearance(principal.getIdAsLong(), ApiMapper.toDomain(request));
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package auth_service.modules.user.api.dto.request;
|
package auth_service.modules.user.api.dto.request;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.AssertTrue;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
@@ -11,12 +10,4 @@ import java.util.List;
|
|||||||
* @param tiles the shown tiles in their order; null resets to the default dashboard
|
* @param tiles the shown tiles in their order; null resets to the default dashboard
|
||||||
*/
|
*/
|
||||||
public record DashboardTilesRequest(@Size(max = 20) List<@NotNull @Valid TileRequest> tiles) {
|
public record DashboardTilesRequest(@Size(max = 20) List<@NotNull @Valid TileRequest> tiles) {
|
||||||
|
|
||||||
public static final String IDS = "GAME_COUNT|MOST_PLAYED|PLAYING|PILE_OF_SHAME|ACTIVITY|ACHIEVEMENTS|STATS_OVERVIEW|"
|
|
||||||
+ ProfileTilesRequest.STAT_IDS;
|
|
||||||
|
|
||||||
@AssertTrue(message = "unknown tile id")
|
|
||||||
boolean isKnownIds() {
|
|
||||||
return TileRequest.knownIds(tiles, IDS);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,13 @@
|
|||||||
package auth_service.modules.user.api.dto.request;
|
package auth_service.modules.user.api.dto.request;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.AssertTrue;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param tiles the shown parts of the public profile in their order; the STATS_ tiles are the shared stat groups
|
* @param tiles the shown parts of the public profile in their order; the STATS_ tiles are Core's stat groups
|
||||||
*/
|
*/
|
||||||
public record ProfileTilesRequest(@NotNull @Size(max = 20) List<@NotNull @Valid TileRequest> tiles) {
|
public record ProfileTilesRequest(@NotNull @Size(max = 20) List<@NotNull @Valid TileRequest> tiles) {
|
||||||
|
|
||||||
/** The stat groups of Core's stats page, as tiles. */
|
|
||||||
public static final String STAT_IDS = "STATS_PILE|STATS_TREND|STATS_TOP_GAMES|STATS_TASTE|STATS_HABITS|STATS_FINISHED";
|
|
||||||
|
|
||||||
public static final String IDS = "SUMMARY|STATUS|PLAYING|FINISHED|" + STAT_IDS;
|
|
||||||
|
|
||||||
@AssertTrue(message = "unknown tile id")
|
|
||||||
boolean isKnownIds() {
|
|
||||||
return TileRequest.knownIds(tiles, IDS);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,17 +3,10 @@ package auth_service.modules.user.api.dto.request;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Pattern;
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The id is checked by the layout request that holds the tile, with {@link #knownIds}.
|
* @param id any tile id of the Frontend; Core and the Frontend skip ids they do not know. At most 30 characters:
|
||||||
|
* the column stores "ID:SIZE" in 32.
|
||||||
*/
|
*/
|
||||||
public record TileRequest(@NotNull String id, @NotNull @Pattern(regexp = "S|M|L") String size) {
|
public record TileRequest(@NotNull @Pattern(regexp = "[A-Z_]{1,30}") String id,
|
||||||
|
@NotNull @Pattern(regexp = "S|M|L") String size) {
|
||||||
/**
|
|
||||||
* @return true when every id matches; a missing list, tile or id is left to its own constraint
|
|
||||||
*/
|
|
||||||
static boolean knownIds(List<TileRequest> tiles, String ids) {
|
|
||||||
return tiles == null || tiles.stream().allMatch(tile -> tile == null || tile.id() == null || tile.id().matches(ids));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ public class UserRepositoryImpl implements UserRepository {
|
|||||||
|
|
||||||
TypedQuery<UserEntity> typedQuery = entityManager.createQuery(userEntityCriteriaQuery);
|
TypedQuery<UserEntity> typedQuery = entityManager.createQuery(userEntityCriteriaQuery);
|
||||||
|
|
||||||
return typedQuery.getResultStream()
|
return typedQuery.getResultList().stream()
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.map(EntityMapper::mapToModel);
|
.map(EntityMapper::mapToModel);
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ public class UserRepositoryImpl implements UserRepository {
|
|||||||
"SELECT new auth_service.modules.user.model.SteamIdHolder(u.id, u.steamIdVerified) "
|
"SELECT new auth_service.modules.user.model.SteamIdHolder(u.id, u.steamIdVerified) "
|
||||||
+ "FROM UserEntity u WHERE u.steamId = :steamId", SteamIdHolder.class)
|
+ "FROM UserEntity u WHERE u.steamId = :steamId", SteamIdHolder.class)
|
||||||
.setParameter("steamId", steamId)
|
.setParameter("steamId", steamId)
|
||||||
.getResultStream()
|
.getResultList().stream()
|
||||||
.findFirst();
|
.findFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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.account.AccountStatePublisher;
|
||||||
import auth_service.shared.security.RequiresRole;
|
import auth_service.shared.security.RequiresRole;
|
||||||
import auth_service.shared.security.Role;
|
import auth_service.shared.security.Role;
|
||||||
import auth_service.shared.security.TokenGenerator;
|
import auth_service.shared.security.TokenGenerator;
|
||||||
@@ -31,6 +32,7 @@ public class UserService {
|
|||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final TokenGenerator tokenGenerator;
|
private final TokenGenerator tokenGenerator;
|
||||||
|
private final AccountStatePublisher accountStatePublisher;
|
||||||
|
|
||||||
@RequiresRole(Role.USER)
|
@RequiresRole(Role.USER)
|
||||||
@Cacheable(value = "users", key = "#userId")
|
@Cacheable(value = "users", key = "#userId")
|
||||||
@@ -74,8 +76,10 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
log.info("Steam ID taken from user {}, who had typed it in, for user {}", holder.get().userId(), userId);
|
log.info("Steam ID taken from user {}, who had typed it in, for user {}", holder.get().userId(), userId);
|
||||||
userRepository.clearSteamId(holder.get().userId());
|
userRepository.clearSteamId(holder.get().userId());
|
||||||
|
accountStatePublisher.publish(holder.get().userId());
|
||||||
}
|
}
|
||||||
userRepository.setVerifiedSteamId(userId, steamId);
|
userRepository.setVerifiedSteamId(userId, steamId);
|
||||||
|
accountStatePublisher.publish(userId);
|
||||||
log.info("Steam ID verified for user {}", userId);
|
log.info("Steam ID verified for user {}", userId);
|
||||||
return SteamLinkOutcome.LINKED;
|
return SteamLinkOutcome.LINKED;
|
||||||
}
|
}
|
||||||
@@ -89,6 +93,7 @@ public class UserService {
|
|||||||
@CacheEvict(value = "publicStats", allEntries = true)})
|
@CacheEvict(value = "publicStats", allEntries = true)})
|
||||||
public String unlinkSteamId(long userId, Role role) {
|
public String unlinkSteamId(long userId, Role role) {
|
||||||
userRepository.clearSteamId(userId);
|
userRepository.clearSteamId(userId);
|
||||||
|
accountStatePublisher.publish(userId);
|
||||||
log.info("Steam ID unlinked for user {}", userId);
|
log.info("Steam ID unlinked for user {}", userId);
|
||||||
return tokenGenerator.generateToken(String.valueOf(userId), null, role);
|
return tokenGenerator.generateToken(String.valueOf(userId), null, role);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package auth_service.shared.account;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port for a change Core copies: the Steam ID or the block of a user. Sends after the commit; a failure only warns,
|
||||||
|
* because the nightly cleanup sends every user again.
|
||||||
|
*/
|
||||||
|
public interface AccountStatePublisher {
|
||||||
|
void publish(long userId);
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ package auth_service.shared.api;
|
|||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
|
||||||
public final class ClientIp {
|
public final class ClientIp {
|
||||||
|
|
||||||
private ClientIp() {
|
private ClientIp() {
|
||||||
@@ -9,15 +12,31 @@ public final class ClientIp {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Traefik appends the real client IP as the last X-Forwarded-For entry. Earlier entries come
|
* Traefik appends the real client IP as the last X-Forwarded-For entry. Earlier entries come
|
||||||
* from the client and can be faked, so only the last one counts. Without a usable last entry
|
* from the client and can be faked, so only the last one counts. The header counts only from a
|
||||||
* (no proxy in front) the connection address counts.
|
* peer in the private network (the proxy); from any other peer, and without a usable last entry,
|
||||||
|
* the connection address counts.
|
||||||
*/
|
*/
|
||||||
public static String of(HttpServletRequest request) {
|
public static String of(HttpServletRequest request) {
|
||||||
String forwardedFor = request.getHeader("X-Forwarded-For");
|
String forwardedFor = request.getHeader("X-Forwarded-For");
|
||||||
if (forwardedFor == null) {
|
if (forwardedFor == null || !isPrivate(request.getRemoteAddr())) {
|
||||||
return request.getRemoteAddr();
|
return request.getRemoteAddr();
|
||||||
}
|
}
|
||||||
String lastHop = forwardedFor.substring(forwardedFor.lastIndexOf(',') + 1).trim();
|
String lastHop = forwardedFor.substring(forwardedFor.lastIndexOf(',') + 1).trim();
|
||||||
return lastHop.isEmpty() ? request.getRemoteAddr() : lastHop;
|
return lastHop.isEmpty() ? request.getRemoteAddr() : lastHop;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isPrivate(String address) {
|
||||||
|
if (address == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// The servlet gives an IP literal, so this does no DNS lookup.
|
||||||
|
InetAddress peer = InetAddress.getByName(address);
|
||||||
|
// fc00::/7 is IPv6's private range; isSiteLocalAddress knows only the old fec0::/10.
|
||||||
|
boolean uniqueLocal = peer.getAddress().length == 16 && (peer.getAddress()[0] & 0xfe) == 0xfc;
|
||||||
|
return peer.isLoopbackAddress() || peer.isSiteLocalAddress() || uniqueLocal;
|
||||||
|
} catch (UnknownHostException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,8 +9,8 @@ import java.lang.annotation.Target;
|
|||||||
/**
|
/**
|
||||||
* The lowest active role that may call this service method, or every method of this class. A method marker
|
* The lowest active role that may call this service method, or every method of this class. A method marker
|
||||||
* replaces the class marker. Every service method a controller calls carries {@link RequiresRole} or
|
* replaces the class marker. Every service method a controller calls carries {@link RequiresRole} or
|
||||||
* {@link Public} (ArchitectureTest). Checked only while an HTTP request is served: scheduled jobs and
|
* {@link Public} (ArchitectureTest). Also checked outside an HTTP request: a scheduled job or a background
|
||||||
* {@code @Async} work run without a user.
|
* thread has no user, so it may call only {@link Public} methods.
|
||||||
*/
|
*/
|
||||||
@Documented
|
@Documented
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ spring:
|
|||||||
locations: classpath:db/migration,classpath:db/local
|
locations: classpath:db/migration,classpath:db/local
|
||||||
|
|
||||||
app:
|
app:
|
||||||
cors:
|
|
||||||
allowed-origins: "http://localhost:5173"
|
|
||||||
auth:
|
auth:
|
||||||
# Plain http; Safari drops Secure cookies on localhost.
|
# Plain http; Safari drops Secure cookies on localhost.
|
||||||
cookie-secure: false
|
cookie-secure: false
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ spring:
|
|||||||
profiles:
|
profiles:
|
||||||
active: prod # Default profile for local startup
|
active: prod # Default profile for local startup
|
||||||
jpa:
|
jpa:
|
||||||
open-in-view: true
|
open-in-view: false
|
||||||
flyway:
|
flyway:
|
||||||
# Force Flyway to be enabled
|
# Force Flyway to be enabled
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -18,12 +18,6 @@ spring:
|
|||||||
threads:
|
threads:
|
||||||
virtual:
|
virtual:
|
||||||
enabled: true
|
enabled: true
|
||||||
mvc:
|
|
||||||
apiversion:
|
|
||||||
use:
|
|
||||||
query-parameter: version
|
|
||||||
default: "1.0"
|
|
||||||
supported: 1.0, 2,0
|
|
||||||
|
|
||||||
|
|
||||||
# Actuator & Micrometer Settings
|
# Actuator & Micrometer Settings
|
||||||
@@ -50,8 +44,8 @@ app:
|
|||||||
commit: ${APP_BUILD_COMMIT:dev}
|
commit: ${APP_BUILD_COMMIT:dev}
|
||||||
time: ${APP_BUILD_TIME:}
|
time: ${APP_BUILD_TIME:}
|
||||||
cors:
|
cors:
|
||||||
# Production default: strictly allow only the frontend domain
|
# Only the frontend; FRONTEND_HOST in the Deployment compose file.
|
||||||
allowed-origins: "https://tbp.andreas-dahm.eu"
|
allowed-origins: ${app.frontend-url}
|
||||||
auth:
|
auth:
|
||||||
cookie-domain: ${APP_AUTH_COOKIE_DOMAIN:}
|
cookie-domain: ${APP_AUTH_COOKIE_DOMAIN:}
|
||||||
cookie-secure: true
|
cookie-secure: true
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
var ticket = jwtService.generateProfileTicket(42L, List.of(), List.of());
|
var ticket = jwtService.generateProfileTicket(42L, List.of(), List.of());
|
||||||
|
|
||||||
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
|
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
|
||||||
assertEquals("public-profile", claims.get("typ", String.class));
|
assertEquals(List.of("tbp-public-profile"), List.copyOf(claims.getAudience()));
|
||||||
assertEquals("42", claims.get("profileUserId", String.class));
|
assertEquals("42", claims.get("profileUserId", String.class));
|
||||||
assertNull(claims.get("userId"));
|
assertNull(claims.get("userId"));
|
||||||
long secondsLeft = (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000;
|
long secondsLeft = (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000;
|
||||||
@@ -166,7 +166,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
System.arraycopy(raw, 0, der, prefix.length, raw.length);
|
System.arraycopy(raw, 0, der, prefix.length, raw.length);
|
||||||
PublicKey fromJwk = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(der));
|
PublicKey fromJwk = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(der));
|
||||||
String token = jwtService.generateToken("7", null, Role.USER);
|
String token = jwtService.generateToken("7", null, Role.USER);
|
||||||
assertEquals("7", Jwts.parser().verifyWith(fromJwk).build().parseSignedClaims(token).getPayload().get("userId"));
|
assertEquals("7", Jwts.parser().verifyWith(fromJwk).build().parseSignedClaims(token).getPayload().getSubject());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -175,7 +175,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
var parsed = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token);
|
var parsed = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token);
|
||||||
assertEquals(KID, parsed.getHeader().getKeyId());
|
assertEquals(KID, parsed.getHeader().getKeyId());
|
||||||
assertEquals("service", parsed.getPayload().get("typ", String.class));
|
assertEquals(List.of("tbp-internal"), List.copyOf(parsed.getPayload().getAudience()));
|
||||||
assertNull(parsed.getPayload().get("userId"));
|
assertNull(parsed.getPayload().get("userId"));
|
||||||
long secondsLeft = (parsed.getPayload().getExpiration().getTime() - System.currentTimeMillis()) / 1000;
|
long secondsLeft = (parsed.getPayload().getExpiration().getTime() - System.currentTimeMillis()) / 1000;
|
||||||
assertTrue(secondsLeft > 0 && secondsLeft <= 60, "expires in " + secondsLeft + "s");
|
assertTrue(secondsLeft > 0 && secondsLeft <= 60, "expires in " + secondsLeft + "s");
|
||||||
@@ -191,7 +191,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parseLoginToken_ATokenFromBeforeRolesIsAUser() {
|
void parseLoginToken_ATokenFromBeforeRolesIsAUser() {
|
||||||
String old = Jwts.builder().header().keyId(KID).and().subject("a@b.de").claim("userId", "7")
|
String old = Jwts.builder().header().keyId(KID).and().issuer("tbp-auth").audience().add("tbp-login").and().subject("7")
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
||||||
.signWith(privateKey, Jwts.SIG.EdDSA).compact();
|
.signWith(privateKey, Jwts.SIG.EdDSA).compact();
|
||||||
|
|
||||||
@@ -223,16 +223,13 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void parseLoginToken_ALoginOfAnAuthFromBeforeItem26_IsAccepted() {
|
void parseLoginToken_ALoginWithoutAud_IsRefused() {
|
||||||
assertEquals("42", jwtService.parseLoginToken(sign(ContractFixture.legacyToken("login"))).orElseThrow().id());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void parseLoginToken_ALoginWithoutTheOldUserIdClaim_IsAccepted() {
|
|
||||||
ObjectNode login = (ObjectNode) ContractFixture.token("login").deepCopy();
|
ObjectNode login = (ObjectNode) ContractFixture.token("login").deepCopy();
|
||||||
login.remove("userId");
|
login.remove("aud");
|
||||||
|
login.remove("iss");
|
||||||
|
login.put("userId", "42");
|
||||||
|
|
||||||
assertEquals("42", jwtService.parseLoginToken(sign(login)).orElseThrow().id());
|
assertTrue(jwtService.parseLoginToken(sign(login)).isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import java.util.List;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.Mockito.lenient;
|
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class RoleAuthorizationManagerTest extends AbstractUnitTest {
|
class RoleAuthorizationManagerTest extends AbstractUnitTest {
|
||||||
|
|
||||||
@@ -62,9 +62,8 @@ class RoleAuthorizationManagerTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
private boolean granted(Authentication authentication, Object target, String method) throws Exception {
|
private boolean granted(Authentication authentication, Object target, String method) throws Exception {
|
||||||
MethodInvocation invocation = mock(MethodInvocation.class);
|
MethodInvocation invocation = mock(MethodInvocation.class);
|
||||||
// Lenient: without a request the manager does not look at the invocation.
|
when(invocation.getThis()).thenReturn(target);
|
||||||
lenient().when(invocation.getThis()).thenReturn(target);
|
when(invocation.getMethod()).thenReturn(target.getClass().getMethod(method));
|
||||||
lenient().when(invocation.getMethod()).thenReturn(target.getClass().getMethod(method));
|
|
||||||
return manager.authorize(() -> authentication, invocation).isGranted();
|
return manager.authorize(() -> authentication, invocation).isGranted();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +93,9 @@ class RoleAuthorizationManagerTest extends AbstractUnitTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void withoutARequest_EverythingRuns() throws Exception {
|
void withoutARequest_OnlyPublicMethodsRun() throws Exception {
|
||||||
assertTrue(granted(null, new UserMarked(), "audit"));
|
assertFalse(granted(null, new UserMarked(), "audit"));
|
||||||
|
assertFalse(granted(null, new UserMarked(), "plain"));
|
||||||
|
assertTrue(granted(null, new UserMarked(), "open"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.test.web.servlet.ResultActions;
|
import org.springframework.test.web.servlet.ResultActions;
|
||||||
@@ -19,7 +20,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -138,7 +139,7 @@ class RoleCheckFlowTest extends AbstractIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void outsideARequest_TheServiceRuns() {
|
void outsideARequest_OnlyPublicMethodsRun() {
|
||||||
assertEquals("auditor", probeService.auditor());
|
assertThrows(AuthenticationCredentialsNotFoundException.class, probeService::auditor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,8 @@ class SessionFlowTest extends AbstractIntegrationTest {
|
|||||||
private static final String EMAIL = "session-flow@example.com";
|
private static final String EMAIL = "session-flow@example.com";
|
||||||
private static final String PASSWORD = "correct horse battery";
|
private static final String PASSWORD = "correct horse battery";
|
||||||
private static final String CLIENT = "web";
|
private static final String CLIENT = "web";
|
||||||
private static final String FRONTEND = "https://tbp.andreas-dahm.eu";
|
// app.frontend-url in tests; CORS allows only it.
|
||||||
|
private static final String FRONTEND = "http://localhost:5173";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package auth_service.modules.account.repo;
|
package auth_service.modules.account.repo;
|
||||||
|
|
||||||
|
import auth_service.modules.account.model.CoreAccountState;
|
||||||
import auth_service.shared.test.AbstractPersistenceTest;
|
import auth_service.shared.test.AbstractPersistenceTest;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -49,6 +50,16 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
|
|||||||
bob = insertUser("bob@example.com");
|
bob = insertUser("bob@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findStates_CarryTheSteamIdAndTheBlock() {
|
||||||
|
jdbc.update("update users set enabled = false, steam_id = 76561198000000001 where id = ?", alice);
|
||||||
|
|
||||||
|
assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, true));
|
||||||
|
assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, true),
|
||||||
|
new CoreAccountState(bob, null, false));
|
||||||
|
assertThat(repository.findState(-1L)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void flagDeleted_SetsTheFlagAndRevokesOnlyThatUsersTokens() {
|
void flagDeleted_SetsTheFlagAndRevokesOnlyThatUsersTokens() {
|
||||||
assertThat(repository.flagDeleted(alice, NOW)).isEqualTo(1);
|
assertThat(repository.flagDeleted(alice, NOW)).isEqualTo(1);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package auth_service.modules.account.service;
|
package auth_service.modules.account.service;
|
||||||
|
|
||||||
|
import auth_service.modules.account.model.CoreAccountState;
|
||||||
import auth_service.modules.account.model.CoreCleanupResult;
|
import auth_service.modules.account.model.CoreCleanupResult;
|
||||||
import auth_service.modules.account.repo.AccountRepository;
|
import auth_service.modules.account.repo.AccountRepository;
|
||||||
import auth_service.shared.test.AbstractUnitTest;
|
import auth_service.shared.test.AbstractUnitTest;
|
||||||
@@ -24,6 +25,8 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
class AccountCleanupServiceTest extends AbstractUnitTest {
|
class AccountCleanupServiceTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, true));
|
||||||
|
|
||||||
private static final Duration RETENTION = Duration.ofDays(30);
|
private static final Duration RETENTION = Duration.ofDays(30);
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
@@ -45,6 +48,7 @@ class AccountCleanupServiceTest extends AbstractUnitTest {
|
|||||||
lenient().when(accountRepository.findFlaggedIds()).thenReturn(List.of(7L, 8L));
|
lenient().when(accountRepository.findFlaggedIds()).thenReturn(List.of(7L, 8L));
|
||||||
lenient().when(accountRepository.findFlaggedBefore(any())).thenReturn(List.of(7L));
|
lenient().when(accountRepository.findFlaggedBefore(any())).thenReturn(List.of(7L));
|
||||||
lenient().when(accountRepository.findAllIds()).thenReturn(List.of(1L, 7L, 8L));
|
lenient().when(accountRepository.findAllIds()).thenReturn(List.of(1L, 7L, 8L));
|
||||||
|
lenient().when(accountRepository.findAllStates()).thenReturn(STATES);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -55,9 +59,22 @@ class AccountCleanupServiceTest extends AbstractUnitTest {
|
|||||||
service(false).runNightly();
|
service(false).runNightly();
|
||||||
|
|
||||||
verify(coreAccountService).replaceDeleted(List.of(7L, 8L));
|
verify(coreAccountService).replaceDeleted(List.of(7L, 8L));
|
||||||
|
verify(coreAccountService).replaceStates(STATES);
|
||||||
verify(accountRepository).deleteUsers(List.of(7L));
|
verify(accountRepository).deleteUsers(List.of(7L));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runNightly_AFailedStateCallStillPurgesAndPrunes() {
|
||||||
|
doThrow(new RestClientException("Core too old")).when(coreAccountService).replaceStates(any());
|
||||||
|
when(coreAccountService.purge(eq(List.of(7L)), anyBoolean())).thenReturn(core(false));
|
||||||
|
when(coreAccountService.prune(eq(List.of(1L, 7L, 8L)), anyBoolean())).thenReturn(core(false));
|
||||||
|
|
||||||
|
service(false).runNightly();
|
||||||
|
|
||||||
|
verify(accountRepository).deleteUsers(List.of(7L));
|
||||||
|
verify(coreAccountService).prune(List.of(1L, 7L, 8L), false);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void runNightly_InDryRunAsksCoreForADryRunAndDeletesNothingInAuth() {
|
void runNightly_InDryRunAsksCoreForADryRunAndDeletesNothingInAuth() {
|
||||||
when(coreAccountService.purge(eq(List.of(7L)), anyBoolean())).thenReturn(core(true));
|
when(coreAccountService.purge(eq(List.of(7L)), anyBoolean())).thenReturn(core(true));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package auth_service.modules.account.service;
|
package auth_service.modules.account.service;
|
||||||
|
|
||||||
|
import auth_service.modules.account.model.CoreAccountState;
|
||||||
import auth_service.modules.account.model.CoreCleanupResult;
|
import auth_service.modules.account.model.CoreCleanupResult;
|
||||||
import auth_service.shared.security.ServiceTokenIssuer;
|
import auth_service.shared.security.ServiceTokenIssuer;
|
||||||
import auth_service.shared.test.AbstractUnitTest;
|
import auth_service.shared.test.AbstractUnitTest;
|
||||||
@@ -65,6 +66,17 @@ class CoreAccountServiceTest extends AbstractUnitTest {
|
|||||||
core.verify();
|
core.verify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyAndReplaceStates_SendTheContractBodies() {
|
||||||
|
expect("applyStates");
|
||||||
|
expect("replaceStates");
|
||||||
|
|
||||||
|
service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, true)));
|
||||||
|
service.replaceStates(List.of(new CoreAccountState(42L, null, false)));
|
||||||
|
|
||||||
|
core.verify();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void purgeAndPrune_ReadTheContractResult() {
|
void purgeAndPrune_ReadTheContractResult() {
|
||||||
expect("purge");
|
expect("purge");
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package auth_service.modules.account.service;
|
||||||
|
|
||||||
|
import auth_service.modules.account.model.CoreAccountState;
|
||||||
|
import auth_service.modules.account.repo.AccountRepository;
|
||||||
|
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.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class CoreAccountStatePublisherTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, true);
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AccountRepository accountRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CoreAccountService coreAccountService;
|
||||||
|
|
||||||
|
private CoreAccountStatePublisher publisher;
|
||||||
|
|
||||||
|
// Runs each task at once, so the test sees the call.
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
publisher = new CoreAccountStatePublisher(accountRepository, coreAccountService, Runnable::run);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutATransaction_SendsAtOnce() {
|
||||||
|
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE));
|
||||||
|
|
||||||
|
publisher.publish(7L);
|
||||||
|
|
||||||
|
verify(coreAccountService).applyStates(List.of(STATE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void inATransaction_SendsOnlyAfterTheCommit() {
|
||||||
|
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE));
|
||||||
|
TransactionSynchronizationManager.initSynchronization();
|
||||||
|
try {
|
||||||
|
publisher.publish(7L);
|
||||||
|
verify(coreAccountService, never()).applyStates(any());
|
||||||
|
|
||||||
|
TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit);
|
||||||
|
} finally {
|
||||||
|
TransactionSynchronizationManager.clearSynchronization();
|
||||||
|
}
|
||||||
|
|
||||||
|
verify(coreAccountService).applyStates(List.of(STATE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void coreDown_OnlyWarns() {
|
||||||
|
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE));
|
||||||
|
doThrow(new RestClientException("Core down")).when(coreAccountService).applyStates(any());
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> publisher.publish(7L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownUser_SendsNothing() {
|
||||||
|
when(accountRepository.findState(7L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
publisher.publish(7L);
|
||||||
|
|
||||||
|
verify(coreAccountService, never()).applyStates(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import auth_service.modules.admin.exception.TargetUserNotFoundException;
|
|||||||
import auth_service.modules.admin.model.TargetState;
|
import auth_service.modules.admin.model.TargetState;
|
||||||
import auth_service.modules.admin.repo.AdminUserRepository;
|
import auth_service.modules.admin.repo.AdminUserRepository;
|
||||||
import auth_service.shared.account.AccountDeleter;
|
import auth_service.shared.account.AccountDeleter;
|
||||||
|
import auth_service.shared.account.AccountStatePublisher;
|
||||||
import auth_service.shared.audit.AdminAuditLog;
|
import auth_service.shared.audit.AdminAuditLog;
|
||||||
import auth_service.shared.security.Role;
|
import auth_service.shared.security.Role;
|
||||||
import auth_service.shared.test.AbstractUnitTest;
|
import auth_service.shared.test.AbstractUnitTest;
|
||||||
@@ -39,11 +40,14 @@ class AdminUserServiceTest extends AbstractUnitTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private AccountDeleter accountDeleter;
|
private AccountDeleter accountDeleter;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AccountStatePublisher accountStatePublisher;
|
||||||
|
|
||||||
private AdminUserService service;
|
private AdminUserService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new AdminUserService(adminUserRepository, adminAuditLog, accountDeleter);
|
service = new AdminUserService(adminUserRepository, adminAuditLog, accountDeleter, accountStatePublisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void target(Role role, boolean deleted) {
|
private void target(Role role, boolean deleted) {
|
||||||
@@ -67,6 +71,7 @@ class AdminUserServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
verify(adminUserRepository).setBlocked(eq(TARGET), eq(true), any());
|
verify(adminUserRepository).setBlocked(eq(TARGET), eq(true), any());
|
||||||
verify(adminAuditLog).record(ADMIN, "BLOCK", TARGET, null);
|
verify(adminAuditLog).record(ADMIN, "BLOCK", TARGET, null);
|
||||||
|
verify(accountStatePublisher).publish(TARGET);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package auth_service.modules.login.model;
|
||||||
|
|
||||||
|
import auth_service.modules.registration.model.Registration;
|
||||||
|
import auth_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class UserTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toString_ShowsNoPassword() {
|
||||||
|
assertThat(User.builder().email("a@example.com").password("hash-secret").build().toString())
|
||||||
|
.contains("a@example.com").doesNotContain("hash-secret");
|
||||||
|
assertThat(Registration.builder().email("a@example.com").rawPassword("raw-secret").encryptedPassword("hash-secret")
|
||||||
|
.build().toString()).doesNotContain("raw-secret").doesNotContain("hash-secret");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getCurrentUserV1_Success() throws Exception {
|
void getCurrentUser_Success() throws Exception {
|
||||||
// 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();
|
||||||
@@ -99,7 +99,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getCurrentUserV1_SteamIdIsSentAsExactString() throws Exception {
|
void getCurrentUser_SteamIdIsSentAsExactString() throws Exception {
|
||||||
// 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();
|
||||||
@@ -119,7 +119,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getCurrentUserV1_NoSteamId() throws Exception {
|
void getCurrentUser_NoSteamId() throws Exception {
|
||||||
// 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();
|
||||||
@@ -138,7 +138,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getCurrentUserV1_NotFound() throws Exception {
|
void getCurrentUser_NotFound() throws Exception {
|
||||||
// Arrange
|
// Arrange
|
||||||
var email = "notfound@example.com";
|
var email = "notfound@example.com";
|
||||||
var principal = new UserPrincipal("1", null);
|
var principal = new UserPrincipal("1", null);
|
||||||
@@ -190,7 +190,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getCurrentUserV1_ReturnsDisplayName() throws Exception {
|
void getCurrentUser_ReturnsDisplayName() throws Exception {
|
||||||
// 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();
|
||||||
@@ -309,7 +309,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getCurrentUserV1_ShowsPublicProfile() throws Exception {
|
void getCurrentUser_ShowsPublicProfile() throws Exception {
|
||||||
var email = "test@example.com";
|
var email = "test@example.com";
|
||||||
var settings = UserSettings.builder().language("en").theme("dark").publicProfile(true).build();
|
var settings = UserSettings.builder().language("en").theme("dark").publicProfile(true).build();
|
||||||
SecurityContextHolder.getContext().setAuthentication(
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
@@ -323,7 +323,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getCurrentUserV1_ShowsAppearance() throws Exception {
|
void getCurrentUser_ShowsAppearance() throws Exception {
|
||||||
var email = "test@example.com";
|
var email = "test@example.com";
|
||||||
var settings = UserSettings.builder().language("en").theme("dark")
|
var settings = UserSettings.builder().language("en").theme("dark")
|
||||||
.appearance(new Appearance("blue", "round", true, 620L, 1086940L, false)).build();
|
.appearance(new Appearance("blue", "round", true, 620L, 1086940L, false)).build();
|
||||||
@@ -343,7 +343,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getCurrentUserV1_WithoutBackground_SendsNull() throws Exception {
|
void getCurrentUser_WithoutBackground_SendsNull() throws Exception {
|
||||||
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();
|
||||||
SecurityContextHolder.getContext().setAuthentication(
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
@@ -362,7 +362,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getCurrentUserV1_ShowsTheTileLayouts() throws Exception {
|
void getCurrentUser_ShowsTheTileLayouts() throws Exception {
|
||||||
String email = "test@example.com";
|
String email = "test@example.com";
|
||||||
UserSettings settings = UserSettings.builder().language("en").theme("dark")
|
UserSettings settings = UserSettings.builder().language("en").theme("dark")
|
||||||
.dashboardTiles(List.of(new Tile("STATS_TREND", "L")))
|
.dashboardTiles(List.of(new Tile("STATS_TREND", "L")))
|
||||||
@@ -382,7 +382,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getCurrentUserV1_NeverChangedDashboard_SendsNullTiles() throws Exception {
|
void getCurrentUser_NeverChangedDashboard_SendsNullTiles() throws Exception {
|
||||||
String email = "test@example.com";
|
String email = "test@example.com";
|
||||||
SecurityContextHolder.getContext().setAuthentication(
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
|
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
|
||||||
@@ -467,19 +467,32 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
verify(userService).updateProfileTiles(1L, List.of(new Tile("STATS_PILE", "M")));
|
verify(userService).updateProfileTiles(1L, List.of(new Tile("STATS_PILE", "M")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A dashboard tile is no profile tile and the other way round; activity and the game count stay private.
|
// Core and the Frontend know the tiles; Auth checks only the shape (Core keeps activity and the overview private).
|
||||||
|
@Test
|
||||||
|
void updateTiles_ANewTileIdIsPassedThrough() throws Exception {
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||||
|
new UserPrincipal("1", null), null, Collections.emptyList()));
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/users/settings/profile-tiles")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"tiles\":[{\"id\":\"STATS_NEW_GROUP\",\"size\":\"M\"}]}"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
verify(userService).updateProfileTiles(1L, List.of(new Tile("STATS_NEW_GROUP", "M")));
|
||||||
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = {
|
@ValueSource(strings = {
|
||||||
"dashboard-tiles|{\"tiles\":[{\"id\":\"SUMMARY\",\"size\":\"M\"}]}",
|
"dashboard-tiles|{\"tiles\":[{\"id\":\"stats_pile\",\"size\":\"M\"}]}",
|
||||||
|
"dashboard-tiles|{\"tiles\":[{\"id\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\"size\":\"M\"}]}",
|
||||||
"dashboard-tiles|{\"tiles\":[{\"id\":\"PLAYING\",\"size\":\"XL\"}]}",
|
"dashboard-tiles|{\"tiles\":[{\"id\":\"PLAYING\",\"size\":\"XL\"}]}",
|
||||||
"dashboard-tiles|{\"tiles\":[{\"id\":\"PLAYING\"}]}",
|
"dashboard-tiles|{\"tiles\":[{\"id\":\"PLAYING\"}]}",
|
||||||
"dashboard-tiles|{\"tiles\":[null]}",
|
"dashboard-tiles|{\"tiles\":[null]}",
|
||||||
"profile-tiles|{\"tiles\":[{\"id\":\"ACTIVITY\",\"size\":\"M\"}]}",
|
"profile-tiles|{\"tiles\":[{\"id\":\"\",\"size\":\"M\"}]}",
|
||||||
"profile-tiles|{\"tiles\":[{\"id\":\"STATS_OVERVIEW\",\"size\":\"M\"}]}",
|
|
||||||
"profile-tiles|{\"tiles\":null}",
|
"profile-tiles|{\"tiles\":null}",
|
||||||
"profile-tiles|{}"
|
"profile-tiles|{}"
|
||||||
})
|
})
|
||||||
void updateTiles_UnknownIdBadSizeOrMissing_IsRejected(String pathAndBody) throws Exception {
|
void updateTiles_BadIdBadSizeOrMissing_IsRejected(String pathAndBody) throws Exception {
|
||||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||||
new UserPrincipal("1", null), null, Collections.emptyList()));
|
new UserPrincipal("1", null), null, Collections.emptyList()));
|
||||||
String[] parts = pathAndBody.split("\\|", 2);
|
String[] parts = pathAndBody.split("\\|", 2);
|
||||||
@@ -505,7 +518,7 @@ class UserControllerTest extends AbstractWebMvcTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getCurrentUserV1_ShowsTheProfileStyle() throws Exception {
|
void getCurrentUser_ShowsTheProfileStyle() throws Exception {
|
||||||
String email = "test@example.com";
|
String email = "test@example.com";
|
||||||
UserSettings settings = UserSettings.builder().language("en").theme("dark")
|
UserSettings settings = UserSettings.builder().language("en").theme("dark")
|
||||||
.profileStyle(new ProfileStyle("red", "sharp", 620L, null)).build();
|
.profileStyle(new ProfileStyle("red", "sharp", 620L, null)).build();
|
||||||
|
|||||||
@@ -357,6 +357,23 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertNull(findByEmail("reset@example.com").orElseThrow().getSettings().getDashboardTiles());
|
assertNull(findByEmail("reset@example.com").orElseThrow().getSettings().getDashboardTiles());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateTiles_TheLongestIdAuthAcceptsIsStored() {
|
||||||
|
UserEntity userEntity = persisted(UserEntity.builder()
|
||||||
|
.email("longtile@example.com")
|
||||||
|
.password("password")
|
||||||
|
.enabled(true)
|
||||||
|
.registrationDate(LocalDateTime.now())
|
||||||
|
.build());
|
||||||
|
List<Tile> profile = List.of(new Tile("STATS_" + "A".repeat(24), "M"));
|
||||||
|
|
||||||
|
userRepository.update(userEntity.getId(), user -> user.getSettings().setProfileTiles(profile));
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
assertEquals(profile, findByEmail("longtile@example.com").orElseThrow().getSettings().getProfileTiles());
|
||||||
|
}
|
||||||
|
|
||||||
private void updateSettings(long userId, String displayName, UserSettings settings) {
|
private void updateSettings(long userId, String displayName, UserSettings settings) {
|
||||||
userRepository.update(userId, user -> {
|
userRepository.update(userId, user -> {
|
||||||
user.setDisplayName(displayName);
|
user.setDisplayName(displayName);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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.account.AccountStatePublisher;
|
||||||
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;
|
||||||
@@ -41,6 +42,9 @@ class UserServiceTest extends AbstractUnitTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private TokenGenerator tokenGenerator;
|
private TokenGenerator tokenGenerator;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AccountStatePublisher accountStatePublisher;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
@@ -124,6 +128,7 @@ class UserServiceTest extends AbstractUnitTest {
|
|||||||
assertEquals(SteamLinkOutcome.LINKED, userService.linkVerifiedSteamId(1L, STEAM_ID));
|
assertEquals(SteamLinkOutcome.LINKED, userService.linkVerifiedSteamId(1L, STEAM_ID));
|
||||||
|
|
||||||
verify(userRepository).setVerifiedSteamId(1L, STEAM_ID);
|
verify(userRepository).setVerifiedSteamId(1L, STEAM_ID);
|
||||||
|
verify(accountStatePublisher).publish(1L);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -145,6 +150,8 @@ class UserServiceTest extends AbstractUnitTest {
|
|||||||
InOrder order = inOrder(userRepository);
|
InOrder order = inOrder(userRepository);
|
||||||
order.verify(userRepository).clearSteamId(2L);
|
order.verify(userRepository).clearSteamId(2L);
|
||||||
order.verify(userRepository).setVerifiedSteamId(1L, STEAM_ID);
|
order.verify(userRepository).setVerifiedSteamId(1L, STEAM_ID);
|
||||||
|
verify(accountStatePublisher).publish(2L);
|
||||||
|
verify(accountStatePublisher).publish(1L);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -155,6 +162,7 @@ class UserServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
verify(userRepository, never()).clearSteamId(anyLong());
|
verify(userRepository, never()).clearSteamId(anyLong());
|
||||||
verify(userRepository, never()).setVerifiedSteamId(anyLong(), anyLong());
|
verify(userRepository, never()).setVerifiedSteamId(anyLong(), anyLong());
|
||||||
|
verifyNoInteractions(accountStatePublisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -164,6 +172,7 @@ class UserServiceTest extends AbstractUnitTest {
|
|||||||
assertEquals("token-without-steam", userService.unlinkSteamId(1L, Role.USER));
|
assertEquals("token-without-steam", userService.unlinkSteamId(1L, Role.USER));
|
||||||
|
|
||||||
verify(userRepository).clearSteamId(1L);
|
verify(userRepository).clearSteamId(1L);
|
||||||
|
verify(accountStatePublisher).publish(1L);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -25,4 +25,24 @@ class ClientIpTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
assertEquals("198.51.100.9", ClientIp.of(request));
|
assertEquals("198.51.100.9", ClientIp.of(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String clientIp(String peer, String forwardedFor) {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.setRemoteAddr(peer);
|
||||||
|
request.addHeader("X-Forwarded-For", forwardedFor);
|
||||||
|
return ClientIp.of(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicPeer_CannotChooseItsIpByTheHeader() {
|
||||||
|
assertEquals("198.51.100.9", clientIp("198.51.100.9", "203.0.113.5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void proxyInThePrivateNetwork_GivesTheLastHop() {
|
||||||
|
assertEquals("203.0.113.5", clientIp("172.18.0.2", "1.2.3.4, 203.0.113.5"));
|
||||||
|
assertEquals("203.0.113.5", clientIp("10.0.0.3", "203.0.113.5"));
|
||||||
|
assertEquals("203.0.113.5", clientIp("127.0.0.1", "203.0.113.5"));
|
||||||
|
assertEquals("203.0.113.5", clientIp("fd00::2", "203.0.113.5"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -31,10 +31,6 @@ public final class ContractFixture {
|
|||||||
return CONTRACT.path("tokens").required(kind);
|
return CONTRACT.path("tokens").required(kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static JsonNode legacyToken(String kind) {
|
|
||||||
return CONTRACT.path("legacyTokens").required(kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static JsonNode internalCall(String name) {
|
public static JsonNode internalCall(String name) {
|
||||||
return CONTRACT.path("internal").required(name);
|
return CONTRACT.path("internal").required(name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
{
|
{
|
||||||
"_about": "The token and internal-call contract between Auth and Core. The same file is in both repos; ToBePlayed-Deployment/scripts/check-contract.sh compares them. iat and exp are left out. legacyTokens are the shapes of an Auth from before item 26: Core and Auth accept them until item 29.",
|
"_about": "The token and internal-call contract between Auth and Core. The same file is in both repos; ToBePlayed-Deployment/scripts/check-contract.sh compares them. iat and exp are left out.",
|
||||||
"tokens": {
|
"tokens": {
|
||||||
"login": {
|
"login": {
|
||||||
"iss": "tbp-auth",
|
"iss": "tbp-auth",
|
||||||
"aud": ["tbp-login"],
|
"aud": ["tbp-login"],
|
||||||
"sub": "42",
|
"sub": "42",
|
||||||
"userId": "42",
|
|
||||||
"role": "USER",
|
"role": "USER",
|
||||||
"steamId": 76561198000000001
|
"steamId": 76561198000000001
|
||||||
},
|
},
|
||||||
@@ -13,7 +12,6 @@
|
|||||||
"iss": "tbp-auth",
|
"iss": "tbp-auth",
|
||||||
"aud": ["tbp-public-profile"],
|
"aud": ["tbp-public-profile"],
|
||||||
"sub": "public-profile",
|
"sub": "public-profile",
|
||||||
"typ": "public-profile",
|
|
||||||
"profileUserId": "42",
|
"profileUserId": "42",
|
||||||
"profileStats": ["OVERVIEW"],
|
"profileStats": ["OVERVIEW"],
|
||||||
"sections": ["SUMMARY"]
|
"sections": ["SUMMARY"]
|
||||||
@@ -21,27 +19,7 @@
|
|||||||
"service": {
|
"service": {
|
||||||
"iss": "tbp-auth",
|
"iss": "tbp-auth",
|
||||||
"aud": ["tbp-internal"],
|
"aud": ["tbp-internal"],
|
||||||
"sub": "tbp-auth",
|
"sub": "tbp-auth"
|
||||||
"typ": "service"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"legacyTokens": {
|
|
||||||
"login": {
|
|
||||||
"sub": "user@example.com",
|
|
||||||
"userId": "42",
|
|
||||||
"role": "USER",
|
|
||||||
"steamId": 76561198000000001
|
|
||||||
},
|
|
||||||
"publicProfile": {
|
|
||||||
"sub": "public-profile",
|
|
||||||
"typ": "public-profile",
|
|
||||||
"profileUserId": "42",
|
|
||||||
"profileStats": ["OVERVIEW"],
|
|
||||||
"sections": ["SUMMARY"]
|
|
||||||
},
|
|
||||||
"service": {
|
|
||||||
"sub": "tbp-auth",
|
|
||||||
"typ": "service"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"internal": {
|
"internal": {
|
||||||
@@ -55,6 +33,16 @@
|
|||||||
"path": "/internal/accounts/deleted",
|
"path": "/internal/accounts/deleted",
|
||||||
"request": {"userIds": []}
|
"request": {"userIds": []}
|
||||||
},
|
},
|
||||||
|
"applyStates": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/internal/accounts/states",
|
||||||
|
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "blocked": true}]}
|
||||||
|
},
|
||||||
|
"replaceStates": {
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/internal/accounts/states",
|
||||||
|
"request": {"accounts": [{"userId": 42, "steamId": null, "blocked": false}]}
|
||||||
|
},
|
||||||
"purge": {
|
"purge": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/internal/accounts/purge",
|
"path": "/internal/accounts/purge",
|
||||||
|
|||||||
Reference in new issue
Block a user