fix:角色设计修改

This commit is contained in:
waner 2026-04-22 18:29:00 +08:00
parent 1fc32dbcf0
commit a3fd33a6e4
34 changed files with 1819 additions and 338 deletions

View File

@ -219,6 +219,7 @@ Open:
- 不满足最小兼容版本时拒绝升级
- `execute.sh` 必填,`precheck.sh`、`verify.sh`、`rollback.sh` 可选
- 执行顺序为:`precheck -> execute -> verify`
- `TMS` 自升级包内脚本第一版不应直接 `stop/start` 当前 TMS推荐只完成文件准备由后端在写入终态和日志后异步触发 `/home/tms/scripts/tms.sh restart`
- `FIRMWARE` 不增加额外后端流程,具体固件刷写、重启、恢复提示由包内脚本负责
- 回滚不自动触发,需要调用回滚接口
- `TMS`、`RECEIVER` 升级成功后会更新 `tms_device_software_version``FIRMWARE` 暂不维护版本表

View File

@ -78,7 +78,7 @@ public class AuthAdminController {
@PostMapping("/roles/{roleCode}/ukeys/issue-sign")
@Operation(summary = "生成 UKey 发行签名", description = "按旧绑定流程为目标角色 UKey 材料生成发行签名。")
@RequireInternalAuth(role = RoleCode.SUPER_ADMIN, authLevel = AuthLevel.LIMITED)
// @RequireInternalAuth(role = RoleCode.SUPER_ADMIN, authLevel = AuthLevel.LIMITED)
public ApiResponse<UKeySignResult> issueUkeyBindingSign(
@PathVariable("roleCode") String roleCode,
@RequestBody UKeySignDTO request,

View File

@ -65,13 +65,36 @@ public class AuthController {
return ApiResponse.success();
}
@PostMapping("/change-password")
@Operation(summary = "修改当前角色口令", description = "基于当前会话校验并更新当前角色口令。")
@AuditedOperation(module = ModuleCode.AUTH, action = ActionType.UPDATE, summary = "修改当前角色口令")
@PostMapping("/full-accounts/{uid}/change-password")
@Operation(summary = "修改当前 FULL 账户口令", description = "基于当前 FULL 会话校验并更新指定席位账户口令。")
@AuditedOperation(module = ModuleCode.AUTH, action = ActionType.UPDATE, summary = "修改当前 FULL 账户口令")
@ReplayProtected
public ApiResponse<Void> changePassword(@Valid @RequestBody ChangePasswordRequest request, HttpServletRequest httpRequest) {
authService.changePassword(
public ApiResponse<Void> changeFullAccountPassword(
@PathVariable("uid") Integer uid,
@Valid @RequestBody ChangePasswordRequest request,
HttpServletRequest httpRequest
) {
authService.changeFullAccountPassword(
(String) httpRequest.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN),
uid,
request.getCurrentPassword(),
request.getNewPassword()
);
return ApiResponse.success();
}
@PostMapping("/limited-accounts/{username}/change-password")
@Operation(summary = "修改当前 LIMITED 账户口令", description = "基于当前 LIMITED 会话校验并更新指定受限账户口令。")
@AuditedOperation(module = ModuleCode.AUTH, action = ActionType.UPDATE, summary = "修改当前 LIMITED 账户口令")
@ReplayProtected
public ApiResponse<Void> changeLimitedAccountPassword(
@PathVariable("username") String username,
@Valid @RequestBody ChangePasswordRequest request,
HttpServletRequest httpRequest
) {
authService.changeLimitedAccountPassword(
(String) httpRequest.getAttribute(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN),
username,
request.getCurrentPassword(),
request.getNewPassword()
);

View File

@ -0,0 +1,46 @@
package com.cisd.tms.modules.auth.dto;
public class AuthSessionPrincipal {
private String type;
private Integer uid;
private String username;
public static AuthSessionPrincipal full(Integer uid) {
AuthSessionPrincipal principal = new AuthSessionPrincipal();
principal.setType("FULL");
principal.setUid(uid);
return principal;
}
public static AuthSessionPrincipal limited(String username) {
AuthSessionPrincipal principal = new AuthSessionPrincipal();
principal.setType("LIMITED");
principal.setUsername(username);
return principal;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public Integer getUid() {
return uid;
}
public void setUid(Integer uid) {
this.uid = uid;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}

View File

@ -3,7 +3,7 @@ package com.cisd.tms.modules.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
@Schema(description = "修改当前角色口令请求")
@Schema(description = "修改当前认证账户口令请求")
public class ChangePasswordRequest {
@NotBlank(message = "currentPassword is required")

View File

@ -0,0 +1,23 @@
package com.cisd.tms.modules.auth.dto;
public class FullLoginAccountRequest {
private Integer uid;
private String password;
public Integer getUid() {
return uid;
}
public void setUid(Integer uid) {
this.uid = uid;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@ -14,8 +14,8 @@ public class LoginRequest {
@Schema(description = "参与角色口令校验的账号列表")
private List<PasswordLoginAccountRequest> accounts;
@Schema(description = "角色口令")
private String rolePassword;
@Schema(description = "参与 FULL 登录校验的席位账户列表")
private List<FullLoginAccountRequest> fullAccounts;
@Schema(description = "UKey 序列号列表,完整登录时需传入角色要求数量的序列号")
private List<@NotBlank(message = "ukey serial must not be blank") String> ukeySerials;
@ -36,12 +36,12 @@ public class LoginRequest {
this.accounts = accounts;
}
public String getRolePassword() {
return rolePassword;
public List<FullLoginAccountRequest> getFullAccounts() {
return fullAccounts;
}
public void setRolePassword(String rolePassword) {
this.rolePassword = rolePassword;
public void setFullAccounts(List<FullLoginAccountRequest> fullAccounts) {
this.fullAccounts = fullAccounts;
}
public List<String> getUkeySerials() {

View File

@ -21,11 +21,11 @@ public class PasswordLoginRequest {
@Schema(description = "参与本次角色口令校验的账号列表")
private List<PasswordLoginAccountRequest> accounts;
@NotBlank(message = "captchaCode is required")
// @NotBlank(message = "captchaCode is required")
@Schema(description = "图形验证码", example = "ABCD")
private String captchaCode;
@NotBlank(message = "captchaId is required")
// @NotBlank(message = "captchaId is required")
@Schema(description = "验证码标识", example = "captcha-001")
private String captchaId;

View File

@ -4,7 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@Schema(description = "单个 UKey 登录证明")
@Schema(description = "单个 UKey 登录因子")
public class UkeyLoginProof {
@NotBlank(message = "pubKey is required")
@ -28,6 +28,9 @@ public class UkeyLoginProof {
@NotBlank(message = "loginSignature is required")
private String loginSignature;
@Schema(description = "当前 UKey 固定席位对应账号的口令,所有角色都必传", example = "12345678")
private String password;
public String getPubKey() {
return pubKey;
}
@ -75,4 +78,12 @@ public class UkeyLoginProof {
public void setLoginSignature(String loginSignature) {
this.loginSignature = loginSignature;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@ -1,5 +1,6 @@
package com.cisd.tms.modules.auth.dto;
import com.fasterxml.jackson.annotation.JsonAlias;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
@ -13,14 +14,11 @@ public class UkeyLoginRequest {
@Schema(description = "角色编码", example = "KEY_ADMIN")
private String roleCode;
@NotBlank(message = "rolePassword is required")
@Schema(description = "角色口令", example = "12345678")
private String rolePassword;
@Valid
@NotEmpty(message = "ukeyProofs is required")
@Schema(description = "UKey 登录证明列表")
private List<UkeyLoginProof> ukeyProofs;
@NotEmpty(message = "loginFactors is required")
@JsonAlias("ukeyProofs")
@Schema(description = "UKey 登录因子列表")
private List<UkeyLoginProof> loginFactors;
public String getRoleCode() {
return roleCode;
@ -30,19 +28,21 @@ public class UkeyLoginRequest {
this.roleCode = roleCode;
}
public String getRolePassword() {
return rolePassword;
public List<UkeyLoginProof> getLoginFactors() {
return loginFactors;
}
public void setRolePassword(String rolePassword) {
this.rolePassword = rolePassword;
public void setLoginFactors(List<UkeyLoginProof> loginFactors) {
this.loginFactors = loginFactors;
}
@Deprecated
public List<UkeyLoginProof> getUkeyProofs() {
return ukeyProofs;
return loginFactors;
}
@Deprecated
public void setUkeyProofs(List<UkeyLoginProof> ukeyProofs) {
this.ukeyProofs = ukeyProofs;
this.loginFactors = ukeyProofs;
}
}

View File

@ -0,0 +1,118 @@
package com.cisd.tms.modules.auth.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.cisd.tms.infrastructure.persistence.entity.BaseEntity;
import java.time.LocalDateTime;
@TableName("tms_auth_full_account")
public class AuthFullAccountEntity extends BaseEntity {
private String roleCode;
private Integer uid;
private String accountName;
private String displayName;
private String passwordHash;
private String passwordSalt;
private String status;
private Boolean needChangePassword;
private Integer failedCount;
private LocalDateTime lockedUntil;
private LocalDateTime lastLoginAt;
private LocalDateTime lastActiveAt;
public String getRoleCode() {
return roleCode;
}
public void setRoleCode(String roleCode) {
this.roleCode = roleCode;
}
public Integer getUid() {
return uid;
}
public void setUid(Integer uid) {
this.uid = uid;
}
public String getAccountName() {
return accountName;
}
public void setAccountName(String accountName) {
this.accountName = accountName;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public String getPasswordSalt() {
return passwordSalt;
}
public void setPasswordSalt(String passwordSalt) {
this.passwordSalt = passwordSalt;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Boolean getNeedChangePassword() {
return needChangePassword;
}
public void setNeedChangePassword(Boolean needChangePassword) {
this.needChangePassword = needChangePassword;
}
public Integer getFailedCount() {
return failedCount;
}
public void setFailedCount(Integer failedCount) {
this.failedCount = failedCount;
}
public LocalDateTime getLockedUntil() {
return lockedUntil;
}
public void setLockedUntil(LocalDateTime lockedUntil) {
this.lockedUntil = lockedUntil;
}
public LocalDateTime getLastLoginAt() {
return lastLoginAt;
}
public void setLastLoginAt(LocalDateTime lastLoginAt) {
this.lastLoginAt = lastLoginAt;
}
public LocalDateTime getLastActiveAt() {
return lastActiveAt;
}
public void setLastActiveAt(LocalDateTime lastActiveAt) {
this.lastActiveAt = lastActiveAt;
}
}

View File

@ -18,7 +18,7 @@ public class AuthSessionEntity extends BaseEntity {
/**
* 本次角色会话在后端侧记录的认证账号列表
*/
private String authenticatedUsersJson;
private String authenticatedPrincipalsJson;
/**
* 当前会话的认证方式区分口令登录和 UKey 登录
*/
@ -48,12 +48,22 @@ public class AuthSessionEntity extends BaseEntity {
this.roleCode = roleCode;
}
public String getAuthenticatedUsersJson() {
return authenticatedUsersJson;
public String getAuthenticatedPrincipalsJson() {
return authenticatedPrincipalsJson;
}
public void setAuthenticatedPrincipalsJson(String authenticatedPrincipalsJson) {
this.authenticatedPrincipalsJson = authenticatedPrincipalsJson;
}
@Deprecated
public String getAuthenticatedUsersJson() {
return authenticatedPrincipalsJson;
}
@Deprecated
public void setAuthenticatedUsersJson(String authenticatedUsersJson) {
this.authenticatedUsersJson = authenticatedUsersJson;
this.authenticatedPrincipalsJson = authenticatedUsersJson;
}
public String getAuthMethod() {

View File

@ -13,6 +13,7 @@ public class AuthUserAccountEntity extends BaseEntity {
private String passwordHash;
private String passwordSalt;
private String status;
private Boolean needChangePassword;
private Integer failedCount;
private LocalDateTime lockedUntil;
private LocalDateTime lastLoginAt;
@ -66,6 +67,14 @@ public class AuthUserAccountEntity extends BaseEntity {
this.status = status;
}
public Boolean getNeedChangePassword() {
return needChangePassword;
}
public void setNeedChangePassword(Boolean needChangePassword) {
this.needChangePassword = needChangePassword;
}
public Integer getFailedCount() {
return failedCount;
}

View File

@ -2,22 +2,13 @@ package com.cisd.tms.modules.auth.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.cisd.tms.infrastructure.persistence.entity.BaseEntity;
import java.time.LocalDateTime;
@TableName("tms_role_account")
public class RoleAccountEntity extends BaseEntity {
private String roleCode;
private String displayName;
private Integer requiredUkeyCount;
private String passwordHash;
private String passwordSalt;
private String status;
private Boolean needChangePassword;
private Integer failedCount;
private LocalDateTime lockedUntil;
private LocalDateTime lastLoginAt;
private LocalDateTime lastActiveAt;
public String getRoleCode() {
return roleCode;
@ -43,22 +34,6 @@ public class RoleAccountEntity extends BaseEntity {
this.requiredUkeyCount = requiredUkeyCount;
}
public String getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public String getPasswordSalt() {
return passwordSalt;
}
public void setPasswordSalt(String passwordSalt) {
this.passwordSalt = passwordSalt;
}
public String getStatus() {
return status;
}
@ -66,44 +41,4 @@ public class RoleAccountEntity extends BaseEntity {
public void setStatus(String status) {
this.status = status;
}
public Boolean getNeedChangePassword() {
return needChangePassword;
}
public void setNeedChangePassword(Boolean needChangePassword) {
this.needChangePassword = needChangePassword;
}
public Integer getFailedCount() {
return failedCount;
}
public void setFailedCount(Integer failedCount) {
this.failedCount = failedCount;
}
public LocalDateTime getLockedUntil() {
return lockedUntil;
}
public void setLockedUntil(LocalDateTime lockedUntil) {
this.lockedUntil = lockedUntil;
}
public LocalDateTime getLastLoginAt() {
return lastLoginAt;
}
public void setLastLoginAt(LocalDateTime lastLoginAt) {
this.lastLoginAt = lastLoginAt;
}
public LocalDateTime getLastActiveAt() {
return lastActiveAt;
}
public void setLastActiveAt(LocalDateTime lastActiveAt) {
this.lastActiveAt = lastActiveAt;
}
}

View File

@ -0,0 +1,15 @@
package com.cisd.tms.modules.auth.mapper;
import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX;
import com.cisd.tms.modules.auth.entity.AuthFullAccountEntity;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface AuthFullAccountMapper extends BaseMapperX<AuthFullAccountEntity> {
AuthFullAccountEntity selectByRoleCodeAndUid(@Param("roleCode") String roleCode, @Param("uid") Integer uid);
List<AuthFullAccountEntity> selectByRoleCode(@Param("roleCode") String roleCode);
}

View File

@ -2,6 +2,8 @@ package com.cisd.tms.modules.auth.mapper;
import com.cisd.tms.infrastructure.persistence.mapper.BaseMapperX;
import com.cisd.tms.modules.auth.entity.AuthSessionEntity;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@ -9,4 +11,17 @@ import org.apache.ibatis.annotations.Param;
public interface AuthSessionMapper extends BaseMapperX<AuthSessionEntity> {
AuthSessionEntity selectBySessionToken(@Param("sessionToken") String sessionToken);
List<AuthSessionEntity> selectActiveByRoleCodeAndAuthLevel(
@Param("roleCode") String roleCode,
@Param("authLevel") String authLevel,
@Param("now") LocalDateTime now
);
List<AuthSessionEntity> selectActiveByRoleCodeAndAuthLevelAndAuthenticatedPrincipalsJson(
@Param("roleCode") String roleCode,
@Param("authLevel") String authLevel,
@Param("authenticatedPrincipalsJson") String authenticatedPrincipalsJson,
@Param("now") LocalDateTime now
);
}

View File

@ -0,0 +1,16 @@
package com.cisd.tms.modules.auth.repository;
import com.cisd.tms.modules.auth.entity.AuthFullAccountEntity;
import java.util.List;
import java.util.Optional;
public interface AuthFullAccountRepository {
Optional<AuthFullAccountEntity> findByRoleCodeAndUid(String roleCode, Integer uid);
List<AuthFullAccountEntity> findByRoleCode(String roleCode);
void save(AuthFullAccountEntity entity);
void update(AuthFullAccountEntity entity);
}

View File

@ -1,12 +1,23 @@
package com.cisd.tms.modules.auth.repository;
import com.cisd.tms.modules.auth.entity.AuthSessionEntity;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface AuthSessionRepository {
Optional<AuthSessionEntity> findBySessionToken(String sessionToken);
List<AuthSessionEntity> findActiveByRoleCodeAndAuthLevel(String roleCode, String authLevel, LocalDateTime now);
List<AuthSessionEntity> findActiveByRoleCodeAndAuthLevelAndAuthenticatedPrincipalsJson(
String roleCode,
String authLevel,
String authenticatedPrincipalsJson,
LocalDateTime now
);
void save(AuthSessionEntity entity);
void update(AuthSessionEntity entity);

View File

@ -0,0 +1,38 @@
package com.cisd.tms.modules.auth.repository.impl;
import com.cisd.tms.modules.auth.entity.AuthFullAccountEntity;
import com.cisd.tms.modules.auth.mapper.AuthFullAccountMapper;
import com.cisd.tms.modules.auth.repository.AuthFullAccountRepository;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Repository;
@Repository
public class AuthFullAccountRepositoryImpl implements AuthFullAccountRepository {
private final AuthFullAccountMapper authFullAccountMapper;
public AuthFullAccountRepositoryImpl(AuthFullAccountMapper authFullAccountMapper) {
this.authFullAccountMapper = authFullAccountMapper;
}
@Override
public Optional<AuthFullAccountEntity> findByRoleCodeAndUid(String roleCode, Integer uid) {
return Optional.ofNullable(authFullAccountMapper.selectByRoleCodeAndUid(roleCode, uid));
}
@Override
public List<AuthFullAccountEntity> findByRoleCode(String roleCode) {
return authFullAccountMapper.selectByRoleCode(roleCode);
}
@Override
public void save(AuthFullAccountEntity entity) {
authFullAccountMapper.insert(entity);
}
@Override
public void update(AuthFullAccountEntity entity) {
authFullAccountMapper.updateById(entity);
}
}

View File

@ -3,6 +3,8 @@ package com.cisd.tms.modules.auth.repository.impl;
import com.cisd.tms.modules.auth.entity.AuthSessionEntity;
import com.cisd.tms.modules.auth.mapper.AuthSessionMapper;
import com.cisd.tms.modules.auth.repository.AuthSessionRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Repository;
@ -20,6 +22,26 @@ public class AuthSessionRepositoryImpl implements AuthSessionRepository {
return Optional.ofNullable(authSessionMapper.selectBySessionToken(sessionToken));
}
@Override
public List<AuthSessionEntity> findActiveByRoleCodeAndAuthLevel(String roleCode, String authLevel, LocalDateTime now) {
return authSessionMapper.selectActiveByRoleCodeAndAuthLevel(roleCode, authLevel, now);
}
@Override
public List<AuthSessionEntity> findActiveByRoleCodeAndAuthLevelAndAuthenticatedPrincipalsJson(
String roleCode,
String authLevel,
String authenticatedPrincipalsJson,
LocalDateTime now
) {
return authSessionMapper.selectActiveByRoleCodeAndAuthLevelAndAuthenticatedPrincipalsJson(
roleCode,
authLevel,
authenticatedPrincipalsJson,
now
);
}
@Override
public void save(AuthSessionEntity entity) {
authSessionMapper.insert(entity);

View File

@ -27,5 +27,7 @@ public interface AuthService {
void logout(String sessionToken);
void changePassword(String sessionToken, String currentPassword, String newPassword);
void changeFullAccountPassword(String sessionToken, Integer uid, String currentPassword, String newPassword);
void changeLimitedAccountPassword(String sessionToken, String username, String currentPassword, String newPassword);
}

View File

@ -3,11 +3,13 @@ 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.entity.AuthFullAccountEntity;
import com.cisd.tms.modules.auth.entity.AuthUserAccountEntity;
import com.cisd.tms.modules.auth.entity.RoleAccountEntity;
import com.cisd.tms.modules.auth.entity.RoleUkeyBindingEntity;
import com.cisd.tms.modules.auth.enums.RoleAccountStatus;
import com.cisd.tms.modules.auth.enums.RoleCode;
import com.cisd.tms.modules.auth.repository.AuthFullAccountRepository;
import com.cisd.tms.modules.auth.repository.AuthUserAccountRepository;
import com.cisd.tms.modules.auth.repository.RoleAccountRepository;
import com.cisd.tms.modules.auth.repository.RoleUkeyBindingRepository;
@ -36,6 +38,7 @@ public class AuthAdminServiceImpl implements AuthAdminService {
static final String DEFAULT_PASSWORD = "12345678";
private final RoleAccountRepository roleAccountRepository;
private final AuthFullAccountRepository authFullAccountRepository;
private final AuthUserAccountRepository authUserAccountRepository;
private final RoleUkeyBindingRepository roleUkeyBindingRepository;
private final PasswordHasher passwordHasher;
@ -48,24 +51,60 @@ public class AuthAdminServiceImpl implements AuthAdminService {
public void enableRole(String operatorRoleCode, String operatorAuthLevel, String targetRoleCode) {
RoleAccountEntity target = loadRole(targetRoleCode);
target.setStatus(RoleAccountStatus.ACTIVE.name());
target.setNeedChangePassword(Boolean.TRUE);
target.setLockedUntil(null);
target.setFailedCount(0);
roleAccountRepository.update(target);
List<AuthFullAccountEntity> fullAccounts = authFullAccountRepository.findByRoleCode(targetRoleCode);
if (fullAccounts.isEmpty()) {
throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "target role full accounts not found");
}
for (AuthFullAccountEntity account : fullAccounts) {
account.setStatus(RoleAccountStatus.ACTIVE.name());
account.setNeedChangePassword(Boolean.TRUE);
account.setFailedCount(0);
account.setLockedUntil(null);
account.setLastActiveAt(null);
account.setLastLoginAt(null);
authFullAccountRepository.update(account);
}
List<AuthUserAccountEntity> accounts = authUserAccountRepository.findByRoleCode(targetRoleCode);
if (accounts.isEmpty()) {
throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "target role user accounts not found");
}
for (AuthUserAccountEntity account : accounts) {
account.setStatus(RoleAccountStatus.ACTIVE.name());
account.setNeedChangePassword(Boolean.TRUE);
account.setFailedCount(0);
account.setLockedUntil(null);
account.setLastActiveAt(null);
account.setLastLoginAt(null);
authUserAccountRepository.update(account);
}
}
@Override
public void resetPassword(String operatorRoleCode, String operatorAuthLevel, String targetRoleCode) {
RoleAccountEntity target = loadRole(targetRoleCode);
String roleSalt = passwordSaltGenerator.nextSalt();
target.setPasswordSalt(roleSalt);
target.setPasswordHash(passwordHasher.hash(DEFAULT_PASSWORD, roleSalt));
target.setStatus(RoleAccountStatus.ACTIVE.name());
target.setNeedChangePassword(Boolean.TRUE);
target.setFailedCount(0);
target.setLockedUntil(null);
roleAccountRepository.update(target);
List<AuthFullAccountEntity> fullAccounts = authFullAccountRepository.findByRoleCode(targetRoleCode);
if (fullAccounts.isEmpty()) {
throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "target role full accounts not found");
}
for (AuthFullAccountEntity account : fullAccounts) {
String newSalt = passwordSaltGenerator.nextSalt();
account.setPasswordSalt(newSalt);
account.setPasswordHash(passwordHasher.hash(DEFAULT_PASSWORD, newSalt));
account.setStatus(RoleAccountStatus.ACTIVE.name());
account.setNeedChangePassword(Boolean.TRUE);
account.setFailedCount(0);
account.setLockedUntil(null);
account.setLastActiveAt(null);
account.setLastLoginAt(null);
authFullAccountRepository.update(account);
}
List<AuthUserAccountEntity> accounts = authUserAccountRepository.findByRoleCode(targetRoleCode);
if (accounts.isEmpty()) {
throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "target role user accounts not found");
@ -75,6 +114,7 @@ public class AuthAdminServiceImpl implements AuthAdminService {
account.setPasswordSalt(newSalt);
account.setPasswordHash(passwordHasher.hash(DEFAULT_PASSWORD, newSalt));
account.setStatus(RoleAccountStatus.ACTIVE.name());
account.setNeedChangePassword(Boolean.TRUE);
account.setFailedCount(0);
account.setLockedUntil(null);
account.setLastActiveAt(null);
@ -97,6 +137,8 @@ public class AuthAdminServiceImpl implements AuthAdminService {
if (uid == null || uid < 1 || uid > targetRole.getRequiredUkeyCount()) {
throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "uid exceeds role ukey requirement");
}
authFullAccountRepository.findByRoleCodeAndUid(targetRoleCode, uid)
.orElseThrow(() -> new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "target role full account not found"));
RoleUkeyBindingEntity binding = roleUkeyBindingRepository
.findActiveByRoleCodeAndUid(targetRoleCode, uid)

View File

@ -2,8 +2,10 @@ 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.modules.auth.dto.AuthSessionPrincipal;
import com.cisd.tms.modules.auth.dto.CaptchaResponse;
import com.cisd.tms.modules.auth.dto.CurrentUserResponse;
import com.cisd.tms.modules.auth.dto.FullLoginAccountRequest;
import com.cisd.tms.modules.auth.dto.LoginRequest;
import com.cisd.tms.modules.auth.dto.LoginResponse;
import com.cisd.tms.modules.auth.dto.PasswordLoginAccountRequest;
@ -12,6 +14,7 @@ import com.cisd.tms.modules.auth.dto.UkeyLoginProof;
import com.cisd.tms.modules.auth.dto.UkeyLoginRandomRequest;
import com.cisd.tms.modules.auth.dto.UkeyLoginRandomResponse;
import com.cisd.tms.modules.auth.dto.UkeyLoginRequest;
import com.cisd.tms.modules.auth.entity.AuthFullAccountEntity;
import com.cisd.tms.modules.auth.entity.AuthSessionEntity;
import com.cisd.tms.modules.auth.entity.AuthUserAccountEntity;
import com.cisd.tms.modules.auth.entity.AuthUserEntity;
@ -21,6 +24,7 @@ import com.cisd.tms.modules.auth.enums.AuthLevel;
import com.cisd.tms.modules.auth.enums.AuthMethod;
import com.cisd.tms.modules.auth.enums.RoleAccountStatus;
import com.cisd.tms.modules.auth.enums.RoleCode;
import com.cisd.tms.modules.auth.repository.AuthFullAccountRepository;
import com.cisd.tms.modules.auth.repository.AuthSessionRepository;
import com.cisd.tms.modules.auth.repository.AuthUserAccountRepository;
import com.cisd.tms.modules.auth.repository.AuthUserRepository;
@ -39,6 +43,8 @@ import com.cisd.tms.modules.mk.dto.UKeySignEntity;
import com.cisd.tms.modules.mk.enums.MasterKeyStatus;
import com.cisd.tms.modules.mk.service.LmkService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Clock;
import java.time.LocalDateTime;
@ -63,6 +69,7 @@ public class AuthServiceImpl implements AuthService {
private final RoleAccountRepository roleAccountRepository;
private final AuthUserRepository authUserRepository;
private final AuthUserAccountRepository authUserAccountRepository;
private final AuthFullAccountRepository authFullAccountRepository;
private final AuthSessionRepository authSessionRepository;
private final RoleUkeyBindingRepository roleUkeyBindingRepository;
private final PasswordHasher passwordHasher;
@ -84,19 +91,24 @@ public class AuthServiceImpl implements AuthService {
validateRoleStatus(roleAccount);
AuthMethod authMethod = resolveAuthMethod(request.getUkeySerials());
List<AuthUserAccountEntity> validatedAccounts = new ArrayList<>();
List<AuthFullAccountEntity> validatedFullAccounts = new ArrayList<>();
if (AuthMethod.PASSWORD == authMethod) {
validatedAccounts = validateRoleAccounts(roleAccount, request.getAccounts());
resetValidatedAccounts(validatedAccounts);
} else {
validateRolePassword(roleAccount, request.getRolePassword());
resetRoleFailureState(roleAccount);
validatedFullAccounts = validateFullAccounts(roleAccount, request.getFullAccounts());
resetValidatedFullAccounts(validatedFullAccounts);
}
AuthLevel authLevel = resolveAuthLevel(roleAccount, request.getUkeySerials());
List<AuthSessionPrincipal> authenticatedPrincipals = AuthMethod.PASSWORD == authMethod
? normalizeAuthenticatedUsers(validatedAccounts)
: normalizeAuthenticatedFullAccounts(validatedFullAccounts);
expireConcurrentSessions(roleAccount.getRoleCode(), authLevel, authenticatedPrincipals);
AuthSessionEntity session = buildSession(
roleAccount.getRoleCode(),
authMethod,
authLevel,
validatedAccounts.isEmpty() ? null : validatedAccounts.stream().map(AuthUserAccountEntity::getUsername).toList()
authenticatedPrincipals
);
authSessionRepository.save(session);
@ -105,14 +117,18 @@ public class AuthServiceImpl implements AuthService {
response.setAuthLevel(authLevel.name());
response.setToken(session.getSessionToken());
response.setExpiresAt(OffsetDateTime.of(session.getExpiresAt(), ZoneOffset.UTC).toString());
response.setNeedChangePassword(needsPasswordChange(roleAccount));
response.setNeedChangePassword(
AuthMethod.PASSWORD == authMethod
? needsPasswordChangeForLimitedAccounts(validatedAccounts)
: needsPasswordChangeForFullAccounts(validatedFullAccounts)
);
return response;
}
@Override
public LoginResponse passwordLogin(PasswordLoginRequest request) {
ensureMasterKeyReady();
captchaService.verify(request.getCaptchaId(), request.getCaptchaCode());
// captchaService.verify(request.getCaptchaId(), request.getCaptchaCode());
LoginRequest loginRequest = new LoginRequest();
loginRequest.setRoleCode(request.getRoleCode());
loginRequest.setAccounts(request.getAccounts());
@ -133,22 +149,22 @@ public class AuthServiceImpl implements AuthService {
ensureMasterKeyReady();
RoleCode roleCode = RoleCode.valueOf(request.getRoleCode());
List<RoleUkeyBindingEntity> activeBindings = roleUkeyBindingRepository.findActiveByRoleCode(roleCode.getCode());
validateUkeyCount(roleCode, activeBindings, request.getUkeyProofs());
validateUkeyCount(roleCode, activeBindings, request.getLoginFactors());
Map<Integer, RoleUkeyBindingEntity> bindingsByUid = activeBindings.stream()
.collect(Collectors.toMap(RoleUkeyBindingEntity::getUid, item -> item, (left, right) -> left, java.util.LinkedHashMap::new));
Set<Integer> requestUids = request.getUkeyProofs().stream()
Set<Integer> requestUids = request.getLoginFactors().stream()
.map(UkeyLoginProof::getUid)
.collect(Collectors.toSet());
if (requestUids.size() != request.getUkeyProofs().size() || !bindingsByUid.keySet().containsAll(requestUids)) {
if (requestUids.size() != request.getLoginFactors().size() || !bindingsByUid.keySet().containsAll(requestUids)) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey auth info does not match bound role");
}
ukeyLoginRandomService.assertIssued(
roleCode.getCode(),
request.getUkeyProofs().stream().map(UkeyLoginProof::getServerRandom).toList()
request.getLoginFactors().stream().map(UkeyLoginProof::getServerRandom).toList()
);
String authKeyPair = lmkService.exportIkPublicKeyHex();
List<String> matchedSerials = new ArrayList<>();
for (UkeyLoginProof proof : request.getUkeyProofs()) {
for (UkeyLoginProof proof : request.getLoginFactors()) {
RoleUkeyBindingEntity binding = bindingsByUid.get(proof.getUid());
if (binding == null || !binding.getUkeyPubkey().equals(proof.getPubKey())) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey auth info does not match bound role");
@ -159,8 +175,8 @@ public class AuthServiceImpl implements AuthService {
}
LoginRequest loginRequest = new LoginRequest();
loginRequest.setRoleCode(request.getRoleCode());
loginRequest.setRolePassword(request.getRolePassword());
loginRequest.setUkeySerials(matchedSerials);
loginRequest.setFullAccounts(buildFixedUidAccounts(request.getRoleCode(), request.getLoginFactors()));
return login(loginRequest);
}
@ -186,8 +202,10 @@ public class AuthServiceImpl implements AuthService {
AuthSessionEntity session = sessionToken == null ? null : authSessionRepository.findBySessionToken(sessionToken).orElse(null);
if (session != null) {
response.setAuthLevel(session.getAuthLevel());
response.setNeedChangePassword(needsPasswordChange(session));
} else {
response.setNeedChangePassword(Boolean.FALSE);
}
response.setNeedChangePassword(needsPasswordChange(roleAccount));
return response;
}
@ -217,25 +235,66 @@ public class AuthServiceImpl implements AuthService {
}
@Override
public void changePassword(String sessionToken, String currentPassword, String newPassword) {
public void changeFullAccountPassword(String sessionToken, Integer uid, String currentPassword, String newPassword) {
AuthSessionEntity session = requireActiveSession(sessionToken);
RoleAccountEntity roleAccount = roleAccountRepository.findByRoleCode(session.getRoleCode())
.orElseThrow(() -> new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role account not found"));
validateRoleStatus(roleAccount);
if (!passwordHasher.matches(currentPassword, roleAccount.getPasswordSalt(), roleAccount.getPasswordHash())) {
onRolePasswordFailed(roleAccount);
if (!AuthLevel.FULL.name().equals(session.getAuthLevel())) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "full session is required");
}
if (uid == null || !containsAuthenticatedFullUid(session, uid)) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "full account is not authenticated in current session");
}
AuthFullAccountEntity fullAccount = authFullAccountRepository.findByRoleCodeAndUid(session.getRoleCode(), uid)
.orElseThrow(() -> new BizException(ErrorCode.UNAUTHORIZED.getCode(), "full account not found"));
validateFullAccountStatus(fullAccount);
if (!passwordHasher.matches(currentPassword, fullAccount.getPasswordSalt(), fullAccount.getPasswordHash())) {
onFullPasswordFailed(fullAccount);
}
String salt = passwordSaltGenerator.nextSalt();
LocalDateTime current = now();
roleAccount.setPasswordSalt(salt);
roleAccount.setPasswordHash(passwordHasher.hash(newPassword, salt));
roleAccount.setNeedChangePassword(Boolean.FALSE);
roleAccount.setFailedCount(0);
roleAccount.setLockedUntil(null);
roleAccount.setStatus(RoleAccountStatus.ACTIVE.name());
roleAccount.setLastActiveAt(current);
roleAccountRepository.update(roleAccount);
fullAccount.setPasswordSalt(salt);
fullAccount.setPasswordHash(passwordHasher.hash(newPassword, salt));
fullAccount.setFailedCount(0);
fullAccount.setLockedUntil(null);
fullAccount.setStatus(RoleAccountStatus.ACTIVE.name());
fullAccount.setNeedChangePassword(Boolean.FALSE);
fullAccount.setLastLoginAt(current);
fullAccount.setLastActiveAt(current);
authFullAccountRepository.update(fullAccount);
session.setLastActiveAt(current);
session.setExpiresAt(current.plusMinutes(IDLE_TIMEOUT_MINUTES));
authSessionRepository.update(session);
}
@Override
public void changeLimitedAccountPassword(String sessionToken, String username, String currentPassword, String newPassword) {
AuthSessionEntity session = requireActiveSession(sessionToken);
if (!AuthLevel.LIMITED.name().equals(session.getAuthLevel())) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "limited session is required");
}
if (username == null || username.isBlank() || !containsAuthenticatedLimitedUsername(session, username)) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "limited account is not authenticated in current session");
}
AuthUserAccountEntity userAccount = authUserAccountRepository.findByUsername(username)
.orElseThrow(() -> new BizException(ErrorCode.UNAUTHORIZED.getCode(), "user account not found"));
validateUserAccountRole(session.getRoleCode(), userAccount);
validateUserAccountStatus(userAccount);
if (!passwordHasher.matches(currentPassword, userAccount.getPasswordSalt(), userAccount.getPasswordHash())) {
onPasswordFailed(userAccount);
}
String salt = passwordSaltGenerator.nextSalt();
LocalDateTime current = now();
userAccount.setPasswordSalt(salt);
userAccount.setPasswordHash(passwordHasher.hash(newPassword, salt));
userAccount.setFailedCount(0);
userAccount.setLockedUntil(null);
userAccount.setStatus(RoleAccountStatus.ACTIVE.name());
userAccount.setNeedChangePassword(Boolean.FALSE);
userAccount.setLastLoginAt(current);
userAccount.setLastActiveAt(current);
authUserAccountRepository.update(userAccount);
session.setLastActiveAt(current);
session.setExpiresAt(current.plusMinutes(IDLE_TIMEOUT_MINUTES));
@ -243,22 +302,9 @@ public class AuthServiceImpl implements AuthService {
}
private void validateRoleStatus(RoleAccountEntity roleAccount) {
if (RoleAccountStatus.UNENABLED.name().equals(roleAccount.getStatus())) {
if (!RoleAccountStatus.ACTIVE.name().equals(roleAccount.getStatus())) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role is not enabled");
}
if (RoleAccountStatus.LOCKED.name().equals(roleAccount.getStatus())
&& (roleAccount.getLockedUntil() == null || roleAccount.getLockedUntil().isAfter(now()))) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role account is locked");
}
}
private void validateRolePassword(RoleAccountEntity roleAccount, String rolePassword) {
if (rolePassword == null || rolePassword.isBlank()) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role password is required");
}
if (!passwordHasher.matches(rolePassword, roleAccount.getPasswordSalt(), roleAccount.getPasswordHash())) {
onRolePasswordFailed(roleAccount);
}
}
private List<AuthUserAccountEntity> validateRoleAccounts(
@ -321,17 +367,27 @@ public class AuthServiceImpl implements AuthService {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "password is incorrect");
}
private void onRolePasswordFailed(RoleAccountEntity roleAccount) {
int failedCount = roleAccount.getFailedCount() == null ? 0 : roleAccount.getFailedCount();
failedCount++;
roleAccount.setFailedCount(failedCount);
if (failedCount >= MAX_FAILED_ATTEMPTS) {
roleAccount.setStatus(RoleAccountStatus.LOCKED.name());
roleAccount.setLockedUntil(now().plusMinutes(IDLE_TIMEOUT_MINUTES));
roleAccountRepository.update(roleAccount);
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role account is locked");
private void validateFullAccountStatus(AuthFullAccountEntity fullAccount) {
if (RoleAccountStatus.UNENABLED.name().equals(fullAccount.getStatus())) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "full account is not enabled");
}
roleAccountRepository.update(roleAccount);
if (RoleAccountStatus.LOCKED.name().equals(fullAccount.getStatus())
&& (fullAccount.getLockedUntil() == null || fullAccount.getLockedUntil().isAfter(now()))) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "full account is locked");
}
}
private void onFullPasswordFailed(AuthFullAccountEntity fullAccount) {
int failedCount = fullAccount.getFailedCount() == null ? 0 : fullAccount.getFailedCount();
failedCount++;
fullAccount.setFailedCount(failedCount);
if (failedCount >= MAX_FAILED_ATTEMPTS) {
fullAccount.setStatus(RoleAccountStatus.LOCKED.name());
fullAccount.setLockedUntil(now().plusMinutes(IDLE_TIMEOUT_MINUTES));
authFullAccountRepository.update(fullAccount);
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "full account is locked");
}
authFullAccountRepository.update(fullAccount);
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "password is incorrect");
}
@ -347,19 +403,77 @@ public class AuthServiceImpl implements AuthService {
}
}
private void resetRoleFailureState(RoleAccountEntity roleAccount) {
roleAccount.setFailedCount(0);
roleAccount.setLockedUntil(null);
roleAccount.setStatus(RoleAccountStatus.ACTIVE.name());
roleAccount.setLastLoginAt(now());
roleAccount.setLastActiveAt(now());
roleAccountRepository.update(roleAccount);
private void resetValidatedFullAccounts(List<AuthFullAccountEntity> validatedAccounts) {
LocalDateTime current = now();
for (AuthFullAccountEntity account : validatedAccounts) {
account.setFailedCount(0);
account.setLockedUntil(null);
account.setStatus(RoleAccountStatus.ACTIVE.name());
account.setLastLoginAt(current);
account.setLastActiveAt(current);
authFullAccountRepository.update(account);
}
}
private int requiredPasswordAccountCount(String roleCode) {
return RoleCode.valueOf(roleCode).getRequiredUkeyCount();
}
private List<AuthFullAccountEntity> validateFullAccounts(
RoleAccountEntity roleAccount,
List<FullLoginAccountRequest> fullAccounts
) {
int expectedCount = authPolicyService.requiredUkeyCount(RoleCode.valueOf(roleAccount.getRoleCode()));
if (fullAccounts == null || fullAccounts.size() != expectedCount) {
throw new BizException(
ErrorCode.UNAUTHORIZED.getCode(),
roleAccount.getRoleCode() + " requires exactly " + expectedCount + " full-account passwords"
);
}
Set<Integer> uniqueUids = new LinkedHashSet<>();
List<AuthFullAccountEntity> validatedAccounts = new ArrayList<>();
for (FullLoginAccountRequest account : fullAccounts) {
Integer uid = account.getUid();
if (uid == null || !uniqueUids.add(uid)) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "duplicate full-account uid submitted");
}
AuthFullAccountEntity fullAccount = authFullAccountRepository
.findByRoleCodeAndUid(roleAccount.getRoleCode(), uid)
.orElseThrow(() -> new BizException(ErrorCode.UNAUTHORIZED.getCode(), "full account not found"));
validateFullAccountStatus(fullAccount);
if (!passwordHasher.matches(account.getPassword(), fullAccount.getPasswordSalt(), fullAccount.getPasswordHash())) {
onFullPasswordFailed(fullAccount);
}
validatedAccounts.add(fullAccount);
}
return validatedAccounts;
}
private List<FullLoginAccountRequest> buildFixedUidAccounts(String roleCode, List<UkeyLoginProof> proofs) {
List<AuthFullAccountEntity> fixedAccounts = authFullAccountRepository.findByRoleCode(roleCode);
int requiredCount = authPolicyService.requiredUkeyCount(RoleCode.valueOf(roleCode));
if (fixedAccounts.size() < requiredCount) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role fixed account mapping is incomplete");
}
List<FullLoginAccountRequest> requests = new ArrayList<>();
for (UkeyLoginProof proof : proofs.stream().sorted(java.util.Comparator.comparing(UkeyLoginProof::getUid)).toList()) {
Integer uid = proof.getUid();
if (uid == null || uid < 1 || uid > fixedAccounts.size()) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "uid does not match fixed role account");
}
if (proof.getPassword() == null || proof.getPassword().isBlank()) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "account password is required");
}
FullLoginAccountRequest accountRequest = new FullLoginAccountRequest();
accountRequest.setUid(uid);
accountRequest.setPassword(proof.getPassword());
requests.add(accountRequest);
}
return requests;
}
private void ensureMasterKeyReady() {
MasterKeyStatus.StatusDetail status = lmkService.getMasterKeyStatus();
if (status == null || status.getCode() == MasterKeyStatus.ABNORMAL.getCode()) {
@ -425,12 +539,12 @@ public class AuthServiceImpl implements AuthService {
String roleCode,
AuthMethod authMethod,
AuthLevel authLevel,
List<String> authenticatedUsers
List<AuthSessionPrincipal> authenticatedPrincipals
) {
LocalDateTime issuedAt = now();
AuthSessionEntity entity = new AuthSessionEntity();
entity.setRoleCode(roleCode);
entity.setAuthenticatedUsersJson(writeAuthenticatedUsers(authenticatedUsers));
entity.setAuthenticatedPrincipalsJson(writeAuthenticatedPrincipals(authenticatedPrincipals));
entity.setAuthMethod(authMethod.name());
entity.setAuthLevel(authLevel.name());
entity.setSessionToken(sessionTokenGenerator.nextToken());
@ -440,23 +554,132 @@ public class AuthServiceImpl implements AuthService {
return entity;
}
private String writeAuthenticatedUsers(List<String> authenticatedUsers) {
if (authenticatedUsers == null) {
private void expireConcurrentSessions(String roleCode, AuthLevel authLevel, List<AuthSessionPrincipal> authenticatedPrincipals) {
LocalDateTime current = now();
List<AuthSessionEntity> sessionsToExpire;
if (AuthLevel.FULL == authLevel) {
sessionsToExpire = authSessionRepository.findActiveByRoleCodeAndAuthLevel(roleCode, authLevel.name(), current);
} else {
sessionsToExpire = authSessionRepository.findActiveByRoleCodeAndAuthLevelAndAuthenticatedPrincipalsJson(
roleCode,
authLevel.name(),
writeAuthenticatedPrincipals(authenticatedPrincipals),
current
);
}
for (AuthSessionEntity session : sessionsToExpire) {
session.setLogoutAt(current);
session.setExpiresAt(current);
authSessionRepository.update(session);
}
}
private List<AuthSessionPrincipal> normalizeAuthenticatedUsers(List<AuthUserAccountEntity> validatedAccounts) {
if (validatedAccounts == null || validatedAccounts.isEmpty()) {
return null;
}
return validatedAccounts.stream()
.map(AuthUserAccountEntity::getUsername)
.sorted()
.map(AuthSessionPrincipal::limited)
.toList();
}
private List<AuthSessionPrincipal> normalizeAuthenticatedFullAccounts(List<AuthFullAccountEntity> validatedAccounts) {
if (validatedAccounts == null || validatedAccounts.isEmpty()) {
return null;
}
return validatedAccounts.stream()
.map(AuthFullAccountEntity::getUid)
.sorted()
.map(AuthSessionPrincipal::full)
.toList();
}
private String writeAuthenticatedPrincipals(List<AuthSessionPrincipal> authenticatedPrincipals) {
if (authenticatedPrincipals == null) {
return null;
}
try {
return objectMapper.writeValueAsString(authenticatedUsers);
return objectMapper.writeValueAsString(authenticatedPrincipals);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("serialize authenticated users failed", ex);
throw new IllegalStateException("serialize authenticated principals failed", ex);
}
}
private List<AuthSessionPrincipal> authenticatedPrincipals(AuthSessionEntity session) {
if (session.getAuthenticatedPrincipalsJson() == null || session.getAuthenticatedPrincipalsJson().isBlank()) {
return List.of();
}
try {
JsonNode root = objectMapper.readTree(session.getAuthenticatedPrincipalsJson());
if (!root.isArray() || root.isEmpty()) {
return List.of();
}
if (root.get(0).isTextual()) {
List<AuthSessionPrincipal> principals = new ArrayList<>();
for (JsonNode node : root) {
String value = node.asText();
if (value.startsWith("FULL:")) {
principals.add(AuthSessionPrincipal.full(Integer.parseInt(value.substring("FULL:".length()))));
} else {
principals.add(AuthSessionPrincipal.limited(value));
}
}
return principals;
}
return objectMapper.readValue(
session.getAuthenticatedPrincipalsJson(),
new TypeReference<List<AuthSessionPrincipal>>() { }
);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("deserialize authenticated principals failed", ex);
}
}
private boolean containsAuthenticatedFullUid(AuthSessionEntity session, Integer uid) {
return authenticatedPrincipals(session).stream()
.anyMatch(principal -> "FULL".equals(principal.getType()) && uid.equals(principal.getUid()));
}
private boolean containsAuthenticatedLimitedUsername(AuthSessionEntity session, String username) {
return authenticatedPrincipals(session).stream()
.anyMatch(principal -> "LIMITED".equals(principal.getType()) && username.equals(principal.getUsername()));
}
private LocalDateTime now() {
return LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
}
private boolean needsPasswordChange(RoleAccountEntity roleAccount) {
return Boolean.TRUE.equals(roleAccount.getNeedChangePassword());
private boolean needsPasswordChangeForLimitedAccounts(List<AuthUserAccountEntity> accounts) {
return accounts != null && accounts.stream().anyMatch(account -> Boolean.TRUE.equals(account.getNeedChangePassword()));
}
private boolean needsPasswordChangeForFullAccounts(List<AuthFullAccountEntity> accounts) {
return accounts != null && accounts.stream().anyMatch(account -> Boolean.TRUE.equals(account.getNeedChangePassword()));
}
private boolean needsPasswordChange(AuthSessionEntity session) {
if (session == null) {
return false;
}
List<AuthSessionPrincipal> principals = authenticatedPrincipals(session);
if (principals.isEmpty()) {
return false;
}
if (AuthLevel.FULL.name().equals(session.getAuthLevel())) {
List<AuthFullAccountEntity> accounts = authFullAccountRepository.findByRoleCode(session.getRoleCode());
return accounts.stream()
.filter(account -> principals.stream()
.anyMatch(principal -> "FULL".equals(principal.getType()) && account.getUid().equals(principal.getUid())))
.anyMatch(account -> Boolean.TRUE.equals(account.getNeedChangePassword()));
}
List<AuthUserAccountEntity> accounts = authUserAccountRepository.findByRoleCode(session.getRoleCode());
return accounts.stream()
.filter(account -> principals.stream()
.anyMatch(principal -> "LIMITED".equals(principal.getType())
&& account.getUsername().equals(principal.getUsername())))
.anyMatch(account -> Boolean.TRUE.equals(account.getNeedChangePassword()));
}
private AuthSessionEntity requireActiveSession(String sessionToken) {

View File

@ -2,25 +2,52 @@ 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.integration.crypto.pcie.Gm0018AlgorithmIds;
import com.cisd.tms.integration.crypto.pcie.model.EccExternalVerifyRequest;
import com.cisd.tms.integration.crypto.pcie.service.PcieCryptoService;
import com.cisd.tms.modules.auth.service.CompatUkeyVerifier;
import com.cisd.tms.modules.mk.service.LmkService;
import java.io.StringReader;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Security;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Arrays;
import java.util.Base64;
import org.bouncycastle.asn1.ASN1EncodableVector;
import org.bouncycastle.asn1.ASN1Encoding;
import org.bouncycastle.asn1.ASN1InputStream;
import org.bouncycastle.asn1.ASN1Integer;
import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.ASN1Sequence;
import org.bouncycastle.asn1.DERSequence;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.jce.ECNamedCurveTable;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.jce.spec.ECParameterSpec;
import org.bouncycastle.jce.spec.ECPublicKeySpec;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.bouncycastle.util.encoders.Hex;
import org.springframework.stereotype.Component;
@Component
public class PcieCompatUkeyVerifier implements CompatUkeyVerifier {
private final LmkService lmkService;
private final PcieCryptoService pcieCryptoService;
private static final String PROVIDER = "BC";
private static final String SIGNATURE_ALGORITHM = "SM3withSM2";
private static final String SM2_CURVE = "sm2p256v1";
public PcieCompatUkeyVerifier(LmkService lmkService, PcieCryptoService pcieCryptoService) {
static {
if (Security.getProvider(PROVIDER) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
private final LmkService lmkService;
public PcieCompatUkeyVerifier(LmkService lmkService) {
this.lmkService = lmkService;
this.pcieCryptoService = pcieCryptoService;
}
@Override
@ -36,32 +63,133 @@ public class PcieCompatUkeyVerifier implements CompatUkeyVerifier {
@Override
public void verifyLoginSignature(String pubKey, String loginSignData, String loginSign) {
EccExternalVerifyRequest request = new EccExternalVerifyRequest();
request.setAlgId(Gm0018AlgorithmIds.SM2_SIGN);
request.setPublicKeyBlob(decodeBlob(pubKey, "login public key is invalid"));
request.setData(loginSignData.getBytes(StandardCharsets.UTF_8));
request.setSignature(decodeBlob(loginSign, "login signature is invalid"));
try {
pcieCryptoService.eccExternalVerify(request);
PublicKey publicKey = parsePublicKey(pubKey);
byte[] signatureBytes = decodeSignature(loginSign);
Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM, PROVIDER);
signature.initVerify(publicKey);
signature.update(loginSignData.getBytes(StandardCharsets.UTF_8));
if (!signature.verify(signatureBytes)) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "login signature verification failed");
}
} catch (BizException ex) {
throw ex;
} catch (IllegalArgumentException ex) {
throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), ex.getMessage());
} catch (RuntimeException ex) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "login signature verification failed");
} catch (Exception ex) {
throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "login signature verification failed");
}
}
private byte[] decodeBlob(String value, String message) {
String normalized = value == null ? "" : value.trim();
if (normalized.isEmpty()) {
throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), message);
private PublicKey parsePublicKey(String value) throws Exception {
String trimmed = value == null ? "" : value.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException("login public key is invalid");
}
if (normalized.matches("(?i)^[0-9a-f]+$") && normalized.length() % 2 == 0) {
return Hex.decode(normalized);
if (trimmed.contains("BEGIN")) {
return parsePemPublicKey(trimmed);
}
String normalized = normalize(trimmed);
byte[] decoded = decodeHexOrBase64(normalized, "login public key is invalid");
try {
return Base64.getDecoder().decode(normalized);
} catch (IllegalArgumentException ex) {
throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), message);
return KeyFactory.getInstance("EC", PROVIDER).generatePublic(new X509EncodedKeySpec(decoded));
} catch (Exception ignored) {
return parseUncompressedPointPublicKey(decoded);
}
}
private PublicKey parsePemPublicKey(String pemContent) throws Exception {
try (PEMParser parser = new PEMParser(new StringReader(pemContent))) {
Object parsed = parser.readObject();
if (!(parsed instanceof SubjectPublicKeyInfo publicKeyInfo)) {
throw new IllegalArgumentException("login public key is invalid");
}
return new JcaPEMKeyConverter().setProvider(PROVIDER).getPublicKey(publicKeyInfo);
}
}
private PublicKey parseUncompressedPointPublicKey(byte[] keyBytes) throws Exception {
byte[] pointBytes = keyBytes;
if (pointBytes.length == 64) {
pointBytes = new byte[65];
pointBytes[0] = 0x04;
System.arraycopy(keyBytes, 0, pointBytes, 1, keyBytes.length);
}
if (pointBytes.length != 65 || pointBytes[0] != 0x04) {
throw new IllegalArgumentException("login public key is invalid");
}
ECParameterSpec parameterSpec = ECNamedCurveTable.getParameterSpec(SM2_CURVE);
ECPublicKeySpec publicKeySpec = new ECPublicKeySpec(
parameterSpec.getCurve().decodePoint(pointBytes),
parameterSpec
);
return KeyFactory.getInstance("EC", PROVIDER).generatePublic(publicKeySpec);
}
private byte[] decodeSignature(String value) {
byte[] decoded = decodeHexOrBase64(normalize(value), "login signature is invalid");
if (isDerSignature(decoded)) {
return decoded;
}
if (decoded.length == 64) {
return rawRsToDer(decoded);
}
throw new IllegalArgumentException("login signature is invalid");
}
private boolean isDerSignature(byte[] signatureBytes) {
try (ASN1InputStream inputStream = new ASN1InputStream(signatureBytes)) {
ASN1Primitive primitive = inputStream.readObject();
if (!(primitive instanceof ASN1Sequence sequence) || sequence.size() != 2) {
return false;
}
return sequence.getObjectAt(0) instanceof ASN1Integer && sequence.getObjectAt(1) instanceof ASN1Integer;
} catch (Exception ex) {
return false;
}
}
private byte[] rawRsToDer(byte[] rawSignature) {
byte[] r = Arrays.copyOfRange(rawSignature, 0, 32);
byte[] s = Arrays.copyOfRange(rawSignature, 32, 64);
ASN1EncodableVector vector = new ASN1EncodableVector();
vector.add(new ASN1Integer(new BigInteger(1, r)));
vector.add(new ASN1Integer(new BigInteger(1, s)));
try {
return new DERSequence(vector).getEncoded(ASN1Encoding.DER);
} catch (Exception ex) {
throw new IllegalArgumentException("login signature is invalid", ex);
}
}
private byte[] decodeHexOrBase64(String value, String message) {
if (value.isEmpty()) {
throw new IllegalArgumentException(message);
}
if (value.matches("(?i)^[0-9a-f]+$") && value.length() % 2 == 0) {
return Hex.decode(value);
}
try {
return Base64.getDecoder().decode(value);
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException(message, ex);
}
}
private String normalize(String value) {
String normalized = value == null ? "" : value.trim();
if (!normalized.contains("BEGIN")) {
return normalized;
}
StringBuilder builder = new StringBuilder();
for (String line : normalized.split("\\R")) {
String trimmed = line.trim();
if (!trimmed.startsWith("-----")) {
builder.append(trimmed);
}
}
return builder.toString();
}
}

View File

@ -244,14 +244,7 @@ CREATE TABLE IF NOT EXISTS tms_role_account (
role_code VARCHAR(64) NOT NULL,
display_name VARCHAR(128) NOT NULL,
required_ukey_count INT NOT NULL,
password_hash VARCHAR(256) NOT NULL,
password_salt VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
need_change_password TINYINT(1) NOT NULL DEFAULT 1,
failed_count INT NOT NULL DEFAULT 0,
locked_until DATETIME(3) NULL,
last_login_at DATETIME(3) NULL,
last_active_at DATETIME(3) NULL,
create_time DATETIME(3) NOT NULL,
update_time DATETIME(3) NOT NULL,
UNIQUE KEY uk_tms_role_account_role_code (role_code)
@ -281,6 +274,7 @@ CREATE TABLE IF NOT EXISTS tms_auth_user_account (
password_hash VARCHAR(256) NOT NULL,
password_salt VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
need_change_password TINYINT(1) NOT NULL DEFAULT 1,
failed_count INT NOT NULL DEFAULT 0,
locked_until DATETIME(3) NULL,
last_login_at DATETIME(3) NULL,
@ -291,11 +285,32 @@ CREATE TABLE IF NOT EXISTS tms_auth_user_account (
KEY idx_tms_auth_user_account_role_code (role_code)
);
CREATE TABLE IF NOT EXISTS tms_auth_full_account (
id BIGINT PRIMARY KEY,
role_code VARCHAR(64) NOT NULL,
uid INT NOT NULL,
account_name VARCHAR(64) NOT NULL,
display_name VARCHAR(128) NOT NULL,
password_hash VARCHAR(256) NOT NULL,
password_salt VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
need_change_password TINYINT(1) NOT NULL DEFAULT 1,
failed_count INT NOT NULL DEFAULT 0,
locked_until DATETIME(3) NULL,
last_login_at DATETIME(3) NULL,
last_active_at DATETIME(3) NULL,
create_time DATETIME(3) NOT NULL,
update_time DATETIME(3) NOT NULL,
UNIQUE KEY uk_tms_auth_full_account_role_uid (role_code, uid),
UNIQUE KEY uk_tms_auth_full_account_name (account_name),
KEY idx_tms_auth_full_account_role_code (role_code)
);
CREATE TABLE IF NOT EXISTS tms_auth_session (
id BIGINT PRIMARY KEY,
session_token VARCHAR(128) NOT NULL,
role_code VARCHAR(64) NOT NULL,
authenticated_users_json JSON NULL,
authenticated_principals_json JSON NULL,
auth_method VARCHAR(32) NOT NULL,
auth_level VARCHAR(32) NOT NULL,
issued_at DATETIME(3) NOT NULL,
@ -379,14 +394,7 @@ INSERT IGNORE INTO tms_role_account (
role_code,
display_name,
required_ukey_count,
password_hash,
password_salt,
status,
need_change_password,
failed_count,
locked_until,
last_login_at,
last_active_at,
create_time,
update_time
)
@ -396,14 +404,7 @@ VALUES
'SUPER_ADMIN',
'超级管理员',
2,
'Viuew8WE+qziUZwGGU0x25cUpzsTF+QvQFU0uhb+yVA=',
'init-super-admin-salt-20260325',
'UNENABLED',
1,
0,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3)
),
@ -412,14 +413,7 @@ VALUES
'KEY_ADMIN',
'密钥管理员',
1,
'/xqmDy3A/q9X5p7XWznIPSBabQ6bxTLAPgtHQ6Be33c=',
'init-key-admin-salt-20260325',
'ACTIVE',
1,
0,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3)
),
@ -428,14 +422,7 @@ VALUES
'AUDIT_ADMIN',
'审计管理员',
1,
'j/J1LKyUOzVEJfbbHe2dwKBKNdabRk502olk/LWIwhg=',
'init-audit-admin-salt-20260325',
'UNENABLED',
1,
0,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3)
),
@ -444,14 +431,7 @@ VALUES
'OPS_ADMIN',
'运维管理员',
1,
'2e9F4aCgsDb+AuD1LrBEqEg8yjy3ODo27UHtx06vj/A=',
'init-ops-admin-salt-20260325',
'UNENABLED',
1,
0,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3)
);
@ -464,6 +444,7 @@ INSERT IGNORE INTO tms_auth_user_account (
password_hash,
password_salt,
status,
need_change_password,
failed_count,
locked_until,
last_login_at,
@ -480,6 +461,7 @@ VALUES
'Viuew8WE+qziUZwGGU0x25cUpzsTF+QvQFU0uhb+yVA=',
'init-super-admin-salt-20260325',
'ACTIVE',
1,
0,
NULL,
NULL,
@ -495,6 +477,7 @@ VALUES
'Viuew8WE+qziUZwGGU0x25cUpzsTF+QvQFU0uhb+yVA=',
'init-super-admin-salt-20260325',
'ACTIVE',
1,
0,
NULL,
NULL,
@ -510,6 +493,7 @@ VALUES
'/xqmDy3A/q9X5p7XWznIPSBabQ6bxTLAPgtHQ6Be33c=',
'init-key-admin-salt-20260325',
'ACTIVE',
1,
0,
NULL,
NULL,
@ -525,6 +509,7 @@ VALUES
'j/J1LKyUOzVEJfbbHe2dwKBKNdabRk502olk/LWIwhg=',
'init-audit-admin-salt-20260325',
'ACTIVE',
1,
0,
NULL,
NULL,
@ -540,6 +525,111 @@ VALUES
'2e9F4aCgsDb+AuD1LrBEqEg8yjy3ODo27UHtx06vj/A=',
'init-ops-admin-salt-20260325',
'ACTIVE',
1,
0,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3)
);
INSERT IGNORE INTO tms_auth_full_account (
id,
role_code,
uid,
account_name,
display_name,
password_hash,
password_salt,
status,
need_change_password,
failed_count,
locked_until,
last_login_at,
last_active_at,
create_time,
update_time
)
VALUES
(
12101,
'SUPER_ADMIN',
1,
'super-admin-full-01',
'超级管理员UKey席位一',
'Viuew8WE+qziUZwGGU0x25cUpzsTF+QvQFU0uhb+yVA=',
'init-super-admin-salt-20260325',
'ACTIVE',
1,
0,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3)
),
(
12102,
'SUPER_ADMIN',
2,
'super-admin-full-02',
'超级管理员UKey席位二',
'Viuew8WE+qziUZwGGU0x25cUpzsTF+QvQFU0uhb+yVA=',
'init-super-admin-salt-20260325',
'ACTIVE',
1,
0,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3)
),
(
12103,
'KEY_ADMIN',
1,
'key-admin-full-01',
'密钥管理员UKey席位',
'/xqmDy3A/q9X5p7XWznIPSBabQ6bxTLAPgtHQ6Be33c=',
'init-key-admin-salt-20260325',
'ACTIVE',
1,
0,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3)
),
(
12104,
'AUDIT_ADMIN',
1,
'audit-admin-full-01',
'审计管理员UKey席位',
'j/J1LKyUOzVEJfbbHe2dwKBKNdabRk502olk/LWIwhg=',
'init-audit-admin-salt-20260325',
'ACTIVE',
1,
0,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP(3),
CURRENT_TIMESTAMP(3)
),
(
12105,
'OPS_ADMIN',
1,
'ops-admin-full-01',
'运维管理员UKey席位',
'2e9F4aCgsDb+AuD1LrBEqEg8yjy3ODo27UHtx06vj/A=',
'init-ops-admin-salt-20260325',
'ACTIVE',
1,
0,
NULL,
NULL,

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.cisd.tms.modules.auth.mapper.AuthFullAccountMapper">
<resultMap id="AuthFullAccountResultMap" type="com.cisd.tms.modules.auth.entity.AuthFullAccountEntity">
<id property="id" column="id"/>
<result property="roleCode" column="role_code"/>
<result property="uid" column="uid"/>
<result property="accountName" column="account_name"/>
<result property="displayName" column="display_name"/>
<result property="passwordHash" column="password_hash"/>
<result property="passwordSalt" column="password_salt"/>
<result property="status" column="status"/>
<result property="needChangePassword" column="need_change_password"/>
<result property="failedCount" column="failed_count"/>
<result property="lockedUntil" column="locked_until"/>
<result property="lastLoginAt" column="last_login_at"/>
<result property="lastActiveAt" column="last_active_at"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<select id="selectByRoleCodeAndUid" resultMap="AuthFullAccountResultMap">
SELECT id,
role_code,
uid,
account_name,
display_name,
password_hash,
password_salt,
status,
need_change_password,
failed_count,
locked_until,
last_login_at,
last_active_at,
create_time,
update_time
FROM tms_auth_full_account
WHERE role_code = #{roleCode}
AND uid = #{uid}
LIMIT 1
</select>
<select id="selectByRoleCode" resultMap="AuthFullAccountResultMap">
SELECT id,
role_code,
uid,
account_name,
display_name,
password_hash,
password_salt,
status,
need_change_password,
failed_count,
locked_until,
last_login_at,
last_active_at,
create_time,
update_time
FROM tms_auth_full_account
WHERE role_code = #{roleCode}
ORDER BY uid ASC
</select>
</mapper>

View File

@ -8,7 +8,7 @@
<id property="id" column="id"/>
<result property="sessionToken" column="session_token"/>
<result property="roleCode" column="role_code"/>
<result property="authenticatedUsersJson" column="authenticated_users_json"/>
<result property="authenticatedPrincipalsJson" column="authenticated_principals_json"/>
<result property="authMethod" column="auth_method"/>
<result property="authLevel" column="auth_level"/>
<result property="issuedAt" column="issued_at"/>
@ -23,7 +23,7 @@
SELECT id,
session_token,
role_code,
authenticated_users_json,
authenticated_principals_json,
auth_method,
auth_level,
issued_at,
@ -36,4 +36,47 @@
WHERE session_token = #{sessionToken}
LIMIT 1
</select>
<select id="selectActiveByRoleCodeAndAuthLevel" resultMap="AuthSessionResultMap">
SELECT id,
session_token,
role_code,
authenticated_principals_json,
auth_method,
auth_level,
issued_at,
last_active_at,
expires_at,
logout_at,
create_time,
update_time
FROM tms_auth_session
WHERE role_code = #{roleCode}
AND auth_level = #{authLevel}
AND logout_at IS NULL
AND expires_at IS NOT NULL
AND expires_at > #{now}
</select>
<select id="selectActiveByRoleCodeAndAuthLevelAndAuthenticatedPrincipalsJson" resultMap="AuthSessionResultMap">
SELECT id,
session_token,
role_code,
authenticated_principals_json,
auth_method,
auth_level,
issued_at,
last_active_at,
expires_at,
logout_at,
create_time,
update_time
FROM tms_auth_session
WHERE role_code = #{roleCode}
AND auth_level = #{authLevel}
AND authenticated_principals_json = #{authenticatedPrincipalsJson}
AND logout_at IS NULL
AND expires_at IS NOT NULL
AND expires_at > #{now}
</select>
</mapper>

View File

@ -12,6 +12,7 @@
<result property="passwordHash" column="password_hash"/>
<result property="passwordSalt" column="password_salt"/>
<result property="status" column="status"/>
<result property="needChangePassword" column="need_change_password"/>
<result property="failedCount" column="failed_count"/>
<result property="lockedUntil" column="locked_until"/>
<result property="lastLoginAt" column="last_login_at"/>
@ -28,6 +29,7 @@
password_hash,
password_salt,
status,
need_change_password,
failed_count,
locked_until,
last_login_at,
@ -47,6 +49,7 @@
password_hash,
password_salt,
status,
need_change_password,
failed_count,
locked_until,
last_login_at,

View File

@ -9,14 +9,7 @@
<result property="roleCode" column="role_code"/>
<result property="displayName" column="display_name"/>
<result property="requiredUkeyCount" column="required_ukey_count"/>
<result property="passwordHash" column="password_hash"/>
<result property="passwordSalt" column="password_salt"/>
<result property="status" column="status"/>
<result property="needChangePassword" column="need_change_password"/>
<result property="failedCount" column="failed_count"/>
<result property="lockedUntil" column="locked_until"/>
<result property="lastLoginAt" column="last_login_at"/>
<result property="lastActiveAt" column="last_active_at"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
</resultMap>
@ -26,14 +19,7 @@
role_code,
display_name,
required_ukey_count,
password_hash,
password_salt,
status,
need_change_password,
failed_count,
locked_until,
last_login_at,
last_active_at,
create_time,
update_time
FROM tms_role_account

View File

@ -92,7 +92,7 @@ class AuthControllerTest {
AuthService authService = Mockito.mock(AuthService.class);
AuthAdminService authAdminService = Mockito.mock(AuthAdminService.class);
LoginResponse response = new LoginResponse();
response.setRoleCode("KEY_ADMIN");
response.setRoleCode("SUPER_ADMIN");
response.setAuthLevel("FULL");
response.setToken("token-ukey-001");
Mockito.when(authService.ukeyLogin(ArgumentMatchers.any())).thenReturn(response);
@ -106,12 +106,12 @@ class AuthControllerTest {
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"roleCode": "KEY_ADMIN",
"rolePassword": "12345678",
"ukeyProofs": [
"roleCode": "SUPER_ADMIN",
"loginFactors": [
{
"pubKey": "PUB-1",
"uid": 1,
"password": "11111111",
"serverRandom": "RB-1",
"issueSignature": "ISSUE-1",
"loginPayload": "LOGIN-DATA-1",
@ -120,6 +120,7 @@ class AuthControllerTest {
{
"pubKey": "PUB-2",
"uid": 2,
"password": "22222222",
"serverRandom": "RB-2",
"issueSignature": "ISSUE-2",
"loginPayload": "LOGIN-DATA-2",
@ -198,7 +199,7 @@ class AuthControllerTest {
}
@Test
void shouldChangePasswordThroughCurrentSessionEndpoint() throws Exception {
void shouldChangeFullAccountPasswordThroughCurrentSessionEndpoint() throws Exception {
AuthService authService = Mockito.mock(AuthService.class);
AuthAdminService authAdminService = Mockito.mock(AuthAdminService.class);
@ -207,7 +208,7 @@ class AuthControllerTest {
.setControllerAdvice(new GlobalExceptionHandler())
.build();
mockMvc.perform(post("/api/v1/auth/change-password")
mockMvc.perform(post("/api/v1/auth/full-accounts/1/change-password")
.requestAttr(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN, "token-change-001")
.contentType(MediaType.APPLICATION_JSON)
.content("""
@ -219,7 +220,32 @@ class AuthControllerTest {
.andExpect(status().isOk())
.andExpect(content().string(containsString("\"success\":true")));
Mockito.verify(authService).changePassword("token-change-001", "12345678", "87654321");
Mockito.verify(authService).changeFullAccountPassword("token-change-001", 1, "12345678", "87654321");
}
@Test
void shouldChangeLimitedAccountPasswordThroughCurrentSessionEndpoint() 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/limited-accounts/audit-admin-01/change-password")
.requestAttr(InternalApiAuthInterceptor.ATTR_SESSION_TOKEN, "token-change-limited-001")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"currentPassword": "12345678",
"newPassword": "87654321"
}
"""))
.andExpect(status().isOk())
.andExpect(content().string(containsString("\"success\":true")));
Mockito.verify(authService).changeLimitedAccountPassword("token-change-limited-001", "audit-admin-01", "12345678", "87654321");
}
@Test

View File

@ -1,11 +1,13 @@
package com.cisd.tms.modules.auth.service;
import com.cisd.tms.modules.auth.entity.AuthUserAccountEntity;
import com.cisd.tms.modules.auth.entity.AuthFullAccountEntity;
import com.cisd.tms.modules.auth.entity.RoleAccountEntity;
import com.cisd.tms.modules.auth.entity.RoleUkeyBindingEntity;
import com.cisd.tms.modules.auth.enums.AuthLevel;
import com.cisd.tms.modules.auth.enums.RoleAccountStatus;
import com.cisd.tms.modules.auth.enums.RoleCode;
import com.cisd.tms.modules.auth.repository.AuthFullAccountRepository;
import com.cisd.tms.modules.auth.repository.AuthUserAccountRepository;
import com.cisd.tms.modules.auth.repository.RoleAccountRepository;
import com.cisd.tms.modules.auth.repository.RoleUkeyBindingRepository;
@ -36,11 +38,18 @@ class AuthAdminServiceTest {
@Test
void shouldEnableRoleWhenOperatorIsKeyAdminWithFullSession() {
InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository();
InMemoryAuthFullAccountRepository fullAccounts = new InMemoryAuthFullAccountRepository();
InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository();
roleAccounts.save(role(RoleCode.AUDIT_ADMIN, RoleAccountStatus.UNENABLED));
fullAccounts.save(fullAccount(RoleCode.AUDIT_ADMIN, 1, "audit-admin-full-01", "OLD-FULL-HASH-1", "OLD-FULL-SALT-1"));
userAccounts.save(user("audit-admin-01", RoleCode.AUDIT_ADMIN, "OLD-HASH-1", "OLD-SALT-1"));
fullAccounts.findByRoleCodeAndUid(RoleCode.AUDIT_ADMIN.getCode(), 1).orElseThrow().setStatus(RoleAccountStatus.UNENABLED.name());
userAccounts.findByUsername("audit-admin-01").orElseThrow().setStatus(RoleAccountStatus.UNENABLED.name());
AuthAdminService service = newAuthAdminService(
roleAccounts,
new InMemoryAuthUserAccountRepository(),
fullAccounts,
userAccounts,
new InMemoryRoleUkeyBindingRepository(),
FIXED_CLOCK,
new FixedSaltSupplier("salt-001")
@ -49,16 +58,29 @@ class AuthAdminServiceTest {
service.enableRole(RoleCode.KEY_ADMIN.getCode(), AuthLevel.FULL.name(), RoleCode.AUDIT_ADMIN.getCode());
RoleAccountEntity updated = roleAccounts.findByRoleCode(RoleCode.AUDIT_ADMIN.getCode()).orElseThrow();
AuthFullAccountEntity fullAccount = fullAccounts.findByRoleCodeAndUid(RoleCode.AUDIT_ADMIN.getCode(), 1).orElseThrow();
AuthUserAccountEntity userAccount = userAccounts.findByUsername("audit-admin-01").orElseThrow();
Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), updated.getStatus());
Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), fullAccount.getStatus());
Assertions.assertTrue(Boolean.TRUE.equals(fullAccount.getNeedChangePassword()));
Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), userAccount.getStatus());
Assertions.assertTrue(Boolean.TRUE.equals(userAccount.getNeedChangePassword()));
}
@Test
void shouldResetPasswordForAllAccountsUnderRole() {
InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository();
InMemoryAuthFullAccountRepository fullAccounts = new InMemoryAuthFullAccountRepository();
InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository();
roleAccounts.save(role(RoleCode.SUPER_ADMIN, RoleAccountStatus.ACTIVE, "OLD-ROLE-HASH", "OLD-ROLE-SALT"));
roleAccounts.save(role(RoleCode.SUPER_ADMIN, RoleAccountStatus.ACTIVE));
fullAccounts.save(fullAccount(RoleCode.SUPER_ADMIN, 1, "super-admin-full-01", "OLD-FULL-HASH-1", "OLD-FULL-SALT-1"));
fullAccounts.save(fullAccount(RoleCode.SUPER_ADMIN, 2, "super-admin-full-02", "OLD-FULL-HASH-2", "OLD-FULL-SALT-2"));
userAccounts.save(user("super-admin-01", RoleCode.SUPER_ADMIN, "OLD-HASH-1", "OLD-SALT-1"));
userAccounts.save(user("super-admin-02", RoleCode.SUPER_ADMIN, "OLD-HASH-2", "OLD-SALT-2"));
fullAccounts.findByRoleCodeAndUid(RoleCode.SUPER_ADMIN.getCode(), 1).orElseThrow().setFailedCount(2);
fullAccounts.findByRoleCodeAndUid(RoleCode.SUPER_ADMIN.getCode(), 1).orElseThrow().setLockedUntil(LocalDateTime.of(2026, 3, 23, 3, 20));
fullAccounts.findByRoleCodeAndUid(RoleCode.SUPER_ADMIN.getCode(), 2).orElseThrow().setFailedCount(5);
fullAccounts.findByRoleCodeAndUid(RoleCode.SUPER_ADMIN.getCode(), 2).orElseThrow().setLockedUntil(LocalDateTime.of(2026, 3, 23, 3, 25));
userAccounts.findByUsername("super-admin-01").orElseThrow().setFailedCount(3);
userAccounts.findByUsername("super-admin-01").orElseThrow().setLockedUntil(LocalDateTime.of(2026, 3, 23, 3, 30));
userAccounts.findByUsername("super-admin-02").orElseThrow().setFailedCount(5);
@ -66,22 +88,37 @@ class AuthAdminServiceTest {
AuthAdminService service = newAuthAdminService(
roleAccounts,
fullAccounts,
userAccounts,
new InMemoryRoleUkeyBindingRepository(),
FIXED_CLOCK,
new FixedSaltSupplier("salt-role-001", "salt-002", "salt-003")
new FixedSaltSupplier("salt-full-001", "salt-full-002", "salt-002", "salt-003")
);
service.resetPassword(RoleCode.KEY_ADMIN.getCode(), AuthLevel.FULL.name(), RoleCode.SUPER_ADMIN.getCode());
RoleAccountEntity role = roleAccounts.findByRoleCode(RoleCode.SUPER_ADMIN.getCode()).orElseThrow();
AuthFullAccountEntity fullFirst = fullAccounts.findByRoleCodeAndUid(RoleCode.SUPER_ADMIN.getCode(), 1).orElseThrow();
AuthFullAccountEntity fullSecond = fullAccounts.findByRoleCodeAndUid(RoleCode.SUPER_ADMIN.getCode(), 2).orElseThrow();
AuthUserAccountEntity first = userAccounts.findByUsername("super-admin-01").orElseThrow();
AuthUserAccountEntity second = userAccounts.findByUsername("super-admin-02").orElseThrow();
Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), role.getStatus());
Assertions.assertEquals("salt-role-001", role.getPasswordSalt());
Assertions.assertEquals("HASH:12345678:salt-role-001", role.getPasswordHash());
Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), fullFirst.getStatus());
Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), fullSecond.getStatus());
Assertions.assertTrue(Boolean.TRUE.equals(fullFirst.getNeedChangePassword()));
Assertions.assertTrue(Boolean.TRUE.equals(fullSecond.getNeedChangePassword()));
Assertions.assertEquals(0, fullFirst.getFailedCount());
Assertions.assertEquals(0, fullSecond.getFailedCount());
Assertions.assertNull(fullFirst.getLockedUntil());
Assertions.assertNull(fullSecond.getLockedUntil());
Assertions.assertEquals("salt-full-001", fullFirst.getPasswordSalt());
Assertions.assertEquals("HASH:12345678:salt-full-001", fullFirst.getPasswordHash());
Assertions.assertEquals("salt-full-002", fullSecond.getPasswordSalt());
Assertions.assertEquals("HASH:12345678:salt-full-002", fullSecond.getPasswordHash());
Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), first.getStatus());
Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), second.getStatus());
Assertions.assertTrue(Boolean.TRUE.equals(first.getNeedChangePassword()));
Assertions.assertTrue(Boolean.TRUE.equals(second.getNeedChangePassword()));
Assertions.assertEquals(0, first.getFailedCount());
Assertions.assertEquals(0, second.getFailedCount());
Assertions.assertNull(first.getLockedUntil());
@ -101,6 +138,7 @@ class AuthAdminServiceTest {
AuthAdminService service = new AuthAdminServiceImpl(
new InMemoryRoleAccountRepository(),
fullAccountRepository(binding(RoleCode.SUPER_ADMIN, 1, "UK-OLD", "PUB-OLD", "SIG-OLD")),
new InMemoryAuthUserAccountRepository(),
bindings,
new FakePasswordHasher(),
@ -136,6 +174,7 @@ class AuthAdminServiceTest {
AuthAdminService service = new AuthAdminServiceImpl(
new InMemoryRoleAccountRepository(),
fullAccountRepository(),
new InMemoryAuthUserAccountRepository(),
new InMemoryRoleUkeyBindingRepository(),
new FakePasswordHasher(),
@ -165,14 +204,6 @@ class AuthAdminServiceTest {
return entity;
}
private static RoleAccountEntity role(RoleCode roleCode, RoleAccountStatus status, String passwordHash, String passwordSalt) {
RoleAccountEntity entity = role(roleCode, status);
entity.setPasswordHash(passwordHash);
entity.setPasswordSalt(passwordSalt);
entity.setFailedCount(0);
return entity;
}
private static AuthUserAccountEntity user(String username, RoleCode roleCode, String passwordHash, String passwordSalt) {
AuthUserAccountEntity entity = new AuthUserAccountEntity();
entity.setId((long) (username.hashCode() & Integer.MAX_VALUE));
@ -182,6 +213,22 @@ class AuthAdminServiceTest {
entity.setPasswordHash(passwordHash);
entity.setPasswordSalt(passwordSalt);
entity.setStatus(RoleAccountStatus.ACTIVE.name());
entity.setNeedChangePassword(Boolean.FALSE);
entity.setFailedCount(0);
return entity;
}
private static AuthFullAccountEntity fullAccount(RoleCode roleCode, int uid, String accountName, String passwordHash, String passwordSalt) {
AuthFullAccountEntity entity = new AuthFullAccountEntity();
entity.setId((long) (accountName.hashCode() & Integer.MAX_VALUE));
entity.setRoleCode(roleCode.getCode());
entity.setUid(uid);
entity.setAccountName(accountName);
entity.setDisplayName(accountName);
entity.setPasswordHash(passwordHash);
entity.setPasswordSalt(passwordSalt);
entity.setStatus(RoleAccountStatus.ACTIVE.name());
entity.setNeedChangePassword(Boolean.FALSE);
entity.setFailedCount(0);
return entity;
}
@ -277,6 +324,33 @@ class AuthAdminServiceTest {
}
}
private static class InMemoryAuthFullAccountRepository implements AuthFullAccountRepository {
private final Map<String, AuthFullAccountEntity> store = new ConcurrentHashMap<>();
@Override
public Optional<AuthFullAccountEntity> findByRoleCodeAndUid(String roleCode, Integer uid) {
return Optional.ofNullable(store.get(roleCode + "#" + uid));
}
@Override
public List<AuthFullAccountEntity> findByRoleCode(String roleCode) {
return store.values().stream()
.filter(entity -> roleCode.equals(entity.getRoleCode()))
.sorted(Comparator.comparing(AuthFullAccountEntity::getUid))
.toList();
}
@Override
public void save(AuthFullAccountEntity entity) {
store.put(entity.getRoleCode() + "#" + entity.getUid(), entity);
}
@Override
public void update(AuthFullAccountEntity entity) {
store.put(entity.getRoleCode() + "#" + entity.getUid(), entity);
}
}
private static class InMemoryRoleUkeyBindingRepository implements RoleUkeyBindingRepository {
private final Map<String, List<RoleUkeyBindingEntity>> store = new ConcurrentHashMap<>();
@ -311,6 +385,7 @@ class AuthAdminServiceTest {
private static AuthAdminService newAuthAdminService(
InMemoryRoleAccountRepository roleAccounts,
InMemoryAuthFullAccountRepository fullAccounts,
InMemoryAuthUserAccountRepository userAccounts,
InMemoryRoleUkeyBindingRepository bindings,
Clock clock,
@ -318,6 +393,7 @@ class AuthAdminServiceTest {
) {
return new AuthAdminServiceImpl(
roleAccounts,
fullAccounts,
userAccounts,
bindings,
new FakePasswordHasher(),
@ -327,4 +403,18 @@ class AuthAdminServiceTest {
saltGenerator
);
}
private static InMemoryAuthFullAccountRepository fullAccountRepository(RoleUkeyBindingEntity... bindings) {
InMemoryAuthFullAccountRepository repository = new InMemoryAuthFullAccountRepository();
for (RoleUkeyBindingEntity binding : bindings) {
repository.save(fullAccount(
RoleCode.valueOf(binding.getRoleCode()),
binding.getUid(),
binding.getRoleCode().toLowerCase() + "-full-" + binding.getUid(),
"HASH:12345678:SALT",
"SALT"
));
}
return repository;
}
}

View File

@ -12,6 +12,7 @@ import com.cisd.tms.modules.auth.dto.UkeyLoginProof;
import com.cisd.tms.modules.auth.dto.UkeyLoginRandomRequest;
import com.cisd.tms.modules.auth.dto.UkeyLoginRandomResponse;
import com.cisd.tms.modules.auth.dto.UkeyLoginRequest;
import com.cisd.tms.modules.auth.entity.AuthFullAccountEntity;
import com.cisd.tms.modules.auth.entity.AuthSessionEntity;
import com.cisd.tms.modules.auth.entity.AuthUserEntity;
import com.cisd.tms.modules.auth.entity.AuthUserAccountEntity;
@ -22,6 +23,7 @@ import com.cisd.tms.modules.auth.enums.AuthMethod;
import com.cisd.tms.modules.auth.enums.RoleAccountStatus;
import com.cisd.tms.modules.auth.enums.RoleCode;
import com.cisd.tms.modules.auth.repository.AuthSessionRepository;
import com.cisd.tms.modules.auth.repository.AuthFullAccountRepository;
import com.cisd.tms.modules.auth.repository.AuthUserAccountRepository;
import com.cisd.tms.modules.auth.repository.AuthUserRepository;
import com.cisd.tms.modules.auth.repository.RoleAccountRepository;
@ -53,12 +55,13 @@ class AuthServiceTest {
InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository();
InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository();
InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository();
roleAccounts.save(activeRole(RoleCode.AUDIT_ADMIN, true));
userAccounts.save(activeUserAccount("audit-admin-01", RoleCode.AUDIT_ADMIN, "12345678", "SALT-A", 0, null));
roleAccounts.save(activeRole(RoleCode.AUDIT_ADMIN));
userAccounts.save(activeUserAccount("audit-admin-01", RoleCode.AUDIT_ADMIN, "12345678", "SALT-A", 0, null, true));
AuthService service = newAuthService(
roleAccounts,
userAccounts,
new InMemoryAuthFullAccountRepository(),
sessions,
new InMemoryRoleUkeyBindingRepository(),
FIXED_CLOCK,
@ -77,7 +80,7 @@ class AuthServiceTest {
Assertions.assertTrue(response.getNeedChangePassword());
AuthSessionEntity session = sessions.findBySessionToken("token-limited-001").orElseThrow();
Assertions.assertEquals(AuthMethod.PASSWORD.name(), session.getAuthMethod());
Assertions.assertEquals("[\"audit-admin-01\"]", session.getAuthenticatedUsersJson());
Assertions.assertEquals("[{\"type\":\"LIMITED\",\"uid\":null,\"username\":\"audit-admin-01\"}]", session.getAuthenticatedPrincipalsJson());
}
@Test
@ -91,6 +94,7 @@ class AuthServiceTest {
AuthService service = newAuthService(
roleAccounts,
userAccounts,
new InMemoryAuthFullAccountRepository(),
new InMemoryAuthSessionRepository(),
new InMemoryRoleUkeyBindingRepository(),
FIXED_CLOCK,
@ -117,6 +121,7 @@ class AuthServiceTest {
AuthService service = newAuthService(
roleAccounts,
userAccounts,
new InMemoryAuthFullAccountRepository(),
new InMemoryAuthSessionRepository(),
new InMemoryRoleUkeyBindingRepository(),
FIXED_CLOCK,
@ -145,13 +150,18 @@ class AuthServiceTest {
@Test
void shouldKeepMeEndpointRoleScoped() {
InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository();
InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository();
InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository();
roleAccounts.save(activeRole(RoleCode.AUDIT_ADMIN, true));
sessions.save(session("token-me-001", RoleCode.AUDIT_ADMIN.getCode(), AuthLevel.LIMITED.name()));
roleAccounts.save(activeRole(RoleCode.AUDIT_ADMIN));
userAccounts.save(activeUserAccount("audit-admin-01", RoleCode.AUDIT_ADMIN, "12345678", "SALT-A", 0, null, true));
AuthSessionEntity session = session("token-me-001", RoleCode.AUDIT_ADMIN.getCode(), AuthLevel.LIMITED.name());
session.setAuthenticatedPrincipalsJson("[{\"type\":\"LIMITED\",\"uid\":null,\"username\":\"audit-admin-01\"}]");
sessions.save(session);
AuthService service = newAuthService(
roleAccounts,
new InMemoryAuthUserAccountRepository(),
userAccounts,
new InMemoryAuthFullAccountRepository(),
sessions,
new InMemoryRoleUkeyBindingRepository(),
FIXED_CLOCK,
@ -168,44 +178,87 @@ class AuthServiceTest {
}
@Test
void shouldChangeCurrentRolePasswordForActiveSession() {
void shouldChangeCurrentFullAccountPasswordForActiveSession() {
InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository();
InMemoryAuthFullAccountRepository fullAccounts = new InMemoryAuthFullAccountRepository();
InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository();
roleAccounts.save(activeRole(RoleCode.KEY_ADMIN, "12345678", "ROLE-SALT-K", 0, null, true));
sessions.save(session("token-change-001", RoleCode.KEY_ADMIN.getCode(), AuthLevel.FULL.name()));
roleAccounts.save(activeRole(RoleCode.KEY_ADMIN));
fullAccounts.save(activeFullAccount(RoleCode.KEY_ADMIN, 1, "key-admin-full-01", "12345678", "FULL-SALT-K", 0, null, true));
AuthSessionEntity session = session("token-change-001", RoleCode.KEY_ADMIN.getCode(), AuthLevel.FULL.name());
session.setAuthenticatedPrincipalsJson("[{\"type\":\"FULL\",\"uid\":1,\"username\":null}]");
sessions.save(session);
AuthService service = newAuthService(
roleAccounts,
new InMemoryAuthUserAccountRepository(),
fullAccounts,
sessions,
new InMemoryRoleUkeyBindingRepository(),
FIXED_CLOCK,
() -> "unused"
);
service.changePassword("token-change-001", "12345678", "87654321");
service.changeFullAccountPassword("token-change-001", 1, "12345678", "87654321");
RoleAccountEntity changed = roleAccounts.findByRoleCode(RoleCode.KEY_ADMIN.getCode()).orElseThrow();
AuthFullAccountEntity changed = fullAccounts.findByRoleCodeAndUid(RoleCode.KEY_ADMIN.getCode(), 1).orElseThrow();
Assertions.assertEquals("salt-test", changed.getPasswordSalt());
Assertions.assertEquals("HASH:87654321:salt-test", changed.getPasswordHash());
Assertions.assertEquals(0, changed.getFailedCount());
Assertions.assertNull(changed.getLockedUntil());
Assertions.assertFalse(Boolean.TRUE.equals(changed.getNeedChangePassword()));
Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), changed.getLastLoginAt());
Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), changed.getLastActiveAt());
}
@Test
void shouldThrowSessionInvalidWhenChangingPasswordWithExpiredSession() {
void shouldChangeCurrentLimitedAccountPasswordForActiveSession() {
InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository();
InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository();
InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository();
roleAccounts.save(activeRole(RoleCode.KEY_ADMIN, "12345678", "ROLE-SALT-K", 0, null, true));
roleAccounts.save(activeRole(RoleCode.AUDIT_ADMIN));
userAccounts.save(activeUserAccount("audit-admin-01", RoleCode.AUDIT_ADMIN, "12345678", "LIMITED-SALT-A", 0, null, true));
AuthSessionEntity session = session("token-change-limited-001", RoleCode.AUDIT_ADMIN.getCode(), AuthLevel.LIMITED.name());
session.setAuthenticatedPrincipalsJson("[{\"type\":\"LIMITED\",\"uid\":null,\"username\":\"audit-admin-01\"}]");
sessions.save(session);
AuthService service = newAuthService(
roleAccounts,
userAccounts,
new InMemoryAuthFullAccountRepository(),
sessions,
new InMemoryRoleUkeyBindingRepository(),
FIXED_CLOCK,
() -> "unused"
);
service.changeLimitedAccountPassword("token-change-limited-001", "audit-admin-01", "12345678", "87654321");
AuthUserAccountEntity changed = userAccounts.findByUsername("audit-admin-01").orElseThrow();
Assertions.assertEquals("salt-test", changed.getPasswordSalt());
Assertions.assertEquals("HASH:87654321:salt-test", changed.getPasswordHash());
Assertions.assertEquals(0, changed.getFailedCount());
Assertions.assertNull(changed.getLockedUntil());
Assertions.assertFalse(Boolean.TRUE.equals(changed.getNeedChangePassword()));
Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), changed.getLastLoginAt());
Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), changed.getLastActiveAt());
}
@Test
void shouldThrowSessionInvalidWhenChangingFullAccountPasswordWithExpiredSession() {
InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository();
InMemoryAuthFullAccountRepository fullAccounts = new InMemoryAuthFullAccountRepository();
InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository();
roleAccounts.save(activeRole(RoleCode.KEY_ADMIN));
fullAccounts.save(activeFullAccount(RoleCode.KEY_ADMIN, 1, "key-admin-full-01", "12345678", "FULL-SALT-K", 0, null));
AuthSessionEntity expiredSession = session("token-expired-001", RoleCode.KEY_ADMIN.getCode(), AuthLevel.FULL.name());
expiredSession.setAuthenticatedPrincipalsJson("[{\"type\":\"FULL\",\"uid\":1,\"username\":null}]");
expiredSession.setExpiresAt(LocalDateTime.of(2026, 3, 23, 1, 59));
sessions.save(expiredSession);
AuthService service = newAuthService(
roleAccounts,
new InMemoryAuthUserAccountRepository(),
fullAccounts,
sessions,
new InMemoryRoleUkeyBindingRepository(),
FIXED_CLOCK,
@ -213,7 +266,7 @@ class AuthServiceTest {
);
BizException exception = Assertions.assertThrows(BizException.class,
() -> service.changePassword("token-expired-001", "12345678", "87654321"));
() -> service.changeFullAccountPassword("token-expired-001", 1, "12345678", "87654321"));
Assertions.assertEquals(ErrorCode.SESSION_INVALID.getCode(), exception.getCode());
Assertions.assertEquals("session expired", exception.getMessage());
@ -233,6 +286,7 @@ class AuthServiceTest {
roleAccounts,
new InMemoryLegacyAuthUserRepository(),
userAccounts,
new InMemoryAuthFullAccountRepository(),
new InMemoryAuthSessionRepository(),
new InMemoryRoleUkeyBindingRepository(),
new FakePasswordHasher(),
@ -262,13 +316,93 @@ class AuthServiceTest {
}
@Test
void shouldVerifyUkeyProofsBeforeCreatingFullSession() {
void shouldKickPreviousLimitedSessionForSameRoleAndSameAccounts() {
InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository();
InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository();
InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository();
roleAccounts.save(activeRole(RoleCode.SUPER_ADMIN));
userAccounts.save(activeUserAccount("super-admin-01", RoleCode.SUPER_ADMIN, "11111111", "SALT-S1", 0, null));
userAccounts.save(activeUserAccount("super-admin-02", RoleCode.SUPER_ADMIN, "22222222", "SALT-S2", 0, null));
AuthSessionEntity previous = session("token-limited-old", RoleCode.SUPER_ADMIN.getCode(), AuthLevel.LIMITED.name());
previous.setAuthenticatedPrincipalsJson("[{\"type\":\"LIMITED\",\"uid\":null,\"username\":\"super-admin-01\"},{\"type\":\"LIMITED\",\"uid\":null,\"username\":\"super-admin-02\"}]");
sessions.save(previous);
AuthService service = newAuthService(
roleAccounts,
userAccounts,
new InMemoryAuthFullAccountRepository(),
sessions,
new InMemoryRoleUkeyBindingRepository(),
FIXED_CLOCK,
new SequenceSessionTokenGenerator("token-limited-new")
);
LoginRequest request = new LoginRequest();
request.setRoleCode(RoleCode.SUPER_ADMIN.getCode());
request.setAccounts(List.of(
account("super-admin-02", "22222222"),
account("super-admin-01", "11111111")
));
LoginResponse response = service.login(request);
Assertions.assertEquals("token-limited-new", response.getToken());
AuthSessionEntity expired = sessions.findBySessionToken("token-limited-old").orElseThrow();
Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), expired.getLogoutAt());
Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), expired.getExpiresAt());
AuthSessionEntity current = sessions.findBySessionToken("token-limited-new").orElseThrow();
Assertions.assertEquals("[{\"type\":\"LIMITED\",\"uid\":null,\"username\":\"super-admin-01\"},{\"type\":\"LIMITED\",\"uid\":null,\"username\":\"super-admin-02\"}]", current.getAuthenticatedPrincipalsJson());
}
@Test
void shouldKickPreviousFullSessionForSameRole() {
InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository();
InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository();
InMemoryAuthFullAccountRepository fullAccounts = new InMemoryAuthFullAccountRepository();
InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository();
InMemoryRoleUkeyBindingRepository ukeyBindings = new InMemoryRoleUkeyBindingRepository();
roleAccounts.save(activeRole(RoleCode.KEY_ADMIN, "12345678", "ROLE-SALT-K"));
userAccounts.save(activeUserAccount("key-admin-01", RoleCode.KEY_ADMIN, "12345678", "SALT-K", 0, null));
roleAccounts.save(activeRole(RoleCode.KEY_ADMIN));
fullAccounts.save(activeFullAccount(RoleCode.KEY_ADMIN, 1, "key-admin-full-01", "12345678", "SALT-K", 0, null));
ukeyBindings.save(activeBinding(RoleCode.KEY_ADMIN, 1, "UK-1", "PUB-1"));
AuthSessionEntity previous = session("token-full-old", RoleCode.KEY_ADMIN.getCode(), AuthLevel.FULL.name());
sessions.save(previous);
AuthService service = newAuthService(
roleAccounts,
userAccounts,
fullAccounts,
sessions,
ukeyBindings,
FIXED_CLOCK,
new SequenceSessionTokenGenerator("token-full-new")
);
LoginRequest request = new LoginRequest();
request.setRoleCode(RoleCode.KEY_ADMIN.getCode());
request.setFullAccounts(List.of(fullAccount(1, "12345678")));
request.setUkeySerials(List.of("UK-1"));
LoginResponse response = service.login(request);
Assertions.assertEquals("token-full-new", response.getToken());
AuthSessionEntity expired = sessions.findBySessionToken("token-full-old").orElseThrow();
Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), expired.getLogoutAt());
Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 0), expired.getExpiresAt());
AuthSessionEntity current = sessions.findBySessionToken("token-full-new").orElseThrow();
Assertions.assertEquals(AuthLevel.FULL.name(), current.getAuthLevel());
}
@Test
void shouldVerifyUkeyProofsBeforeCreatingFullSession() {
InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository();
InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository();
InMemoryAuthFullAccountRepository fullAccounts = new InMemoryAuthFullAccountRepository();
InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository();
InMemoryRoleUkeyBindingRepository ukeyBindings = new InMemoryRoleUkeyBindingRepository();
roleAccounts.save(activeRole(RoleCode.KEY_ADMIN));
fullAccounts.save(activeFullAccount(RoleCode.KEY_ADMIN, 1, "key-admin-full-01", "12345678", "SALT-K", 0, null));
ukeyBindings.save(activeBinding(RoleCode.KEY_ADMIN, 1, "UK-1", "PUB-1"));
LmkService lmkService = org.mockito.Mockito.mock(LmkService.class);
@ -282,6 +416,7 @@ class AuthServiceTest {
roleAccounts,
new InMemoryLegacyAuthUserRepository(),
userAccounts,
fullAccounts,
sessions,
ukeyBindings,
new FakePasswordHasher(),
@ -303,9 +438,8 @@ class AuthServiceTest {
UkeyLoginRequest request = new UkeyLoginRequest();
request.setRoleCode(RoleCode.KEY_ADMIN.getCode());
request.setRolePassword("12345678");
request.setUkeyProofs(List.of(
proof("PUB-1", 1, "RB-1", "ISSUE-1", "LOGIN-DATA-1", "LOGIN-SIGN-1")
request.setLoginFactors(List.of(
proofWithPassword("PUB-1", 1, "12345678", "RB-1", "ISSUE-1", "LOGIN-DATA-1", "LOGIN-SIGN-1")
));
LoginResponse response = service.ukeyLogin(request);
@ -321,12 +455,13 @@ class AuthServiceTest {
}
@Test
void shouldLockRolePasswordDuringUkeyLoginWhenRolePasswordIsWrong() {
void shouldLockFullAccountDuringUkeyLoginWhenSeatPasswordIsWrong() {
InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository();
InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository();
InMemoryAuthFullAccountRepository fullAccounts = new InMemoryAuthFullAccountRepository();
InMemoryRoleUkeyBindingRepository ukeyBindings = new InMemoryRoleUkeyBindingRepository();
roleAccounts.save(activeRole(RoleCode.KEY_ADMIN, "12345678", "ROLE-SALT-K", 4, null));
userAccounts.save(activeUserAccount("key-admin-01", RoleCode.KEY_ADMIN, "12345678", "SALT-K", 0, null));
roleAccounts.save(activeRole(RoleCode.KEY_ADMIN));
fullAccounts.save(activeFullAccount(RoleCode.KEY_ADMIN, 1, "key-admin-full-01", "12345678", "SALT-K", 4, null));
ukeyBindings.save(activeBinding(RoleCode.KEY_ADMIN, 1, "UK-1", "PUB-1"));
LmkService lmkService = org.mockito.Mockito.mock(LmkService.class);
@ -339,6 +474,7 @@ class AuthServiceTest {
roleAccounts,
new InMemoryLegacyAuthUserRepository(),
userAccounts,
fullAccounts,
new InMemoryAuthSessionRepository(),
ukeyBindings,
new FakePasswordHasher(),
@ -355,62 +491,150 @@ class AuthServiceTest {
UkeyLoginRequest request = new UkeyLoginRequest();
request.setRoleCode(RoleCode.KEY_ADMIN.getCode());
request.setRolePassword("bad-role-password");
request.setUkeyProofs(List.of(
proof("PUB-1", 1, "RB-1", "ISSUE-1", "LOGIN-DATA-1", "LOGIN-SIGN-1")
request.setLoginFactors(List.of(
proofWithPassword("PUB-1", 1, "bad-password", "RB-1", "ISSUE-1", "LOGIN-DATA-1", "LOGIN-SIGN-1")
));
BizException exception = Assertions.assertThrows(BizException.class, () -> service.ukeyLogin(request));
Assertions.assertEquals("role account is locked", exception.getMessage());
RoleAccountEntity updated = roleAccounts.findByRoleCode(RoleCode.KEY_ADMIN.getCode()).orElseThrow();
Assertions.assertEquals("full account is locked", exception.getMessage());
AuthFullAccountEntity updated = fullAccounts.findByRoleCodeAndUid(RoleCode.KEY_ADMIN.getCode(), 1).orElseThrow();
Assertions.assertEquals(5, updated.getFailedCount());
Assertions.assertEquals(RoleAccountStatus.LOCKED.name(), updated.getStatus());
Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 10), updated.getLockedUntil());
}
private static RoleAccountEntity activeRole(RoleCode roleCode) {
return activeRole(roleCode, false);
@Test
void shouldLoginSuperAdminWithPerUidPasswordsDuringUkeyLogin() {
InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository();
InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository();
InMemoryAuthFullAccountRepository fullAccounts = new InMemoryAuthFullAccountRepository();
InMemoryAuthSessionRepository sessions = new InMemoryAuthSessionRepository();
InMemoryRoleUkeyBindingRepository ukeyBindings = new InMemoryRoleUkeyBindingRepository();
roleAccounts.save(activeRole(RoleCode.SUPER_ADMIN));
fullAccounts.save(activeFullAccount(RoleCode.SUPER_ADMIN, 1, "super-admin-full-01", "11111111", "SALT-S1", 0, null));
fullAccounts.save(activeFullAccount(RoleCode.SUPER_ADMIN, 2, "super-admin-full-02", "22222222", "SALT-S2", 0, null));
ukeyBindings.save(activeBinding(RoleCode.SUPER_ADMIN, 1, "UK-1", "PUB-1"));
ukeyBindings.save(activeBinding(RoleCode.SUPER_ADMIN, 2, "UK-2", "PUB-2"));
LmkService lmkService = org.mockito.Mockito.mock(LmkService.class);
UkeyLoginRandomService randomService = org.mockito.Mockito.mock(UkeyLoginRandomService.class);
CompatUkeyVerifier verifier = org.mockito.Mockito.mock(CompatUkeyVerifier.class);
org.mockito.Mockito.when(lmkService.getMasterKeyStatus()).thenReturn(MasterKeyStatus.NORMAL.getDetail("MAC-001"));
org.mockito.Mockito.when(lmkService.exportIkPublicKeyHex()).thenReturn("IK-PUB-001");
org.mockito.Mockito.when(randomService.issue(RoleCode.SUPER_ADMIN.getCode(), 2)).thenReturn(List.of("RB-1", "RB-2"));
AuthService service = new AuthServiceImpl(
roleAccounts,
new InMemoryLegacyAuthUserRepository(),
userAccounts,
fullAccounts,
sessions,
ukeyBindings,
new FakePasswordHasher(),
lmkService,
randomService,
verifier,
new InMemoryCaptchaService(),
new ObjectMapper(),
FIXED_CLOCK,
() -> "token-full-super-001",
() -> "salt-full-super-001",
new AuthPolicyServiceImpl()
);
UkeyLoginRandomRequest randomRequest = new UkeyLoginRandomRequest();
randomRequest.setRoleCode(RoleCode.SUPER_ADMIN.getCode());
UkeyLoginRandomResponse ignored = service.issueUkeyLoginRandoms(randomRequest);
Assertions.assertNotNull(ignored);
UkeyLoginRequest request = new UkeyLoginRequest();
request.setRoleCode(RoleCode.SUPER_ADMIN.getCode());
request.setLoginFactors(List.of(
proofWithPassword("PUB-1", 1, "11111111", "RB-1", "ISSUE-1", "LOGIN-DATA-1", "LOGIN-SIGN-1"),
proofWithPassword("PUB-2", 2, "22222222", "RB-2", "ISSUE-2", "LOGIN-DATA-2", "LOGIN-SIGN-2")
));
LoginResponse response = service.ukeyLogin(request);
Assertions.assertEquals("token-full-super-001", response.getToken());
Assertions.assertEquals(AuthLevel.FULL.name(), response.getAuthLevel());
AuthSessionEntity session = sessions.findBySessionToken("token-full-super-001").orElseThrow();
Assertions.assertEquals("[{\"type\":\"FULL\",\"uid\":1,\"username\":null},{\"type\":\"FULL\",\"uid\":2,\"username\":null}]", session.getAuthenticatedPrincipalsJson());
org.mockito.Mockito.verify(randomService).assertIssued(RoleCode.SUPER_ADMIN.getCode(), List.of("RB-1", "RB-2"));
org.mockito.Mockito.verify(verifier).verifyIssuedBinding(
"{\"pubKey\":\"PUB-1\",\"authKeyPair\":\"IK-PUB-001\",\"role\":\"SUPER_ADMIN\",\"uid\":1}",
"ISSUE-1"
);
org.mockito.Mockito.verify(verifier).verifyIssuedBinding(
"{\"pubKey\":\"PUB-2\",\"authKeyPair\":\"IK-PUB-001\",\"role\":\"SUPER_ADMIN\",\"uid\":2}",
"ISSUE-2"
);
}
private static RoleAccountEntity activeRole(RoleCode roleCode, boolean needChangePassword) {
@Test
void shouldLockOnlyMappedFullAccountDuringSuperAdminUkeyLoginWhenPasswordIsWrong() {
InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository();
InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository();
InMemoryAuthFullAccountRepository fullAccounts = new InMemoryAuthFullAccountRepository();
InMemoryRoleUkeyBindingRepository ukeyBindings = new InMemoryRoleUkeyBindingRepository();
roleAccounts.save(activeRole(RoleCode.SUPER_ADMIN));
fullAccounts.save(activeFullAccount(RoleCode.SUPER_ADMIN, 1, "super-admin-full-01", "11111111", "SALT-S1", 4, null));
fullAccounts.save(activeFullAccount(RoleCode.SUPER_ADMIN, 2, "super-admin-full-02", "22222222", "SALT-S2", 0, null));
ukeyBindings.save(activeBinding(RoleCode.SUPER_ADMIN, 1, "UK-1", "PUB-1"));
ukeyBindings.save(activeBinding(RoleCode.SUPER_ADMIN, 2, "UK-2", "PUB-2"));
LmkService lmkService = org.mockito.Mockito.mock(LmkService.class);
UkeyLoginRandomService randomService = org.mockito.Mockito.mock(UkeyLoginRandomService.class);
CompatUkeyVerifier verifier = org.mockito.Mockito.mock(CompatUkeyVerifier.class);
org.mockito.Mockito.when(lmkService.getMasterKeyStatus()).thenReturn(MasterKeyStatus.NORMAL.getDetail("MAC-001"));
org.mockito.Mockito.when(lmkService.exportIkPublicKeyHex()).thenReturn("IK-PUB-001");
AuthService service = new AuthServiceImpl(
roleAccounts,
new InMemoryLegacyAuthUserRepository(),
userAccounts,
fullAccounts,
new InMemoryAuthSessionRepository(),
ukeyBindings,
new FakePasswordHasher(),
lmkService,
randomService,
verifier,
new InMemoryCaptchaService(),
new ObjectMapper(),
FIXED_CLOCK,
() -> "unused",
() -> "salt-full-super-002",
new AuthPolicyServiceImpl()
);
UkeyLoginRequest request = new UkeyLoginRequest();
request.setRoleCode(RoleCode.SUPER_ADMIN.getCode());
request.setLoginFactors(List.of(
proofWithPassword("PUB-1", 1, "bad-password", "RB-1", "ISSUE-1", "LOGIN-DATA-1", "LOGIN-SIGN-1"),
proofWithPassword("PUB-2", 2, "22222222", "RB-2", "ISSUE-2", "LOGIN-DATA-2", "LOGIN-SIGN-2")
));
BizException exception = Assertions.assertThrows(BizException.class, () -> service.ukeyLogin(request));
Assertions.assertEquals("full account is locked", exception.getMessage());
AuthFullAccountEntity locked = fullAccounts.findByRoleCodeAndUid(RoleCode.SUPER_ADMIN.getCode(), 1).orElseThrow();
AuthFullAccountEntity untouched = fullAccounts.findByRoleCodeAndUid(RoleCode.SUPER_ADMIN.getCode(), 2).orElseThrow();
Assertions.assertEquals(RoleAccountStatus.LOCKED.name(), locked.getStatus());
Assertions.assertEquals(5, locked.getFailedCount());
Assertions.assertEquals(LocalDateTime.of(2026, 3, 23, 2, 10), locked.getLockedUntil());
Assertions.assertEquals(0, untouched.getFailedCount());
Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), untouched.getStatus());
}
private static RoleAccountEntity activeRole(RoleCode roleCode) {
RoleAccountEntity entity = new RoleAccountEntity();
entity.setId((long) roleCode.ordinal() + 1);
entity.setRoleCode(roleCode.getCode());
entity.setDisplayName(roleCode.getDisplayName());
entity.setRequiredUkeyCount(roleCode.getRequiredUkeyCount());
entity.setStatus(RoleAccountStatus.ACTIVE.name());
entity.setNeedChangePassword(needChangePassword);
return entity;
}
private static RoleAccountEntity activeRole(RoleCode roleCode, String rolePassword, String salt) {
return activeRole(roleCode, rolePassword, salt, 0, null, false);
}
private static RoleAccountEntity activeRole(
RoleCode roleCode,
String rolePassword,
String salt,
int failedCount,
LocalDateTime lockedUntil
) {
return activeRole(roleCode, rolePassword, salt, failedCount, lockedUntil, false);
}
private static RoleAccountEntity activeRole(
RoleCode roleCode,
String rolePassword,
String salt,
int failedCount,
LocalDateTime lockedUntil,
boolean needChangePassword
) {
RoleAccountEntity entity = activeRole(roleCode, needChangePassword);
entity.setPasswordHash("HASH:" + rolePassword + ":" + salt);
entity.setPasswordSalt(salt);
entity.setFailedCount(failedCount);
entity.setLockedUntil(lockedUntil);
return entity;
}
@ -421,6 +645,18 @@ class AuthServiceTest {
String salt,
int failedCount,
LocalDateTime lockedUntil
) {
return activeUserAccount(username, roleCode, password, salt, failedCount, lockedUntil, false);
}
private static AuthUserAccountEntity activeUserAccount(
String username,
RoleCode roleCode,
String password,
String salt,
int failedCount,
LocalDateTime lockedUntil,
boolean needChangePassword
) {
AuthUserAccountEntity entity = new AuthUserAccountEntity();
entity.setId((long) (username.hashCode() & Integer.MAX_VALUE));
@ -430,6 +666,44 @@ class AuthServiceTest {
entity.setPasswordSalt(salt);
entity.setPasswordHash("HASH:" + password + ":" + salt);
entity.setStatus(RoleAccountStatus.ACTIVE.name());
entity.setNeedChangePassword(needChangePassword);
entity.setFailedCount(failedCount);
entity.setLockedUntil(lockedUntil);
return entity;
}
private static AuthFullAccountEntity activeFullAccount(
RoleCode roleCode,
int uid,
String accountName,
String password,
String salt,
int failedCount,
LocalDateTime lockedUntil
) {
return activeFullAccount(roleCode, uid, accountName, password, salt, failedCount, lockedUntil, false);
}
private static AuthFullAccountEntity activeFullAccount(
RoleCode roleCode,
int uid,
String accountName,
String password,
String salt,
int failedCount,
LocalDateTime lockedUntil,
boolean needChangePassword
) {
AuthFullAccountEntity entity = new AuthFullAccountEntity();
entity.setId((long) (accountName.hashCode() & Integer.MAX_VALUE));
entity.setRoleCode(roleCode.getCode());
entity.setUid(uid);
entity.setAccountName(accountName);
entity.setDisplayName(accountName);
entity.setPasswordSalt(salt);
entity.setPasswordHash("HASH:" + password + ":" + salt);
entity.setStatus(RoleAccountStatus.ACTIVE.name());
entity.setNeedChangePassword(needChangePassword);
entity.setFailedCount(failedCount);
entity.setLockedUntil(lockedUntil);
return entity;
@ -442,6 +716,13 @@ class AuthServiceTest {
return request;
}
private static com.cisd.tms.modules.auth.dto.FullLoginAccountRequest fullAccount(int uid, String password) {
com.cisd.tms.modules.auth.dto.FullLoginAccountRequest request = new com.cisd.tms.modules.auth.dto.FullLoginAccountRequest();
request.setUid(uid);
request.setPassword(password);
return request;
}
private static RoleUkeyBindingEntity activeBinding(
RoleCode roleCode,
int uid,
@ -476,6 +757,20 @@ class AuthServiceTest {
return proof;
}
private static UkeyLoginProof proofWithPassword(
String pubKey,
int uid,
String password,
String serverRandom,
String issueSignature,
String loginPayload,
String loginSignature
) {
UkeyLoginProof proof = proof(pubKey, uid, serverRandom, issueSignature, loginPayload, loginSignature);
proof.setPassword(password);
return proof;
}
private static AuthSessionEntity session(String token, String roleCode, String authLevel) {
AuthSessionEntity entity = new AuthSessionEntity();
entity.setId((long) (token.hashCode() & Integer.MAX_VALUE));
@ -491,6 +786,7 @@ class AuthServiceTest {
private static AuthService newAuthService(
InMemoryRoleAccountRepository roleAccounts,
InMemoryAuthUserAccountRepository userAccounts,
InMemoryAuthFullAccountRepository fullAccounts,
InMemoryAuthSessionRepository sessions,
InMemoryRoleUkeyBindingRepository ukeyBindings,
Clock clock,
@ -500,6 +796,7 @@ class AuthServiceTest {
roleAccounts,
new InMemoryLegacyAuthUserRepository(),
userAccounts,
fullAccounts,
sessions,
ukeyBindings,
new FakePasswordHasher(),
@ -528,6 +825,23 @@ class AuthServiceTest {
}
}
private static class SequenceSessionTokenGenerator implements SessionTokenGenerator {
private final List<String> tokens;
private int index = 0;
private SequenceSessionTokenGenerator(String... tokens) {
this.tokens = List.of(tokens);
}
@Override
public String nextToken() {
if (index >= tokens.size()) {
return tokens.get(tokens.size() - 1);
}
return tokens.get(index++);
}
}
private static class InMemoryRoleAccountRepository implements RoleAccountRepository {
private final Map<String, RoleAccountEntity> store = new ConcurrentHashMap<>();
@ -574,6 +888,33 @@ class AuthServiceTest {
}
}
private static class InMemoryAuthFullAccountRepository implements AuthFullAccountRepository {
private final Map<String, AuthFullAccountEntity> store = new ConcurrentHashMap<>();
@Override
public Optional<AuthFullAccountEntity> findByRoleCodeAndUid(String roleCode, Integer uid) {
return Optional.ofNullable(store.get(roleCode + "#" + uid));
}
@Override
public List<AuthFullAccountEntity> findByRoleCode(String roleCode) {
return store.values().stream()
.filter(entity -> roleCode.equals(entity.getRoleCode()))
.sorted(Comparator.comparing(AuthFullAccountEntity::getUid))
.toList();
}
@Override
public void save(AuthFullAccountEntity entity) {
store.put(entity.getRoleCode() + "#" + entity.getUid(), entity);
}
@Override
public void update(AuthFullAccountEntity entity) {
store.put(entity.getRoleCode() + "#" + entity.getUid(), entity);
}
}
private static class InMemoryLegacyAuthUserRepository implements AuthUserRepository {
@Override
@ -590,6 +931,32 @@ class AuthServiceTest {
return Optional.ofNullable(store.get(sessionToken));
}
@Override
public List<AuthSessionEntity> findActiveByRoleCodeAndAuthLevel(String roleCode, String authLevel, LocalDateTime now) {
return store.values().stream()
.filter(entity -> roleCode.equals(entity.getRoleCode()))
.filter(entity -> authLevel.equals(entity.getAuthLevel()))
.filter(entity -> entity.getLogoutAt() == null)
.filter(entity -> entity.getExpiresAt() != null && entity.getExpiresAt().isAfter(now))
.toList();
}
@Override
public List<AuthSessionEntity> findActiveByRoleCodeAndAuthLevelAndAuthenticatedPrincipalsJson(
String roleCode,
String authLevel,
String authenticatedPrincipalsJson,
LocalDateTime now
) {
return store.values().stream()
.filter(entity -> roleCode.equals(entity.getRoleCode()))
.filter(entity -> authLevel.equals(entity.getAuthLevel()))
.filter(entity -> java.util.Objects.equals(authenticatedPrincipalsJson, entity.getAuthenticatedPrincipalsJson()))
.filter(entity -> entity.getLogoutAt() == null)
.filter(entity -> entity.getExpiresAt() != null && entity.getExpiresAt().isAfter(now))
.toList();
}
@Override
public void save(AuthSessionEntity entity) {
store.put(entity.getSessionToken(), entity);

View File

@ -0,0 +1,119 @@
package com.cisd.tms.modules.auth.service.impl;
import com.cisd.tms.common.exception.BizException;
import com.cisd.tms.modules.mk.service.LmkService;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Security;
import java.security.Signature;
import java.security.interfaces.ECPublicKey;
import java.security.spec.ECGenParameterSpec;
import java.util.Base64;
import org.bouncycastle.util.encoders.Hex;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemWriter;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
class PcieCompatUkeyVerifierTest {
private static final String PROVIDER = "BC";
static {
if (Security.getProvider(PROVIDER) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
@Test
void shouldVerifyLoginSignatureWithPemPublicKeyAndBase64DerSignature() throws Exception {
KeyPair keyPair = generateSm2KeyPair();
String payload = "LOGIN-DATA-1";
String pemPublicKey = toPem("PUBLIC KEY", keyPair.getPublic().getEncoded());
String signatureBase64 = Base64.getEncoder().encodeToString(signDer(payload, keyPair));
verifier().verifyLoginSignature(pemPublicKey, payload, signatureBase64);
}
@Test
void shouldVerifyLoginSignatureWithRawPointHexAndRawRsHexSignature() throws Exception {
KeyPair keyPair = generateSm2KeyPair();
String payload = "LOGIN-DATA-2";
String rawPointHex = Hex.toHexString(uncompressedPoint((ECPublicKey) keyPair.getPublic()));
String rawSignatureHex = Hex.toHexString(derToRawRs(signDer(payload, keyPair)));
verifier().verifyLoginSignature(rawPointHex, payload, rawSignatureHex);
}
@Test
void shouldRejectWhenLoginSignatureDoesNotMatch() throws Exception {
KeyPair keyPair = generateSm2KeyPair();
String payload = "LOGIN-DATA-3";
String base64PublicKey = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
String signatureBase64 = Base64.getEncoder().encodeToString(signDer("OTHER-DATA", keyPair));
BizException exception = Assertions.assertThrows(
BizException.class,
() -> verifier().verifyLoginSignature(base64PublicKey, payload, signatureBase64)
);
Assertions.assertEquals("login signature verification failed", exception.getMessage());
}
private PcieCompatUkeyVerifier verifier() {
return new PcieCompatUkeyVerifier(Mockito.mock(LmkService.class));
}
private KeyPair generateSm2KeyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC", PROVIDER);
generator.initialize(new ECGenParameterSpec("sm2p256v1"));
return generator.generateKeyPair();
}
private byte[] signDer(String data, KeyPair keyPair) throws Exception {
Signature signature = Signature.getInstance("SM3withSM2", PROVIDER);
signature.initSign(keyPair.getPrivate());
signature.update(data.getBytes(StandardCharsets.UTF_8));
return signature.sign();
}
private byte[] uncompressedPoint(ECPublicKey publicKey) {
byte[] x = toFixed32(publicKey.getW().getAffineX().toByteArray());
byte[] y = toFixed32(publicKey.getW().getAffineY().toByteArray());
byte[] result = new byte[65];
result[0] = 0x04;
System.arraycopy(x, 0, result, 1, 32);
System.arraycopy(y, 0, result, 33, 32);
return result;
}
private byte[] derToRawRs(byte[] derSignature) throws Exception {
org.bouncycastle.asn1.ASN1Sequence sequence =
(org.bouncycastle.asn1.ASN1Sequence) org.bouncycastle.asn1.ASN1Primitive.fromByteArray(derSignature);
byte[] r = toFixed32(((org.bouncycastle.asn1.ASN1Integer) sequence.getObjectAt(0)).getPositiveValue().toByteArray());
byte[] s = toFixed32(((org.bouncycastle.asn1.ASN1Integer) sequence.getObjectAt(1)).getPositiveValue().toByteArray());
byte[] result = new byte[64];
System.arraycopy(r, 0, result, 0, 32);
System.arraycopy(s, 0, result, 32, 32);
return result;
}
private byte[] toFixed32(byte[] value) {
byte[] result = new byte[32];
int copyLength = Math.min(value.length, 32);
System.arraycopy(value, value.length - copyLength, result, 32 - copyLength, copyLength);
return result;
}
private String toPem(String type, byte[] content) throws Exception {
StringWriter writer = new StringWriter();
try (PemWriter pemWriter = new PemWriter(writer)) {
pemWriter.writeObject(new PemObject(type, content));
}
return writer.toString();
}
}

View File

@ -35,8 +35,10 @@ class ReplayProtectedEndpointsTest {
@Test
void shouldProtectSensitiveControllersAndMethods() throws Exception {
Assertions.assertNotNull(annotation(AuthController.class, "changePassword",
com.cisd.tms.modules.auth.dto.ChangePasswordRequest.class, jakarta.servlet.http.HttpServletRequest.class));
Assertions.assertNotNull(annotation(AuthController.class, "changeFullAccountPassword",
Integer.class, com.cisd.tms.modules.auth.dto.ChangePasswordRequest.class, jakarta.servlet.http.HttpServletRequest.class));
Assertions.assertNotNull(annotation(AuthController.class, "changeLimitedAccountPassword",
String.class, com.cisd.tms.modules.auth.dto.ChangePasswordRequest.class, jakarta.servlet.http.HttpServletRequest.class));
Assertions.assertNotNull(annotation(DeviceController.class, "restart", jakarta.servlet.http.HttpServletRequest.class));
Assertions.assertNotNull(AnnotatedElementUtils.findMergedAnnotation(AuthAdminController.class, ReplayProtected.class));