Public Access
feat(admin): add roles, view as and the admin users API
users.role (USER < AUDITOR < PLATFORM_ADMIN) goes into the access token as the active role. POST /api/auth/view-as lowers the own login and is kept per refresh token, never above the stored role. /api/admin/users lists, blocks and deletes users; every action is written to admin_audit. A blocked login answers 403 ACCOUNT_BLOCKED, only after the right password.
This commit is contained in:
1 parent
7dc2fb613e
commit
2ba0d99148
55 files changed
+1180
-39
No files matched your search
@@ -38,6 +38,15 @@ To Be Played is split into three repositories:
|
||||
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. Its `profileStats` claim
|
||||
names the stat groups the user chose to show there (none by default). The ticket is not a login:
|
||||
both services reject every token with a `typ` claim on normal endpoints.
|
||||
- **Roles**: one per user, a ladder `USER < AUDITOR < PLATFORM_ADMIN` (`users.role`, the first admin is set by
|
||||
SQL). The access token carries the active role in the `role` claim. **View as**: an admin switches the own login
|
||||
down to USER or AUDITOR and back (`POST /api/auth/view-as`); the choice is stored per login in
|
||||
`refresh_tokens.active_role` and never goes above the stored role, so a demoted admin loses the rights at the next
|
||||
refresh. Core does not check roles yet.
|
||||
- **Admin users page** (`/api/admin/**`, active role PLATFORM_ADMIN): search and page all users, block and unblock
|
||||
(`users.enabled`; blocking ends every login, the login then answers `403 ACCOUNT_BLOCKED`, but only after the
|
||||
right password), delete (the same flag and nightly cleanup as "Delete my account"). No action on the own account
|
||||
or on another admin. Every action and every "view as" is a row in `admin_audit`.
|
||||
- **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets
|
||||
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no public profile, no Steam
|
||||
link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an undo needs a
|
||||
@@ -57,6 +66,10 @@ To Be Played is split into three repositories:
|
||||
| `PUT` | `/api/users/settings` | access token |
|
||||
| `POST` | `/api/users/steam-link` | access token, returns Steam's login URL and sets the binding cookie |
|
||||
| `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie |
|
||||
| `POST` | `/api/auth/view-as` (`{role}`, null = own role; new cookies; `403 ROLE_NOT_ALLOWED` above the own role) | refresh cookie |
|
||||
| `GET` | `/api/admin/users?query=&page=&size=` (`{content, page, size, totalElements, totalPages}`) | PLATFORM_ADMIN |
|
||||
| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`) | PLATFORM_ADMIN |
|
||||
| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN |
|
||||
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token |
|
||||
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings |
|
||||
| `PUT` | `/api/users/settings/appearance` | access token |
|
||||
@@ -91,6 +104,9 @@ layers:
|
||||
`version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image.
|
||||
`account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core
|
||||
through `CoreAccountService`.
|
||||
`admin` (users page, audit) works the same way. It deletes through the port `shared/account/AccountDeleter`, and
|
||||
the login module writes "view as" to the audit table through `shared/audit/AdminAuditLog`, because modules never
|
||||
use each other.
|
||||
|
||||
ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer
|
||||
violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
|
||||
@@ -113,6 +129,9 @@ sequenceDiagram
|
||||
B->>A: POST /api/auth/refresh with tbp_refresh
|
||||
A-->>B: New tbp_access and tbp_refresh, old refresh token rotated
|
||||
B->>C: Same API call again
|
||||
Note over B,A: View as (admin only)
|
||||
B->>A: POST /api/auth/view-as {role} with tbp_refresh
|
||||
A-->>B: New tbp_access (role claim) and tbp_refresh, active role stored for this login
|
||||
```
|
||||
|
||||
1. `LoginService` checks the password. `LoginController` sets both HttpOnly cookies (`AuthCookies`), so no script
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth_service.configuration.security;
|
||||
|
||||
import auth_service.shared.api.AuthCookies;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
@@ -9,13 +10,15 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.jspecify.annotations.NonNull;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -47,7 +50,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
||||
principal.get(),
|
||||
null,
|
||||
Collections.emptyList()
|
||||
authorities(principal.get().role())
|
||||
);
|
||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
@@ -56,6 +59,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active role and every role below it, so hasRole("AUDITOR") also lets an admin in.
|
||||
*/
|
||||
private static List<SimpleGrantedAuthority> authorities(Role role) {
|
||||
return Arrays.stream(Role.values()).filter(role::atLeast)
|
||||
.map(each -> new SimpleGrantedAuthority("ROLE_" + each.name())).toList();
|
||||
}
|
||||
|
||||
private static Optional<String> bearer(HttpServletRequest request) {
|
||||
String header = request.getHeader("Authorization");
|
||||
return header != null && header.startsWith(BEARER) ? Optional.of(header.substring(BEARER.length())) : Optional.empty();
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth_service.configuration.security;
|
||||
|
||||
import auth_service.shared.security.ProfileTicketIssuer;
|
||||
import auth_service.shared.security.PublicKeyPublisher;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.security.ServiceTokenIssuer;
|
||||
import auth_service.shared.security.SessionProperties;
|
||||
import auth_service.shared.security.TokenGenerator;
|
||||
@@ -38,6 +39,7 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
||||
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 ROLE_CLAIM = "role";
|
||||
|
||||
private final JwtConfig jwtConfig;
|
||||
private final SessionProperties sessionProperties;
|
||||
@@ -62,11 +64,12 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateToken(String userId, String email, Long steamId) {
|
||||
public String generateToken(String userId, String email, Long steamId, Role role) {
|
||||
var builder = Jwts.builder()
|
||||
.header().keyId(jwtConfig.getKid()).and()
|
||||
.subject(email)
|
||||
.claim("userId", userId)
|
||||
.claim(ROLE_CLAIM, role.name())
|
||||
.issuedAt(new Date(System.currentTimeMillis()))
|
||||
.expiration(new Date(System.currentTimeMillis() + sessionProperties.getAccessTokenTtl().toMillis()));
|
||||
|
||||
@@ -126,7 +129,8 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
|
||||
if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class)));
|
||||
return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class),
|
||||
Role.parse(claims.get(ROLE_CLAIM, String.class))));
|
||||
} catch (Exception _) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@ public class SecurityConfig {
|
||||
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(WHITE_LIST_URLS).permitAll()
|
||||
// The active role: an admin who views as USER gets 403 here.
|
||||
.requestMatchers("/api/admin/**").hasRole("PLATFORM_ADMIN")
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth_service.modules.account.service;
|
||||
import auth_service.modules.account.exception.InvalidPasswordException;
|
||||
import auth_service.modules.account.exception.TooManyDeleteAttemptsException;
|
||||
import auth_service.modules.account.repo.AccountRepository;
|
||||
import auth_service.shared.account.AccountDeleter;
|
||||
import auth_service.shared.security.AttemptLimiter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
@@ -16,7 +17,7 @@ import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class AccountService {
|
||||
public class AccountService implements AccountDeleter {
|
||||
|
||||
private static final Duration LOCK = Duration.ofMinutes(15);
|
||||
private static final int MAX_WRONG_PASSWORDS = 5;
|
||||
@@ -53,7 +54,19 @@ public class AccountService {
|
||||
throw new InvalidPasswordException();
|
||||
}
|
||||
attemptLimiter.reset(key);
|
||||
accountRepository.flagDeleted(userId, Instant.now());
|
||||
flagAndTellCore(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@CacheEvict(value = {"usersByEmail", "publicStats"}, allEntries = true)
|
||||
public boolean deleteByAdmin(long userId) {
|
||||
return flagAndTellCore(userId);
|
||||
}
|
||||
|
||||
private boolean flagAndTellCore(long userId) {
|
||||
if (accountRepository.flagDeleted(userId, Instant.now()) == 0) {
|
||||
return false;
|
||||
}
|
||||
log.info("Account of user {} flagged as deleted", userId);
|
||||
try {
|
||||
coreAccountService.markDeleted(List.of(userId));
|
||||
@@ -61,5 +74,6 @@ public class AccountService {
|
||||
log.warn("Core did not take the delete of user {}, the nightly cleanup sends it again: {}", userId,
|
||||
e.getMessage());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package auth_service.modules.admin.api;
|
||||
|
||||
import auth_service.modules.admin.exception.AdminActionRefusedException;
|
||||
import auth_service.modules.admin.exception.TargetUserNotFoundException;
|
||||
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice(assignableTypes = AdminUserController.class)
|
||||
@Order(value = 1)
|
||||
public class AdminExceptionController {
|
||||
|
||||
@ExceptionHandler(AdminActionRefusedException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(AdminActionRefusedException ex) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(TargetUserNotFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(TargetUserNotFoundException ex) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse("USER_NOT_FOUND", ex.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package auth_service.modules.admin.api;
|
||||
|
||||
import auth_service.modules.admin.api.dto.request.BlockRequest;
|
||||
import auth_service.modules.admin.api.dto.response.AdminUserPageResponse;
|
||||
import auth_service.modules.admin.api.dto.response.AdminUserResponse;
|
||||
import auth_service.modules.admin.model.AdminUser;
|
||||
import auth_service.modules.admin.model.AdminUserPage;
|
||||
import auth_service.modules.admin.service.AdminUserService;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/users")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminUserController {
|
||||
|
||||
private final AdminUserService adminUserService;
|
||||
|
||||
@GetMapping(version = "1.0")
|
||||
public ResponseEntity<AdminUserPageResponse> findUsers(@RequestParam(defaultValue = "") String query,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
AdminUserPage found = adminUserService.findUsers(query, page, size);
|
||||
return ResponseEntity.ok(new AdminUserPageResponse(found.content().stream().map(AdminUserController::toApi).toList(),
|
||||
found.page(), found.size(), found.totalElements(), found.totalPages()));
|
||||
}
|
||||
|
||||
@PutMapping(value = "/{userId}/blocked", version = "1.0")
|
||||
public ResponseEntity<Void> setBlocked(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId,
|
||||
@Valid @RequestBody BlockRequest request) {
|
||||
adminUserService.setBlocked(principal.getIdAsLong(), userId, request.blocked());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@DeleteMapping(value = "/{userId}", version = "1.0")
|
||||
public ResponseEntity<Void> delete(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId) {
|
||||
adminUserService.delete(principal.getIdAsLong(), userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static AdminUserResponse toApi(AdminUser user) {
|
||||
return new AdminUserResponse(user.id(), user.email(), user.displayName(), user.role().name(),
|
||||
user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package auth_service.modules.admin.api.dto.request;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record BlockRequest(@NotNull Boolean blocked) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.admin.api.dto.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record AdminUserPageResponse(List<AdminUserResponse> content, int page, int size, long totalElements,
|
||||
int totalPages) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package auth_service.modules.admin.api.dto.response;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record AdminUserResponse(long id, String email, String displayName, String role, LocalDateTime registrationDate,
|
||||
LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package auth_service.modules.admin.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class AdminActionRefusedException extends RuntimeException {
|
||||
/**
|
||||
* SELF_ACTION, TARGET_IS_ADMIN or ALREADY_DELETED
|
||||
*/
|
||||
private final String code;
|
||||
|
||||
public AdminActionRefusedException(String code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.admin.exception;
|
||||
|
||||
public class TargetUserNotFoundException extends RuntimeException {
|
||||
public TargetUserNotFoundException(long userId) {
|
||||
super("No user with id " + userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package auth_service.modules.admin.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* @param deletedAt set while the account waits for the nightly cleanup
|
||||
*/
|
||||
public record AdminUser(long id, String email, String displayName, Role role, LocalDateTime registrationDate,
|
||||
LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package auth_service.modules.admin.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The page shape of Core's library list, so the frontend pages work the same way.
|
||||
*/
|
||||
public record AdminUserPage(List<AdminUser> content, int page, int size, long totalElements, int totalPages) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package auth_service.modules.admin.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
public record TargetState(Role role, boolean deleted) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package auth_service.modules.admin.repo;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class AdminAuditRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public AdminAuditRepository(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
public void insert(long actorUserId, String action, Long targetUserId, String detail) {
|
||||
jdbc.update("INSERT INTO admin_audit (actor_user_id, action, target_user_id, detail) VALUES (?, ?, ?, ?)",
|
||||
actorUserId, action, targetUserId, detail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package auth_service.modules.admin.repo;
|
||||
|
||||
import auth_service.modules.admin.model.AdminUser;
|
||||
import auth_service.modules.admin.model.TargetState;
|
||||
import auth_service.shared.security.Role;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Plain SQL on users, because the user and login modules own the entities.
|
||||
*/
|
||||
@Repository
|
||||
public class AdminUserRepository {
|
||||
|
||||
// An empty query matches every user; % and _ in the query are searched as text.
|
||||
private static final String MATCHES = "(? = '' OR email ILIKE ? ESCAPE '\\' OR display_name ILIKE ? ESCAPE '\\')";
|
||||
|
||||
private static final RowMapper<AdminUser> ROW = (rs, row) -> new AdminUser(
|
||||
rs.getLong("id"),
|
||||
rs.getString("email"),
|
||||
rs.getString("display_name"),
|
||||
Role.parse(rs.getString("role")),
|
||||
localDateTime(rs.getTimestamp("registration_date")),
|
||||
localDateTime(rs.getTimestamp("last_login_date")),
|
||||
rs.getObject("steam_id") != null,
|
||||
!rs.getBoolean("enabled"),
|
||||
rs.getTimestamp("deleted_at") != null ? rs.getTimestamp("deleted_at").toInstant() : null);
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public AdminUserRepository(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
private static LocalDateTime localDateTime(Timestamp timestamp) {
|
||||
return timestamp != null ? timestamp.toLocalDateTime() : null;
|
||||
}
|
||||
|
||||
private static Object[] matchArgs(String query) {
|
||||
String pattern = "%" + query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%";
|
||||
return new Object[]{query, pattern, pattern};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param query part of the email or display name, any case; empty for all users
|
||||
*/
|
||||
public List<AdminUser> findPage(String query, int page, int size) {
|
||||
Object[] match = matchArgs(query);
|
||||
return jdbc.query("""
|
||||
SELECT id, email, display_name, role, registration_date, last_login_date, steam_id, enabled, deleted_at
|
||||
FROM users WHERE %s ORDER BY id LIMIT ? OFFSET ?""".formatted(MATCHES), ROW,
|
||||
match[0], match[1], match[2], size, (long) page * size);
|
||||
}
|
||||
|
||||
public long count(String query) {
|
||||
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE " + MATCHES, Long.class, matchArgs(query));
|
||||
return count != null ? count : 0L;
|
||||
}
|
||||
|
||||
public Optional<TargetState> findState(long userId) {
|
||||
return jdbc.query("SELECT role, deleted_at FROM users WHERE id = ?",
|
||||
(rs, row) -> new TargetState(Role.parse(rs.getString("role")), rs.getTimestamp("deleted_at") != null),
|
||||
userId).stream().findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking also ends every login of the user.
|
||||
*/
|
||||
@Transactional
|
||||
public void setBlocked(long userId, boolean blocked, Instant now) {
|
||||
jdbc.update("UPDATE users SET enabled = ? WHERE id = ?", !blocked, userId);
|
||||
if (blocked) {
|
||||
jdbc.update("UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL",
|
||||
Timestamp.from(now), userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package auth_service.modules.admin.service;
|
||||
|
||||
import auth_service.modules.admin.exception.AdminActionRefusedException;
|
||||
import auth_service.modules.admin.exception.TargetUserNotFoundException;
|
||||
import auth_service.modules.admin.model.AdminUserPage;
|
||||
import auth_service.modules.admin.model.TargetState;
|
||||
import auth_service.modules.admin.repo.AdminUserRepository;
|
||||
import auth_service.shared.account.AccountDeleter;
|
||||
import auth_service.shared.audit.AdminAuditLog;
|
||||
import auth_service.shared.security.Role;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AdminUserService {
|
||||
|
||||
private static final int MAX_PAGE_SIZE = 100;
|
||||
|
||||
private final AdminUserRepository adminUserRepository;
|
||||
private final AdminAuditLog adminAuditLog;
|
||||
private final AccountDeleter accountDeleter;
|
||||
|
||||
public AdminUserPage findUsers(String query, int page, int size) {
|
||||
String search = query == null ? "" : query.trim();
|
||||
int pageSize = Math.clamp(size, 1, MAX_PAGE_SIZE);
|
||||
int pageNumber = Math.max(page, 0);
|
||||
long total = adminUserRepository.count(search);
|
||||
return new AdminUserPage(adminUserRepository.findPage(search, pageNumber, pageSize), pageNumber, pageSize, total,
|
||||
(int) Math.ceil((double) total / pageSize));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@CacheEvict(value = {"usersByEmail", "publicStats"}, allEntries = true)
|
||||
public void setBlocked(long actorUserId, long targetUserId, boolean blocked) {
|
||||
TargetState target = checkedTarget(actorUserId, targetUserId);
|
||||
if (target.deleted()) {
|
||||
throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted");
|
||||
}
|
||||
adminUserRepository.setBlocked(targetUserId, blocked, Instant.now());
|
||||
adminAuditLog.record(actorUserId, blocked ? "BLOCK" : "UNBLOCK", targetUserId, null);
|
||||
log.info("Admin {} {} user {}", actorUserId, blocked ? "blocked" : "unblocked", targetUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same flag and nightly cleanup as "Delete my account". A user flagged before is not an error. No
|
||||
* transaction: the flag commits before the port calls Core, so Core never hides a user Auth did not flag.
|
||||
*/
|
||||
public void delete(long actorUserId, long targetUserId) {
|
||||
checkedTarget(actorUserId, targetUserId);
|
||||
if (accountDeleter.deleteByAdmin(targetUserId)) {
|
||||
adminAuditLog.record(actorUserId, "DELETE", targetUserId, null);
|
||||
log.info("Admin {} deleted user {}", actorUserId, targetUserId);
|
||||
}
|
||||
}
|
||||
|
||||
private TargetState checkedTarget(long actorUserId, long targetUserId) {
|
||||
if (actorUserId == targetUserId) {
|
||||
throw new AdminActionRefusedException("SELF_ACTION", "Admins cannot block or delete their own account");
|
||||
}
|
||||
TargetState target = adminUserRepository.findState(targetUserId)
|
||||
.orElseThrow(() -> new TargetUserNotFoundException(targetUserId));
|
||||
if (target.role() == Role.PLATFORM_ADMIN) {
|
||||
throw new AdminActionRefusedException("TARGET_IS_ADMIN", "Admins cannot block or delete another admin");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package auth_service.modules.admin.service;
|
||||
|
||||
import auth_service.modules.admin.repo.AdminAuditRepository;
|
||||
import auth_service.shared.audit.AdminAuditLog;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditLogService implements AdminAuditLog {
|
||||
|
||||
private final AdminAuditRepository adminAuditRepository;
|
||||
|
||||
@Override
|
||||
public void record(long actorUserId, String action, Long targetUserId, String detail) {
|
||||
adminAuditRepository.insert(actorUserId, action, targetUserId, detail);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth_service.modules.login.api;
|
||||
|
||||
import auth_service.modules.login.api.dto.request.LoginRequest;
|
||||
import auth_service.modules.login.api.dto.request.ViewAsRequest;
|
||||
import auth_service.modules.login.api.dto.response.LoginResponse;
|
||||
import auth_service.modules.login.api.mapper.ApiMapper;
|
||||
import auth_service.modules.login.model.Session;
|
||||
@@ -62,11 +63,29 @@ public class LoginController {
|
||||
@CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String refreshToken) {
|
||||
Optional<Session> session = refreshTokenService.refresh(refreshToken);
|
||||
if (session.isEmpty()) {
|
||||
return sessionExpired();
|
||||
}
|
||||
return ResponseEntity.noContent().headers(cookies(sessionCookies(session.get()))).build();
|
||||
}
|
||||
|
||||
private ResponseEntity<ErrorResponse> sessionExpired() {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.headers(cookies(authCookies.cleared()))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(new ErrorResponse("SESSION_EXPIRED", "Please log in again"));
|
||||
}
|
||||
|
||||
/**
|
||||
* "View as": the same as a refresh, with the given role for this login. Under /api/auth, because the refresh
|
||||
* cookie is sent only there.
|
||||
*/
|
||||
@PostMapping("/view-as")
|
||||
public ResponseEntity<?> viewAs(@RequestBody ViewAsRequest request,
|
||||
@CookieValue(name = AuthCookies.REFRESH_COOKIE, required = false) String refreshToken) {
|
||||
Optional<Session> session = refreshTokenService.switchRole(refreshToken, request.role());
|
||||
if (session.isEmpty()) {
|
||||
return sessionExpired();
|
||||
}
|
||||
return ResponseEntity.noContent().headers(cookies(sessionCookies(session.get()))).build();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package auth_service.modules.login.api;
|
||||
|
||||
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||
import auth_service.modules.login.exception.AccountBlockedException;
|
||||
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||
import auth_service.modules.login.exception.RoleNotAllowedException;
|
||||
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||
import lombok.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -38,6 +40,16 @@ public class LoginExceptionController {
|
||||
.body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccountBlockedException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(AccountBlockedException ex) {
|
||||
return response(new ErrorResponse("ACCOUNT_BLOCKED", ex.getMessage()), HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@ExceptionHandler(RoleNotAllowedException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(RoleNotAllowedException ex) {
|
||||
return response(new ErrorResponse("ROLE_NOT_ALLOWED", ex.getMessage()), HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@ExceptionHandler(InvalidCredentialsException.class)
|
||||
public ResponseEntity<ErrorResponse> clientError(InvalidCredentialsException ex) {
|
||||
var body = new ErrorResponse("INVALID_CREDENTIALS", ex.getMessage());
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package auth_service.modules.login.api.dto.request;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
/**
|
||||
* @param role null goes back to the own role
|
||||
*/
|
||||
public record ViewAsRequest(Role role) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.login.exception;
|
||||
|
||||
public class AccountBlockedException extends RuntimeException {
|
||||
public AccountBlockedException() {
|
||||
super("This account is blocked");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.login.exception;
|
||||
|
||||
public class RoleNotAllowedException extends RuntimeException {
|
||||
public RoleNotAllowedException() {
|
||||
super("You cannot act with a role above your own");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
package auth_service.modules.login.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record RefreshToken(long id, long userId, UUID familyId, Instant expiresAt, Instant rotatedAt, Instant revokedAt) {
|
||||
/**
|
||||
* @param activeRole the "view as" role of this login, null for the stored role
|
||||
*/
|
||||
public record RefreshToken(long id, long userId, UUID familyId, Instant expiresAt, Instant rotatedAt, Instant revokedAt,
|
||||
Role activeRole) {
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package auth_service.modules.login.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -12,6 +13,8 @@ public class User {
|
||||
private Long steamId;
|
||||
private boolean enabled;
|
||||
private boolean deleted;
|
||||
@Builder.Default
|
||||
private Role role = Role.USER;
|
||||
|
||||
public boolean canLogin() {
|
||||
return enabled && !deleted;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth_service.modules.login.persistence;
|
||||
|
||||
import auth_service.modules.login.model.RefreshToken;
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
@@ -8,7 +9,10 @@ import java.util.UUID;
|
||||
|
||||
public interface RefreshTokenRepository {
|
||||
|
||||
void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt);
|
||||
/**
|
||||
* @param activeRole null for the stored role
|
||||
*/
|
||||
void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt, Role activeRole);
|
||||
|
||||
/**
|
||||
* Locks the row until the transaction ends, so two refreshes with the same token run one after the other.
|
||||
|
||||
+3
-1
@@ -1,6 +1,7 @@
|
||||
package auth_service.modules.login.persistence;
|
||||
|
||||
import auth_service.modules.login.model.RefreshToken;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.modules.login.persistence.entities.RefreshTokenEntity;
|
||||
import auth_service.modules.login.persistence.mapper.EntityMapper;
|
||||
import jakarta.persistence.EntityManager;
|
||||
@@ -19,13 +20,14 @@ public class RefreshTokenRepositoryImpl implements RefreshTokenRepository {
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Override
|
||||
public void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt) {
|
||||
public void save(long userId, UUID familyId, String tokenHash, Instant createdAt, Instant expiresAt, Role activeRole) {
|
||||
RefreshTokenEntity entity = new RefreshTokenEntity();
|
||||
entity.setUserId(userId);
|
||||
entity.setFamilyId(familyId);
|
||||
entity.setTokenHash(tokenHash);
|
||||
entity.setCreatedAt(createdAt);
|
||||
entity.setExpiresAt(expiresAt);
|
||||
entity.setActiveRole(activeRole == null ? null : activeRole.name());
|
||||
entityManager.persist(entity);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,4 +39,8 @@ public class MinimalUserEntity {
|
||||
|
||||
@Column(name = "deleted_at", insertable = false, updatable = false)
|
||||
private Instant deletedAt;
|
||||
|
||||
// Set by SQL only (the first admin).
|
||||
@Column(name = "role", insertable = false, updatable = false)
|
||||
private String role;
|
||||
}
|
||||
@@ -42,4 +42,7 @@ public class RefreshTokenEntity {
|
||||
|
||||
@Column(name = "revoked_at")
|
||||
private Instant revokedAt;
|
||||
|
||||
@Column(name = "active_role")
|
||||
private String activeRole;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import auth_service.modules.login.model.RefreshToken;
|
||||
import auth_service.modules.login.model.User;
|
||||
import auth_service.modules.login.persistence.entities.MinimalUserEntity;
|
||||
import auth_service.modules.login.persistence.entities.RefreshTokenEntity;
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
public abstract class EntityMapper {
|
||||
|
||||
@@ -20,6 +21,7 @@ public abstract class EntityMapper {
|
||||
model.setSteamId(entity.getSteamId());
|
||||
model.setEnabled(entity.isEnabled());
|
||||
model.setDeleted(entity.getDeletedAt() != null);
|
||||
model.setRole(Role.parse(entity.getRole()));
|
||||
}
|
||||
|
||||
return model;
|
||||
@@ -27,6 +29,7 @@ public abstract class EntityMapper {
|
||||
|
||||
public static RefreshToken mapToModel(RefreshTokenEntity entity) {
|
||||
return new RefreshToken(entity.getId(), entity.getUserId(), entity.getFamilyId(), entity.getExpiresAt(),
|
||||
entity.getRotatedAt(), entity.getRevokedAt());
|
||||
entity.getRotatedAt(), entity.getRevokedAt(),
|
||||
entity.getActiveRole() == null ? null : Role.parse(entity.getActiveRole()));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package auth_service.modules.login.service;
|
||||
|
||||
import auth_service.shared.security.TokenGenerator;
|
||||
import auth_service.modules.login.exception.AccountBlockedException;
|
||||
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||
import auth_service.modules.login.model.Session;
|
||||
import auth_service.modules.login.model.User;
|
||||
import auth_service.modules.login.persistence.MinimalUserRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -36,12 +38,14 @@ public class LoginService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates a user and starts a session. Unknown email, wrong password and disabled
|
||||
* account all fail with the same InvalidCredentialsException.
|
||||
* Authenticates a user and starts a session. Unknown email, wrong password and deleted account all fail with the
|
||||
* same InvalidCredentialsException; a blocked account says so, but only after the right password.
|
||||
*
|
||||
* @param previousRefreshToken the refresh cookie this browser still sends, or null. The new cookie replaces it,
|
||||
* so its login is revoked; else it would stay valid unseen for 30 days.
|
||||
*/
|
||||
// /api/users/me caches the user by email; a role set by SQL must show after the next login.
|
||||
@CacheEvict(value = "usersByEmail", key = "#userToLogin.email")
|
||||
public Session login(User userToLogin, String clientIp, String previousRefreshToken) {
|
||||
String email = userToLogin.getEmail();
|
||||
loginAttemptService.acquire(email, clientIp);
|
||||
@@ -50,15 +54,22 @@ public class LoginService {
|
||||
String hash = foundUser.map(User::getPassword).orElse(dummyHash);
|
||||
boolean passwordMatches = passwordEncoder.matches(userToLogin.getPassword(), hash);
|
||||
|
||||
if (foundUser.isEmpty() || !passwordMatches || !foundUser.get().canLogin()) {
|
||||
if (foundUser.isEmpty() || !passwordMatches || foundUser.get().isDeleted()) {
|
||||
throw new InvalidCredentialsException("Invalid email or password");
|
||||
}
|
||||
// Only after the right password, so a wrong one never tells that the account exists and is blocked.
|
||||
if (!foundUser.get().isEnabled()) {
|
||||
// The right password is no failed attempt; else the user gets 429 and stays locked after an unblock.
|
||||
loginAttemptService.recordSuccess(email, clientIp);
|
||||
throw new AccountBlockedException();
|
||||
}
|
||||
|
||||
User user = foundUser.get();
|
||||
loginAttemptService.recordSuccess(email, clientIp);
|
||||
minimalUserRepository.updateLoginDate(user.getId());
|
||||
refreshTokenService.revoke(previousRefreshToken);
|
||||
String accessToken = tokenGenerator.generateToken(String.valueOf(user.getId()), user.getEmail(), user.getSteamId());
|
||||
String accessToken = tokenGenerator.generateToken(String.valueOf(user.getId()), user.getEmail(), user.getSteamId(),
|
||||
user.getRole());
|
||||
return new Session(accessToken, refreshTokenService.startFamily(user.getId()));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import auth_service.modules.login.model.Session;
|
||||
import auth_service.modules.login.model.User;
|
||||
import auth_service.modules.login.persistence.MinimalUserRepository;
|
||||
import auth_service.modules.login.persistence.RefreshTokenRepository;
|
||||
import auth_service.modules.login.exception.RoleNotAllowedException;
|
||||
import auth_service.shared.audit.AdminAuditLog;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.security.SessionProperties;
|
||||
import auth_service.shared.security.TokenGenerator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -39,10 +42,11 @@ public class RefreshTokenService {
|
||||
private final MinimalUserRepository userRepository;
|
||||
private final TokenGenerator tokenGenerator;
|
||||
private final SessionProperties sessionProperties;
|
||||
private final AdminAuditLog adminAuditLog;
|
||||
|
||||
@Transactional
|
||||
public String startFamily(long userId) {
|
||||
return issue(userId, UUID.randomUUID(), Instant.now());
|
||||
return issue(userId, UUID.randomUUID(), Instant.now(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +57,22 @@ public class RefreshTokenService {
|
||||
*/
|
||||
@Transactional
|
||||
public Optional<Session> refresh(String rawToken) {
|
||||
return rotate(rawToken, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like refresh, and the new tokens act with another role of this login ("view as"). The login keeps it on
|
||||
* every refresh until the next switch.
|
||||
*
|
||||
* @param role null goes back to the stored role
|
||||
* @throws RoleNotAllowedException for a role above the stored one
|
||||
*/
|
||||
@Transactional
|
||||
public Optional<Session> switchRole(String rawToken, Role role) {
|
||||
return rotate(rawToken, true, role);
|
||||
}
|
||||
|
||||
private Optional<Session> rotate(String rawToken, boolean switching, Role requested) {
|
||||
if (isBlank(rawToken)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
@@ -75,11 +95,21 @@ public class RefreshTokenService {
|
||||
refreshTokenRepository.revokeFamily(token.familyId(), now);
|
||||
return Optional.empty();
|
||||
}
|
||||
refreshTokenRepository.markRotated(token.id(), now);
|
||||
String next = issue(token.userId(), token.familyId(), now);
|
||||
User current = user.get();
|
||||
Role stored = current.getRole();
|
||||
if (switching && requested != null && !stored.atLeast(requested)) {
|
||||
throw new RoleNotAllowedException();
|
||||
}
|
||||
Role chosen = switching ? requested : token.activeRole();
|
||||
// Never above the stored role: a demoted admin loses the rights at the next refresh.
|
||||
Role active = chosen == null ? stored : Role.lower(chosen, stored);
|
||||
refreshTokenRepository.markRotated(token.id(), now);
|
||||
String next = issue(token.userId(), token.familyId(), now, chosen);
|
||||
if (switching && stored != Role.USER) {
|
||||
adminAuditLog.record(current.getId(), "VIEW_AS", null, requested == null ? "OWN_ROLE" : requested.name());
|
||||
}
|
||||
String accessToken = tokenGenerator.generateToken(
|
||||
String.valueOf(current.getId()), current.getEmail(), current.getSteamId());
|
||||
String.valueOf(current.getId()), current.getEmail(), current.getSteamId(), active);
|
||||
return Optional.of(new Session(accessToken, next));
|
||||
}
|
||||
|
||||
@@ -102,11 +132,12 @@ public class RefreshTokenService {
|
||||
log.info("Deleted {} expired refresh tokens", deleted);
|
||||
}
|
||||
|
||||
private String issue(long userId, UUID familyId, Instant now) {
|
||||
private String issue(long userId, UUID familyId, Instant now, Role activeRole) {
|
||||
byte[] bytes = new byte[TOKEN_BYTES];
|
||||
RANDOM.nextBytes(bytes);
|
||||
String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
refreshTokenRepository.save(userId, familyId, hash(rawToken), now, now.plus(sessionProperties.getRefreshTokenTtl()));
|
||||
refreshTokenRepository.save(userId, familyId, hash(rawToken), now, now.plus(sessionProperties.getRefreshTokenTtl()),
|
||||
activeRole);
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ public class SteamLinkController {
|
||||
*/
|
||||
@DeleteMapping(value = "/api/users/steam-link", version = "1.0")
|
||||
public ResponseEntity<Void> unlink(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
String token = userService.unlinkSteamId(principal.getIdAsLong(), principal.email());
|
||||
String token = userService.unlinkSteamId(principal.getIdAsLong(), principal.email(), principal.role());
|
||||
return ResponseEntity.noContent()
|
||||
.header(HttpHeaders.SET_COOKIE, authCookies.access(token).toString())
|
||||
.build();
|
||||
|
||||
@@ -35,7 +35,7 @@ public class UserController {
|
||||
// this is the email from the login, if a user is not authenticated, it will not even come to this point.
|
||||
var authenticatedEmail = principal.email();
|
||||
var profileResponse = this.userService.getUserByEmail(authenticatedEmail);
|
||||
return ResponseEntity.ok(ApiMapper.toApi(profileResponse));
|
||||
return ResponseEntity.ok(ApiMapper.toApi(profileResponse, principal.role()));
|
||||
}
|
||||
|
||||
@PutMapping(value = "/settings", version = "1.0", produces = "application/json")
|
||||
|
||||
@@ -5,5 +5,5 @@ import lombok.Builder;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Builder
|
||||
public record UserProfileResponse(long id, String email, String role, LocalDateTime lastLogin, LocalDateTime createdDate, String steamId, boolean steamIdVerified, String displayName, SettingsResponse settings) {
|
||||
public record UserProfileResponse(long id, String email, String role, String activeRole, LocalDateTime lastLogin, LocalDateTime createdDate, String steamId, boolean steamIdVerified, String displayName, SettingsResponse settings) {
|
||||
}
|
||||
@@ -9,12 +9,16 @@ import auth_service.modules.user.api.dto.response.UserProfileResponse;
|
||||
import auth_service.modules.user.model.Appearance;
|
||||
import auth_service.modules.user.model.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
public abstract class ApiMapper {
|
||||
private ApiMapper() {
|
||||
}
|
||||
|
||||
public static UserProfileResponse toApi(User user) {
|
||||
/**
|
||||
* @param activeRole the role of this login ("view as")
|
||||
*/
|
||||
public static UserProfileResponse toApi(User user, Role activeRole) {
|
||||
return UserProfileResponse.builder()
|
||||
.id(user.getId())
|
||||
.email(user.getEmail())
|
||||
@@ -23,7 +27,8 @@ public abstract class ApiMapper {
|
||||
.steamId(user.getSteamId() == null ? null : String.valueOf(user.getSteamId()))
|
||||
.steamIdVerified(user.isSteamIdVerified())
|
||||
.displayName(user.getDisplayName())
|
||||
.role("USER")
|
||||
.role(user.getRole().name())
|
||||
.activeRole(activeRole.name())
|
||||
.settings(SettingsResponse.builder()
|
||||
.id(user.getId())
|
||||
.useDarkTheme(user.getSettings().getTheme().equals("dark"))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package auth_service.modules.user.model;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -16,6 +17,8 @@ public class User {
|
||||
private Long steamId;
|
||||
private boolean steamIdVerified;
|
||||
private String displayName;
|
||||
@Builder.Default
|
||||
private Role role = Role.USER;
|
||||
|
||||
private UserSettings settings;
|
||||
}
|
||||
@@ -41,6 +41,9 @@ public class UserEntity {
|
||||
@Column(name = "deleted_at", insertable = false, updatable = false)
|
||||
private Instant deletedAt;
|
||||
|
||||
@Column(name = "role", insertable = false, updatable = false)
|
||||
private String role;
|
||||
|
||||
@Column(name = "display_name", length = 24)
|
||||
private String displayName;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import auth_service.modules.user.model.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
import auth_service.modules.user.persistence.entities.UserEntity;
|
||||
import auth_service.modules.user.persistence.entities.UserSettingsEntity;
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -49,6 +50,7 @@ public abstract class EntityMapper {
|
||||
.steamId(entity.getSteamId())
|
||||
.steamIdVerified(entity.isSteamIdVerified())
|
||||
.displayName(entity.getDisplayName())
|
||||
.role(Role.parse(entity.getRole()))
|
||||
.settings(settingsModel)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import auth_service.modules.user.model.SteamLinkOutcome;
|
||||
import auth_service.modules.user.model.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
import auth_service.modules.user.persistence.UserRepository;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.security.TokenGenerator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -77,10 +78,10 @@ public class UserService {
|
||||
*/
|
||||
@Transactional
|
||||
@CacheEvict(value = "usersByEmail", allEntries = true)
|
||||
public String unlinkSteamId(long userId, String email) {
|
||||
public String unlinkSteamId(long userId, String email, Role role) {
|
||||
userRepository.clearSteamId(userId);
|
||||
log.info("Steam ID unlinked for user {}", userId);
|
||||
return tokenGenerator.generateToken(String.valueOf(userId), email, null);
|
||||
return tokenGenerator.generateToken(String.valueOf(userId), email, null, role);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package auth_service.shared.account;
|
||||
|
||||
/**
|
||||
* Port for a delete that is not the user's own, e.g. by an admin. The same flag and nightly cleanup as
|
||||
* "Delete my account".
|
||||
*/
|
||||
public interface AccountDeleter {
|
||||
/**
|
||||
* @return false when the user was flagged before
|
||||
*/
|
||||
boolean deleteByAdmin(long userId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package auth_service.shared.audit;
|
||||
|
||||
/**
|
||||
* Port for the audit table of admin actions, so modules other than admin can write to it.
|
||||
*/
|
||||
public interface AdminAuditLog {
|
||||
/**
|
||||
* @param targetUserId null for an action without a target user, e.g. "view as"
|
||||
*/
|
||||
void record(long actorUserId, String action, Long targetUserId, String detail);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package auth_service.shared.security;
|
||||
|
||||
/**
|
||||
* A ladder: each role has every right of the roles before it.
|
||||
*/
|
||||
public enum Role {
|
||||
USER, AUDITOR, PLATFORM_ADMIN;
|
||||
|
||||
public boolean atLeast(Role other) {
|
||||
return compareTo(other) >= 0;
|
||||
}
|
||||
|
||||
public static Role lower(Role a, Role b) {
|
||||
return a.compareTo(b) <= 0 ? a : b;
|
||||
}
|
||||
|
||||
/**
|
||||
* USER for null or an unknown name, e.g. a token from before roles.
|
||||
*/
|
||||
public static Role parse(String name) {
|
||||
if (name == null) {
|
||||
return USER;
|
||||
}
|
||||
try {
|
||||
return valueOf(name);
|
||||
} catch (IllegalArgumentException _) {
|
||||
return USER;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,9 @@ public interface TokenGenerator {
|
||||
* @param userId The unique identifier of the user.
|
||||
* @param email The email address of the user.
|
||||
* @param steamId The Steam ID of the user.
|
||||
* @param role The active role of the login.
|
||||
* @return The generated token string.
|
||||
*/
|
||||
String generateToken(String userId, String email, Long steamId);
|
||||
String generateToken(String userId, String email, Long steamId, Role role);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,20 @@ package auth_service.shared.security;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
/**
|
||||
* @param role the active role of this login ("view as"), never above the stored one
|
||||
*/
|
||||
public record UserPrincipal(
|
||||
String id,
|
||||
String email,
|
||||
Long steamId
|
||||
Long steamId,
|
||||
Role role
|
||||
) implements Principal {
|
||||
|
||||
public UserPrincipal(String id, String email, Long steamId) {
|
||||
this(id, email, steamId, Role.USER);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return id;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- One role per user, a ladder: each role has every right of the roles before it. Set the first admin by SQL.
|
||||
ALTER TABLE users ADD COLUMN role VARCHAR(16) NOT NULL DEFAULT 'USER'
|
||||
CHECK (role IN ('USER', 'AUDITOR', 'PLATFORM_ADMIN'));
|
||||
|
||||
-- "View as": the role this login acts with, copied on every rotation. NULL = the stored role.
|
||||
ALTER TABLE refresh_tokens ADD COLUMN active_role VARCHAR(16)
|
||||
CHECK (active_role IN ('USER', 'AUDITOR', 'PLATFORM_ADMIN'));
|
||||
|
||||
-- One row per admin action. No foreign keys: the rows outlive a deleted user.
|
||||
CREATE TABLE admin_audit (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
actor_user_id BIGINT NOT NULL,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
target_user_id BIGINT,
|
||||
detail VARCHAR(64),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX idx_admin_audit_target ON admin_audit (target_user_id);
|
||||
@@ -1,5 +1,6 @@
|
||||
package auth_service.configuration.security;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.security.SessionProperties;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
@@ -12,9 +13,11 @@ import org.mockito.Mock;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -41,10 +44,12 @@ class JwtServiceTest extends AbstractUnitTest {
|
||||
|
||||
private JwtService jwtService;
|
||||
private PublicKey publicKey;
|
||||
private PrivateKey privateKey;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
KeyPair keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||
privateKey = keyPair.getPrivate();
|
||||
String privateB64 = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
|
||||
String publicB64 = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
|
||||
|
||||
@@ -61,7 +66,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void generateToken_Success() {
|
||||
var token = jwtService.generateToken("0815", "test@example.com", null);
|
||||
var token = jwtService.generateToken("0815", "test@example.com", null, Role.USER);
|
||||
|
||||
assertNotNull(token);
|
||||
assertFalse(token.isEmpty());
|
||||
@@ -69,7 +74,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void generateToken_setsKidHeader() {
|
||||
var token = jwtService.generateToken("0815", "test@example.com", null);
|
||||
var token = jwtService.generateToken("0815", "test@example.com", null, Role.USER);
|
||||
|
||||
var header = Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
@@ -83,7 +88,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void parseLoginToken_ReturnsTheUser() {
|
||||
var token = jwtService.generateToken("0815", "test@example.com", 123456789L);
|
||||
var token = jwtService.generateToken("0815", "test@example.com", 123456789L, Role.USER);
|
||||
|
||||
assertEquals(Optional.of(new UserPrincipal("0815", "test@example.com", 123456789L)),
|
||||
jwtService.parseLoginToken(token));
|
||||
@@ -96,7 +101,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void generateToken_ExpiresAfterTheAccessTokenTtl() {
|
||||
var token = jwtService.generateToken("0815", "test@example.com", null);
|
||||
var token = jwtService.generateToken("0815", "test@example.com", null, Role.USER);
|
||||
|
||||
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token).getPayload();
|
||||
long secondsLeft = (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000;
|
||||
@@ -156,7 +161,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
||||
System.arraycopy(prefix, 0, der, 0, prefix.length);
|
||||
System.arraycopy(raw, 0, der, prefix.length, raw.length);
|
||||
PublicKey fromJwk = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(der));
|
||||
String token = jwtService.generateToken("7", "a@b.de", null);
|
||||
String token = jwtService.generateToken("7", "a@b.de", null, Role.USER);
|
||||
assertEquals("7", Jwts.parser().verifyWith(fromJwk).build().parseSignedClaims(token).getPayload().get("userId"));
|
||||
}
|
||||
|
||||
@@ -172,4 +177,20 @@ class JwtServiceTest extends AbstractUnitTest {
|
||||
assertTrue(secondsLeft > 0 && secondsLeft <= 60, "expires in " + secondsLeft + "s");
|
||||
assertEquals(Optional.empty(), jwtService.parseLoginToken(token));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseLoginToken_ReadsTheRole() {
|
||||
String token = jwtService.generateToken("7", "a@b.de", null, Role.AUDITOR);
|
||||
|
||||
assertEquals(Role.AUDITOR, jwtService.parseLoginToken(token).orElseThrow().role());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseLoginToken_ATokenFromBeforeRolesIsAUser() {
|
||||
String old = Jwts.builder().header().keyId(KID).and().subject("a@b.de").claim("userId", "7")
|
||||
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
||||
.signWith(privateKey, Jwts.SIG.EdDSA).compact();
|
||||
|
||||
assertEquals(Role.USER, jwtService.parseLoginToken(old).orElseThrow().role());
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ class AccountServiceTest extends AbstractUnitTest {
|
||||
service = new AccountService(accountRepository, passwordEncoder, coreAccountService);
|
||||
lenient().when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.of("hash"));
|
||||
lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true);
|
||||
lenient().when(accountRepository.flagDeleted(eq(USER_ID), any())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package auth_service.modules.admin.api;
|
||||
|
||||
import auth_service.configuration.security.ClientHeaderFilter;
|
||||
import auth_service.shared.api.AuthCookies;
|
||||
import auth_service.shared.test.AbstractIntegrationTest;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.ResultActions;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Roles, "view as" and the admin users API against a real database.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class AdminFlowTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final String PASSWORD = "correct horse battery";
|
||||
private static final String ADMIN = "admin-flow@example.com";
|
||||
private static final String OTHER_ADMIN = "admin-flow-2@example.com";
|
||||
private static final String PLAYER = "admin-flow-player@example.com";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
private long playerId;
|
||||
private long otherAdminId;
|
||||
|
||||
private long insertUser(String email, String role) {
|
||||
jdbc.update("DELETE FROM users WHERE email = ?", email);
|
||||
return jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) VALUES (?, ?, true, ?, ?) "
|
||||
+ "RETURNING id", Long.class, email, passwordEncoder.encode(PASSWORD), role, email.substring(0, 16));
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
insertUser(ADMIN, "PLATFORM_ADMIN");
|
||||
otherAdminId = insertUser(OTHER_ADMIN, "PLATFORM_ADMIN");
|
||||
playerId = insertUser(PLAYER, "USER");
|
||||
}
|
||||
|
||||
private MockHttpServletResponse login(String email) throws Exception {
|
||||
return mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}"))
|
||||
.andReturn().getResponse();
|
||||
}
|
||||
|
||||
private record Session(String access, String refresh) {
|
||||
static Session of(MockHttpServletResponse response) {
|
||||
return new Session(response.getCookie(AuthCookies.ACCESS_COOKIE).getValue(),
|
||||
response.getCookie(AuthCookies.REFRESH_COOKIE).getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private ResultActions listUsers(Session session) throws Exception {
|
||||
return mockMvc.perform(get("/api/admin/users").param("query", "admin-flow")
|
||||
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access())));
|
||||
}
|
||||
|
||||
private MockHttpServletResponse viewAs(Session session, String role) throws Exception {
|
||||
String body = role == null ? "{\"role\":null}" : "{\"role\":\"" + role + "\"}";
|
||||
return mockMvc.perform(post("/api/auth/view-as").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(new Cookie(AuthCookies.REFRESH_COOKIE, session.refresh()))
|
||||
.contentType(MediaType.APPLICATION_JSON).content(body))
|
||||
.andReturn().getResponse();
|
||||
}
|
||||
|
||||
private Session refresh(Session session) throws Exception {
|
||||
MockHttpServletResponse response = mockMvc.perform(post("/api/auth/refresh")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(new Cookie(AuthCookies.REFRESH_COOKIE, session.refresh())))
|
||||
.andReturn().getResponse();
|
||||
assertThat(response.getStatus()).isEqualTo(204);
|
||||
return Session.of(response);
|
||||
}
|
||||
|
||||
private ResultActions block(Session session, long userId, boolean blocked) throws Exception {
|
||||
return mockMvc.perform(put("/api/admin/users/" + userId + "/blocked").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access()))
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"blocked\":" + blocked + "}"));
|
||||
}
|
||||
|
||||
private long audits(String action, long target) {
|
||||
return jdbc.queryForObject("SELECT COUNT(*) FROM admin_audit WHERE action = ? AND target_user_id = ?",
|
||||
Long.class, action, target);
|
||||
}
|
||||
|
||||
@Test
|
||||
void admin_ListsUsersWithSearchAndPaging() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
|
||||
listUsers(admin).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.totalElements").value(3))
|
||||
.andExpect(jsonPath("$.content[0].email").value(ADMIN))
|
||||
.andExpect(jsonPath("$.content[0].role").value("PLATFORM_ADMIN"));
|
||||
mockMvc.perform(get("/api/admin/users").param("query", "admin-flow").param("size", "2").param("page", "1")
|
||||
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access())))
|
||||
.andExpect(jsonPath("$.content.length()").value(1))
|
||||
.andExpect(jsonPath("$.totalPages").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userAndAnonymous_AreRefused() throws Exception {
|
||||
listUsers(Session.of(login(PLAYER))).andExpect(status().isForbidden());
|
||||
mockMvc.perform(get("/api/admin/users")).andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewAs_LowersTheRightsSurvivesARefreshAndGoesBack() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
|
||||
MockHttpServletResponse asUser = viewAs(admin, "USER");
|
||||
assertThat(asUser.getStatus()).isEqualTo(204);
|
||||
Session user = Session.of(asUser);
|
||||
listUsers(user).andExpect(status().isForbidden());
|
||||
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, user.access())))
|
||||
.andExpect(jsonPath("$.role").value("PLATFORM_ADMIN"))
|
||||
.andExpect(jsonPath("$.activeRole").value("USER"));
|
||||
|
||||
Session refreshed = refresh(user);
|
||||
listUsers(refreshed).andExpect(status().isForbidden());
|
||||
|
||||
Session back = Session.of(viewAs(refreshed, null));
|
||||
listUsers(back).andExpect(status().isOk());
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM admin_audit a JOIN users u ON u.id = a.actor_user_id "
|
||||
+ "WHERE u.email = ? AND a.action = 'VIEW_AS'", Long.class, ADMIN)).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewAs_AboveTheOwnRoleIsRefused() throws Exception {
|
||||
MockHttpServletResponse response = viewAs(Session.of(login(PLAYER)), "PLATFORM_ADMIN");
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(403);
|
||||
assertThat(response.getContentAsString()).contains("ROLE_NOT_ALLOWED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void demotedAdmin_LosesTheRightsAtTheNextRefresh() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
jdbc.update("UPDATE users SET role = 'USER' WHERE email = ?", ADMIN);
|
||||
|
||||
listUsers(refresh(admin)).andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void block_EndsTheSessionsAndTheLoginTellsWhy() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
Session player = Session.of(login(PLAYER));
|
||||
|
||||
block(admin, playerId, true).andExpect(status().isNoContent());
|
||||
|
||||
assertThat(login(PLAYER).getContentAsString()).contains("ACCOUNT_BLOCKED");
|
||||
assertThat(login(PLAYER).getStatus()).isEqualTo(403);
|
||||
assertThat(mockMvc.perform(post("/api/auth/refresh").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(new Cookie(AuthCookies.REFRESH_COOKIE, player.refresh()))).andReturn().getResponse().getStatus())
|
||||
.isEqualTo(401);
|
||||
assertThat(audits("BLOCK", playerId)).isEqualTo(1);
|
||||
|
||||
block(admin, playerId, false).andExpect(status().isNoContent());
|
||||
assertThat(login(PLAYER).getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_FlagsTheUser() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
|
||||
mockMvc.perform(delete("/api/admin/users/" + playerId).header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access())))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
assertThat(jdbc.queryForObject("SELECT deleted_at FROM users WHERE id = ?", Object.class, playerId)).isNotNull();
|
||||
assertThat(login(PLAYER).getStatus()).isEqualTo(401);
|
||||
assertThat(audits("DELETE", playerId)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void promotedUser_SeesTheNewRoleAfterLoggingInAgain() throws Exception {
|
||||
Session before = Session.of(login(PLAYER));
|
||||
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, before.access())))
|
||||
.andExpect(jsonPath("$.role").value("USER"));
|
||||
|
||||
jdbc.update("UPDATE users SET role = 'PLATFORM_ADMIN' WHERE email = ?", PLAYER);
|
||||
Session after = Session.of(login(PLAYER));
|
||||
|
||||
listUsers(after).andExpect(status().isOk());
|
||||
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, after.access())))
|
||||
.andExpect(jsonPath("$.activeRole").value("PLATFORM_ADMIN"))
|
||||
.andExpect(jsonPath("$.role").value("PLATFORM_ADMIN"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewAs_KeepsTheChoiceThroughADemotionAndBack() throws Exception {
|
||||
Session auditor = Session.of(viewAs(Session.of(login(ADMIN)), "AUDITOR"));
|
||||
jdbc.update("UPDATE users SET role = 'USER' WHERE email = ?", ADMIN);
|
||||
Session demoted = refresh(auditor);
|
||||
jdbc.update("UPDATE users SET role = 'PLATFORM_ADMIN' WHERE email = ?", ADMIN);
|
||||
|
||||
Session promoted = refresh(demoted);
|
||||
|
||||
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, promoted.access())))
|
||||
.andExpect(jsonPath("$.activeRole").value("AUDITOR"));
|
||||
listUsers(Session.of(viewAs(promoted, null))).andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void guards_RefuseAnotherAdminAndAnUnknownUser() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
|
||||
block(admin, otherAdminId, true).andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("TARGET_IS_ADMIN"));
|
||||
block(admin, 999_999_999L, true).andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package auth_service.modules.admin.repo;
|
||||
|
||||
import auth_service.modules.admin.model.AdminUser;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@Import(AdminUserRepository.class)
|
||||
class AdminUserRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Autowired
|
||||
private AdminUserRepository repository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
private long alice;
|
||||
|
||||
private long insert(String email, String name) {
|
||||
return jdbc.queryForObject("INSERT INTO users (email, password, enabled, display_name) VALUES (?, 'x', true, ?) "
|
||||
+ "RETURNING id", Long.class, email, name);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("DELETE FROM users");
|
||||
alice = insert("alice@example.com", "Alice_1");
|
||||
insert("bob@example.com", "Bob");
|
||||
insert("carol@sample.org", "100%Carol");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPage_SearchesEmailAndNameInAnyCase() {
|
||||
assertThat(repository.findPage("EXAMPLE", 0, 10)).extracting(AdminUser::email)
|
||||
.containsExactly("alice@example.com", "bob@example.com");
|
||||
assertThat(repository.findPage("bob", 0, 10)).extracting(AdminUser::email).containsExactly("bob@example.com");
|
||||
assertThat(repository.count("")).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPage_TreatsLikeCharactersAsText() {
|
||||
assertThat(repository.findPage("%", 0, 10)).extracting(AdminUser::email).containsExactly("carol@sample.org");
|
||||
assertThat(repository.findPage("e_1", 0, 10)).extracting(AdminUser::email).containsExactly("alice@example.com");
|
||||
assertThat(repository.count("_")).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPage_Pages() {
|
||||
assertThat(repository.findPage("", 1, 2)).extracting(AdminUser::email).containsExactly("carol@sample.org");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPage_ShowsRoleBlockAndDelete() {
|
||||
jdbc.update("UPDATE users SET role = 'AUDITOR', enabled = false, deleted_at = NOW(), steam_id = 1 WHERE id = ?", alice);
|
||||
|
||||
AdminUser found = repository.findPage("alice", 0, 10).getFirst();
|
||||
|
||||
assertThat(found.role()).isEqualTo(Role.AUDITOR);
|
||||
assertThat(found.blocked()).isTrue();
|
||||
assertThat(found.steamLinked()).isTrue();
|
||||
assertThat(found.deletedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void setBlocked_RevokesTheLoginsAndUnblockKeepsThemRevoked() {
|
||||
jdbc.update("INSERT INTO refresh_tokens (user_id, family_id, token_hash, created_at, expires_at) "
|
||||
+ "VALUES (?, gen_random_uuid(), 'h1', NOW(), NOW() + interval '1 day')", alice);
|
||||
|
||||
repository.setBlocked(alice, true, Instant.now());
|
||||
|
||||
assertThat(jdbc.queryForObject("SELECT enabled FROM users WHERE id = ?", Boolean.class, alice)).isFalse();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM refresh_tokens WHERE user_id = ? AND revoked_at IS NULL",
|
||||
Long.class, alice)).isZero();
|
||||
|
||||
repository.setBlocked(alice, false, Instant.now());
|
||||
assertThat(jdbc.queryForObject("SELECT enabled FROM users WHERE id = ?", Boolean.class, alice)).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package auth_service.modules.admin.service;
|
||||
|
||||
import auth_service.modules.admin.exception.AdminActionRefusedException;
|
||||
import auth_service.modules.admin.exception.TargetUserNotFoundException;
|
||||
import auth_service.modules.admin.model.TargetState;
|
||||
import auth_service.modules.admin.repo.AdminUserRepository;
|
||||
import auth_service.shared.account.AccountDeleter;
|
||||
import auth_service.shared.audit.AdminAuditLog;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AdminUserServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final long ADMIN = 1L;
|
||||
private static final long TARGET = 2L;
|
||||
|
||||
@Mock
|
||||
private AdminUserRepository adminUserRepository;
|
||||
|
||||
@Mock
|
||||
private AdminAuditLog adminAuditLog;
|
||||
|
||||
@Mock
|
||||
private AccountDeleter accountDeleter;
|
||||
|
||||
private AdminUserService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new AdminUserService(adminUserRepository, adminAuditLog, accountDeleter);
|
||||
}
|
||||
|
||||
private void target(Role role, boolean deleted) {
|
||||
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted)));
|
||||
}
|
||||
|
||||
private String refusal(Runnable action) {
|
||||
try {
|
||||
action.run();
|
||||
} catch (AdminActionRefusedException e) {
|
||||
return e.getCode();
|
||||
}
|
||||
throw new AssertionError("not refused");
|
||||
}
|
||||
|
||||
@Test
|
||||
void block_BlocksAndWritesTheAudit() {
|
||||
target(Role.USER, false);
|
||||
|
||||
service.setBlocked(ADMIN, TARGET, true);
|
||||
|
||||
verify(adminUserRepository).setBlocked(eq(TARGET), eq(true), any());
|
||||
verify(adminAuditLog).record(ADMIN, "BLOCK", TARGET, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unblock_WritesItsOwnAuditAction() {
|
||||
target(Role.AUDITOR, false);
|
||||
|
||||
service.setBlocked(ADMIN, TARGET, false);
|
||||
|
||||
verify(adminAuditLog).record(ADMIN, "UNBLOCK", TARGET, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void guards_RefuseTheOwnAccountAnotherAdminAndADeletedUser() {
|
||||
assertThat(refusal(() -> service.setBlocked(ADMIN, ADMIN, true))).isEqualTo("SELF_ACTION");
|
||||
assertThat(refusal(() -> service.delete(ADMIN, ADMIN))).isEqualTo("SELF_ACTION");
|
||||
|
||||
target(Role.PLATFORM_ADMIN, false);
|
||||
assertThat(refusal(() -> service.setBlocked(ADMIN, TARGET, true))).isEqualTo("TARGET_IS_ADMIN");
|
||||
assertThat(refusal(() -> service.delete(ADMIN, TARGET))).isEqualTo("TARGET_IS_ADMIN");
|
||||
|
||||
target(Role.USER, true);
|
||||
assertThat(refusal(() -> service.setBlocked(ADMIN, TARGET, true))).isEqualTo("ALREADY_DELETED");
|
||||
|
||||
verify(adminUserRepository, never()).setBlocked(anyLong(), anyBoolean(), any());
|
||||
verifyNoInteractions(accountDeleter, adminAuditLog);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownUser_IsNotFound() {
|
||||
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.delete(ADMIN, TARGET)).isInstanceOf(TargetUserNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_GoesThroughTheAccountFlagAndIsAudited() {
|
||||
target(Role.USER, false);
|
||||
when(accountDeleter.deleteByAdmin(TARGET)).thenReturn(true);
|
||||
|
||||
service.delete(ADMIN, TARGET);
|
||||
|
||||
verify(adminAuditLog).record(ADMIN, "DELETE", TARGET, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_OfAnAlreadyFlaggedUserWritesNoAudit() {
|
||||
target(Role.USER, true);
|
||||
when(accountDeleter.deleteByAdmin(TARGET)).thenReturn(false);
|
||||
|
||||
service.delete(ADMIN, TARGET);
|
||||
|
||||
verifyNoInteractions(adminAuditLog);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findUsers_ClampsTheSizeAndCountsThePages() {
|
||||
when(adminUserRepository.count("bob")).thenReturn(250L);
|
||||
|
||||
assertThat(service.findUsers(" bob ", -1, 500).totalPages()).isEqualTo(3);
|
||||
verify(adminUserRepository).findPage("bob", 0, 100);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package auth_service.modules.login.service;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.security.TokenGenerator;
|
||||
import auth_service.modules.login.exception.AccountBlockedException;
|
||||
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||
import auth_service.modules.login.model.Session;
|
||||
@@ -75,7 +77,7 @@ class LoginServiceTest extends AbstractUnitTest {
|
||||
.build();
|
||||
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
|
||||
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
|
||||
when(tokenGenerator.generateToken("815", "test@example.com", null)).thenReturn("mock-token");
|
||||
when(tokenGenerator.generateToken("815", "test@example.com", null, Role.USER)).thenReturn("mock-token");
|
||||
when(refreshTokenService.startFamily(815L)).thenReturn("mock-refresh");
|
||||
|
||||
// Act
|
||||
@@ -140,7 +142,7 @@ class LoginServiceTest extends AbstractUnitTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_DisabledUser() {
|
||||
void login_BlockedUserWithTheRightPassword_IsToldItIsBlocked() {
|
||||
// Arrange
|
||||
var dbUser = User.builder()
|
||||
.id(815L)
|
||||
@@ -152,8 +154,30 @@ class LoginServiceTest extends AbstractUnitTest {
|
||||
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(AccountBlockedException.class, () -> loginService.login(user, IP, null));
|
||||
verify(tokenGenerator, never()).generateToken(any(), any(), any(), any());
|
||||
verify(loginAttemptService).recordSuccess("test@example.com", IP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_BlockedUserWithAWrongPassword_LooksLikeAnyWrongPassword() {
|
||||
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(false).build();
|
||||
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
|
||||
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(false);
|
||||
|
||||
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
|
||||
verify(tokenGenerator, never()).generateToken(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_TheTokenCarriesTheStoredRole() {
|
||||
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true)
|
||||
.role(Role.PLATFORM_ADMIN).build();
|
||||
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
|
||||
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
|
||||
|
||||
loginService.login(user, IP, null);
|
||||
|
||||
verify(tokenGenerator).generateToken("815", "test@example.com", null, Role.PLATFORM_ADMIN);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package auth_service.modules.user.service;
|
||||
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
import auth_service.modules.user.model.Appearance;
|
||||
@@ -150,9 +151,9 @@ class UserServiceTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void unlinkSteamId_ClearsItAndReturnsATokenWithoutIt() {
|
||||
when(tokenGenerator.generateToken("1", "test@example.com", null)).thenReturn("token-without-steam");
|
||||
when(tokenGenerator.generateToken("1", "test@example.com", null, Role.USER)).thenReturn("token-without-steam");
|
||||
|
||||
assertEquals("token-without-steam", userService.unlinkSteamId(1L, "test@example.com"));
|
||||
assertEquals("token-without-steam", userService.unlinkSteamId(1L, "test@example.com", Role.USER));
|
||||
|
||||
verify(userRepository).clearSteamId(1L);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package auth_service.shared.security;
|
||||
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class RoleTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void theLadderGoesUserAuditorAdmin() {
|
||||
assertTrue(Role.PLATFORM_ADMIN.atLeast(Role.AUDITOR));
|
||||
assertTrue(Role.AUDITOR.atLeast(Role.AUDITOR));
|
||||
assertFalse(Role.USER.atLeast(Role.AUDITOR));
|
||||
assertEquals(Role.USER, Role.lower(Role.PLATFORM_ADMIN, Role.USER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_UnknownOrMissingIsUser() {
|
||||
assertEquals(Role.AUDITOR, Role.parse("AUDITOR"));
|
||||
assertEquals(Role.USER, Role.parse(null));
|
||||
assertEquals(Role.USER, Role.parse("ROOT"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user