diff --git a/src/main/java/com/cisd/tms/modules/auth/config/AuthSupportConfiguration.java b/src/main/java/com/cisd/tms/modules/auth/config/AuthSupportConfiguration.java new file mode 100644 index 0000000..a44b0d0 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/auth/config/AuthSupportConfiguration.java @@ -0,0 +1,14 @@ +package com.cisd.tms.modules.auth.config; + +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AuthSupportConfiguration { + + @Bean + public Clock authClock() { + return Clock.systemUTC(); + } +} diff --git a/src/main/java/com/cisd/tms/modules/auth/service/AuthAdminService.java b/src/main/java/com/cisd/tms/modules/auth/service/AuthAdminService.java index e50da5b..10ee7d9 100644 --- a/src/main/java/com/cisd/tms/modules/auth/service/AuthAdminService.java +++ b/src/main/java/com/cisd/tms/modules/auth/service/AuthAdminService.java @@ -1,143 +1,15 @@ package com.cisd.tms.modules.auth.service; -import com.cisd.tms.common.enums.ErrorCode; -import com.cisd.tms.common.exception.BizException; -import com.cisd.tms.integration.crypto.pcie.service.MockPcieCryptoService; -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.RoleAccountRepository; -import com.cisd.tms.modules.auth.repository.RoleUkeyBindingRepository; -import com.cisd.tms.common.util.TraceIdUtil; -import com.cisd.tms.modules.mk.config.MasterKeyInitProperties; import com.cisd.tms.modules.mk.dto.UKeySignDTO; -import com.cisd.tms.modules.mk.dto.UKeySignEntity; import com.cisd.tms.modules.mk.dto.UKeySignResult; -import com.cisd.tms.modules.mk.service.LmkService; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.time.Clock; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.List; -import java.util.Objects; -import java.util.UUID; -import java.util.function.Supplier; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -@Service -public class AuthAdminService { +public interface AuthAdminService { - static final String DEFAULT_PASSWORD = "12345678"; + void enableRole(String operatorRoleCode, String operatorAuthLevel, String targetRoleCode); - private final RoleAccountRepository roleAccountRepository; - private final RoleUkeyBindingRepository roleUkeyBindingRepository; - private final PasswordHasher passwordHasher; - private final LmkService lmkService; - private final ObjectMapper objectMapper; - private final Clock clock; - private final Supplier saltSupplier; + void resetPassword(String operatorRoleCode, String operatorAuthLevel, String targetRoleCode); - @Autowired - public AuthAdminService( - RoleAccountRepository roleAccountRepository, - RoleUkeyBindingRepository roleUkeyBindingRepository, - PasswordHasher passwordHasher, - LmkService lmkService, - ObjectMapper objectMapper - ) { - this( - roleAccountRepository, - roleUkeyBindingRepository, - passwordHasher, - lmkService, - objectMapper, - Clock.systemUTC(), - () -> UUID.randomUUID().toString().replace("-", "") - ); - } - - AuthAdminService( - RoleAccountRepository roleAccountRepository, - RoleUkeyBindingRepository roleUkeyBindingRepository, - LmkService lmkService, - PasswordHasher passwordHasher, - Clock clock, - Supplier saltSupplier - ) { - this( - roleAccountRepository, - roleUkeyBindingRepository, - passwordHasher, - lmkService, - new ObjectMapper(), - clock, - saltSupplier - ); - } - - AuthAdminService( - RoleAccountRepository roleAccountRepository, - RoleUkeyBindingRepository roleUkeyBindingRepository, - PasswordHasher passwordHasher, - Clock clock, - Supplier saltSupplier - ) { - this( - roleAccountRepository, - roleUkeyBindingRepository, - new LmkService(new MockPcieCryptoService(), new MasterKeyInitProperties()), - passwordHasher, - clock, - saltSupplier - ); - } - - AuthAdminService( - RoleAccountRepository roleAccountRepository, - RoleUkeyBindingRepository roleUkeyBindingRepository, - PasswordHasher passwordHasher, - LmkService lmkService, - ObjectMapper objectMapper, - Clock clock, - Supplier saltSupplier - ) { - this.roleAccountRepository = roleAccountRepository; - this.roleUkeyBindingRepository = roleUkeyBindingRepository; - this.passwordHasher = passwordHasher; - this.lmkService = lmkService; - this.objectMapper = objectMapper; - this.clock = clock; - this.saltSupplier = saltSupplier; - } - - public void enableRole(String operatorRoleCode, String operatorAuthLevel, String targetRoleCode) { - requireKeyAdminFull(operatorRoleCode, operatorAuthLevel); - RoleAccountEntity target = loadRole(targetRoleCode); - target.setStatus(RoleAccountStatus.ACTIVE.name()); - target.setNeedChangePassword(Boolean.TRUE); - target.setLockedUntil(null); - target.setFailedCount(0); - roleAccountRepository.update(target); - } - - public void resetPassword(String operatorRoleCode, String operatorAuthLevel, String targetRoleCode) { - requireKeyAdminFull(operatorRoleCode, operatorAuthLevel); - RoleAccountEntity target = loadRole(targetRoleCode); - String newSalt = saltSupplier.get(); - target.setPasswordSalt(newSalt); - target.setPasswordHash(passwordHasher.hash(DEFAULT_PASSWORD, newSalt)); - target.setStatus(RoleAccountStatus.ACTIVE.name()); - target.setNeedChangePassword(Boolean.TRUE); - target.setFailedCount(0); - target.setLockedUntil(null); - roleAccountRepository.update(target); - } - - public void bindUkey( + void bindUkey( String operatorRoleCode, String operatorAuthLevel, String targetRoleCode, @@ -147,31 +19,9 @@ public class AuthAdminService { String uid, String rid, String issuerSign - ) { - requireKeyAdminFull(operatorRoleCode, operatorAuthLevel); - RoleCode targetRole = resolveRoleCode(targetRoleCode); - if (slotNo == null || slotNo < 1 || slotNo > targetRole.getRequiredUkeyCount()) { - throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "slotNo exceeds role ukey requirement"); - } + ); - RoleUkeyBindingEntity binding = roleUkeyBindingRepository - .findActiveByRoleCodeAndSlotNo(targetRoleCode, slotNo) - .orElseGet(RoleUkeyBindingEntity::new); - if (binding.getId() == null) { - binding.setId((long) Math.abs(Objects.hash(targetRoleCode, slotNo, ukeySerial, TraceIdUtil.newTraceId()))); - binding.setRoleCode(targetRoleCode); - binding.setSlotNo(slotNo); - binding.setStatus("ACTIVE"); - applyBinding(binding, ukeySerial, ukeyPubkey, uid, rid, issuerSign); - roleUkeyBindingRepository.save(binding); - return; - } - - applyBinding(binding, ukeySerial, ukeyPubkey, uid, rid, issuerSign); - roleUkeyBindingRepository.update(binding); - } - - public void bindIssuedUkey( + void bindIssuedUkey( String operatorRoleCode, String operatorAuthLevel, String targetRoleCode, @@ -181,105 +31,12 @@ public class AuthAdminService { String uid, String rid, String issuerSign - ) { - requireKeyAdminFull(operatorRoleCode, operatorAuthLevel); - resolveRoleCode(targetRoleCode); - verifyIssuerSignature(targetRoleCode, ukeyPubkey, uid, rid, issuerSign); - bindUkey(operatorRoleCode, operatorAuthLevel, targetRoleCode, slotNo, ukeySerial, ukeyPubkey, uid, rid, issuerSign); - } + ); - public UKeySignResult issueUkeyBindingSign( + UKeySignResult issueUkeyBindingSign( String operatorRoleCode, String operatorAuthLevel, String targetRoleCode, UKeySignDTO request - ) { - requireKeyAdminFull(operatorRoleCode, operatorAuthLevel); - RoleCode targetRole = resolveRoleCode(targetRoleCode); - String authKeyPair = lmkService.exportIkPublicKeyHex(); - - UKeySignDTO dto = new UKeySignDTO(); - dto.setPubKey(request.getPubKey()); - // 新卡统一使用规范角色编码,不再兼容旧卡角色字符串。 - dto.setRole(targetRole.getCode()); - dto.setUid(request.getUid()); - dto.setRid(request.getRid()); - dto.setExtra(request.getExtra()); - - String signValue = lmkService.signIk(toIssuePayload(dto, authKeyPair)); - com.cisd.tms.modules.mk.dto.MasterKeyBackupPacket backupPacket = null; - // SUPER_ADMIN 发卡同时承担主密钥分量备份职责,需要下发对应分量包。 - if (RoleCode.SUPER_ADMIN == targetRole) { - backupPacket = lmkService.buildBackupPacket(Integer.parseInt(request.getUid())); - } - return UKeySignResult.builder() - .sign(signValue) - .backupPacket(backupPacket) - .build(); - } - - private void applyBinding( - RoleUkeyBindingEntity binding, - String ukeySerial, - String ukeyPubkey, - String uid, - String rid, - String issuerSign - ) { - binding.setUkeySerial(ukeySerial); - binding.setUkeyPubkey(ukeyPubkey); - binding.setUid(uid); - binding.setRid(rid); - binding.setIssuerSign(issuerSign); - binding.setStatus("ACTIVE"); - binding.setBoundAt(now()); - binding.setUnboundAt(null); - } - - private void requireKeyAdminFull(String operatorRoleCode, String operatorAuthLevel) { - if (!RoleCode.KEY_ADMIN.getCode().equals(operatorRoleCode) || !AuthLevel.FULL.name().equals(operatorAuthLevel)) { - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "operation requires KEY_ADMIN FULL session"); - } - } - - private RoleAccountEntity loadRole(String roleCode) { - resolveRoleCode(roleCode); - return roleAccountRepository.findByRoleCode(roleCode) - .orElseThrow(() -> new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "target role account not found")); - } - - private RoleCode resolveRoleCode(String roleCode) { - return List.of(RoleCode.values()).stream() - .filter(item -> item.getCode().equals(roleCode)) - .findFirst() - .orElseThrow(() -> new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "invalid roleCode")); - } - - private LocalDateTime now() { - return LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); - } - - private void verifyIssuerSignature(String targetRoleCode, String ukeyPubkey, String uid, String rid, String issuerSign) { - try { - UKeySignDTO dto = new UKeySignDTO(); - dto.setPubKey(ukeyPubkey); - dto.setRole(resolveRoleCode(targetRoleCode).getCode()); - dto.setUid(uid); - dto.setRid(rid); - String authKeyPair = lmkService.exportIkPublicKeyHex(); - lmkService.verifyIk(toIssuePayload(dto, authKeyPair), issuerSign); - } catch (BizException ex) { - throw ex; - } catch (RuntimeException ex) { - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey issuer signature verification failed"); - } - } - - private String toIssuePayload(UKeySignDTO dto, String authKeyPair) { - try { - return objectMapper.writeValueAsString(UKeySignEntity.getInstance(dto, authKeyPair)); - } catch (JsonProcessingException ex) { - throw new IllegalStateException("serialize ukey issue payload failed", ex); - } - } + ); } diff --git a/src/main/java/com/cisd/tms/modules/auth/service/AuthPolicyService.java b/src/main/java/com/cisd/tms/modules/auth/service/AuthPolicyService.java index e6df136..545831c 100644 --- a/src/main/java/com/cisd/tms/modules/auth/service/AuthPolicyService.java +++ b/src/main/java/com/cisd/tms/modules/auth/service/AuthPolicyService.java @@ -3,22 +3,10 @@ package com.cisd.tms.modules.auth.service; import com.cisd.tms.modules.auth.enums.AuthLevel; import com.cisd.tms.modules.auth.enums.AuthMethod; import com.cisd.tms.modules.auth.enums.RoleCode; -import org.springframework.stereotype.Service; -@Service -public class AuthPolicyService { +public interface AuthPolicyService { - /** - * 统一根据认证方式推导本次登录会话的认证等级。 - */ - public AuthLevel resolveAuthLevel(AuthMethod authMethod) { - return authMethod == AuthMethod.UKEY ? AuthLevel.FULL : AuthLevel.LIMITED; - } + AuthLevel resolveAuthLevel(AuthMethod authMethod); - /** - * 统一读取角色要求的 UKey 数量,避免控制器和服务层散落硬编码。 - */ - public int requiredUkeyCount(RoleCode roleCode) { - return roleCode.getRequiredUkeyCount(); - } + int requiredUkeyCount(RoleCode roleCode); } diff --git a/src/main/java/com/cisd/tms/modules/auth/service/AuthService.java b/src/main/java/com/cisd/tms/modules/auth/service/AuthService.java index 5311682..390c4c3 100644 --- a/src/main/java/com/cisd/tms/modules/auth/service/AuthService.java +++ b/src/main/java/com/cisd/tms/modules/auth/service/AuthService.java @@ -1,445 +1,31 @@ package com.cisd.tms.modules.auth.service; -import com.cisd.tms.common.enums.ErrorCode; -import com.cisd.tms.common.exception.BizException; import com.cisd.tms.modules.auth.dto.CaptchaResponse; import com.cisd.tms.modules.auth.dto.CurrentUserResponse; import com.cisd.tms.modules.auth.dto.LoginRequest; import com.cisd.tms.modules.auth.dto.LoginResponse; import com.cisd.tms.modules.auth.dto.PasswordLoginRequest; -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.AuthSessionEntity; -import com.cisd.tms.modules.auth.entity.AuthUserEntity; -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.AuthMethod; -import com.cisd.tms.modules.auth.enums.RoleAccountStatus; -import com.cisd.tms.modules.auth.enums.RoleCode; -import com.cisd.tms.modules.auth.repository.AuthUserRepository; -import com.cisd.tms.modules.auth.repository.AuthSessionRepository; -import com.cisd.tms.modules.auth.repository.RoleAccountRepository; -import com.cisd.tms.modules.auth.repository.RoleUkeyBindingRepository; -import com.cisd.tms.modules.mk.dto.UKeySignDTO; -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.databind.ObjectMapper; -import java.util.Base64; -import java.time.Clock; -import java.time.LocalDateTime; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import java.util.function.Supplier; -import java.util.stream.Collectors; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -@Service -public class AuthService { +public interface AuthService { - private static final int MAX_FAILED_ATTEMPTS = 5; - private static final int IDLE_TIMEOUT_MINUTES = 10; + LoginResponse login(LoginRequest request); - private final RoleAccountRepository roleAccountRepository; - private final AuthUserRepository authUserRepository; - private final AuthSessionRepository authSessionRepository; - private final RoleUkeyBindingRepository roleUkeyBindingRepository; - private final PasswordHasher passwordHasher; - private final LmkService lmkService; - private final UkeyLoginRandomService ukeyLoginRandomService; - private final CompatUkeyVerifier compatUkeyVerifier; - private final CaptchaService captchaService; - private final ObjectMapper objectMapper; - private final Clock clock; - private final Supplier tokenSupplier; + LoginResponse passwordLogin(PasswordLoginRequest request); - @Autowired - public AuthService( - RoleAccountRepository roleAccountRepository, - AuthUserRepository authUserRepository, - AuthSessionRepository authSessionRepository, - RoleUkeyBindingRepository roleUkeyBindingRepository, - PasswordHasher passwordHasher - ) { - this( - roleAccountRepository, - authUserRepository, - authSessionRepository, - roleUkeyBindingRepository, - passwordHasher, - null, - null, - null, - null, - new ObjectMapper(), - Clock.systemUTC(), - () -> "tms-" + UUID.randomUUID() - ); - } + UkeyLoginRandomResponse issueUkeyLoginRandoms(UkeyLoginRandomRequest request); - AuthService( - RoleAccountRepository roleAccountRepository, - AuthUserRepository authUserRepository, - AuthSessionRepository authSessionRepository, - RoleUkeyBindingRepository roleUkeyBindingRepository, - PasswordHasher passwordHasher, - Clock clock, - Supplier tokenSupplier - ) { - this( - roleAccountRepository, - authUserRepository, - authSessionRepository, - roleUkeyBindingRepository, - passwordHasher, - null, - null, - null, - null, - new ObjectMapper(), - clock, - tokenSupplier - ); - } + LoginResponse ukeyLogin(UkeyLoginRequest request); - AuthService( - RoleAccountRepository roleAccountRepository, - AuthUserRepository authUserRepository, - AuthSessionRepository authSessionRepository, - RoleUkeyBindingRepository roleUkeyBindingRepository, - PasswordHasher passwordHasher, - LmkService lmkService, - UkeyLoginRandomService ukeyLoginRandomService, - CompatUkeyVerifier compatUkeyVerifier, - CaptchaService captchaService, - ObjectMapper objectMapper, - Clock clock, - Supplier tokenSupplier - ) { - this.roleAccountRepository = roleAccountRepository; - this.authUserRepository = authUserRepository; - this.authSessionRepository = authSessionRepository; - this.roleUkeyBindingRepository = roleUkeyBindingRepository; - this.passwordHasher = passwordHasher; - this.lmkService = lmkService; - this.ukeyLoginRandomService = ukeyLoginRandomService; - this.compatUkeyVerifier = compatUkeyVerifier; - this.captchaService = captchaService; - this.objectMapper = objectMapper; - this.clock = clock; - this.tokenSupplier = tokenSupplier; - } + CaptchaResponse issueCaptcha(); - public LoginResponse login(LoginRequest request) { - RoleAccountEntity roleAccount = roleAccountRepository.findByRoleCode(request.getRoleCode()) - .orElseThrow(() -> new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role account not found")); + CurrentUserResponse me(String username); - validateRoleStatus(roleAccount); - if (!passwordHasher.matches(request.getPassword(), roleAccount.getPasswordSalt(), roleAccount.getPasswordHash())) { - onPasswordFailed(roleAccount); - } + CurrentUserResponse me(String username, String sessionToken); - resetFailureState(roleAccount); - AuthMethod authMethod = resolveAuthMethod(request.getUkeySerials()); - AuthLevel authLevel = resolveAuthLevel(roleAccount, request.getUkeySerials()); - AuthSessionEntity session = buildSession(roleAccount.getRoleCode(), authMethod, authLevel); - authSessionRepository.save(session); + void logout(String sessionToken); - LoginResponse response = new LoginResponse(); - response.setRoleCode(roleAccount.getRoleCode()); - response.setAuthLevel(authLevel.name()); - response.setToken(session.getSessionToken()); - response.setExpiresAt(OffsetDateTime.of(session.getExpiresAt(), ZoneOffset.UTC).toString()); - response.setNeedChangePassword(Boolean.TRUE.equals(roleAccount.getNeedChangePassword())); - return response; - } - - /** - * 标准化口令登录入口,当前先复用统一登录实现并签发 LIMITED 会话。 - */ - public LoginResponse passwordLogin(PasswordLoginRequest request) { - ensureMasterKeyReady(); - requireCaptchaService().verify(request.getCaptchaId(), request.getCaptchaCode()); - LoginRequest loginRequest = new LoginRequest(); - loginRequest.setRoleCode(request.getRoleCode()); - loginRequest.setPassword(request.getPassword()); - return login(loginRequest); - } - - /** - * 标准化 UKey 随机数申请入口。当前先按角色要求生成占位随机数,后续再接旧系统完整校验链路。 - */ - public UkeyLoginRandomResponse issueUkeyLoginRandoms(UkeyLoginRandomRequest request) { - RoleCode roleCode = RoleCode.valueOf(request.getRoleCode()); - UkeyLoginRandomResponse response = new UkeyLoginRandomResponse(); - response.setRoleCode(roleCode.getCode()); - response.setRandoms(requireUkeyLoginRandomService().issue(roleCode.getCode(), roleCode.getRequiredUkeyCount())); - return response; - } - - /** - * 标准化 UKey 登录入口,当前先按证明数量映射到完整登录。 - */ - public LoginResponse ukeyLogin(UkeyLoginRequest request) { - ensureMasterKeyReady(); - RoleCode roleCode = RoleCode.valueOf(request.getRoleCode()); - List activeBindings = roleUkeyBindingRepository.findActiveByRoleCode(roleCode.getCode()); - validateUkeyCount(roleCode, activeBindings, request.getUkeyProofs()); - Map bindingsByPubKey = activeBindings.stream() - .collect(Collectors.toMap(RoleUkeyBindingEntity::getUkeyPubkey, item -> item, (left, right) -> left, java.util.LinkedHashMap::new)); - Set requestPubKeys = request.getUkeyProofs().stream() - .map(UkeyLoginProof::getPubKey) - .collect(Collectors.toSet()); - if (requestPubKeys.size() != request.getUkeyProofs().size() || !bindingsByPubKey.keySet().containsAll(requestPubKeys)) { - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey auth info does not match bound role"); - } - requireUkeyLoginRandomService().assertIssued( - roleCode.getCode(), - request.getUkeyProofs().stream().map(UkeyLoginProof::getServerRandom).toList() - ); - String authKeyPair = requireLmkService().exportIkPublicKeyHex(); - List matchedSerials = new java.util.ArrayList<>(); - for (UkeyLoginProof proof : request.getUkeyProofs()) { - RoleUkeyBindingEntity binding = bindingsByPubKey.get(proof.getPubKey()); - requireCompatUkeyVerifier().verifyIssuedBinding(buildIssuePayload(request.getRoleCode(), proof, authKeyPair), proof.getIssueSignature()); - requireCompatUkeyVerifier().verifyLoginSignature(proof.getPubKey(), proof.getLoginPayload(), proof.getLoginSignature()); - matchedSerials.add(binding.getUkeySerial()); - } - LoginRequest loginRequest = new LoginRequest(); - loginRequest.setRoleCode(request.getRoleCode()); - loginRequest.setPassword(request.getPassword()); - loginRequest.setUkeySerials(matchedSerials); - return login(loginRequest); - } - - /** - * 标准化验证码生成入口。当前先返回可用于联调的占位图像内容。 - */ - public CaptchaResponse issueCaptcha() { - return requireCaptchaService().issueCaptcha(); - } - - public CurrentUserResponse me(String username) { - return me(username, null); - } - - public CurrentUserResponse me(String username, String sessionToken) { - String normalized = username == null || username.isBlank() ? "admin" : username; - RoleAccountEntity roleAccount = roleAccountRepository.findByRoleCode(normalized).orElse(null); - if (roleAccount != null) { - CurrentUserResponse response = new CurrentUserResponse(); - response.setUsername(roleAccount.getRoleCode()); - response.setDisplayName(roleAccount.getDisplayName()); - response.setRole(roleAccount.getRoleCode()); - AuthSessionEntity session = sessionToken == null ? null : authSessionRepository.findBySessionToken(sessionToken).orElse(null); - if (session != null) { - response.setAuthLevel(session.getAuthLevel()); - } - response.setNeedChangePassword(Boolean.TRUE.equals(roleAccount.getNeedChangePassword())); - return response; - } - - AuthUserEntity entity = authUserRepository.findByUsername(normalized).orElse(null); - - CurrentUserResponse response = new CurrentUserResponse(); - if (entity == null) { - response.setUsername(normalized); - response.setDisplayName("TMS Administrator"); - response.setRole("ADMIN"); - return response; - } - - response.setUsername(entity.getUsername()); - response.setDisplayName(entity.getDisplayName()); - response.setRole(entity.getRole()); - return response; - } - - public void logout(String sessionToken) { - AuthSessionEntity session = requireActiveSession(sessionToken); - session.setLogoutAt(now()); - session.setExpiresAt(now()); - authSessionRepository.update(session); - } - - public void changePassword(String sessionToken, String currentPassword, String newPassword) { - AuthSessionEntity session = requireActiveSession(sessionToken); - RoleAccountEntity roleAccount = roleAccountRepository.findByRoleCode(session.getRoleCode()) - .orElseThrow(() -> new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role account not found")); - if (!passwordHasher.matches(currentPassword, roleAccount.getPasswordSalt(), roleAccount.getPasswordHash())) { - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "current password is incorrect"); - } - - String newSalt = UUID.randomUUID().toString().replace("-", ""); - roleAccount.setPasswordSalt(newSalt); - roleAccount.setPasswordHash(passwordHasher.hash(newPassword, newSalt)); - roleAccount.setNeedChangePassword(Boolean.FALSE); - roleAccount.setFailedCount(0); - roleAccount.setLockedUntil(null); - roleAccount.setStatus(RoleAccountStatus.ACTIVE.name()); - roleAccountRepository.update(roleAccount); - } - - private void validateRoleStatus(RoleAccountEntity roleAccount) { - if (RoleAccountStatus.UNENABLED.name().equals(roleAccount.getStatus())) { - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role is not enabled"); - } - if (RoleAccountStatus.LOCKED.name().equals(roleAccount.getStatus()) && isStillLocked(roleAccount)) { - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role account is locked"); - } - } - - private boolean isStillLocked(RoleAccountEntity roleAccount) { - return roleAccount.getLockedUntil() == null || roleAccount.getLockedUntil().isAfter(now()); - } - - private void onPasswordFailed(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"); - } - roleAccountRepository.update(roleAccount); - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "password is incorrect"); - } - - private void resetFailureState(RoleAccountEntity roleAccount) { - roleAccount.setFailedCount(0); - roleAccount.setLockedUntil(null); - roleAccount.setStatus(RoleAccountStatus.ACTIVE.name()); - roleAccount.setLastLoginAt(now()); - roleAccount.setLastActiveAt(now()); - roleAccountRepository.update(roleAccount); - } - - private AuthMethod resolveAuthMethod(List ukeySerials) { - return (ukeySerials == null || ukeySerials.isEmpty()) ? AuthMethod.PASSWORD : AuthMethod.UKEY; - } - - private void ensureMasterKeyReady() { - LmkService service = requireLmkService(); - MasterKeyStatus.StatusDetail status = service.getMasterKeyStatus(); - if (status == null || status.getCode() == MasterKeyStatus.ABNORMAL.getCode()) { - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "master key is not ready"); - } - } - - private AuthLevel resolveAuthLevel(RoleAccountEntity roleAccount, List ukeySerials) { - if (ukeySerials == null || ukeySerials.isEmpty()) { - return AuthLevel.LIMITED; - } - - Set uniqueSerials = new LinkedHashSet<>(ukeySerials); - if (uniqueSerials.size() != roleAccount.getRequiredUkeyCount()) { - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey count does not satisfy role requirement"); - } - - List bindings = roleUkeyBindingRepository.findActiveByRoleCode(roleAccount.getRoleCode()); - if (bindings.size() != roleAccount.getRequiredUkeyCount()) { - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey count does not satisfy role requirement"); - } - - Set boundSerials = bindings.stream() - .map(RoleUkeyBindingEntity::getUkeySerial) - .collect(java.util.stream.Collectors.toSet()); - if (!boundSerials.containsAll(uniqueSerials)) { - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey verification failed"); - } - return AuthLevel.FULL; - } - - private void validateUkeyCount( - RoleCode roleCode, - List activeBindings, - List proofs - ) { - if (proofs == null || proofs.size() != roleCode.getRequiredUkeyCount()) { - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey count does not satisfy role requirement"); - } - if (activeBindings.size() != roleCode.getRequiredUkeyCount()) { - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey count does not satisfy role requirement"); - } - } - - private String buildIssuePayload(String roleCode, UkeyLoginProof proof, String authKeyPair) { - UKeySignDTO dto = new UKeySignDTO(); - dto.setPubKey(proof.getPubKey()); - // 新卡统一使用规范角色编码参与签名和验签。 - dto.setRole(RoleCode.valueOf(roleCode).getCode()); - dto.setUid(proof.getUid()); - dto.setRid(proof.getRid()); - try { - return objectMapper.writeValueAsString(UKeySignEntity.getInstance(dto, authKeyPair)); - } catch (JsonProcessingException ex) { - throw new IllegalStateException("serialize ukey issue payload failed", ex); - } - } - - private AuthSessionEntity buildSession(String roleCode, AuthMethod authMethod, AuthLevel authLevel) { - LocalDateTime issuedAt = now(); - AuthSessionEntity entity = new AuthSessionEntity(); - entity.setRoleCode(roleCode); - entity.setAuthMethod(authMethod.name()); - entity.setAuthLevel(authLevel.name()); - entity.setSessionToken(tokenSupplier.get()); - entity.setIssuedAt(issuedAt); - entity.setLastActiveAt(issuedAt); - entity.setExpiresAt(issuedAt.plusMinutes(IDLE_TIMEOUT_MINUTES)); - return entity; - } - - private LocalDateTime now() { - return LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); - } - - private AuthSessionEntity requireActiveSession(String sessionToken) { - AuthSessionEntity session = authSessionRepository.findBySessionToken(sessionToken) - .orElseThrow(() -> new BizException(ErrorCode.UNAUTHORIZED.getCode(), "session not found")); - if (session.getLogoutAt() != null || session.getExpiresAt() == null || !session.getExpiresAt().isAfter(now())) { - throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "session expired"); - } - return session; - } - - private LmkService requireLmkService() { - if (lmkService == null) { - throw new IllegalStateException("lmkService is required for this operation"); - } - return lmkService; - } - - private UkeyLoginRandomService requireUkeyLoginRandomService() { - if (ukeyLoginRandomService == null) { - throw new IllegalStateException("ukeyLoginRandomService is required for this operation"); - } - return ukeyLoginRandomService; - } - - private CompatUkeyVerifier requireCompatUkeyVerifier() { - if (compatUkeyVerifier == null) { - throw new IllegalStateException("compatUkeyVerifier is required for this operation"); - } - return compatUkeyVerifier; - } - - private CaptchaService requireCaptchaService() { - if (captchaService == null) { - throw new IllegalStateException("captchaService is required for this operation"); - } - return captchaService; - } + void changePassword(String sessionToken, String currentPassword, String newPassword); } diff --git a/src/main/java/com/cisd/tms/modules/auth/service/PasswordSaltGenerator.java b/src/main/java/com/cisd/tms/modules/auth/service/PasswordSaltGenerator.java new file mode 100644 index 0000000..6dd06f5 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/auth/service/PasswordSaltGenerator.java @@ -0,0 +1,7 @@ +package com.cisd.tms.modules.auth.service; + +@FunctionalInterface +public interface PasswordSaltGenerator { + + String nextSalt(); +} diff --git a/src/main/java/com/cisd/tms/modules/auth/service/SessionTokenGenerator.java b/src/main/java/com/cisd/tms/modules/auth/service/SessionTokenGenerator.java new file mode 100644 index 0000000..41d53f7 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/auth/service/SessionTokenGenerator.java @@ -0,0 +1,7 @@ +package com.cisd.tms.modules.auth.service; + +@FunctionalInterface +public interface SessionTokenGenerator { + + String nextToken(); +} diff --git a/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthAdminServiceImpl.java b/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthAdminServiceImpl.java new file mode 100644 index 0000000..c1510c4 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthAdminServiceImpl.java @@ -0,0 +1,215 @@ +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.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.RoleAccountRepository; +import com.cisd.tms.modules.auth.repository.RoleUkeyBindingRepository; +import com.cisd.tms.modules.auth.service.AuthAdminService; +import com.cisd.tms.modules.auth.service.PasswordHasher; +import com.cisd.tms.modules.auth.service.PasswordSaltGenerator; +import com.cisd.tms.modules.mk.dto.MasterKeyBackupPacket; +import com.cisd.tms.modules.mk.dto.UKeySignDTO; +import com.cisd.tms.modules.mk.dto.UKeySignEntity; +import com.cisd.tms.modules.mk.dto.UKeySignResult; +import com.cisd.tms.modules.mk.service.LmkService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Objects; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +public class AuthAdminServiceImpl implements AuthAdminService { + + static final String DEFAULT_PASSWORD = "12345678"; + + private final RoleAccountRepository roleAccountRepository; + private final RoleUkeyBindingRepository roleUkeyBindingRepository; + private final PasswordHasher passwordHasher; + private final LmkService lmkService; + private final ObjectMapper objectMapper; + private final Clock clock; + private final PasswordSaltGenerator passwordSaltGenerator; + + @Override + public void enableRole(String operatorRoleCode, String operatorAuthLevel, String targetRoleCode) { + requireKeyAdminFull(operatorRoleCode, operatorAuthLevel); + RoleAccountEntity target = loadRole(targetRoleCode); + target.setStatus(RoleAccountStatus.ACTIVE.name()); + target.setNeedChangePassword(Boolean.TRUE); + target.setLockedUntil(null); + target.setFailedCount(0); + roleAccountRepository.update(target); + } + + @Override + public void resetPassword(String operatorRoleCode, String operatorAuthLevel, String targetRoleCode) { + requireKeyAdminFull(operatorRoleCode, operatorAuthLevel); + RoleAccountEntity target = loadRole(targetRoleCode); + String newSalt = passwordSaltGenerator.nextSalt(); + target.setPasswordSalt(newSalt); + target.setPasswordHash(passwordHasher.hash(DEFAULT_PASSWORD, newSalt)); + target.setStatus(RoleAccountStatus.ACTIVE.name()); + target.setNeedChangePassword(Boolean.TRUE); + target.setFailedCount(0); + target.setLockedUntil(null); + roleAccountRepository.update(target); + } + + @Override + public void bindUkey( + String operatorRoleCode, + String operatorAuthLevel, + String targetRoleCode, + Integer slotNo, + String ukeySerial, + String ukeyPubkey, + String uid, + String rid, + String issuerSign + ) { + requireKeyAdminFull(operatorRoleCode, operatorAuthLevel); + RoleCode targetRole = resolveRoleCode(targetRoleCode); + if (slotNo == null || slotNo < 1 || slotNo > targetRole.getRequiredUkeyCount()) { + throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "slotNo exceeds role ukey requirement"); + } + + RoleUkeyBindingEntity binding = roleUkeyBindingRepository + .findActiveByRoleCodeAndSlotNo(targetRoleCode, slotNo) + .orElseGet(RoleUkeyBindingEntity::new); + if (binding.getId() == null) { + binding.setId((long) Math.abs(Objects.hash(targetRoleCode, slotNo, ukeySerial, TraceIdUtil.newTraceId()))); + binding.setRoleCode(targetRoleCode); + binding.setSlotNo(slotNo); + binding.setStatus("ACTIVE"); + applyBinding(binding, ukeySerial, ukeyPubkey, uid, rid, issuerSign); + roleUkeyBindingRepository.save(binding); + return; + } + + applyBinding(binding, ukeySerial, ukeyPubkey, uid, rid, issuerSign); + roleUkeyBindingRepository.update(binding); + } + + @Override + public void bindIssuedUkey( + String operatorRoleCode, + String operatorAuthLevel, + String targetRoleCode, + Integer slotNo, + String ukeySerial, + String ukeyPubkey, + String uid, + String rid, + String issuerSign + ) { + requireKeyAdminFull(operatorRoleCode, operatorAuthLevel); + resolveRoleCode(targetRoleCode); + verifyIssuerSignature(targetRoleCode, ukeyPubkey, uid, rid, issuerSign); + bindUkey(operatorRoleCode, operatorAuthLevel, targetRoleCode, slotNo, ukeySerial, ukeyPubkey, uid, rid, issuerSign); + } + + @Override + public UKeySignResult issueUkeyBindingSign( + String operatorRoleCode, + String operatorAuthLevel, + String targetRoleCode, + UKeySignDTO request + ) { + requireKeyAdminFull(operatorRoleCode, operatorAuthLevel); + RoleCode targetRole = resolveRoleCode(targetRoleCode); + String authKeyPair = lmkService.exportIkPublicKeyHex(); + + UKeySignDTO dto = new UKeySignDTO(); + dto.setPubKey(request.getPubKey()); + dto.setRole(targetRole.getCode()); + dto.setUid(request.getUid()); + dto.setRid(request.getRid()); + + String signValue = lmkService.signIk(toIssuePayload(dto, authKeyPair)); + MasterKeyBackupPacket backupPacket = null; + if (RoleCode.SUPER_ADMIN == targetRole) { + backupPacket = lmkService.buildBackupPacket(Integer.parseInt(request.getUid())); + } + return UKeySignResult.builder() + .sign(signValue) + .backupPacket(backupPacket) + .build(); + } + + private void applyBinding( + RoleUkeyBindingEntity binding, + String ukeySerial, + String ukeyPubkey, + String uid, + String rid, + String issuerSign + ) { + binding.setUkeySerial(ukeySerial); + binding.setUkeyPubkey(ukeyPubkey); + binding.setUid(uid); + binding.setRid(rid); + binding.setIssuerSign(issuerSign); + binding.setStatus("ACTIVE"); + binding.setBoundAt(now()); + binding.setUnboundAt(null); + } + + private void requireKeyAdminFull(String operatorRoleCode, String operatorAuthLevel) { + if (!RoleCode.KEY_ADMIN.getCode().equals(operatorRoleCode) || !AuthLevel.FULL.name().equals(operatorAuthLevel)) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "operation requires KEY_ADMIN FULL session"); + } + } + + private RoleAccountEntity loadRole(String roleCode) { + resolveRoleCode(roleCode); + return roleAccountRepository.findByRoleCode(roleCode) + .orElseThrow(() -> new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "target role account not found")); + } + + private RoleCode resolveRoleCode(String roleCode) { + return List.of(RoleCode.values()).stream() + .filter(item -> item.getCode().equals(roleCode)) + .findFirst() + .orElseThrow(() -> new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "invalid roleCode")); + } + + private LocalDateTime now() { + return LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); + } + + private void verifyIssuerSignature(String targetRoleCode, String ukeyPubkey, String uid, String rid, String issuerSign) { + try { + UKeySignDTO dto = new UKeySignDTO(); + dto.setPubKey(ukeyPubkey); + dto.setRole(resolveRoleCode(targetRoleCode).getCode()); + dto.setUid(uid); + dto.setRid(rid); + String authKeyPair = lmkService.exportIkPublicKeyHex(); + lmkService.verifyIk(toIssuePayload(dto, authKeyPair), issuerSign); + } catch (BizException ex) { + throw ex; + } catch (RuntimeException ex) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey issuer signature verification failed"); + } + } + + private String toIssuePayload(UKeySignDTO dto, String authKeyPair) { + try { + return objectMapper.writeValueAsString(UKeySignEntity.getInstance(dto, authKeyPair)); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("serialize ukey issue payload failed", ex); + } + } +} diff --git a/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthPolicyServiceImpl.java b/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthPolicyServiceImpl.java new file mode 100644 index 0000000..be3699d --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthPolicyServiceImpl.java @@ -0,0 +1,29 @@ +package com.cisd.tms.modules.auth.service.impl; + +import com.cisd.tms.modules.auth.enums.AuthLevel; +import com.cisd.tms.modules.auth.enums.AuthMethod; +import com.cisd.tms.modules.auth.enums.RoleCode; +import com.cisd.tms.modules.auth.service.AuthPolicyService; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +public class AuthPolicyServiceImpl implements AuthPolicyService { + + /** + * 统一根据认证方式推导本次登录会话的认证等级。 + */ + @Override + public AuthLevel resolveAuthLevel(AuthMethod authMethod) { + return authMethod == AuthMethod.UKEY ? AuthLevel.FULL : AuthLevel.LIMITED; + } + + /** + * 统一读取角色要求的 UKey 数量,避免控制器和服务层散落硬编码。 + */ + @Override + public int requiredUkeyCount(RoleCode roleCode) { + return roleCode.getRequiredUkeyCount(); + } +} diff --git a/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthServiceImpl.java new file mode 100644 index 0000000..f57edbf --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/auth/service/impl/AuthServiceImpl.java @@ -0,0 +1,336 @@ +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.CaptchaResponse; +import com.cisd.tms.modules.auth.dto.CurrentUserResponse; +import com.cisd.tms.modules.auth.dto.LoginRequest; +import com.cisd.tms.modules.auth.dto.LoginResponse; +import com.cisd.tms.modules.auth.dto.PasswordLoginRequest; +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.AuthSessionEntity; +import com.cisd.tms.modules.auth.entity.AuthUserEntity; +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.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.AuthUserRepository; +import com.cisd.tms.modules.auth.repository.RoleAccountRepository; +import com.cisd.tms.modules.auth.repository.RoleUkeyBindingRepository; +import com.cisd.tms.modules.auth.service.*; +import com.cisd.tms.modules.mk.dto.UKeySignDTO; +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.databind.ObjectMapper; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +public class AuthServiceImpl implements AuthService { + + private static final int MAX_FAILED_ATTEMPTS = 5; + private static final int IDLE_TIMEOUT_MINUTES = 10; + + private final RoleAccountRepository roleAccountRepository; + private final AuthUserRepository authUserRepository; + private final AuthSessionRepository authSessionRepository; + private final RoleUkeyBindingRepository roleUkeyBindingRepository; + private final PasswordHasher passwordHasher; + private final LmkService lmkService; + private final UkeyLoginRandomService ukeyLoginRandomService; + private final CompatUkeyVerifier compatUkeyVerifier; + private final CaptchaService captchaService; + private final ObjectMapper objectMapper; + private final Clock clock; + private final SessionTokenGenerator sessionTokenGenerator; + private final PasswordSaltGenerator passwordSaltGenerator; + private final AuthPolicyService authPolicyService; + + @Override + public LoginResponse login(LoginRequest request) { + RoleAccountEntity roleAccount = roleAccountRepository.findByRoleCode(request.getRoleCode()) + .orElseThrow(() -> new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role account not found")); + + validateRoleStatus(roleAccount); + if (!passwordHasher.matches(request.getPassword(), roleAccount.getPasswordSalt(), roleAccount.getPasswordHash())) { + onPasswordFailed(roleAccount); + } + + resetFailureState(roleAccount); + AuthMethod authMethod = resolveAuthMethod(request.getUkeySerials()); + AuthLevel authLevel = resolveAuthLevel(roleAccount, request.getUkeySerials()); + AuthSessionEntity session = buildSession(roleAccount.getRoleCode(), authMethod, authLevel); + authSessionRepository.save(session); + + LoginResponse response = new LoginResponse(); + response.setRoleCode(roleAccount.getRoleCode()); + response.setAuthLevel(authLevel.name()); + response.setToken(session.getSessionToken()); + response.setExpiresAt(OffsetDateTime.of(session.getExpiresAt(), ZoneOffset.UTC).toString()); + response.setNeedChangePassword(Boolean.TRUE.equals(roleAccount.getNeedChangePassword())); + return response; + } + + @Override + public LoginResponse passwordLogin(PasswordLoginRequest request) { + ensureMasterKeyReady(); + captchaService.verify(request.getCaptchaId(), request.getCaptchaCode()); + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setRoleCode(request.getRoleCode()); + loginRequest.setPassword(request.getPassword()); + return login(loginRequest); + } + + @Override + public UkeyLoginRandomResponse issueUkeyLoginRandoms(UkeyLoginRandomRequest request) { + RoleCode roleCode = RoleCode.valueOf(request.getRoleCode()); + UkeyLoginRandomResponse response = new UkeyLoginRandomResponse(); + response.setRoleCode(roleCode.getCode()); + response.setRandoms(ukeyLoginRandomService.issue(roleCode.getCode(), authPolicyService.requiredUkeyCount(roleCode))); + return response; + } + + @Override + public LoginResponse ukeyLogin(UkeyLoginRequest request) { + ensureMasterKeyReady(); + RoleCode roleCode = RoleCode.valueOf(request.getRoleCode()); + List activeBindings = roleUkeyBindingRepository.findActiveByRoleCode(roleCode.getCode()); + validateUkeyCount(roleCode, activeBindings, request.getUkeyProofs()); + Map bindingsByPubKey = activeBindings.stream() + .collect(Collectors.toMap(RoleUkeyBindingEntity::getUkeyPubkey, item -> item, (left, right) -> left, java.util.LinkedHashMap::new)); + Set requestPubKeys = request.getUkeyProofs().stream() + .map(UkeyLoginProof::getPubKey) + .collect(Collectors.toSet()); + if (requestPubKeys.size() != request.getUkeyProofs().size() || !bindingsByPubKey.keySet().containsAll(requestPubKeys)) { + 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() + ); + String authKeyPair = lmkService.exportIkPublicKeyHex(); + List matchedSerials = new java.util.ArrayList<>(); + for (UkeyLoginProof proof : request.getUkeyProofs()) { + RoleUkeyBindingEntity binding = bindingsByPubKey.get(proof.getPubKey()); + compatUkeyVerifier.verifyIssuedBinding(buildIssuePayload(request.getRoleCode(), proof, authKeyPair), proof.getIssueSignature()); + compatUkeyVerifier.verifyLoginSignature(proof.getPubKey(), proof.getLoginPayload(), proof.getLoginSignature()); + matchedSerials.add(binding.getUkeySerial()); + } + LoginRequest loginRequest = new LoginRequest(); + loginRequest.setRoleCode(request.getRoleCode()); + loginRequest.setPassword(request.getPassword()); + loginRequest.setUkeySerials(matchedSerials); + return login(loginRequest); + } + + @Override + public CaptchaResponse issueCaptcha() { + return captchaService.issueCaptcha(); + } + + @Override + public CurrentUserResponse me(String username) { + return me(username, null); + } + + @Override + public CurrentUserResponse me(String username, String sessionToken) { + String normalized = username == null || username.isBlank() ? "admin" : username; + RoleAccountEntity roleAccount = roleAccountRepository.findByRoleCode(normalized).orElse(null); + if (roleAccount != null) { + CurrentUserResponse response = new CurrentUserResponse(); + response.setUsername(roleAccount.getRoleCode()); + response.setDisplayName(roleAccount.getDisplayName()); + response.setRole(roleAccount.getRoleCode()); + AuthSessionEntity session = sessionToken == null ? null : authSessionRepository.findBySessionToken(sessionToken).orElse(null); + if (session != null) { + response.setAuthLevel(session.getAuthLevel()); + } + response.setNeedChangePassword(Boolean.TRUE.equals(roleAccount.getNeedChangePassword())); + return response; + } + + AuthUserEntity entity = authUserRepository.findByUsername(normalized).orElse(null); + + CurrentUserResponse response = new CurrentUserResponse(); + if (entity == null) { + response.setUsername(normalized); + response.setDisplayName("TMS Administrator"); + response.setRole("ADMIN"); + return response; + } + + response.setUsername(entity.getUsername()); + response.setDisplayName(entity.getDisplayName()); + response.setRole(entity.getRole()); + return response; + } + + @Override + public void logout(String sessionToken) { + AuthSessionEntity session = requireActiveSession(sessionToken); + session.setLogoutAt(now()); + session.setExpiresAt(now()); + authSessionRepository.update(session); + } + + @Override + public void changePassword(String sessionToken, String currentPassword, String newPassword) { + AuthSessionEntity session = requireActiveSession(sessionToken); + RoleAccountEntity roleAccount = roleAccountRepository.findByRoleCode(session.getRoleCode()) + .orElseThrow(() -> new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role account not found")); + if (!passwordHasher.matches(currentPassword, roleAccount.getPasswordSalt(), roleAccount.getPasswordHash())) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "current password is incorrect"); + } + + String newSalt = passwordSaltGenerator.nextSalt(); + roleAccount.setPasswordSalt(newSalt); + roleAccount.setPasswordHash(passwordHasher.hash(newPassword, newSalt)); + roleAccount.setNeedChangePassword(Boolean.FALSE); + roleAccount.setFailedCount(0); + roleAccount.setLockedUntil(null); + roleAccount.setStatus(RoleAccountStatus.ACTIVE.name()); + roleAccountRepository.update(roleAccount); + } + + private void validateRoleStatus(RoleAccountEntity roleAccount) { + if (RoleAccountStatus.UNENABLED.name().equals(roleAccount.getStatus())) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role is not enabled"); + } + if (RoleAccountStatus.LOCKED.name().equals(roleAccount.getStatus()) && isStillLocked(roleAccount)) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "role account is locked"); + } + } + + private boolean isStillLocked(RoleAccountEntity roleAccount) { + return roleAccount.getLockedUntil() == null || roleAccount.getLockedUntil().isAfter(now()); + } + + private void onPasswordFailed(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"); + } + roleAccountRepository.update(roleAccount); + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "password is incorrect"); + } + + private void resetFailureState(RoleAccountEntity roleAccount) { + roleAccount.setFailedCount(0); + roleAccount.setLockedUntil(null); + roleAccount.setStatus(RoleAccountStatus.ACTIVE.name()); + roleAccount.setLastLoginAt(now()); + roleAccount.setLastActiveAt(now()); + roleAccountRepository.update(roleAccount); + } + + private void ensureMasterKeyReady() { + MasterKeyStatus.StatusDetail status = lmkService.getMasterKeyStatus(); + if (status == null || status.getCode() == MasterKeyStatus.ABNORMAL.getCode()) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "master key is not ready"); + } + } + + private AuthMethod resolveAuthMethod(List ukeySerials) { + return (ukeySerials == null || ukeySerials.isEmpty()) ? AuthMethod.PASSWORD : AuthMethod.UKEY; + } + + private AuthLevel resolveAuthLevel(RoleAccountEntity roleAccount, List ukeySerials) { + if (ukeySerials == null || ukeySerials.isEmpty()) { + return authPolicyService.resolveAuthLevel(AuthMethod.PASSWORD); + } + + Set uniqueSerials = new LinkedHashSet<>(ukeySerials); + if (uniqueSerials.size() != roleAccount.getRequiredUkeyCount()) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey count does not satisfy role requirement"); + } + + List bindings = roleUkeyBindingRepository.findActiveByRoleCode(roleAccount.getRoleCode()); + if (bindings.size() != roleAccount.getRequiredUkeyCount()) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey count does not satisfy role requirement"); + } + + Set boundSerials = bindings.stream() + .map(RoleUkeyBindingEntity::getUkeySerial) + .collect(java.util.stream.Collectors.toSet()); + if (!boundSerials.containsAll(uniqueSerials)) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey verification failed"); + } + return authPolicyService.resolveAuthLevel(AuthMethod.UKEY); + } + + private void validateUkeyCount( + RoleCode roleCode, + List activeBindings, + List proofs + ) { + if (proofs == null || proofs.size() != authPolicyService.requiredUkeyCount(roleCode)) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey count does not satisfy role requirement"); + } + if (activeBindings.size() != authPolicyService.requiredUkeyCount(roleCode)) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "ukey count does not satisfy role requirement"); + } + } + + private String buildIssuePayload(String roleCode, UkeyLoginProof proof, String authKeyPair) { + UKeySignDTO dto = new UKeySignDTO(); + dto.setPubKey(proof.getPubKey()); + dto.setRole(RoleCode.valueOf(roleCode).getCode()); + dto.setUid(proof.getUid()); + dto.setRid(proof.getRid()); + try { + return objectMapper.writeValueAsString(UKeySignEntity.getInstance(dto, authKeyPair)); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("serialize ukey issue payload failed", ex); + } + } + + private AuthSessionEntity buildSession(String roleCode, AuthMethod authMethod, AuthLevel authLevel) { + LocalDateTime issuedAt = now(); + AuthSessionEntity entity = new AuthSessionEntity(); + entity.setRoleCode(roleCode); + entity.setAuthMethod(authMethod.name()); + entity.setAuthLevel(authLevel.name()); + entity.setSessionToken(sessionTokenGenerator.nextToken()); + entity.setIssuedAt(issuedAt); + entity.setLastActiveAt(issuedAt); + entity.setExpiresAt(issuedAt.plusMinutes(IDLE_TIMEOUT_MINUTES)); + return entity; + } + + private LocalDateTime now() { + return LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); + } + + private AuthSessionEntity requireActiveSession(String sessionToken) { + AuthSessionEntity session = authSessionRepository.findBySessionToken(sessionToken) + .orElseThrow(() -> new BizException(ErrorCode.UNAUTHORIZED.getCode(), "session not found")); + if (session.getLogoutAt() != null || session.getExpiresAt() == null || !session.getExpiresAt().isAfter(now())) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "session expired"); + } + return session; + } +} diff --git a/src/main/java/com/cisd/tms/modules/auth/service/impl/InMemoryCaptchaService.java b/src/main/java/com/cisd/tms/modules/auth/service/impl/InMemoryCaptchaService.java new file mode 100644 index 0000000..809b150 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/auth/service/impl/InMemoryCaptchaService.java @@ -0,0 +1,131 @@ +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.CaptchaResponse; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import javax.imageio.ImageIO; + +import com.cisd.tms.modules.auth.service.CaptchaService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class InMemoryCaptchaService implements CaptchaService { + + private static final long EXPIRE_MINUTES = 10; + private static final int CAPTCHA_LENGTH = 4; + private static final int IMAGE_WIDTH = 130; + private static final int IMAGE_HEIGHT = 40; + private static final char[] CAPTCHA_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".toCharArray(); + + private final Clock clock; + private final Map store = new ConcurrentHashMap<>(); + + @Autowired + public InMemoryCaptchaService() { + this(Clock.systemUTC()); + } + + InMemoryCaptchaService(Clock clock) { + this.clock = clock; + } + + @Override + public CaptchaResponse issueCaptcha() { + String captchaCode = randomCaptchaCode(); + String captchaId = "captcha-" + UUID.randomUUID(); + store.put(captchaId, new CaptchaEntry(captchaCode, clock.instant().plus(EXPIRE_MINUTES, ChronoUnit.MINUTES))); + + CaptchaResponse response = new CaptchaResponse(); + response.setCaptchaId(captchaId); +// response.setImageBase64(Base64.getEncoder().encodeToString("ABCD".getBytes(StandardCharsets.UTF_8))); + response.setImageBase64(renderCaptchaImageBase64(captchaCode)); + return response; + } + + @Override + public void verify(String captchaId, String captchaCode) { + CaptchaEntry entry = store.remove(captchaId); + if (entry == null || entry.expiresAt().isBefore(clock.instant())) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "captcha verification failed"); + } + if (captchaCode == null || !entry.code().equalsIgnoreCase(captchaCode.trim())) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "captcha verification failed"); + } + } + + private record CaptchaEntry(String code, Instant expiresAt) { + } + + private String randomCaptchaCode() { + StringBuilder builder = new StringBuilder(CAPTCHA_LENGTH); + for (int i = 0; i < CAPTCHA_LENGTH; i++) { + builder.append(CAPTCHA_CHARS[ThreadLocalRandom.current().nextInt(CAPTCHA_CHARS.length)]); + } + return builder.toString(); + } + + private String renderCaptchaImageBase64(String captchaCode) { + try { + BufferedImage image = new BufferedImage(IMAGE_WIDTH, IMAGE_HEIGHT, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + try { + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT); + + // 适度加干扰线,避免返回“纯文本图片”。 + graphics.setStroke(new BasicStroke(1.2f)); + for (int i = 0; i < 6; i++) { + graphics.setColor(randomColor(80, 180)); + graphics.drawLine( + ThreadLocalRandom.current().nextInt(IMAGE_WIDTH), + ThreadLocalRandom.current().nextInt(IMAGE_HEIGHT), + ThreadLocalRandom.current().nextInt(IMAGE_WIDTH), + ThreadLocalRandom.current().nextInt(IMAGE_HEIGHT) + ); + } + + graphics.setFont(new Font("SansSerif", Font.BOLD, 28)); + for (int i = 0; i < captchaCode.length(); i++) { + graphics.setColor(randomColor(30, 140)); + graphics.drawString(String.valueOf(captchaCode.charAt(i)), 18 + i * 24, 30 + ThreadLocalRandom.current().nextInt(4)); + } + } finally { + graphics.dispose(); + } + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ImageIO.write(image, "png", outputStream); + return Base64.getEncoder().encodeToString(outputStream.toByteArray()); + } catch (Exception ex) { + throw new IllegalStateException("generate captcha image failed", ex); + } + } + + private Color randomColor(int min, int max) { + int bound = max - min; + return new Color( + min + ThreadLocalRandom.current().nextInt(bound), + min + ThreadLocalRandom.current().nextInt(bound), + min + ThreadLocalRandom.current().nextInt(bound) + ); + } +} diff --git a/src/main/java/com/cisd/tms/modules/auth/service/InMemoryUkeyLoginRandomService.java b/src/main/java/com/cisd/tms/modules/auth/service/impl/InMemoryUkeyLoginRandomService.java similarity index 95% rename from src/main/java/com/cisd/tms/modules/auth/service/InMemoryUkeyLoginRandomService.java rename to src/main/java/com/cisd/tms/modules/auth/service/impl/InMemoryUkeyLoginRandomService.java index d9201a7..d5a53be 100644 --- a/src/main/java/com/cisd/tms/modules/auth/service/InMemoryUkeyLoginRandomService.java +++ b/src/main/java/com/cisd/tms/modules/auth/service/impl/InMemoryUkeyLoginRandomService.java @@ -1,4 +1,4 @@ -package com.cisd.tms.modules.auth.service; +package com.cisd.tms.modules.auth.service.impl; import com.cisd.tms.common.enums.ErrorCode; import com.cisd.tms.common.exception.BizException; @@ -10,6 +10,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; + +import com.cisd.tms.modules.auth.service.UkeyLoginRandomService; import org.bouncycastle.util.encoders.Hex; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/cisd/tms/modules/auth/service/Pbkdf2PasswordHasher.java b/src/main/java/com/cisd/tms/modules/auth/service/impl/Pbkdf2PasswordHasher.java similarity index 92% rename from src/main/java/com/cisd/tms/modules/auth/service/Pbkdf2PasswordHasher.java rename to src/main/java/com/cisd/tms/modules/auth/service/impl/Pbkdf2PasswordHasher.java index e93da15..5e5be13 100644 --- a/src/main/java/com/cisd/tms/modules/auth/service/Pbkdf2PasswordHasher.java +++ b/src/main/java/com/cisd/tms/modules/auth/service/impl/Pbkdf2PasswordHasher.java @@ -1,4 +1,4 @@ -package com.cisd.tms.modules.auth.service; +package com.cisd.tms.modules.auth.service.impl; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; @@ -6,6 +6,8 @@ import java.security.MessageDigest; import java.util.Base64; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.PBEKeySpec; + +import com.cisd.tms.modules.auth.service.PasswordHasher; import org.springframework.stereotype.Component; @Component diff --git a/src/main/java/com/cisd/tms/modules/auth/service/PcieCompatUkeyVerifier.java b/src/main/java/com/cisd/tms/modules/auth/service/impl/PcieCompatUkeyVerifier.java similarity index 96% rename from src/main/java/com/cisd/tms/modules/auth/service/PcieCompatUkeyVerifier.java rename to src/main/java/com/cisd/tms/modules/auth/service/impl/PcieCompatUkeyVerifier.java index 0df74c9..cf6837c 100644 --- a/src/main/java/com/cisd/tms/modules/auth/service/PcieCompatUkeyVerifier.java +++ b/src/main/java/com/cisd/tms/modules/auth/service/impl/PcieCompatUkeyVerifier.java @@ -1,10 +1,11 @@ -package com.cisd.tms.modules.auth.service; +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.nio.charset.StandardCharsets; import java.util.Base64; diff --git a/src/main/java/com/cisd/tms/modules/auth/service/impl/UuidPasswordSaltGenerator.java b/src/main/java/com/cisd/tms/modules/auth/service/impl/UuidPasswordSaltGenerator.java new file mode 100644 index 0000000..635bc2b --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/auth/service/impl/UuidPasswordSaltGenerator.java @@ -0,0 +1,15 @@ +package com.cisd.tms.modules.auth.service.impl; + +import java.util.UUID; + +import com.cisd.tms.modules.auth.service.PasswordSaltGenerator; +import org.springframework.stereotype.Service; + +@Service +public class UuidPasswordSaltGenerator implements PasswordSaltGenerator { + + @Override + public String nextSalt() { + return UUID.randomUUID().toString().replace("-", ""); + } +} diff --git a/src/main/java/com/cisd/tms/modules/auth/service/impl/UuidSessionTokenGenerator.java b/src/main/java/com/cisd/tms/modules/auth/service/impl/UuidSessionTokenGenerator.java new file mode 100644 index 0000000..3b2edb9 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/auth/service/impl/UuidSessionTokenGenerator.java @@ -0,0 +1,15 @@ +package com.cisd.tms.modules.auth.service.impl; + +import java.util.UUID; + +import com.cisd.tms.modules.auth.service.SessionTokenGenerator; +import org.springframework.stereotype.Service; + +@Service +public class UuidSessionTokenGenerator implements SessionTokenGenerator { + + @Override + public String nextToken() { + return "tms-" + UUID.randomUUID(); + } +} diff --git a/src/test/java/com/cisd/tms/TmsApplicationTests.java b/src/test/java/com/cisd/tms/TmsApplicationTests.java index bd4a89a..caf1096 100644 --- a/src/test/java/com/cisd/tms/TmsApplicationTests.java +++ b/src/test/java/com/cisd/tms/TmsApplicationTests.java @@ -9,6 +9,7 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -42,6 +43,14 @@ class TmsApplicationTests { .andExpect(content().string(org.hamcrest.Matchers.containsString("\"summary\":\"UKey 登录\""))); } + @Test + void shouldIssueCaptchaThroughAuthEndpoint() throws Exception { + mockMvc.perform(post("/api/v1/auth/captcha")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"captchaId\""))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"imageBase64\""))); + } + @Test void shouldCreateAuthRoleTablesAndSeedFixedRoleAccounts() { ClassPathResource migration = new ClassPathResource("db/migration/V1__tms_schema_full.sql"); diff --git a/src/test/java/com/cisd/tms/modules/auth/service/AuthAdminServiceTest.java b/src/test/java/com/cisd/tms/modules/auth/service/AuthAdminServiceTest.java index 9329d2a..a7f49fd 100644 --- a/src/test/java/com/cisd/tms/modules/auth/service/AuthAdminServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/auth/service/AuthAdminServiceTest.java @@ -8,6 +8,7 @@ import com.cisd.tms.modules.auth.enums.RoleAccountStatus; import com.cisd.tms.modules.auth.enums.RoleCode; import com.cisd.tms.modules.auth.repository.RoleAccountRepository; import com.cisd.tms.modules.auth.repository.RoleUkeyBindingRepository; +import com.cisd.tms.modules.auth.service.impl.AuthAdminServiceImpl; import com.cisd.tms.modules.mk.dto.UKeySignDTO; import com.cisd.tms.modules.mk.dto.UKeySignResult; import com.cisd.tms.modules.mk.service.LmkService; @@ -35,10 +36,9 @@ class AuthAdminServiceTest { InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository(); roleAccounts.save(role(RoleCode.AUDIT_ADMIN, RoleAccountStatus.UNENABLED, "OLD-HASH", "OLD-SALT", true)); - AuthAdminService service = new AuthAdminService( + AuthAdminService service = newAuthAdminService( roleAccounts, new InMemoryRoleUkeyBindingRepository(), - new FakePasswordHasher(), FIXED_CLOCK, new FixedSaltSupplier("salt-001") ); @@ -52,10 +52,9 @@ class AuthAdminServiceTest { @Test void shouldRejectEnableRoleWhenOperatorIsNotKeyAdminFull() { - AuthAdminService service = new AuthAdminService( + AuthAdminService service = newAuthAdminService( new InMemoryRoleAccountRepository(), new InMemoryRoleUkeyBindingRepository(), - new FakePasswordHasher(), FIXED_CLOCK, new FixedSaltSupplier("salt-001") ); @@ -76,10 +75,9 @@ class AuthAdminServiceTest { target.setLockedUntil(LocalDateTime.of(2026, 3, 23, 3, 30)); roleAccounts.save(target); - AuthAdminService service = new AuthAdminService( + AuthAdminService service = newAuthAdminService( roleAccounts, new InMemoryRoleUkeyBindingRepository(), - new FakePasswordHasher(), FIXED_CLOCK, new FixedSaltSupplier("salt-002") ); @@ -102,11 +100,12 @@ class AuthAdminServiceTest { LmkService lmkService = org.mockito.Mockito.mock(LmkService.class); org.mockito.Mockito.when(lmkService.exportIkPublicKeyHex()).thenReturn("IK-PUB-001"); - AuthAdminService service = new AuthAdminService( + AuthAdminService service = new AuthAdminServiceImpl( new InMemoryRoleAccountRepository(), bindings, - lmkService, new FakePasswordHasher(), + lmkService, + new com.fasterxml.jackson.databind.ObjectMapper(), FIXED_CLOCK, new FixedSaltSupplier("salt-001") ); @@ -139,11 +138,12 @@ class AuthAdminServiceTest { @Test void shouldRejectBindingWhenSlotExceedsRoleRequirement() { LmkService lmkService = org.mockito.Mockito.mock(LmkService.class); - AuthAdminService service = new AuthAdminService( + AuthAdminService service = new AuthAdminServiceImpl( new InMemoryRoleAccountRepository(), new InMemoryRoleUkeyBindingRepository(), - lmkService, new FakePasswordHasher(), + lmkService, + new com.fasterxml.jackson.databind.ObjectMapper(), FIXED_CLOCK, new FixedSaltSupplier("salt-001") ); @@ -175,11 +175,12 @@ class AuthAdminServiceTest { packet.setComponentIndex(1); org.mockito.Mockito.when(lmkService.buildBackupPacket(1)).thenReturn(packet); - AuthAdminService service = new AuthAdminService( + AuthAdminService service = new AuthAdminServiceImpl( new InMemoryRoleAccountRepository(), new InMemoryRoleUkeyBindingRepository(), - lmkService, new FakePasswordHasher(), + lmkService, + new com.fasterxml.jackson.databind.ObjectMapper(), FIXED_CLOCK, new FixedSaltSupplier("salt-001") ); @@ -188,7 +189,7 @@ class AuthAdminServiceTest { dto.setRole("ignored"); dto.setUid("1"); dto.setRid("RID-001"); - dto.setExtra("EXTRA-001"); +// dto.setExtra("EXTRA-001"); UKeySignResult result = service.issueUkeyBindingSign(RoleCode.KEY_ADMIN.getCode(), AuthLevel.FULL.name(), RoleCode.SUPER_ADMIN.getCode(), dto); @@ -207,11 +208,12 @@ class AuthAdminServiceTest { org.mockito.Mockito.when(lmkService.exportIkPublicKeyHex()).thenReturn("IK-PUB-001"); org.mockito.Mockito.when(lmkService.signIk(org.mockito.ArgumentMatchers.anyString())).thenReturn("ISSUE-SIGN-002"); - AuthAdminService service = new AuthAdminService( + AuthAdminService service = new AuthAdminServiceImpl( new InMemoryRoleAccountRepository(), new InMemoryRoleUkeyBindingRepository(), - lmkService, new FakePasswordHasher(), + lmkService, + new com.fasterxml.jackson.databind.ObjectMapper(), FIXED_CLOCK, new FixedSaltSupplier("salt-001") ); @@ -220,7 +222,7 @@ class AuthAdminServiceTest { dto.setRole("ignored"); dto.setUid("1"); dto.setRid("RID-002"); - dto.setExtra("EXTRA-KEEP"); +// dto.setExtra("EXTRA-KEEP"); UKeySignResult result = service.issueUkeyBindingSign(RoleCode.KEY_ADMIN.getCode(), AuthLevel.FULL.name(), RoleCode.AUDIT_ADMIN.getCode(), dto); @@ -280,7 +282,7 @@ class AuthAdminServiceTest { } } - private static class FixedSaltSupplier implements java.util.function.Supplier { + private static class FixedSaltSupplier implements PasswordSaltGenerator { private final Deque salts; private FixedSaltSupplier(String... salts) { @@ -288,7 +290,7 @@ class AuthAdminServiceTest { } @Override - public String get() { + public String nextSalt() { return Optional.ofNullable(salts.pollFirst()).orElse("salt-fallback"); } } @@ -343,4 +345,21 @@ class AuthAdminServiceTest { list.add(entity); } } + + private static AuthAdminService newAuthAdminService( + InMemoryRoleAccountRepository roleAccounts, + InMemoryRoleUkeyBindingRepository bindings, + Clock clock, + PasswordSaltGenerator saltGenerator + ) { + return new AuthAdminServiceImpl( + roleAccounts, + bindings, + new FakePasswordHasher(), + org.mockito.Mockito.mock(LmkService.class), + new com.fasterxml.jackson.databind.ObjectMapper(), + clock, + saltGenerator + ); + } } diff --git a/src/test/java/com/cisd/tms/modules/auth/service/AuthDomainModelTest.java b/src/test/java/com/cisd/tms/modules/auth/service/AuthDomainModelTest.java index 8349c7b..a62caba 100644 --- a/src/test/java/com/cisd/tms/modules/auth/service/AuthDomainModelTest.java +++ b/src/test/java/com/cisd/tms/modules/auth/service/AuthDomainModelTest.java @@ -1,5 +1,6 @@ package com.cisd.tms.modules.auth.service; +import com.cisd.tms.modules.auth.service.impl.AuthPolicyServiceImpl; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -32,13 +33,13 @@ class AuthDomainModelTest { void shouldResolveExpectedAuthLevelByAuthMethod() { Assertions.assertEquals( com.cisd.tms.modules.auth.enums.AuthLevel.LIMITED, - new com.cisd.tms.modules.auth.service.AuthPolicyService().resolveAuthLevel( + new AuthPolicyServiceImpl().resolveAuthLevel( com.cisd.tms.modules.auth.enums.AuthMethod.PASSWORD ) ); Assertions.assertEquals( com.cisd.tms.modules.auth.enums.AuthLevel.FULL, - new com.cisd.tms.modules.auth.service.AuthPolicyService().resolveAuthLevel( + new AuthPolicyServiceImpl().resolveAuthLevel( com.cisd.tms.modules.auth.enums.AuthMethod.UKEY ) ); diff --git a/src/test/java/com/cisd/tms/modules/auth/service/AuthServiceTest.java b/src/test/java/com/cisd/tms/modules/auth/service/AuthServiceTest.java index 234e348..15d8a1a 100644 --- a/src/test/java/com/cisd/tms/modules/auth/service/AuthServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/auth/service/AuthServiceTest.java @@ -21,6 +21,8 @@ import com.cisd.tms.modules.auth.repository.AuthUserRepository; import com.cisd.tms.modules.auth.repository.AuthSessionRepository; import com.cisd.tms.modules.auth.repository.RoleAccountRepository; import com.cisd.tms.modules.auth.repository.RoleUkeyBindingRepository; +import com.cisd.tms.modules.auth.service.impl.AuthPolicyServiceImpl; +import com.cisd.tms.modules.auth.service.impl.AuthServiceImpl; import com.cisd.tms.modules.mk.enums.MasterKeyStatus; import com.cisd.tms.modules.mk.service.LmkService; import java.time.Clock; @@ -48,12 +50,10 @@ class AuthServiceTest { InMemoryRoleUkeyBindingRepository ukeyBindings = new InMemoryRoleUkeyBindingRepository(); roleAccounts.save(activeRole(RoleCode.AUDIT_ADMIN, "HASH:12345678:SALT-A", "SALT-A", 0, true)); - AuthService service = new AuthService( + AuthService service = newAuthService( roleAccounts, - new InMemoryLegacyAuthUserRepository(), sessions, ukeyBindings, - new FakePasswordHasher(), FIXED_CLOCK, () -> "token-limited-001" ); @@ -86,12 +86,10 @@ class AuthServiceTest { ukeyBindings.save(activeBinding(RoleCode.KEY_ADMIN, 1, "UK-1")); ukeyBindings.save(activeBinding(RoleCode.KEY_ADMIN, 2, "UK-2")); - AuthService service = new AuthService( + AuthService service = newAuthService( roleAccounts, - new InMemoryLegacyAuthUserRepository(), sessions, ukeyBindings, - new FakePasswordHasher(), FIXED_CLOCK, () -> "token-full-001" ); @@ -115,12 +113,10 @@ class AuthServiceTest { InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository(); roleAccounts.save(role(RoleCode.OPS_ADMIN, RoleAccountStatus.UNENABLED, "HASH:12345678:SALT-O", "SALT-O", 0, true, null)); - AuthService service = new AuthService( + AuthService service = newAuthService( roleAccounts, - new InMemoryLegacyAuthUserRepository(), new InMemoryAuthSessionRepository(), new InMemoryRoleUkeyBindingRepository(), - new FakePasswordHasher(), FIXED_CLOCK, () -> "unused" ); @@ -140,12 +136,10 @@ class AuthServiceTest { RoleAccountEntity role = activeRole(RoleCode.AUDIT_ADMIN, "HASH:12345678:SALT-A", "SALT-A", 4, false); roleAccounts.save(role); - AuthService service = new AuthService( + AuthService service = newAuthService( roleAccounts, - new InMemoryLegacyAuthUserRepository(), new InMemoryAuthSessionRepository(), new InMemoryRoleUkeyBindingRepository(), - new FakePasswordHasher(), FIXED_CLOCK, () -> "unused" ); @@ -172,12 +166,10 @@ class AuthServiceTest { ukeyBindings.save(activeBinding(RoleCode.SUPER_ADMIN, 2, "UK-2")); ukeyBindings.save(activeBinding(RoleCode.SUPER_ADMIN, 3, "UK-3")); - AuthService service = new AuthService( + AuthService service = newAuthService( roleAccounts, - new InMemoryLegacyAuthUserRepository(), new InMemoryAuthSessionRepository(), ukeyBindings, - new FakePasswordHasher(), FIXED_CLOCK, () -> "unused" ); @@ -199,12 +191,10 @@ class AuthServiceTest { roleAccounts.save(activeRole(RoleCode.AUDIT_ADMIN, "HASH:12345678:SALT-A", "SALT-A", 0, false)); sessions.save(session("token-me-001", RoleCode.AUDIT_ADMIN.getCode(), AuthLevel.LIMITED.name())); - AuthService service = new AuthService( + AuthService service = newAuthService( roleAccounts, - new InMemoryLegacyAuthUserRepository(), sessions, new InMemoryRoleUkeyBindingRepository(), - new FakePasswordHasher(), FIXED_CLOCK, () -> "unused" ); @@ -225,12 +215,10 @@ class AuthServiceTest { roleAccounts.save(activeRole(RoleCode.KEY_ADMIN, "HASH:12345678:SALT-K", "SALT-K", 0, true)); sessions.save(session("token-change-001", RoleCode.KEY_ADMIN.getCode(), AuthLevel.FULL.name())); - AuthService service = new AuthService( + AuthService service = newAuthService( roleAccounts, - new InMemoryLegacyAuthUserRepository(), sessions, new InMemoryRoleUkeyBindingRepository(), - new FakePasswordHasher(), FIXED_CLOCK, () -> "unused" ); @@ -250,12 +238,10 @@ class AuthServiceTest { roleAccounts.save(activeRole(RoleCode.OPS_ADMIN, "HASH:12345678:SALT-O", "SALT-O", 0, false)); sessions.save(session("token-logout-001", RoleCode.OPS_ADMIN.getCode(), AuthLevel.LIMITED.name())); - AuthService service = new AuthService( + AuthService service = newAuthService( roleAccounts, - new InMemoryLegacyAuthUserRepository(), sessions, new InMemoryRoleUkeyBindingRepository(), - new FakePasswordHasher(), FIXED_CLOCK, () -> "unused" ); @@ -272,7 +258,7 @@ class AuthServiceTest { UkeyLoginRandomService randomService = org.mockito.Mockito.mock(UkeyLoginRandomService.class); org.mockito.Mockito.when(randomService.issue(RoleCode.SUPER_ADMIN.getCode(), 3)).thenReturn(List.of("RB-1", "RB-2", "RB-3")); - AuthService service = new AuthService( + AuthService service = new AuthServiceImpl( new InMemoryRoleAccountRepository(), new InMemoryLegacyAuthUserRepository(), new InMemoryAuthSessionRepository(), @@ -284,7 +270,9 @@ class AuthServiceTest { new InMemoryCaptchaService(), new ObjectMapper(), FIXED_CLOCK, - () -> "unused" + () -> "unused", + () -> "salt-unused", + new AuthPolicyServiceImpl() ); UkeyLoginRandomRequest request = new UkeyLoginRandomRequest(); @@ -305,7 +293,7 @@ class AuthServiceTest { InMemoryCaptchaService captchaService = new InMemoryCaptchaService(); String captchaId = captchaService.issue("ABCD").getCaptchaId(); - AuthService service = new AuthService( + AuthService service = new AuthServiceImpl( roleAccounts, new InMemoryLegacyAuthUserRepository(), new InMemoryAuthSessionRepository(), @@ -317,7 +305,9 @@ class AuthServiceTest { captchaService, new ObjectMapper(), FIXED_CLOCK, - () -> "unused" + () -> "unused", + () -> "salt-unused", + new AuthPolicyServiceImpl() ); PasswordLoginRequest request = new PasswordLoginRequest(); @@ -339,7 +329,7 @@ class AuthServiceTest { org.mockito.Mockito.when(lmkService.getMasterKeyStatus()).thenReturn(MasterKeyStatus.NORMAL.getDetail("MAC-001")); InMemoryCaptchaService captchaService = new InMemoryCaptchaService(); - AuthService service = new AuthService( + AuthService service = new AuthServiceImpl( roleAccounts, new InMemoryLegacyAuthUserRepository(), new InMemoryAuthSessionRepository(), @@ -351,7 +341,9 @@ class AuthServiceTest { captchaService, new ObjectMapper(), FIXED_CLOCK, - () -> "token-password-002" + () -> "token-password-002", + () -> "salt-password-002", + new AuthPolicyServiceImpl() ); CaptchaResponse captcha = service.issueCaptcha(); @@ -384,7 +376,7 @@ class AuthServiceTest { org.mockito.Mockito.when(lmkService.exportIkPublicKeyHex()).thenReturn("IK-PUB-001"); org.mockito.Mockito.when(randomService.issue(RoleCode.KEY_ADMIN.getCode(), 2)).thenReturn(List.of("RB-1", "RB-2")); - AuthService service = new AuthService( + AuthService service = new AuthServiceImpl( roleAccounts, new InMemoryLegacyAuthUserRepository(), sessions, @@ -396,7 +388,9 @@ class AuthServiceTest { new InMemoryCaptchaService(), new ObjectMapper(), FIXED_CLOCK, - () -> "token-full-ukey-002" + () -> "token-full-ukey-002", + () -> "salt-full-ukey-002", + new AuthPolicyServiceImpl() ); UkeyLoginRandomRequest randomRequest = new UkeyLoginRandomRequest(); @@ -638,4 +632,29 @@ class AuthServiceTest { } } } + + private static AuthService newAuthService( + InMemoryRoleAccountRepository roleAccounts, + InMemoryAuthSessionRepository sessions, + InMemoryRoleUkeyBindingRepository ukeyBindings, + Clock clock, + SessionTokenGenerator tokenGenerator + ) { + return new AuthServiceImpl( + roleAccounts, + new InMemoryLegacyAuthUserRepository(), + sessions, + ukeyBindings, + new FakePasswordHasher(), + org.mockito.Mockito.mock(LmkService.class), + org.mockito.Mockito.mock(UkeyLoginRandomService.class), + org.mockito.Mockito.mock(CompatUkeyVerifier.class), + new InMemoryCaptchaService(), + new ObjectMapper(), + clock, + tokenGenerator, + () -> "salt-test", + new AuthPolicyServiceImpl() + ); + } } diff --git a/src/test/java/com/cisd/tms/modules/auth/service/impl/InMemoryCaptchaServiceTest.java b/src/test/java/com/cisd/tms/modules/auth/service/impl/InMemoryCaptchaServiceTest.java new file mode 100644 index 0000000..a46ad69 --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/auth/service/impl/InMemoryCaptchaServiceTest.java @@ -0,0 +1,47 @@ +package com.cisd.tms.modules.auth.service.impl; + +import com.cisd.tms.common.exception.BizException; +import com.cisd.tms.modules.auth.dto.CaptchaResponse; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class InMemoryCaptchaServiceTest { + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-03-31T02:00:00Z"), ZoneOffset.UTC); + + @Test + void shouldReturnDecodableCaptchaImageInsteadOfPlaceholderText() throws Exception { + InMemoryCaptchaService service = new InMemoryCaptchaService(FIXED_CLOCK); + + CaptchaResponse response = service.issueCaptcha(); + + Assertions.assertNotNull(response.getCaptchaId()); + Assertions.assertNotNull(response.getImageBase64()); + Assertions.assertNotEquals( + Base64.getEncoder().encodeToString("ABCD".getBytes()), + response.getImageBase64() + ); + + byte[] bytes = Base64.getDecoder().decode(response.getImageBase64()); + BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); + Assertions.assertNotNull(image); + Assertions.assertTrue(image.getWidth() > 0); + Assertions.assertTrue(image.getHeight() > 0); + } + + @Test + void shouldRejectWrongCaptchaCode() { + InMemoryCaptchaService service = new InMemoryCaptchaService(FIXED_CLOCK); + + CaptchaResponse response = service.issueCaptcha(); + + Assertions.assertThrows(BizException.class, () -> service.verify(response.getCaptchaId(), "WRONG")); + } +}