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
@@ -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;
+4 -1
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