fix:授权
This commit is contained in:
parent
b2fc4da556
commit
0e94269539
@ -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<>();
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user