fix:角色改密

This commit is contained in:
waner 2026-04-27 10:31:19 +08:00
parent 291c7cfc8a
commit 4ecda99001
18 changed files with 405 additions and 66 deletions

View File

@ -93,6 +93,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
.addPathPatterns("/api/**")
.excludePathPatterns(
"/api/v1/device/status",
"/api/v1/masterKey/status",
"/api/v1/auth/password-login",
"/api/v1/auth/ukey-login",
"/api/v1/auth/ukey-login/randoms",

View File

@ -4,6 +4,7 @@ import com.cisd.tms.common.api.ApiResponse;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.modules.auth.dto.AdminChangePasswordRequest;
import com.cisd.tms.modules.auth.dto.AuthUserListResponse;
import com.cisd.tms.modules.auth.enums.AuthLevel;
import com.cisd.tms.modules.auth.enums.RoleCode;
import com.cisd.tms.modules.auth.security.RequireInternalAuth;
@ -25,25 +26,11 @@ import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Tag(name = "认证管理", description = "角色启用、密码重置与 UKey 绑定接口")
@Tag(name = "认证管理", description = "角色密码重置、改密与 UKey 绑定接口")
public class AuthAdminController {
private final AuthAdminService authAdminService;
@PostMapping("/roles/{roleCode}/enable")
@Operation(summary = "启用角色", description = "仅允许 SUPER_ADMIN FULL 会话启用目标角色。")
@ReplayProtected
@RequireInternalAuth(role = RoleCode.SUPER_ADMIN, authLevel = AuthLevel.FULL)
@AuditedOperation(module = ModuleCode.AUTH, action = ActionType.ENABLE, summary = "启用角色")
public ApiResponse<Void> enableRole(@PathVariable("roleCode") String roleCode, HttpServletRequest request) {
authAdminService.enableRole(
(String) request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE),
(String) request.getAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL),
roleCode
);
return ApiResponse.success();
}
@PostMapping("/roles/{roleCode}/reset-password")
@Operation(summary = "重置角色密码", description = "仅允许 SUPER_ADMIN FULL 会话重置目标角色密码。")
@ReplayProtected
@ -58,6 +45,18 @@ public class AuthAdminController {
return ApiResponse.success();
}
@GetMapping("/users")
@Operation(summary = "查询用户管理列表", description = "按角色类型和状态分页查询角色席位账号列表。")
// @RequireInternalAuth(role = RoleCode.SUPER_ADMIN, authLevel = AuthLevel.FULL)
public ApiResponse<AuthUserListResponse> listUsers(
@RequestParam(value = "roleCode", required = false) String roleCode,
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "pageSize", defaultValue = "10") int pageSize
) {
return ApiResponse.success(authAdminService.listUsers(roleCode, status, page, pageSize));
}
@PostMapping("/roles/{roleCode}/accounts/{uid}/change-password")
@Operation(summary = "管理员修改角色席位口令", description = "管理员为目标角色指定席位账号设置新口令。")
@ReplayProtected

View File

@ -11,5 +11,7 @@ public interface AuthFullAccountMapper extends BaseMapperX<AuthFullAccountEntity
AuthFullAccountEntity selectByRoleCodeAndUid(@Param("roleCode") String roleCode, @Param("uid") Integer uid);
List<AuthFullAccountEntity> selectAllAccounts();
List<AuthFullAccountEntity> selectByRoleCode(@Param("roleCode") String roleCode);
}

View File

@ -2,6 +2,7 @@ package com.cisd.tms.modules.auth.mapper;
import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX;
import com.cisd.tms.modules.auth.entity.RoleAccountEntity;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@ -9,4 +10,6 @@ import org.apache.ibatis.annotations.Param;
public interface RoleAccountMapper extends BaseMapperX<RoleAccountEntity> {
RoleAccountEntity selectByRoleCode(@Param("roleCode") String roleCode);
List<RoleAccountEntity> selectAllRoles();
}

View File

@ -8,6 +8,8 @@ public interface AuthFullAccountRepository {
Optional<AuthFullAccountEntity> findByRoleCodeAndUid(String roleCode, Integer uid);
List<AuthFullAccountEntity> findAll();
List<AuthFullAccountEntity> findByRoleCode(String roleCode);
void save(AuthFullAccountEntity entity);

View File

@ -1,12 +1,15 @@
package com.cisd.tms.modules.auth.repository;
import com.cisd.tms.modules.auth.entity.RoleAccountEntity;
import java.util.List;
import java.util.Optional;
public interface RoleAccountRepository {
Optional<RoleAccountEntity> findByRoleCode(String roleCode);
List<RoleAccountEntity> findAll();
void save(RoleAccountEntity entity);
void update(RoleAccountEntity entity);

View File

@ -21,6 +21,11 @@ public class AuthFullAccountRepositoryImpl implements AuthFullAccountRepository
return Optional.ofNullable(authFullAccountMapper.selectByRoleCodeAndUid(roleCode, uid));
}
@Override
public List<AuthFullAccountEntity> findAll() {
return authFullAccountMapper.selectAllAccounts();
}
@Override
public List<AuthFullAccountEntity> findByRoleCode(String roleCode) {
return authFullAccountMapper.selectByRoleCode(roleCode);

View File

@ -3,6 +3,7 @@ package com.cisd.tms.modules.auth.repository.impl;
import com.cisd.tms.modules.auth.entity.RoleAccountEntity;
import com.cisd.tms.modules.auth.mapper.RoleAccountMapper;
import com.cisd.tms.modules.auth.repository.RoleAccountRepository;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Repository;
@ -20,6 +21,11 @@ public class RoleAccountRepositoryImpl implements RoleAccountRepository {
return Optional.ofNullable(roleAccountMapper.selectByRoleCode(roleCode));
}
@Override
public List<RoleAccountEntity> findAll() {
return roleAccountMapper.selectAllRoles();
}
@Override
public void save(RoleAccountEntity entity) {
roleAccountMapper.insert(entity);

View File

@ -1,14 +1,15 @@
package com.cisd.tms.modules.auth.service;
import com.cisd.tms.modules.auth.dto.AuthUserListResponse;
import com.cisd.tms.modules.mk.dto.UKeySignDTO;
import com.cisd.tms.modules.mk.dto.UKeySignResult;
public interface AuthAdminService {
void enableRole(String operatorRoleCode, String operatorAuthLevel, String targetRoleCode);
void resetPassword(String operatorRoleCode, String operatorAuthLevel, String targetRoleCode);
AuthUserListResponse listUsers(String roleCode, String status, int page, int pageSize);
void changeAccountPassword(
String operatorRoleCode,
String operatorAuthLevel,

View File

@ -3,6 +3,8 @@ package com.cisd.tms.modules.auth.service.impl;
import com.cisd.tms.common.enums.ErrorCode;
import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.common.util.TraceIdUtil;
import com.cisd.tms.modules.auth.dto.AuthUserListItemResponse;
import com.cisd.tms.modules.auth.dto.AuthUserListResponse;
import com.cisd.tms.modules.auth.entity.AuthFullAccountEntity;
import com.cisd.tms.modules.auth.entity.RoleAccountEntity;
import com.cisd.tms.modules.auth.entity.RoleUkeyBindingEntity;
@ -25,8 +27,13 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@ -45,8 +52,26 @@ public class AuthAdminServiceImpl implements AuthAdminService {
private final Clock clock;
private final PasswordSaltGenerator passwordSaltGenerator;
@Override
public void enableRole(String operatorRoleCode, String operatorAuthLevel, String targetRoleCode) {
private void enableRoleAfterUkeyBindingIfReady(RoleCode targetRole) {
if (!hasRequiredActiveUkeyBindings(targetRole)) {
return;
}
RoleAccountEntity target = loadRole(targetRole.getCode());
if (RoleAccountStatus.ACTIVE.name().equals(target.getStatus())) {
return;
}
activateRoleAndSeatAccounts(targetRole.getCode());
}
private boolean hasRequiredActiveUkeyBindings(RoleCode targetRole) {
return roleUkeyBindingRepository.findActiveByRoleCode(targetRole.getCode()).stream()
.map(RoleUkeyBindingEntity::getUid)
.filter(Objects::nonNull)
.distinct()
.count() >= targetRole.getRequiredUkeyCount();
}
private void activateRoleAndSeatAccounts(String targetRoleCode) {
RoleAccountEntity target = loadRole(targetRoleCode);
target.setStatus(RoleAccountStatus.ACTIVE.name());
roleAccountRepository.update(target);
@ -64,7 +89,6 @@ public class AuthAdminServiceImpl implements AuthAdminService {
account.setLastLoginAt(null);
authFullAccountRepository.update(account);
}
}
@Override
@ -94,6 +118,35 @@ public class AuthAdminServiceImpl implements AuthAdminService {
}
@Override
public AuthUserListResponse listUsers(String roleCode, String status, int page, int pageSize) {
int normalizedPage = Math.max(page, 1);
int normalizedPageSize = Math.min(Math.max(pageSize, 1), 100);
Map<String, RoleAccountEntity> rolesByCode = roleAccountRepository.findAll().stream()
.collect(Collectors.toMap(RoleAccountEntity::getRoleCode, item -> item, (left, right) -> left));
List<AuthFullAccountEntity> accounts = authFullAccountRepository.findAll();
String normalizedRoleFilter = normalizeRoleFilter(roleCode, rolesByCode);
String normalizedStatusFilter = normalizeStatusFilter(status);
List<AuthUserListItemResponse> filteredItems = accounts.stream()
.map(account -> toUserListItem(account, rolesByCode.get(account.getRoleCode())))
.filter(item -> normalizedRoleFilter == null || normalizedRoleFilter.equals(item.getRoleCode()))
.filter(item -> normalizedStatusFilter == null || normalizedStatusFilter.equals(item.getStatus()))
.sorted(Comparator
.comparingInt((AuthUserListItemResponse item) -> roleOrder(item.getRoleCode()))
.thenComparing(AuthUserListItemResponse::getUid, Comparator.nullsLast(Integer::compareTo)))
.toList();
int fromIndex = Math.min((normalizedPage - 1) * normalizedPageSize, filteredItems.size());
int toIndex = Math.min(fromIndex + normalizedPageSize, filteredItems.size());
AuthUserListResponse response = new AuthUserListResponse();
response.setPage(normalizedPage);
response.setPageSize(normalizedPageSize);
response.setTotal(filteredItems.size());
response.setItems(new ArrayList<>(filteredItems.subList(fromIndex, toIndex)));
return response;
}
@Override
public void changeAccountPassword(
String operatorRoleCode,
@ -118,6 +171,103 @@ public class AuthAdminServiceImpl implements AuthAdminService {
authFullAccountRepository.update(account);
}
private AuthUserListItemResponse toUserListItem(AuthFullAccountEntity account, RoleAccountEntity role) {
List<String> ukeySerials = roleUkeyBindingRepository
.findActiveByRoleCodeAndUid(account.getRoleCode(), account.getUid())
.stream()
.map(RoleUkeyBindingEntity::getUkeySerial)
.filter(Objects::nonNull)
.distinct()
.toList();
String effectiveStatus = effectiveStatus(role, account);
AuthUserListItemResponse item = new AuthUserListItemResponse();
item.setRoleCode(account.getRoleCode());
item.setRoleName(role == null ? roleDisplayName(account.getRoleCode()) : role.getDisplayName());
item.setUid(account.getUid());
item.setUsername(account.getAccountName());
item.setDisplayName(account.getDisplayName());
item.setStatus(effectiveStatus);
item.setStatusName(statusName(effectiveStatus));
item.setLastLoginAt(account.getLastLoginAt());
item.setPasswordChangedAt(account.getPasswordChangedAt());
item.setFailedCount(account.getFailedCount() == null ? 0 : account.getFailedCount());
item.setUkeySerials(ukeySerials);
item.setUkeySerialNo(String.join(",", ukeySerials));
return item;
}
private String effectiveStatus(RoleAccountEntity role, AuthFullAccountEntity account) {
if (role != null && !RoleAccountStatus.ACTIVE.name().equals(role.getStatus())) {
return role.getStatus();
}
return account.getStatus();
}
private String normalizeRoleFilter(String roleFilter, Map<String, RoleAccountEntity> rolesByCode) {
if (roleFilter == null || roleFilter.isBlank()) {
return null;
}
String value = roleFilter.trim();
for (RoleCode roleCode : RoleCode.values()) {
if (roleCode.getCode().equals(value) || roleCode.getDisplayName().equals(value)) {
return roleCode.getCode();
}
}
return rolesByCode.values().stream()
.filter(role -> value.equals(role.getDisplayName()))
.map(RoleAccountEntity::getRoleCode)
.findFirst()
.orElse(value);
}
private String normalizeStatusFilter(String status) {
if (status == null || status.isBlank()) {
return null;
}
String value = status.trim();
if (Set.of("ACTIVE", "NORMAL", "正常").contains(value)) {
return RoleAccountStatus.ACTIVE.name();
}
if (Set.of("LOCKED", "锁定").contains(value)) {
return RoleAccountStatus.LOCKED.name();
}
if (Set.of("UNENABLED", "DISABLED", "未启用").contains(value)) {
return RoleAccountStatus.UNENABLED.name();
}
return value;
}
private String statusName(String status) {
if (RoleAccountStatus.ACTIVE.name().equals(status)) {
return "正常";
}
if (RoleAccountStatus.LOCKED.name().equals(status)) {
return "锁定";
}
if (RoleAccountStatus.UNENABLED.name().equals(status)) {
return "未启用";
}
return status;
}
private String roleDisplayName(String roleCode) {
return List.of(RoleCode.values()).stream()
.filter(item -> item.getCode().equals(roleCode))
.map(RoleCode::getDisplayName)
.findFirst()
.orElse(roleCode);
}
private int roleOrder(String roleCode) {
RoleCode[] values = RoleCode.values();
for (int i = 0; i < values.length; i++) {
if (values[i].getCode().equals(roleCode)) {
return i;
}
}
return values.length;
}
private void bindUkey(
String targetRoleCode,
Integer uid,
@ -188,6 +338,7 @@ public class AuthAdminServiceImpl implements AuthAdminService {
request.getPubKey(),
signValue
);
enableRoleAfterUkeyBindingIfReady(targetRole);
return UKeySignResult.builder()
.sign(signValue)
.backupPacket(backupPacket)

View File

@ -377,7 +377,7 @@ VALUES
'SUPER_ADMIN',
'超级管理员',
2,
'UNENABLED',
'ACTIVE',
CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3)
),

View File

@ -46,6 +46,27 @@
LIMIT 1
</select>
<select id="selectAllAccounts" resultMap="AuthFullAccountResultMap">
SELECT id,
role_code,
uid,
account_name,
display_name,
password_hash,
password_salt,
status,
need_change_password,
password_changed_at,
failed_count,
locked_until,
last_login_at,
last_active_at,
create_time,
update_time
FROM tms_auth_full_account
ORDER BY role_code ASC, uid ASC
</select>
<select id="selectByRoleCode" resultMap="AuthFullAccountResultMap">
SELECT id,
role_code,

View File

@ -26,4 +26,16 @@
WHERE role_code = #{roleCode}
LIMIT 1
</select>
<select id="selectAllRoles" resultMap="RoleAccountResultMap">
SELECT id,
role_code,
display_name,
required_ukey_count,
status,
create_time,
update_time
FROM tms_role_account
ORDER BY role_code ASC
</select>
</mapper>

View File

@ -3,6 +3,8 @@ package com.cisd.tms.modules.auth.controller;
import com.cisd.tms.common.exception.GlobalExceptionHandler;
import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.modules.auth.dto.CaptchaResponse;
import com.cisd.tms.modules.auth.dto.AuthUserListItemResponse;
import com.cisd.tms.modules.auth.dto.AuthUserListResponse;
import com.cisd.tms.modules.auth.dto.CurrentUserResponse;
import com.cisd.tms.modules.auth.dto.LoginResponse;
import com.cisd.tms.modules.auth.dto.UkeyLoginRandomResponse;
@ -223,25 +225,6 @@ class AuthControllerTest {
Mockito.verify(authService).changeAccountPassword("token-change-001", 1, "12345678", "Abc1234!");
}
@Test
void shouldEnableRoleThroughAdminEndpoint() throws Exception {
AuthService authService = Mockito.mock(AuthService.class);
AuthAdminService authAdminService = Mockito.mock(AuthAdminService.class);
MockMvc mockMvc = MockMvcBuilders
.standaloneSetup(new AuthController(authService), new AuthAdminController(authAdminService))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
mockMvc.perform(post("/api/v1/auth/roles/AUDIT_ADMIN/enable")
.requestAttr(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "KEY_ADMIN")
.requestAttr(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL, "FULL"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("\"success\":true")));
Mockito.verify(authAdminService).enableRole("KEY_ADMIN", "FULL", "AUDIT_ADMIN");
}
@Test
void shouldChangeAccountPasswordThroughAdminEndpoint() throws Exception {
AuthService authService = Mockito.mock(AuthService.class);
@ -268,6 +251,46 @@ class AuthControllerTest {
Mockito.verify(authAdminService).changeAccountPassword("SUPER_ADMIN", "FULL", "KEY_ADMIN", 1, "12345678", "Abc1234!");
}
@Test
void shouldListUsersThroughAdminEndpoint() throws Exception {
AuthService authService = Mockito.mock(AuthService.class);
AuthAdminService authAdminService = Mockito.mock(AuthAdminService.class);
AuthUserListItemResponse item = new AuthUserListItemResponse();
item.setRoleCode("SUPER_ADMIN");
item.setRoleName("超级管理员");
item.setUid(1);
item.setUsername("super-admin-full-01");
item.setStatus("ACTIVE");
item.setStatusName("正常");
item.setUkeySerials(java.util.List.of("UK-PRIMARY", "UK-BACKUP"));
item.setUkeySerialNo("UK-PRIMARY,UK-BACKUP");
AuthUserListResponse response = new AuthUserListResponse();
response.setPage(1);
response.setPageSize(10);
response.setTotal(1);
response.setItems(java.util.List.of(item));
Mockito.when(authAdminService.listUsers("SUPER_ADMIN", "ACTIVE", 1, 10)).thenReturn(response);
MockMvc mockMvc = MockMvcBuilders
.standaloneSetup(new AuthController(authService), new AuthAdminController(authAdminService))
.setControllerAdvice(new GlobalExceptionHandler())
.build();
mockMvc.perform(get("/api/v1/auth/users")
.requestAttr(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "SUPER_ADMIN")
.requestAttr(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL, "FULL")
.param("roleCode", "SUPER_ADMIN")
.param("status", "ACTIVE")
.param("page", "1")
.param("pageSize", "10"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("\"roleCode\":\"SUPER_ADMIN\"")))
.andExpect(content().string(containsString("\"username\":\"super-admin-full-01\"")))
.andExpect(content().string(containsString("\"ukeySerialNo\":\"UK-PRIMARY,UK-BACKUP\"")));
Mockito.verify(authAdminService).listUsers("SUPER_ADMIN", "ACTIVE", 1, 10);
}
@Test
void shouldIssueSuperAdminUkeyBindingSignWithoutLoginContext() throws Exception {
AuthService authService = Mockito.mock(AuthService.class);

View File

@ -17,7 +17,7 @@ class InternalAuthorizationInterceptorTest {
void shouldRejectWhenRoleDoesNotMatchRequiredRole() throws Exception {
OperationAuditService operationAuditService = Mockito.mock(OperationAuditService.class);
InternalAuthorizationInterceptor interceptor = new InternalAuthorizationInterceptor(new ObjectMapper(), operationAuditService);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/roles/AUDIT_ADMIN/enable");
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/roles/AUDIT_ADMIN/reset-password");
request.setAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "AUDIT_ADMIN");
request.setAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL, "FULL");
MockHttpServletResponse response = new MockHttpServletResponse();

View File

@ -35,29 +35,6 @@ class AuthAdminServiceTest {
private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-03-23T02:00:00Z"), ZoneOffset.UTC);
@Test
void shouldEnableRoleAndUnifiedSeatAccounts() {
InMemoryRoleAccountRepository roles = new InMemoryRoleAccountRepository();
InMemoryAuthFullAccountRepository accounts = new InMemoryAuthFullAccountRepository();
RoleAccountEntity role = role(RoleCode.AUDIT_ADMIN, RoleAccountStatus.UNENABLED);
roles.save(role);
AuthFullAccountEntity account = account(RoleCode.AUDIT_ADMIN, 1, "audit-admin-01", "HASH-OLD", "SALT-OLD");
account.setStatus(RoleAccountStatus.UNENABLED.name());
account.setFailedCount(3);
account.setLockedUntil(LocalDateTime.of(2026, 3, 23, 3, 0));
accounts.save(account);
service(roles, accounts, new InMemoryRoleUkeyBindingRepository(), Mockito.mock(LmkService.class))
.enableRole(RoleCode.SUPER_ADMIN.getCode(), AuthLevel.FULL.name(), RoleCode.AUDIT_ADMIN.getCode());
Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), roles.findByRoleCode(RoleCode.AUDIT_ADMIN.getCode()).orElseThrow().getStatus());
AuthFullAccountEntity changed = accounts.findByRoleCodeAndUid(RoleCode.AUDIT_ADMIN.getCode(), 1).orElseThrow();
Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), changed.getStatus());
Assertions.assertTrue(Boolean.TRUE.equals(changed.getNeedChangePassword()));
Assertions.assertEquals(0, changed.getFailedCount());
Assertions.assertNull(changed.getLockedUntil());
}
@Test
void shouldResetOnlyUnifiedSeatPasswords() {
InMemoryRoleAccountRepository roles = new InMemoryRoleAccountRepository();
@ -154,6 +131,109 @@ class AuthAdminServiceTest {
Assertions.assertTrue(activeUidBindings.stream().anyMatch(item -> "UK-BACKUP".equals(item.getUkeySerial())));
}
@Test
void shouldActivateRoleWhenRequiredUkeyBindingIsIssued() {
InMemoryRoleAccountRepository roles = new InMemoryRoleAccountRepository();
InMemoryAuthFullAccountRepository accounts = new InMemoryAuthFullAccountRepository();
InMemoryRoleUkeyBindingRepository bindings = new InMemoryRoleUkeyBindingRepository();
roles.save(role(RoleCode.AUDIT_ADMIN, RoleAccountStatus.UNENABLED));
AuthFullAccountEntity account = account(RoleCode.AUDIT_ADMIN, 1, "audit-admin-01", "HASH-OLD", "SALT-OLD");
account.setStatus(RoleAccountStatus.UNENABLED.name());
account.setFailedCount(3);
account.setLockedUntil(LocalDateTime.of(2026, 3, 23, 3, 0));
accounts.save(account);
LmkService lmkService = Mockito.mock(LmkService.class);
Mockito.when(lmkService.exportIkPublicKeyHex()).thenReturn("IK-PUB-001");
Mockito.when(lmkService.signIk(Mockito.anyString())).thenReturn("ISSUE-SIGN-AUDIT");
UKeySignDTO dto = new UKeySignDTO();
dto.setPubKey("PUB-AUDIT");
dto.setUkeySerial("UK-AUDIT");
dto.setUid(1);
service(roles, accounts, bindings, lmkService)
.issueUkeyBindingSign(RoleCode.SUPER_ADMIN.getCode(), AuthLevel.FULL.name(), RoleCode.AUDIT_ADMIN.getCode(), dto);
Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), roles.findByRoleCode(RoleCode.AUDIT_ADMIN.getCode()).orElseThrow().getStatus());
AuthFullAccountEntity changed = accounts.findByRoleCodeAndUid(RoleCode.AUDIT_ADMIN.getCode(), 1).orElseThrow();
Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), changed.getStatus());
Assertions.assertTrue(Boolean.TRUE.equals(changed.getNeedChangePassword()));
Assertions.assertEquals(0, changed.getFailedCount());
Assertions.assertNull(changed.getLockedUntil());
}
@Test
void shouldNotActivateRoleUntilAllRequiredUkeySeatsAreBound() {
InMemoryRoleAccountRepository roles = new InMemoryRoleAccountRepository();
InMemoryAuthFullAccountRepository accounts = new InMemoryAuthFullAccountRepository();
InMemoryRoleUkeyBindingRepository bindings = new InMemoryRoleUkeyBindingRepository();
roles.save(role(RoleCode.SUPER_ADMIN, RoleAccountStatus.UNENABLED));
accounts.save(account(RoleCode.SUPER_ADMIN, 1, "super-admin-01", "HASH-OLD-1", "SALT-OLD-1"));
accounts.save(account(RoleCode.SUPER_ADMIN, 2, "super-admin-02", "HASH-OLD-2", "SALT-OLD-2"));
LmkService lmkService = Mockito.mock(LmkService.class);
Mockito.when(lmkService.exportIkPublicKeyHex()).thenReturn("IK-PUB-001");
Mockito.when(lmkService.signIk(Mockito.anyString())).thenReturn("ISSUE-SIGN-SUPER");
MasterKeyBackupPacket packet = new MasterKeyBackupPacket();
packet.setPacketIndex(1);
Mockito.when(lmkService.buildBackupPacket(1)).thenReturn(packet);
UKeySignDTO dto = new UKeySignDTO();
dto.setPubKey("PUB-SUPER-1");
dto.setUkeySerial("UK-SUPER-1");
dto.setUid(1);
service(roles, accounts, bindings, lmkService)
.issueUkeyBindingSign(null, null, RoleCode.SUPER_ADMIN.getCode(), dto);
Assertions.assertEquals(RoleAccountStatus.UNENABLED.name(), roles.findByRoleCode(RoleCode.SUPER_ADMIN.getCode()).orElseThrow().getStatus());
}
@Test
void shouldListUsersWithEffectiveStatusAndUkeySerials() {
InMemoryRoleAccountRepository roles = new InMemoryRoleAccountRepository();
InMemoryAuthFullAccountRepository accounts = new InMemoryAuthFullAccountRepository();
InMemoryRoleUkeyBindingRepository bindings = new InMemoryRoleUkeyBindingRepository();
RoleAccountEntity superAdmin = role(RoleCode.SUPER_ADMIN, RoleAccountStatus.ACTIVE);
superAdmin.setDisplayName("超级管理员");
RoleAccountEntity auditAdmin = role(RoleCode.AUDIT_ADMIN, RoleAccountStatus.UNENABLED);
roles.save(superAdmin);
roles.save(auditAdmin);
AuthFullAccountEntity first = account(RoleCode.SUPER_ADMIN, 1, "super-admin-full-01", "HASH-1", "SALT-1");
first.setDisplayName("超级管理员UKey席位一");
first.setLastLoginAt(LocalDateTime.of(2026, 1, 22, 11, 23, 52));
first.setPasswordChangedAt(LocalDateTime.of(2026, 1, 21, 11, 23, 52));
AuthFullAccountEntity second = account(RoleCode.SUPER_ADMIN, 2, "super-admin-full-02", "HASH-2", "SALT-2");
second.setStatus(RoleAccountStatus.LOCKED.name());
second.setFailedCount(5);
AuthFullAccountEntity audit = account(RoleCode.AUDIT_ADMIN, 1, "audit-admin-full-01", "HASH-3", "SALT-3");
audit.setStatus(RoleAccountStatus.ACTIVE.name());
accounts.save(first);
accounts.save(second);
accounts.save(audit);
bindings.save(binding(RoleCode.SUPER_ADMIN, 1, "UK-PRIMARY", "PUB-PRIMARY", "SIG-PRIMARY"));
bindings.save(binding(RoleCode.SUPER_ADMIN, 1, "UK-BACKUP", "PUB-BACKUP", "SIG-BACKUP"));
bindings.save(binding(RoleCode.SUPER_ADMIN, 2, "UK-LOCKED", "PUB-LOCKED", "SIG-LOCKED"));
var response = service(roles, accounts, bindings, Mockito.mock(LmkService.class))
.listUsers("超级管理员", "正常", 1, 10);
Assertions.assertEquals(1, response.getPage());
Assertions.assertEquals(10, response.getPageSize());
Assertions.assertEquals(1, response.getTotal());
Assertions.assertEquals("SUPER_ADMIN", response.getItems().get(0).getRoleCode());
Assertions.assertEquals(1, response.getItems().get(0).getUid());
Assertions.assertEquals("超级管理员", response.getItems().get(0).getRoleName());
Assertions.assertEquals("super-admin-full-01", response.getItems().get(0).getUsername());
Assertions.assertEquals("正常", response.getItems().get(0).getStatusName());
Assertions.assertEquals(List.of("UK-PRIMARY", "UK-BACKUP"), response.getItems().get(0).getUkeySerials());
Assertions.assertEquals("UK-PRIMARY,UK-BACKUP", response.getItems().get(0).getUkeySerialNo());
var disabledResponse = service(roles, accounts, bindings, Mockito.mock(LmkService.class))
.listUsers(null, "未启用", 1, 10);
Assertions.assertEquals(1, disabledResponse.getTotal());
Assertions.assertEquals("AUDIT_ADMIN", disabledResponse.getItems().get(0).getRoleCode());
Assertions.assertEquals("UNENABLED", disabledResponse.getItems().get(0).getStatus());
}
private static AuthAdminService service(
InMemoryRoleAccountRepository roles,
InMemoryAuthFullAccountRepository accounts,
@ -247,6 +327,13 @@ class AuthAdminServiceTest {
return Optional.ofNullable(store.get(roleCode));
}
@Override
public List<RoleAccountEntity> findAll() {
return store.values().stream()
.sorted(Comparator.comparing(RoleAccountEntity::getRoleCode))
.toList();
}
@Override
public void save(RoleAccountEntity entity) {
store.put(entity.getRoleCode(), entity);
@ -274,6 +361,15 @@ class AuthAdminServiceTest {
.toList();
}
@Override
public List<AuthFullAccountEntity> findAll() {
return store.values().stream()
.sorted(Comparator
.comparing(AuthFullAccountEntity::getRoleCode)
.thenComparing(AuthFullAccountEntity::getUid))
.toList();
}
@Override
public void save(AuthFullAccountEntity entity) {
store.put(entity.getRoleCode() + "#" + entity.getUid(), entity);

View File

@ -400,6 +400,13 @@ class AuthServiceTest {
return Optional.ofNullable(store.get(roleCode));
}
@Override
public List<RoleAccountEntity> findAll() {
return store.values().stream()
.sorted(Comparator.comparing(RoleAccountEntity::getRoleCode))
.toList();
}
@Override
public void save(RoleAccountEntity entity) {
store.put(entity.getRoleCode(), entity);
@ -427,6 +434,15 @@ class AuthServiceTest {
.toList();
}
@Override
public List<AuthFullAccountEntity> findAll() {
return store.values().stream()
.sorted(Comparator
.comparing(AuthFullAccountEntity::getRoleCode)
.thenComparing(AuthFullAccountEntity::getUid))
.toList();
}
@Override
public void save(AuthFullAccountEntity entity) {
store.put(entity.getRoleCode() + "#" + entity.getUid(), entity);

View File

@ -40,8 +40,6 @@ class ReplayProtectedEndpointsTest {
Assertions.assertNotNull(annotation(DeviceController.class, "restart", jakarta.servlet.http.HttpServletRequest.class));
Assertions.assertNull(AnnotatedElementUtils.findMergedAnnotation(AuthAdminController.class, ReplayProtected.class));
Assertions.assertNotNull(effectiveAnnotation(AuthAdminController.class, "enableRole",
String.class, jakarta.servlet.http.HttpServletRequest.class));
Assertions.assertNotNull(effectiveAnnotation(AuthAdminController.class, "resetPassword",
String.class, jakarta.servlet.http.HttpServletRequest.class));
Assertions.assertNotNull(effectiveAnnotation(AuthAdminController.class, "issueUkeyBindingSign",