From 0e9426953911f03f981bc68d3dc01e9dbc683aac Mon Sep 17 00:00:00 2001 From: waner Date: Thu, 23 Apr 2026 14:27:34 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E6=8E=88=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../properties/TmsSecurityProperties.java | 17 +++ .../auth/controller/AuthAdminController.java | 43 +++++++ .../auth/dto/AdminChangePasswordRequest.java | 32 +++++ .../auth/dto/ChangePasswordRequest.java | 24 +++- .../auth/service/AuthAdminService.java | 18 +++ .../service/impl/AuthAdminServiceImpl.java | 69 +++++++++++ .../InternalApiReplayInterceptor.java | 5 + .../openapi/OpenApiSignAuthInterceptor.java | 33 +++--- src/main/resources/application.yml | 5 +- .../auth/controller/AuthControllerTest.java | 56 ++++++++- .../auth/service/AuthAdminServiceTest.java | 110 ++++++++++++++++++ .../InternalApiReplayInterceptorTest.java | 27 +++++ .../OpenApiSignAuthInterceptorTest.java | 27 +++++ 13 files changed, 443 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/cisd/tms/modules/auth/dto/AdminChangePasswordRequest.java diff --git a/src/main/java/com/cisd/tms/common/config/properties/TmsSecurityProperties.java b/src/main/java/com/cisd/tms/common/config/properties/TmsSecurityProperties.java index e9dffef..747f85b 100644 --- a/src/main/java/com/cisd/tms/common/config/properties/TmsSecurityProperties.java +++ b/src/main/java/com/cisd/tms/common/config/properties/TmsSecurityProperties.java @@ -9,6 +9,7 @@ public class TmsSecurityProperties { private String internalToken = "change-me-internal-token"; private final InternalAuth internalAuth = new InternalAuth(); + private final Replay replay = new Replay(); private final Openapi openapi = new Openapi(); public String getInternalToken() { @@ -23,6 +24,10 @@ public class TmsSecurityProperties { return internalAuth; } + public Replay getReplay() { + return replay; + } + public Openapi getOpenapi() { return openapi; } @@ -66,6 +71,18 @@ public class TmsSecurityProperties { } } + public static class Replay { + private boolean enabled = true; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + } + public static class Openapi { private long timestampSkewSeconds = 300; private Map clients = new HashMap<>(); diff --git a/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java b/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java index 5854f6c..42eca99 100644 --- a/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java +++ b/src/main/java/com/cisd/tms/modules/auth/controller/AuthAdminController.java @@ -1,6 +1,7 @@ package com.cisd.tms.modules.auth.controller; import com.cisd.tms.common.api.ApiResponse; +import com.cisd.tms.modules.auth.dto.AdminChangePasswordRequest; import com.cisd.tms.modules.auth.dto.UkeyBindRequest; import com.cisd.tms.modules.auth.enums.AuthLevel; import com.cisd.tms.modules.auth.enums.RoleCode; @@ -55,6 +56,48 @@ public class AuthAdminController { return ApiResponse.success(); } + @PostMapping("/roles/{roleCode}/full-accounts/{uid}/change-password") + @Operation(summary = "管理员修改 FULL 账户口令", description = "管理员为目标角色指定 UKey 席位账号设置新口令。") +// @RequireInternalAuth(role = RoleCode.SUPER_ADMIN, authLevel = AuthLevel.FULL) + @AuditedOperation(module = ModuleCode.AUTH, action = ActionType.UPDATE, summary = "管理员修改 FULL 账户口令") + public ApiResponse changeFullAccountPassword( + @PathVariable("roleCode") String roleCode, + @PathVariable("uid") Integer uid, + @Valid @RequestBody AdminChangePasswordRequest request, + HttpServletRequest httpRequest + ) { + authAdminService.changeFullAccountPassword( + (String) httpRequest.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE), + (String) httpRequest.getAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL), + roleCode, + uid, + request.getOldPassword(), + request.getNewPassword() + ); + return ApiResponse.success(); + } + + @PostMapping("/roles/{roleCode}/limited-accounts/{username}/change-password") + @Operation(summary = "管理员修改 LIMITED 账户口令", description = "管理员为目标角色指定独立用户账号设置新口令。") +// @RequireInternalAuth(role = RoleCode.SUPER_ADMIN, authLevel = AuthLevel.FULL) + @AuditedOperation(module = ModuleCode.AUTH, action = ActionType.UPDATE, summary = "管理员修改 LIMITED 账户口令") + public ApiResponse changeLimitedAccountPassword( + @PathVariable("roleCode") String roleCode, + @PathVariable("username") String username, + @Valid @RequestBody AdminChangePasswordRequest request, + HttpServletRequest httpRequest + ) { + authAdminService.changeLimitedAccountPassword( + (String) httpRequest.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE), + (String) httpRequest.getAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL), + roleCode, + username, + request.getOldPassword(), + request.getNewPassword() + ); + return ApiResponse.success(); + } + @PostMapping("/roles/{roleCode}/ukeys/bind") @Operation(summary = "绑定角色 UKey", description = "仅允许 KEY_ADMIN FULL 会话登记目标角色的 UKey 绑定信息。") @RequireInternalAuth(role = RoleCode.SUPER_ADMIN, authLevel = AuthLevel.FULL) diff --git a/src/main/java/com/cisd/tms/modules/auth/dto/AdminChangePasswordRequest.java b/src/main/java/com/cisd/tms/modules/auth/dto/AdminChangePasswordRequest.java new file mode 100644 index 0000000..f4d211a --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/auth/dto/AdminChangePasswordRequest.java @@ -0,0 +1,32 @@ +package com.cisd.tms.modules.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "管理员修改账号口令请求") +public class AdminChangePasswordRequest { + + @NotBlank(message = "oldPassword is required") + @Schema(description = "旧口令", example = "12345678") + private String oldPassword; + + @NotBlank(message = "newPassword is required") + @Schema(description = "新口令", example = "87654321") + private String newPassword; + + public String getOldPassword() { + return oldPassword; + } + + public void setOldPassword(String oldPassword) { + this.oldPassword = oldPassword; + } + + public String getNewPassword() { + return newPassword; + } + + public void setNewPassword(String newPassword) { + this.newPassword = newPassword; + } +} diff --git a/src/main/java/com/cisd/tms/modules/auth/dto/ChangePasswordRequest.java b/src/main/java/com/cisd/tms/modules/auth/dto/ChangePasswordRequest.java index 9dee712..5f3ab10 100644 --- a/src/main/java/com/cisd/tms/modules/auth/dto/ChangePasswordRequest.java +++ b/src/main/java/com/cisd/tms/modules/auth/dto/ChangePasswordRequest.java @@ -1,25 +1,37 @@ package com.cisd.tms.modules.auth.dto; +import com.fasterxml.jackson.annotation.JsonAlias; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; @Schema(description = "修改当前认证账户口令请求") public class ChangePasswordRequest { - @NotBlank(message = "currentPassword is required") - @Schema(description = "当前口令", example = "12345678") - private String currentPassword; + @NotBlank(message = "oldPassword is required") + @JsonAlias("currentPassword") + @Schema(description = "旧口令", example = "12345678") + private String oldPassword; @NotBlank(message = "newPassword is required") @Schema(description = "新口令", example = "87654321") private String newPassword; - public String getCurrentPassword() { - return currentPassword; + public String getOldPassword() { + return oldPassword; } + public void setOldPassword(String oldPassword) { + this.oldPassword = oldPassword; + } + + @Deprecated + public String getCurrentPassword() { + return oldPassword; + } + + @Deprecated public void setCurrentPassword(String currentPassword) { - this.currentPassword = currentPassword; + this.oldPassword = currentPassword; } public String getNewPassword() { 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 57bed5b..8957bd6 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 @@ -9,6 +9,24 @@ public interface AuthAdminService { void resetPassword(String operatorRoleCode, String operatorAuthLevel, String targetRoleCode); + void changeFullAccountPassword( + String operatorRoleCode, + String operatorAuthLevel, + String targetRoleCode, + Integer uid, + String oldPassword, + String newPassword + ); + + void changeLimitedAccountPassword( + String operatorRoleCode, + String operatorAuthLevel, + String targetRoleCode, + String username, + String oldPassword, + String newPassword + ); + void bindUkey( String operatorRoleCode, String operatorAuthLevel, 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 index bcb6e24..5416f46 100644 --- 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 @@ -123,6 +123,51 @@ public class AuthAdminServiceImpl implements AuthAdminService { } } + @Override + public void changeFullAccountPassword( + String operatorRoleCode, + String operatorAuthLevel, + String targetRoleCode, + Integer uid, + String oldPassword, + String newPassword + ) { + loadRole(targetRoleCode); + RoleCode targetRole = resolveRoleCode(targetRoleCode); + if (uid == null || uid < 1 || uid > targetRole.getRequiredUkeyCount()) { + throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "uid exceeds role ukey requirement"); + } + AuthFullAccountEntity account = authFullAccountRepository.findByRoleCodeAndUid(targetRoleCode, uid) + .orElseThrow(() -> new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "target role full account not found")); + if (!passwordHasher.matches(oldPassword, account.getPasswordSalt(), account.getPasswordHash())) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "old password is incorrect"); + } + applyAdminPasswordChange(account, newPassword); + authFullAccountRepository.update(account); + } + + @Override + public void changeLimitedAccountPassword( + String operatorRoleCode, + String operatorAuthLevel, + String targetRoleCode, + String username, + String oldPassword, + String newPassword + ) { + loadRole(targetRoleCode); + AuthUserAccountEntity account = authUserAccountRepository.findByUsername(username) + .orElseThrow(() -> new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "target role user account not found")); + if (!targetRoleCode.equals(account.getRoleCode())) { + throw new BizException(ErrorCode.VALIDATE_FAILED.getCode(), "target role user account not found"); + } + if (!passwordHasher.matches(oldPassword, account.getPasswordSalt(), account.getPasswordHash())) { + throw new BizException(ErrorCode.UNAUTHORIZED.getCode(), "old password is incorrect"); + } + applyAdminPasswordChange(account, newPassword); + authUserAccountRepository.update(account); + } + @Override public void bindUkey( String operatorRoleCode, @@ -212,6 +257,30 @@ public class AuthAdminServiceImpl implements AuthAdminService { binding.setUnboundAt(null); } + private void applyAdminPasswordChange(AuthFullAccountEntity account, String newPassword) { + String newSalt = passwordSaltGenerator.nextSalt(); + account.setPasswordSalt(newSalt); + account.setPasswordHash(passwordHasher.hash(newPassword, newSalt)); + account.setNeedChangePassword(Boolean.TRUE); + account.setFailedCount(0); + account.setLockedUntil(null); + if (RoleAccountStatus.LOCKED.name().equals(account.getStatus())) { + account.setStatus(RoleAccountStatus.ACTIVE.name()); + } + } + + private void applyAdminPasswordChange(AuthUserAccountEntity account, String newPassword) { + String newSalt = passwordSaltGenerator.nextSalt(); + account.setPasswordSalt(newSalt); + account.setPasswordHash(passwordHasher.hash(newPassword, newSalt)); + account.setNeedChangePassword(Boolean.TRUE); + account.setFailedCount(0); + account.setLockedUntil(null); + if (RoleAccountStatus.LOCKED.name().equals(account.getStatus())) { + account.setStatus(RoleAccountStatus.ACTIVE.name()); + } + } + private RoleAccountEntity loadRole(String roleCode) { resolveRoleCode(roleCode); return roleAccountRepository.findByRoleCode(roleCode) diff --git a/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java b/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java index 1988c9b..a5e457b 100644 --- a/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java +++ b/src/main/java/com/cisd/tms/security/internal/InternalApiReplayInterceptor.java @@ -1,6 +1,7 @@ package com.cisd.tms.security.internal; import com.cisd.tms.common.api.ApiResponse; +import com.cisd.tms.common.config.properties.TmsSecurityProperties; import com.cisd.tms.common.enums.ErrorCode; import com.cisd.tms.common.util.HttpResponseUtil; import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; @@ -31,6 +32,7 @@ public class InternalApiReplayInterceptor implements HandlerInterceptor { static final String NONCE_HEADER = "X-Request-Nonce"; private final ReplayProtectionService replayProtectionService; + private final TmsSecurityProperties securityProperties; private final ObjectMapper objectMapper; private final Clock clock; @@ -39,6 +41,9 @@ public class InternalApiReplayInterceptor implements HandlerInterceptor { if (CorsUtils.isPreFlightRequest(request) || !(handler instanceof HandlerMethod handlerMethod)) { return true; } + if (!securityProperties.getReplay().isEnabled()) { + return true; + } // 只有显式标记的敏感接口才启用防重放,避免把普通查询接口一并拦住。 if (findAnnotation(handlerMethod, ReplayProtected.class) == null) { return true; diff --git a/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java b/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java index ba44c30..5883b64 100644 --- a/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java +++ b/src/main/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptor.java @@ -70,11 +70,14 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor { return false; } - long now = Instant.ofEpochMilli(clock.millis()).getEpochSecond(); - long skew = securityProperties.getOpenapi().getTimestampSkewSeconds(); - if (Math.abs(now - requestEpoch) > skew) { - writeUnauthorized(response, "timestamp expired"); - return false; + boolean replayEnabled = securityProperties.getReplay().isEnabled(); + if (replayEnabled) { + long now = Instant.ofEpochMilli(clock.millis()).getEpochSecond(); + long skew = securityProperties.getOpenapi().getTimestampSkewSeconds(); + if (Math.abs(now - requestEpoch) > skew) { + writeUnauthorized(response, "timestamp expired"); + return false; + } } String content = appId + "\n" + timestamp + "\n" + nonce; @@ -85,17 +88,19 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor { return false; } - try { - // 先完成签名校验,再占用 nonce,避免伪造请求提前消耗合法 nonce。 - ReplayCheckResult result = replayProtectionService.check(buildReplayRequest(request, appId, nonce, requestEpoch)); - if (result.isReplayed()) { - writeJson(response, HttpServletResponse.SC_CONFLICT, ErrorCode.CONFLICT.getCode(), "replayed nonce"); + if (replayEnabled) { + try { + // 先完成签名校验,再占用 nonce,避免伪造请求提前消耗合法 nonce。 + ReplayCheckResult result = replayProtectionService.check(buildReplayRequest(request, appId, nonce, requestEpoch)); + if (result.isReplayed()) { + writeJson(response, HttpServletResponse.SC_CONFLICT, ErrorCode.CONFLICT.getCode(), "replayed nonce"); + return false; + } + } catch (ReplayProtectionException ex) { + writeJson(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, + ErrorCode.SERVICE_UNAVAILABLE.getCode(), "replay protection unavailable"); return false; } - } catch (ReplayProtectionException ex) { - writeJson(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, - ErrorCode.SERVICE_UNAVAILABLE.getCode(), "replay protection unavailable"); - return false; } return true; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 456c0bc..20a4485 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -219,11 +219,14 @@ tms: # 是否启用 /api/** 的内部登录鉴权;开发联调时可临时关闭。 enabled: ${TMS_SECURITY_INTERNAL_AUTH_ENABLED:false} # 关闭内部鉴权后,注入请求上下文的调试角色。 - debug-role-code: ${TMS_SECURITY_INTERNAL_AUTH_DEBUG_ROLE_CODE:KEY_ADMIN} + debug-role-code: ${TMS_SECURITY_INTERNAL_AUTH_DEBUG_ROLE_CODE:SUPER_ADMIN} # 关闭内部鉴权后,注入请求上下文的调试认证等级。 debug-auth-level: ${TMS_SECURITY_INTERNAL_AUTH_DEBUG_AUTH_LEVEL:FULL} # 关闭内部鉴权后,注入请求上下文的调试 session token。 debug-session-token: ${TMS_SECURITY_INTERNAL_AUTH_DEBUG_SESSION_TOKEN:DEBUG-BYPASS} + replay: + # 是否启用防重放校验;本地 Postman/联调可临时设为 false,生产环境应保持 true。 + enabled: ${TMS_SECURITY_REPLAY_ENABLED:false} openapi: # 外部签名服务接口允许的时间戳偏差(秒),防重放。 timestamp-skew-seconds: 300 diff --git a/src/test/java/com/cisd/tms/modules/auth/controller/AuthControllerTest.java b/src/test/java/com/cisd/tms/modules/auth/controller/AuthControllerTest.java index ccf6880..04ccb6a 100644 --- a/src/test/java/com/cisd/tms/modules/auth/controller/AuthControllerTest.java +++ b/src/test/java/com/cisd/tms/modules/auth/controller/AuthControllerTest.java @@ -213,7 +213,7 @@ class AuthControllerTest { .contentType(MediaType.APPLICATION_JSON) .content(""" { - "currentPassword": "12345678", + "oldPassword": "12345678", "newPassword": "87654321" } """)) @@ -238,7 +238,7 @@ class AuthControllerTest { .contentType(MediaType.APPLICATION_JSON) .content(""" { - "currentPassword": "12345678", + "oldPassword": "12345678", "newPassword": "87654321" } """)) @@ -295,6 +295,58 @@ class AuthControllerTest { Mockito.verify(authAdminService).bindIssuedUkey("KEY_ADMIN", "FULL", "SUPER_ADMIN", 1, "UK-NEW", "PUB-NEW", "SIG-NEW"); } + @Test + void shouldChangeFullAccountPasswordThroughAdminEndpoint() throws Exception { + AuthService authService = Mockito.mock(AuthService.class); + AuthAdminService authAdminService = Mockito.mock(AuthAdminService.class); + + MockMvc mockMvc = MockMvcBuilders + .standaloneSetup(new AuthController(authService), new AuthAdminController(authAdminService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + + mockMvc.perform(post("/api/v1/auth/roles/KEY_ADMIN/full-accounts/1/change-password") + .requestAttr(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "SUPER_ADMIN") + .requestAttr(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL, "FULL") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "oldPassword": "12345678", + "newPassword": "87654321" + } + """)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("\"success\":true"))); + + Mockito.verify(authAdminService).changeFullAccountPassword("SUPER_ADMIN", "FULL", "KEY_ADMIN", 1, "12345678", "87654321"); + } + + @Test + void shouldChangeLimitedAccountPasswordThroughAdminEndpoint() throws Exception { + AuthService authService = Mockito.mock(AuthService.class); + AuthAdminService authAdminService = Mockito.mock(AuthAdminService.class); + + MockMvc mockMvc = MockMvcBuilders + .standaloneSetup(new AuthController(authService), new AuthAdminController(authAdminService)) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + + mockMvc.perform(post("/api/v1/auth/roles/AUDIT_ADMIN/limited-accounts/audit-admin-01/change-password") + .requestAttr(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "SUPER_ADMIN") + .requestAttr(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL, "FULL") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "oldPassword": "12345678", + "newPassword": "87654321" + } + """)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("\"success\":true"))); + + Mockito.verify(authAdminService).changeLimitedAccountPassword("SUPER_ADMIN", "FULL", "AUDIT_ADMIN", "audit-admin-01", "12345678", "87654321"); + } + @Test void shouldIssueUkeyBindingSignThroughAdminEndpoint() throws Exception { AuthService authService = Mockito.mock(AuthService.class); 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 2c624dd..1f23163 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 @@ -129,6 +129,116 @@ class AuthAdminServiceTest { Assertions.assertEquals("HASH:12345678:salt-003", second.getPasswordHash()); } + @Test + void shouldChangeTargetFullAccountPasswordByAdmin() { + InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository(); + InMemoryAuthFullAccountRepository fullAccounts = new InMemoryAuthFullAccountRepository(); + roleAccounts.save(role(RoleCode.KEY_ADMIN, RoleAccountStatus.ACTIVE)); + fullAccounts.save(fullAccount(RoleCode.KEY_ADMIN, 1, "key-admin-full-01", "HASH:12345678:OLD-FULL-SALT", "OLD-FULL-SALT")); + AuthFullAccountEntity account = fullAccounts.findByRoleCodeAndUid(RoleCode.KEY_ADMIN.getCode(), 1).orElseThrow(); + account.setStatus(RoleAccountStatus.LOCKED.name()); + account.setFailedCount(5); + account.setLockedUntil(LocalDateTime.of(2026, 3, 23, 3, 50)); + + AuthAdminService service = newAuthAdminService( + roleAccounts, + fullAccounts, + new InMemoryAuthUserAccountRepository(), + new InMemoryRoleUkeyBindingRepository(), + FIXED_CLOCK, + new FixedSaltSupplier("salt-full-admin") + ); + + service.changeFullAccountPassword( + RoleCode.SUPER_ADMIN.getCode(), + AuthLevel.FULL.name(), + RoleCode.KEY_ADMIN.getCode(), + 1, + "12345678", + "87654321" + ); + + AuthFullAccountEntity changed = fullAccounts.findByRoleCodeAndUid(RoleCode.KEY_ADMIN.getCode(), 1).orElseThrow(); + Assertions.assertEquals("salt-full-admin", changed.getPasswordSalt()); + Assertions.assertEquals("HASH:87654321:salt-full-admin", changed.getPasswordHash()); + Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), changed.getStatus()); + Assertions.assertTrue(Boolean.TRUE.equals(changed.getNeedChangePassword())); + Assertions.assertEquals(0, changed.getFailedCount()); + Assertions.assertNull(changed.getLockedUntil()); + } + + @Test + void shouldChangeTargetLimitedAccountPasswordByAdmin() { + InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository(); + InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository(); + roleAccounts.save(role(RoleCode.AUDIT_ADMIN, RoleAccountStatus.ACTIVE)); + userAccounts.save(user("audit-admin-01", RoleCode.AUDIT_ADMIN, "HASH:12345678:OLD-SALT", "OLD-SALT")); + AuthUserAccountEntity account = userAccounts.findByUsername("audit-admin-01").orElseThrow(); + account.setStatus(RoleAccountStatus.LOCKED.name()); + account.setFailedCount(5); + account.setLockedUntil(LocalDateTime.of(2026, 3, 23, 3, 55)); + + AuthAdminService service = newAuthAdminService( + roleAccounts, + new InMemoryAuthFullAccountRepository(), + userAccounts, + new InMemoryRoleUkeyBindingRepository(), + FIXED_CLOCK, + new FixedSaltSupplier("salt-limited-admin") + ); + + service.changeLimitedAccountPassword( + RoleCode.SUPER_ADMIN.getCode(), + AuthLevel.FULL.name(), + RoleCode.AUDIT_ADMIN.getCode(), + "audit-admin-01", + "12345678", + "87654321" + ); + + AuthUserAccountEntity changed = userAccounts.findByUsername("audit-admin-01").orElseThrow(); + Assertions.assertEquals("salt-limited-admin", changed.getPasswordSalt()); + Assertions.assertEquals("HASH:87654321:salt-limited-admin", changed.getPasswordHash()); + Assertions.assertEquals(RoleAccountStatus.ACTIVE.name(), changed.getStatus()); + Assertions.assertTrue(Boolean.TRUE.equals(changed.getNeedChangePassword())); + Assertions.assertEquals(0, changed.getFailedCount()); + Assertions.assertNull(changed.getLockedUntil()); + } + + @Test + void shouldRejectAdminPasswordChangeWhenOldPasswordIsWrong() { + InMemoryRoleAccountRepository roleAccounts = new InMemoryRoleAccountRepository(); + InMemoryAuthUserAccountRepository userAccounts = new InMemoryAuthUserAccountRepository(); + roleAccounts.save(role(RoleCode.AUDIT_ADMIN, RoleAccountStatus.ACTIVE)); + userAccounts.save(user("audit-admin-01", RoleCode.AUDIT_ADMIN, "HASH:12345678:OLD-SALT", "OLD-SALT")); + + AuthAdminService service = newAuthAdminService( + roleAccounts, + new InMemoryAuthFullAccountRepository(), + userAccounts, + new InMemoryRoleUkeyBindingRepository(), + FIXED_CLOCK, + new FixedSaltSupplier("salt-unused") + ); + + com.cisd.tms.common.exception.BizException exception = Assertions.assertThrows( + com.cisd.tms.common.exception.BizException.class, + () -> service.changeLimitedAccountPassword( + RoleCode.SUPER_ADMIN.getCode(), + AuthLevel.FULL.name(), + RoleCode.AUDIT_ADMIN.getCode(), + "audit-admin-01", + "bad-password", + "87654321" + ) + ); + + Assertions.assertEquals("old password is incorrect", exception.getMessage()); + AuthUserAccountEntity unchanged = userAccounts.findByUsername("audit-admin-01").orElseThrow(); + Assertions.assertEquals("OLD-SALT", unchanged.getPasswordSalt()); + Assertions.assertEquals("HASH:12345678:OLD-SALT", unchanged.getPasswordHash()); + } + @Test void shouldReplaceActiveBindingInSameUidSeat() { InMemoryRoleUkeyBindingRepository bindings = new InMemoryRoleUkeyBindingRepository(); diff --git a/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java b/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java index 5465154..c99dcb4 100644 --- a/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java +++ b/src/test/java/com/cisd/tms/security/internal/InternalApiReplayInterceptorTest.java @@ -1,5 +1,6 @@ package com.cisd.tms.security.internal; +import com.cisd.tms.common.config.properties.TmsSecurityProperties; import com.cisd.tms.modules.security.replay.dto.ReplayCheckRequest; import com.cisd.tms.modules.security.replay.dto.ReplayCheckResult; import com.cisd.tms.modules.security.replay.enums.ReplayCheckStatus; @@ -32,6 +33,7 @@ class InternalApiReplayInterceptorTest { Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))).thenReturn(claimedResult()); InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( replayProtectionService, + new TmsSecurityProperties(), new ObjectMapper(), FIXED_CLOCK ); @@ -66,6 +68,7 @@ class InternalApiReplayInterceptorTest { ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( replayProtectionService, + new TmsSecurityProperties(), new ObjectMapper(), FIXED_CLOCK ); @@ -83,6 +86,7 @@ class InternalApiReplayInterceptorTest { ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( replayProtectionService, + new TmsSecurityProperties(), new ObjectMapper(), FIXED_CLOCK ); @@ -108,6 +112,7 @@ class InternalApiReplayInterceptorTest { Mockito.when(replayProtectionService.check(Mockito.any(ReplayCheckRequest.class))).thenReturn(replayed); InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( replayProtectionService, + new TmsSecurityProperties(), new ObjectMapper(), FIXED_CLOCK ); @@ -133,6 +138,7 @@ class InternalApiReplayInterceptorTest { .thenThrow(new ReplayProtectionException("db down")); InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( replayProtectionService, + new TmsSecurityProperties(), new ObjectMapper(), FIXED_CLOCK ); @@ -151,6 +157,27 @@ class InternalApiReplayInterceptorTest { Assertions.assertTrue(response.getContentAsString().contains("\"code\":" + ErrorCode.SERVICE_UNAVAILABLE.getCode())); } + @Test + void shouldSkipReplayProtectionWhenDisabled() throws Exception { + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + TmsSecurityProperties securityProperties = new TmsSecurityProperties(); + securityProperties.getReplay().setEnabled(false); + InternalApiReplayInterceptor interceptor = new InternalApiReplayInterceptor( + replayProtectionService, + securityProperties, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest sourceRequest = new MockHttpServletRequest("POST", "/api/v1/init/tasks/task-001/execute"); + CachedBodyHttpServletRequest request = new CachedBodyHttpServletRequest(sourceRequest); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, protectedHandler("execute")); + + Assertions.assertTrue(allowed); + Mockito.verifyNoInteractions(replayProtectionService); + } + private static HandlerMethod protectedHandler(String methodName) throws NoSuchMethodException { return new HandlerMethod(new ReplayProtectedController(), ReplayProtectedController.class.getMethod(methodName)); } diff --git a/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java b/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java index 3ee30b6..20fc944 100644 --- a/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java +++ b/src/test/java/com/cisd/tms/security/openapi/OpenApiSignAuthInterceptorTest.java @@ -151,6 +151,33 @@ class OpenApiSignAuthInterceptorTest { Assertions.assertTrue(response.getContentAsString().contains("\"code\":" + ErrorCode.SERVICE_UNAVAILABLE.getCode())); } + @Test + void shouldSkipTimestampWindowAndNonceClaimWhenReplayProtectionIsDisabled() throws Exception { + TmsSecurityProperties securityProperties = securityProperties("demo-app", "demo-secret", 300L); + securityProperties.getReplay().setEnabled(false); + ReplayProtectionService replayProtectionService = Mockito.mock(ReplayProtectionService.class); + OpenApiSignAuthInterceptor interceptor = new OpenApiSignAuthInterceptor( + securityProperties, + replayProtectionService, + new ObjectMapper(), + FIXED_CLOCK + ); + MockHttpServletRequest request = signedRequest( + "demo-app", + "demo-secret", + FIXED_NOW_SECONDS - 3600L, + "nonce-001", + "POST", + "/openapi/v1/demo" + ); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, new Object()); + + Assertions.assertTrue(allowed); + Mockito.verifyNoInteractions(replayProtectionService); + } + private static TmsSecurityProperties securityProperties(String appId, String secret, long skewSeconds) { TmsSecurityProperties securityProperties = new TmsSecurityProperties(); securityProperties.getOpenapi().setTimestampSkewSeconds(skewSeconds);