fix:授权

This commit is contained in:
waner 2026-04-23 14:27:34 +08:00
parent b2fc4da556
commit 0e94269539
13 changed files with 443 additions and 23 deletions

View File

@ -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<String, String> clients = new HashMap<>();

View File

@ -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<Void> 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<Void> 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)

View File

@ -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;
}
}

View File

@ -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() {

View File

@ -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,

View File

@ -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)

View File

@ -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;

View File

@ -70,12 +70,15 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor {
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;
String expected = HmacUtil.hmacSha256Hex(appSecret, content);
@ -85,6 +88,7 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor {
return false;
}
if (replayEnabled) {
try {
// 先完成签名校验再占用 nonce避免伪造请求提前消耗合法 nonce
ReplayCheckResult result = replayProtectionService.check(buildReplayRequest(request, appId, nonce, requestEpoch));
@ -97,6 +101,7 @@ public class OpenApiSignAuthInterceptor implements HandlerInterceptor {
ErrorCode.SERVICE_UNAVAILABLE.getCode(), "replay protection unavailable");
return false;
}
}
return true;
}

View File

@ -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

View File

@ -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);

View File

@ -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();

View File

@ -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));
}

View File

@ -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);