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
+1184
-43
No files matched your search
@@ -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