diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/auth/AuthController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/auth/AuthController.java index 3a83984f..4e93e56b 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/auth/AuthController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/auth/AuthController.java @@ -2,15 +2,22 @@ package tech.easyflow.admin.controller.auth; import cn.dev33.satoken.annotation.SaIgnore; import cn.dev33.satoken.stp.StpUtil; +import com.alibaba.fastjson2.JSONObject; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import tech.easyflow.auth.entity.LoginDTO; -import tech.easyflow.auth.entity.LoginVO; +import tech.easyflow.auth.entity.*; +import tech.easyflow.auth.service.AuthCredentialKeyService; import tech.easyflow.auth.service.AuthService; +import tech.easyflow.common.captcha.tainai.CaptchaVerificationService; +import tech.easyflow.common.constant.Constants; import tech.easyflow.common.domain.Result; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.log.annotation.LogRecord; import javax.annotation.Resource; import java.util.List; @@ -21,9 +28,23 @@ public class AuthController { @Resource private AuthService authService; + @Resource + private AuthCredentialKeyService credentialKeyService; + @Resource + private CaptchaVerificationService captchaVerificationService; + + @GetMapping("credential-key") + public Result credentialKey() { + return Result.ok(credentialKeyService.getCurrentCredentialKey()); + } @PostMapping("login") - public Result login(@JsonBody LoginDTO loginDTO) { + public Result login(@JsonBody EncryptedCredentialDTO encryptedCredential) { + JSONObject payload = credentialKeyService.decryptPayload(encryptedCredential); + if (!captchaVerificationService.verify(payload.getString("validToken"))) { + return captchaVerificationService.failureResult(); + } + LoginDTO loginDTO = toLoginDTO(payload); LoginVO res = authService.login(loginDTO); return Result.ok(res); } @@ -45,4 +66,23 @@ public class AuthController { List permissionList = StpUtil.getPermissionList(); return Result.ok(permissionList); } + + @PostMapping("credential-key/rotate") + @LogRecord("轮换认证传输密钥") + public Result rotateCredentialKey(@JsonBody CredentialKeyRotateDTO rotateDTO) { + StpUtil.checkLogin(); + LoginAccount loginAccount = SaTokenUtil.getLoginAccount(); + if (loginAccount == null || !Constants.SUPER_ADMIN_ID.equals(loginAccount.getId())) { + throw new BusinessException("仅超级管理员可轮换认证密钥"); + } + credentialKeyService.rotate(rotateDTO != null && Boolean.TRUE.equals(rotateDTO.getEmergency())); + return Result.ok(); + } + + private LoginDTO toLoginDTO(JSONObject payload) { + LoginDTO loginDTO = new LoginDTO(); + loginDTO.setAccount(payload.getString("account")); + loginDTO.setPassword(payload.getString("password")); + return loginDTO; + } } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java index 16397a74..5ad45374 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java @@ -3,6 +3,7 @@ package tech.easyflow.admin.controller.system; import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.stp.StpUtil; import cn.hutool.crypto.digest.BCrypt; +import com.alibaba.fastjson2.JSONObject; import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.query.QueryWrapper; import jakarta.servlet.http.HttpServletResponse; @@ -12,6 +13,8 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.auth.entity.EncryptedCredentialDTO; +import tech.easyflow.auth.service.AuthCredentialKeyService; import tech.easyflow.common.constant.enums.EnumAccountType; import tech.easyflow.common.constant.enums.EnumDataStatus; import tech.easyflow.common.domain.Result; @@ -27,12 +30,13 @@ import tech.easyflow.system.entity.vo.SysAccountImportResultVo; import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.util.SysPasswordPolicy; -import java.net.URLEncoder; import java.io.Serializable; import java.math.BigInteger; +import java.net.URLEncoder; import java.util.Collection; import java.util.Date; import java.util.List; +import java.util.Map; /** * 用户表 控制层。 @@ -43,8 +47,11 @@ import java.util.List; @RestController("sysAccountController") @RequestMapping("/api/v1/sysAccount") public class SysAccountController extends BaseCurdController { - public SysAccountController(SysAccountService service) { + private final AuthCredentialKeyService credentialKeyService; + + public SysAccountController(SysAccountService service, AuthCredentialKeyService credentialKeyService) { super(service); + this.credentialKeyService = credentialKeyService; } @Override @@ -67,7 +74,7 @@ public class SysAccountController extends BaseCurdController 0) { return Result.fail(1, "用户名已存在"); } - String password = entity.getPassword(); + String password = decryptInitialPassword(entity.getPasswordCredential()); if (!StringUtil.hasText(password)) { return Result.fail(1, "密码不能为空"); } @@ -143,14 +150,14 @@ public class SysAccountController extends BaseCurdController updatePassword(@JsonBody(value = "password", required = true) String password, - @JsonBody(value = "newPassword", required = true) String newPassword, - @JsonBody(value = "confirmPassword", required = true) String confirmPassword) { + public Result updatePassword(@JsonBody EncryptedCredentialDTO encryptedCredential) { + JSONObject payload = credentialKeyService.decryptPayload(encryptedCredential); + String password = payload.getString("password"); + String newPassword = payload.getString("newPassword"); + String confirmPassword = payload.getString("confirmPassword"); BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId(); SysAccount record = service.getById(loginAccountId); if (record == null) { @@ -174,6 +181,28 @@ public class SysAccountController extends BaseCurdController passwordCredential) { + if (passwordCredential == null || passwordCredential.isEmpty()) { + return null; + } + JSONObject payload = credentialKeyService.decryptPayload(toEncryptedCredential(passwordCredential)); + return payload.getString("password"); + } + + private EncryptedCredentialDTO toEncryptedCredential(Map passwordCredential) { + EncryptedCredentialDTO encryptedCredential = new EncryptedCredentialDTO(); + encryptedCredential.setKeyId(asString(passwordCredential.get("keyId"))); + encryptedCredential.setEncryptedKey(asString(passwordCredential.get("encryptedKey"))); + encryptedCredential.setIv(asString(passwordCredential.get("iv"))); + encryptedCredential.setCiphertext(asString(passwordCredential.get("ciphertext"))); + encryptedCredential.setNonce(asString(passwordCredential.get("nonce"))); + return encryptedCredential; + } + + private String asString(Object value) { + return value == null ? null : String.valueOf(value); + } + @PostMapping("/resetPassword") @SaCheckPermission("/api/v1/sysAccount/save") public Result resetPassword(@JsonBody(value = "id", required = true) BigInteger id) { diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/auth/UcAuthController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/auth/UcAuthController.java index ebf26eb6..3a75e992 100644 --- a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/auth/UcAuthController.java +++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/auth/UcAuthController.java @@ -1,10 +1,18 @@ package tech.easyflow.usercenter.controller.auth; import cn.dev33.satoken.stp.StpUtil; -import org.springframework.web.bind.annotation.*; +import com.alibaba.fastjson2.JSONObject; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import tech.easyflow.auth.entity.CredentialKeyVO; +import tech.easyflow.auth.entity.EncryptedCredentialDTO; import tech.easyflow.auth.entity.LoginDTO; import tech.easyflow.auth.entity.LoginVO; +import tech.easyflow.auth.service.AuthCredentialKeyService; import tech.easyflow.auth.service.AuthService; +import tech.easyflow.common.captcha.tainai.CaptchaVerificationService; import tech.easyflow.common.domain.Result; import tech.easyflow.common.web.jsonbody.JsonBody; @@ -20,13 +28,30 @@ public class UcAuthController { @Resource private AuthService authService; + @Resource + private AuthCredentialKeyService credentialKeyService; + @Resource + private CaptchaVerificationService captchaVerificationService; + + /** + * 获取登录加密公钥。 + */ + @GetMapping("credential-key") + public Result credentialKey() { + return Result.ok(credentialKeyService.getCurrentCredentialKey()); + } /** * 登录 - * @param loginDTO 登录参数 + * @param encryptedCredential 加密登录参数 */ @PostMapping("login") - public Result login(@JsonBody LoginDTO loginDTO) { + public Result login(@JsonBody EncryptedCredentialDTO encryptedCredential) { + JSONObject payload = credentialKeyService.decryptPayload(encryptedCredential); + if (!captchaVerificationService.verify(payload.getString("validToken"))) { + return captchaVerificationService.failureResult(); + } + LoginDTO loginDTO = toLoginDTO(payload); LoginVO res = authService.login(loginDTO); return Result.ok(res); } @@ -48,4 +73,11 @@ public class UcAuthController { List permissionList = StpUtil.getPermissionList(); return Result.ok(permissionList); } + + private LoginDTO toLoginDTO(JSONObject payload) { + LoginDTO loginDTO = new LoginDTO(); + loginDTO.setAccount(payload.getString("account")); + loginDTO.setPassword(payload.getString("password")); + return loginDTO; + } } diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/system/UcSysAccountController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/system/UcSysAccountController.java index 46e66e9f..388d1aca 100644 --- a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/system/UcSysAccountController.java +++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/system/UcSysAccountController.java @@ -1,10 +1,13 @@ package tech.easyflow.usercenter.controller.system; import cn.hutool.crypto.digest.BCrypt; +import com.alibaba.fastjson2.JSONObject; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import tech.easyflow.auth.entity.EncryptedCredentialDTO; +import tech.easyflow.auth.service.AuthCredentialKeyService; import tech.easyflow.common.domain.Result; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.satoken.util.SaTokenUtil; @@ -29,6 +32,8 @@ public class UcSysAccountController { @Resource private SysAccountService service; + @Resource + private AuthCredentialKeyService credentialKeyService; /** * 获取用户的信息 @@ -61,14 +66,14 @@ public class UcSysAccountController { /** * 修改密码 * - * @param password 用户的旧密码 - * @param newPassword 新密码 - * @param confirmPassword 确认密码 + * @param encryptedCredential 加密后的旧密码、新密码与确认密码 */ @PostMapping("/updatePassword") - public Result updatePassword(@JsonBody(value = "password", required = true) String password, - @JsonBody(value = "newPassword", required = true) String newPassword, - @JsonBody(value = "confirmPassword", required = true) String confirmPassword) { + public Result updatePassword(@JsonBody EncryptedCredentialDTO encryptedCredential) { + JSONObject payload = credentialKeyService.decryptPayload(encryptedCredential); + String password = payload.getString("password"); + String newPassword = payload.getString("newPassword"); + String confirmPassword = payload.getString("confirmPassword"); BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId(); SysAccount record = service.getById(loginAccountId); if (record == null) { diff --git a/easyflow-commons/easyflow-common-captcha/src/main/java/tech/easyflow/common/captcha/tainai/CaptchaMvcConfig.java b/easyflow-commons/easyflow-common-captcha/src/main/java/tech/easyflow/common/captcha/tainai/CaptchaMvcConfig.java index 74b220fd..5048ba28 100644 --- a/easyflow-commons/easyflow-common-captcha/src/main/java/tech/easyflow/common/captcha/tainai/CaptchaMvcConfig.java +++ b/easyflow-commons/easyflow-common-captcha/src/main/java/tech/easyflow/common/captcha/tainai/CaptchaMvcConfig.java @@ -1,6 +1,5 @@ package tech.easyflow.common.captcha.tainai; -import jakarta.annotation.Resource; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -8,14 +7,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CaptchaMvcConfig implements WebMvcConfigurer { - @Resource - private CaptchaValidInterceptor interceptor; - + /** + * 加密登录请求在控制器解密后校验验证码,避免明文 token 出现在请求体顶层。 + * + * @param registry 拦截器注册器 + */ @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(interceptor) - .order(1) - .addPathPatterns("/api/v1/auth/login") - .addPathPatterns("/userCenter/auth/login"); + // 保留扩展点,当前登录验证码校验由 AuthController / UcAuthController 在解密后完成。 } } diff --git a/easyflow-commons/easyflow-common-captcha/src/main/java/tech/easyflow/common/captcha/tainai/CaptchaValidInterceptor.java b/easyflow-commons/easyflow-common-captcha/src/main/java/tech/easyflow/common/captcha/tainai/CaptchaValidInterceptor.java index 532d8399..2d92e6a8 100644 --- a/easyflow-commons/easyflow-common-captcha/src/main/java/tech/easyflow/common/captcha/tainai/CaptchaValidInterceptor.java +++ b/easyflow-commons/easyflow-common-captcha/src/main/java/tech/easyflow/common/captcha/tainai/CaptchaValidInterceptor.java @@ -1,14 +1,11 @@ package tech.easyflow.common.captcha.tainai; -import cloud.tianai.captcha.application.ImageCaptchaApplication; -import cloud.tianai.captcha.spring.plugins.secondary.SecondaryVerificationApplication; import com.alibaba.fastjson.JSONObject; import jakarta.annotation.Resource; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; -import tech.easyflow.common.domain.Result; import tech.easyflow.common.util.RequestUtil; import tech.easyflow.common.util.ResponseUtil; @@ -16,26 +13,20 @@ import tech.easyflow.common.util.ResponseUtil; public class CaptchaValidInterceptor implements HandlerInterceptor { @Resource - private ImageCaptchaApplication application; + private CaptchaVerificationService captchaVerificationService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { JSONObject jsonObject = (JSONObject) RequestUtil.readJsonObjectOrArray(request); String validToken = jsonObject.getString("validToken"); - if (validToken == null || validToken.isEmpty()) { - renderNotLogin(response); - return false; - } - boolean valid = ((SecondaryVerificationApplication) application).secondaryVerification(validToken); - if (!valid) { + if (!captchaVerificationService.verify(validToken)) { renderNotLogin(response); return false; } return true; } - private static void renderNotLogin(HttpServletResponse response) { - Result result = Result.fail(99, "验证失败,请重试!"); - ResponseUtil.renderJson(response, result); + private void renderNotLogin(HttpServletResponse response) { + ResponseUtil.renderJson(response, captchaVerificationService.failureResult()); } } diff --git a/easyflow-commons/easyflow-common-captcha/src/main/java/tech/easyflow/common/captcha/tainai/CaptchaVerificationService.java b/easyflow-commons/easyflow-common-captcha/src/main/java/tech/easyflow/common/captcha/tainai/CaptchaVerificationService.java new file mode 100644 index 00000000..7d354c34 --- /dev/null +++ b/easyflow-commons/easyflow-common-captcha/src/main/java/tech/easyflow/common/captcha/tainai/CaptchaVerificationService.java @@ -0,0 +1,54 @@ +package tech.easyflow.common.captcha.tainai; + +import cloud.tianai.captcha.application.ImageCaptchaApplication; +import cloud.tianai.captcha.spring.plugins.secondary.SecondaryVerificationApplication; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import tech.easyflow.common.domain.Result; + +/** + * 验证码二次校验服务。 + */ +@Service +public class CaptchaVerificationService { + + /** + * 验证码失败业务码。 + */ + public static final int CAPTCHA_ERROR_CODE = 99; + + /** + * 验证码失败提示。 + */ + public static final String CAPTCHA_ERROR_MESSAGE = "验证失败,请重试!"; + + @Resource + private ImageCaptchaApplication application; + + /** + * 校验验证码二次验证令牌。 + * + * @param validToken 二次验证令牌 + * @return 是否校验通过 + */ + public boolean verify(String validToken) { + if (!StringUtils.hasText(validToken)) { + return false; + } + return ((SecondaryVerificationApplication) application).secondaryVerification(validToken); + } + + /** + * 构造验证码失败响应。 + * + * @param 响应数据类型 + * @return 统一失败响应 + */ + public Result failureResult() { + Result result = new Result<>(); + result.setErrorCode(CAPTCHA_ERROR_CODE); + result.setMessage(CAPTCHA_ERROR_MESSAGE); + return result; + } +} diff --git a/easyflow-modules/easyflow-module-auth/pom.xml b/easyflow-modules/easyflow-module-auth/pom.xml index c0712cc4..1a28f1d8 100644 --- a/easyflow-modules/easyflow-module-auth/pom.xml +++ b/easyflow-modules/easyflow-module-auth/pom.xml @@ -20,10 +20,18 @@ tech.easyflow easyflow-common-web + + tech.easyflow + easyflow-common-cache + tech.easyflow easyflow-module-system + + com.mybatis-flex + mybatis-flex-spring-boot3-starter + junit junit diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/config/AuthModuleConfig.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/config/AuthModuleConfig.java index 3cf9aeaa..4b1b2360 100644 --- a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/config/AuthModuleConfig.java +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/config/AuthModuleConfig.java @@ -1,10 +1,14 @@ package tech.easyflow.auth.config; +import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableScheduling; @AutoConfiguration +@EnableScheduling @ComponentScan("tech.easyflow.auth") +@MapperScan("tech.easyflow.auth.mapper") public class AuthModuleConfig { public AuthModuleConfig() { diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/config/CredentialKeyProperties.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/config/CredentialKeyProperties.java new file mode 100644 index 00000000..e17b5e95 --- /dev/null +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/config/CredentialKeyProperties.java @@ -0,0 +1,92 @@ +package tech.easyflow.auth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +/** + * 登录凭证加密密钥轮换配置。 + */ +@Configuration +@ConfigurationProperties(prefix = "easyflow.auth.credential-key") +public class CredentialKeyProperties { + + /** + * 密钥完整生命周期。 + */ + private Duration lifetime = Duration.ofDays(30); + + /** + * 到期前提前轮换窗口。 + */ + private Duration rotateBefore = Duration.ofDays(1); + + /** + * 自动轮换检查间隔。 + */ + private Duration checkInterval = Duration.ofHours(1); + + /** + * 前端安全材料有效期。 + */ + private Duration materialTtl = Duration.ofMinutes(5); + + /** + * 登录 nonce 有效期。 + */ + private Duration nonceTtl = Duration.ofMinutes(5); + + /** + * 旧密钥短期保留解密窗口。 + */ + private Duration previousRetain = Duration.ofMinutes(15); + + public Duration getLifetime() { + return lifetime; + } + + public void setLifetime(Duration lifetime) { + this.lifetime = lifetime; + } + + public Duration getRotateBefore() { + return rotateBefore; + } + + public void setRotateBefore(Duration rotateBefore) { + this.rotateBefore = rotateBefore; + } + + public Duration getCheckInterval() { + return checkInterval; + } + + public void setCheckInterval(Duration checkInterval) { + this.checkInterval = checkInterval; + } + + public Duration getMaterialTtl() { + return materialTtl; + } + + public void setMaterialTtl(Duration materialTtl) { + this.materialTtl = materialTtl; + } + + public Duration getNonceTtl() { + return nonceTtl; + } + + public void setNonceTtl(Duration nonceTtl) { + this.nonceTtl = nonceTtl; + } + + public Duration getPreviousRetain() { + return previousRetain; + } + + public void setPreviousRetain(Duration previousRetain) { + this.previousRetain = previousRetain; + } +} diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/constant/CredentialKeyStatus.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/constant/CredentialKeyStatus.java new file mode 100644 index 00000000..ca3ea093 --- /dev/null +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/constant/CredentialKeyStatus.java @@ -0,0 +1,30 @@ +package tech.easyflow.auth.constant; + +/** + * 认证传输密钥状态常量。 + */ +public final class CredentialKeyStatus { + + /** + * 当前下发给前端的新请求密钥。 + */ + public static final String ACTIVE = "ACTIVE"; + + /** + * 旧请求短期解密密钥。 + */ + public static final String PREVIOUS = "PREVIOUS"; + + /** + * 已退役密钥。 + */ + public static final String RETIRED = "RETIRED"; + + /** + * 紧急吊销密钥。 + */ + public static final String REVOKED = "REVOKED"; + + private CredentialKeyStatus() { + } +} diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/AuthCredentialKey.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/AuthCredentialKey.java new file mode 100644 index 00000000..94bd9533 --- /dev/null +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/AuthCredentialKey.java @@ -0,0 +1,243 @@ +package tech.easyflow.auth.entity; + +import com.mybatisflex.annotation.Column; +import com.mybatisflex.annotation.Id; +import com.mybatisflex.annotation.KeyType; +import com.mybatisflex.annotation.Table; + +import java.io.Serializable; +import java.math.BigInteger; +import java.util.Date; + +/** + * 认证传输密钥环实体。 + */ +@Table(value = "tb_auth_credential_key", comment = "认证传输密钥环") +public class AuthCredentialKey implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 主键。 + */ + @Id(keyType = KeyType.Generator, value = "snowFlakeId", comment = "ID") + private BigInteger id; + + /** + * 对外使用的密钥标识。 + */ + @Column(comment = "密钥标识") + private String keyId; + + /** + * PEM 格式公钥。 + */ + @Column(comment = "公钥PEM") + private String publicKeyPem; + + /** + * 由部署主密钥加密后的 PEM 私钥。 + */ + @Column(comment = "加密后的私钥PEM") + private String encryptedPrivateKey; + + /** + * 公钥 DER 的 SHA-256 指纹。 + */ + @Column(comment = "公钥指纹") + private String fingerprint; + + /** + * 密钥状态。 + */ + @Column(comment = "状态") + private String status; + + /** + * 轮换序号。 + */ + @Column(comment = "轮换序号") + private Long rotateSeq; + + /** + * 上一密钥标识。 + */ + @Column(comment = "上一密钥标识") + private String parentKeyId; + + /** + * 新密钥被旧密钥授权的 canonical payload。 + */ + @Column(comment = "轮换载荷") + private String transitionPayload; + + /** + * 旧私钥对轮换载荷的签名。 + */ + @Column(comment = "轮换签名") + private String transitionSignature; + + /** + * 生效时间。 + */ + @Column(comment = "生效时间") + private Date notBefore; + + /** + * 过期时间。 + */ + @Column(comment = "过期时间") + private Date expiresAt; + + /** + * 计划轮换时间。 + */ + @Column(comment = "计划轮换时间") + private Date rotateAt; + + /** + * 旧密钥可继续解密的截止时间。 + */ + @Column(comment = "旧密钥保留解密截止时间") + private Date previousUntil; + + /** + * 创建时间。 + */ + @Column(comment = "创建时间") + private Date created; + + /** + * 修改时间。 + */ + @Column(comment = "修改时间") + private Date modified; + + public BigInteger getId() { + return id; + } + + public void setId(BigInteger id) { + this.id = id; + } + + public String getKeyId() { + return keyId; + } + + public void setKeyId(String keyId) { + this.keyId = keyId; + } + + public String getPublicKeyPem() { + return publicKeyPem; + } + + public void setPublicKeyPem(String publicKeyPem) { + this.publicKeyPem = publicKeyPem; + } + + public String getEncryptedPrivateKey() { + return encryptedPrivateKey; + } + + public void setEncryptedPrivateKey(String encryptedPrivateKey) { + this.encryptedPrivateKey = encryptedPrivateKey; + } + + public String getFingerprint() { + return fingerprint; + } + + public void setFingerprint(String fingerprint) { + this.fingerprint = fingerprint; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Long getRotateSeq() { + return rotateSeq; + } + + public void setRotateSeq(Long rotateSeq) { + this.rotateSeq = rotateSeq; + } + + public String getParentKeyId() { + return parentKeyId; + } + + public void setParentKeyId(String parentKeyId) { + this.parentKeyId = parentKeyId; + } + + public String getTransitionPayload() { + return transitionPayload; + } + + public void setTransitionPayload(String transitionPayload) { + this.transitionPayload = transitionPayload; + } + + public String getTransitionSignature() { + return transitionSignature; + } + + public void setTransitionSignature(String transitionSignature) { + this.transitionSignature = transitionSignature; + } + + public Date getNotBefore() { + return notBefore; + } + + public void setNotBefore(Date notBefore) { + this.notBefore = notBefore; + } + + public Date getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Date expiresAt) { + this.expiresAt = expiresAt; + } + + public Date getRotateAt() { + return rotateAt; + } + + public void setRotateAt(Date rotateAt) { + this.rotateAt = rotateAt; + } + + public Date getPreviousUntil() { + return previousUntil; + } + + public void setPreviousUntil(Date previousUntil) { + this.previousUntil = previousUntil; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public Date getModified() { + return modified; + } + + public void setModified(Date modified) { + this.modified = modified; + } +} diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/CredentialKeyRotateDTO.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/CredentialKeyRotateDTO.java new file mode 100644 index 00000000..acdea85e --- /dev/null +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/CredentialKeyRotateDTO.java @@ -0,0 +1,20 @@ +package tech.easyflow.auth.entity; + +/** + * 密钥轮换请求。 + */ +public class CredentialKeyRotateDTO { + + /** + * 是否紧急轮换。 + */ + private Boolean emergency; + + public Boolean getEmergency() { + return emergency; + } + + public void setEmergency(Boolean emergency) { + this.emergency = emergency; + } +} diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/CredentialKeyTransitionVO.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/CredentialKeyTransitionVO.java new file mode 100644 index 00000000..123db86d --- /dev/null +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/CredentialKeyTransitionVO.java @@ -0,0 +1,59 @@ +package tech.easyflow.auth.entity; + +/** + * 前端公钥固定值平滑交接材料。 + */ +public class CredentialKeyTransitionVO { + + /** + * 旧密钥标识。 + */ + private String fromKeyId; + + /** + * 新密钥标识。 + */ + private String toKeyId; + + /** + * Base64URL canonical payload。 + */ + private String payload; + + /** + * 旧私钥对 payload 的签名。 + */ + private String signature; + + public String getFromKeyId() { + return fromKeyId; + } + + public void setFromKeyId(String fromKeyId) { + this.fromKeyId = fromKeyId; + } + + public String getToKeyId() { + return toKeyId; + } + + public void setToKeyId(String toKeyId) { + this.toKeyId = toKeyId; + } + + public String getPayload() { + return payload; + } + + public void setPayload(String payload) { + this.payload = payload; + } + + public String getSignature() { + return signature; + } + + public void setSignature(String signature) { + this.signature = signature; + } +} diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/CredentialKeyVO.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/CredentialKeyVO.java new file mode 100644 index 00000000..20fb3108 --- /dev/null +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/CredentialKeyVO.java @@ -0,0 +1,101 @@ +package tech.easyflow.auth.entity; + +import java.util.Date; +import java.util.List; + +/** + * 登录凭证加密公钥材料。 + */ +public class CredentialKeyVO { + + /** + * 当前密钥标识。 + */ + private String keyId; + + /** + * PEM 格式公钥。 + */ + private String publicKey; + + /** + * 公钥指纹。 + */ + private String fingerprint; + + /** + * 加密算法描述。 + */ + private String algorithm; + + /** + * 一次性 nonce。 + */ + private String nonce; + + /** + * 安全材料过期时间。 + */ + private Date expiresAt; + + /** + * 公钥轮换链。 + */ + private List transitions; + + public String getKeyId() { + return keyId; + } + + public void setKeyId(String keyId) { + this.keyId = keyId; + } + + public String getPublicKey() { + return publicKey; + } + + public void setPublicKey(String publicKey) { + this.publicKey = publicKey; + } + + public String getFingerprint() { + return fingerprint; + } + + public void setFingerprint(String fingerprint) { + this.fingerprint = fingerprint; + } + + public String getAlgorithm() { + return algorithm; + } + + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } + + public String getNonce() { + return nonce; + } + + public void setNonce(String nonce) { + this.nonce = nonce; + } + + public Date getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Date expiresAt) { + this.expiresAt = expiresAt; + } + + public List getTransitions() { + return transitions; + } + + public void setTransitions(List transitions) { + this.transitions = transitions; + } +} diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/EncryptedCredentialDTO.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/EncryptedCredentialDTO.java new file mode 100644 index 00000000..2538ffbd --- /dev/null +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/EncryptedCredentialDTO.java @@ -0,0 +1,79 @@ +package tech.easyflow.auth.entity; + +import javax.validation.constraints.NotEmpty; + +/** + * 前端密码加密信封。 + */ +public class EncryptedCredentialDTO { + + /** + * 密钥标识。 + */ + @NotEmpty(message = "keyId不能为空") + private String keyId; + + /** + * RSA-OAEP 加密后的 AES 密钥。 + */ + @NotEmpty(message = "encryptedKey不能为空") + private String encryptedKey; + + /** + * AES-GCM 初始向量。 + */ + @NotEmpty(message = "iv不能为空") + private String iv; + + /** + * AES-GCM 密文,包含认证标签。 + */ + @NotEmpty(message = "ciphertext不能为空") + private String ciphertext; + + /** + * 一次性请求随机数。 + */ + @NotEmpty(message = "nonce不能为空") + private String nonce; + + public String getKeyId() { + return keyId; + } + + public void setKeyId(String keyId) { + this.keyId = keyId; + } + + public String getEncryptedKey() { + return encryptedKey; + } + + public void setEncryptedKey(String encryptedKey) { + this.encryptedKey = encryptedKey; + } + + public String getIv() { + return iv; + } + + public void setIv(String iv) { + this.iv = iv; + } + + public String getCiphertext() { + return ciphertext; + } + + public void setCiphertext(String ciphertext) { + this.ciphertext = ciphertext; + } + + public String getNonce() { + return nonce; + } + + public void setNonce(String nonce) { + this.nonce = nonce; + } +} diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/mapper/AuthCredentialKeyMapper.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/mapper/AuthCredentialKeyMapper.java new file mode 100644 index 00000000..122c4118 --- /dev/null +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/mapper/AuthCredentialKeyMapper.java @@ -0,0 +1,10 @@ +package tech.easyflow.auth.mapper; + +import com.mybatisflex.core.BaseMapper; +import tech.easyflow.auth.entity.AuthCredentialKey; + +/** + * 认证传输密钥映射层。 + */ +public interface AuthCredentialKeyMapper extends BaseMapper { +} diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/AuthCredentialKeyService.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/AuthCredentialKeyService.java new file mode 100644 index 00000000..48b8eeb3 --- /dev/null +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/AuthCredentialKeyService.java @@ -0,0 +1,40 @@ +package tech.easyflow.auth.service; + +import com.alibaba.fastjson2.JSONObject; +import com.mybatisflex.core.service.IService; +import tech.easyflow.auth.entity.AuthCredentialKey; +import tech.easyflow.auth.entity.CredentialKeyVO; +import tech.easyflow.auth.entity.EncryptedCredentialDTO; + +/** + * 认证传输密钥服务。 + */ +public interface AuthCredentialKeyService extends IService { + + /** + * 获取当前前端加密材料。 + * + * @return 公钥、指纹、nonce 和轮换链 + */ + CredentialKeyVO getCurrentCredentialKey(); + + /** + * 解密前端加密信封。 + * + * @param encryptedCredential 加密信封 + * @return 明文 JSON 载荷 + */ + JSONObject decryptPayload(EncryptedCredentialDTO encryptedCredential); + + /** + * 触发一次密钥轮换。 + * + * @param emergency 是否紧急吊销旧密钥 + */ + void rotate(boolean emergency); + + /** + * 自动轮换与旧密钥清理。 + */ + void maintainKeyRing(); +} diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthCredentialKeyServiceImpl.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthCredentialKeyServiceImpl.java new file mode 100644 index 00000000..51ab86ad --- /dev/null +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthCredentialKeyServiceImpl.java @@ -0,0 +1,471 @@ +package tech.easyflow.auth.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.spring.service.impl.ServiceImpl; +import jakarta.annotation.PostConstruct; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import tech.easyflow.auth.config.CredentialKeyProperties; +import tech.easyflow.auth.constant.CredentialKeyStatus; +import tech.easyflow.auth.entity.AuthCredentialKey; +import tech.easyflow.auth.entity.CredentialKeyTransitionVO; +import tech.easyflow.auth.entity.CredentialKeyVO; +import tech.easyflow.auth.entity.EncryptedCredentialDTO; +import tech.easyflow.auth.mapper.AuthCredentialKeyMapper; +import tech.easyflow.auth.service.AuthCredentialKeyService; +import tech.easyflow.common.cache.RedisLockExecutor; +import tech.easyflow.common.web.exceptions.BusinessException; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Duration; +import java.util.*; + +/** + * 认证传输密钥服务实现。 + */ +@Service +public class AuthCredentialKeyServiceImpl + extends ServiceImpl + implements AuthCredentialKeyService { + + private static final String MASTER_KEY_ENV = "EASYFLOW_CREDENTIAL_MASTER_KEY"; + private static final String ALGORITHM = "RSA-OAEP-256 + AES-256-GCM"; + private static final String LOCK_KEY = "easyflow:auth:credential-key:lock"; + private static final String NONCE_KEY_PREFIX = "easyflow:auth:credential-key:nonce:"; + private static final Duration LOCK_WAIT = Duration.ofSeconds(5); + private static final Duration LOCK_LEASE = Duration.ofSeconds(30); + private static final int AES_GCM_TAG_BITS = 128; + private static final int RSA_KEY_SIZE = 2048; + private static final DefaultRedisScript CONSUME_NONCE_SCRIPT; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + static { + CONSUME_NONCE_SCRIPT = new DefaultRedisScript<>(); + CONSUME_NONCE_SCRIPT.setScriptText( + "local value = redis.call('get', KEYS[1]); " + + "if value then redis.call('del', KEYS[1]); return value; end; " + + "return nil;" + ); + CONSUME_NONCE_SCRIPT.setResultType(String.class); + } + + private final CredentialKeyProperties properties; + private final RedisLockExecutor redisLockExecutor; + private final StringRedisTemplate stringRedisTemplate; + private byte[] masterKey; + + /** + * 创建认证传输密钥服务。 + * + * @param properties 密钥配置 + * @param redisLockExecutor Redis 分布式锁执行器 + * @param stringRedisTemplate Redis 字符串模板 + */ + public AuthCredentialKeyServiceImpl(CredentialKeyProperties properties, + RedisLockExecutor redisLockExecutor, + StringRedisTemplate stringRedisTemplate) { + this.properties = properties; + this.redisLockExecutor = redisLockExecutor; + this.stringRedisTemplate = stringRedisTemplate; + } + + /** + * 初始化主密钥并确保密钥环可用。 + */ + @PostConstruct + public void init() { + this.masterKey = loadMasterKey(); + ensureActiveKey(); + } + + /** + * 周期维护密钥环。 + */ + @Override + @Scheduled(fixedDelayString = "#{@credentialKeyProperties.checkInterval.toMillis()}", + initialDelay = 60000L) + public void maintainKeyRing() { + ensureActiveKey(); + retireExpiredPreviousKeys(); + AuthCredentialKey active = getActiveKey(); + if (active != null && active.getRotateAt() != null + && active.getRotateAt().getTime() <= System.currentTimeMillis()) { + rotate(false); + } + } + + /** + * 获取当前公钥材料。 + * + * @return 公钥材料 + */ + @Override + public CredentialKeyVO getCurrentCredentialKey() { + AuthCredentialKey active = ensureActiveKey(); + String nonce = issueNonce(active.getKeyId()); + CredentialKeyVO vo = new CredentialKeyVO(); + vo.setKeyId(active.getKeyId()); + vo.setPublicKey(active.getPublicKeyPem()); + vo.setFingerprint(active.getFingerprint()); + vo.setAlgorithm(ALGORITHM); + vo.setNonce(nonce); + vo.setExpiresAt(new Date(System.currentTimeMillis() + safeDuration(properties.getMaterialTtl()).toMillis())); + vo.setTransitions(getTransitions()); + return vo; + } + + /** + * 解密前端加密信封。 + * + * @param encryptedCredential 加密信封 + * @return 明文 JSON + */ + @Override + public JSONObject decryptPayload(EncryptedCredentialDTO encryptedCredential) { + validateEnvelope(encryptedCredential); + AuthCredentialKey key = getByKeyId(encryptedCredential.getKeyId()); + validateDecryptableKey(key); + consumeNonce(encryptedCredential.getNonce(), encryptedCredential.getKeyId()); + try { + PrivateKey privateKey = parsePrivateKey(decryptPrivateKeyPem(key.getEncryptedPrivateKey())); + byte[] aesKey = decryptAesKey(privateKey, decodeBase64(encryptedCredential.getEncryptedKey())); + byte[] plaintext = decryptCiphertext(aesKey, decodeBase64(encryptedCredential.getIv()), + decodeBase64(encryptedCredential.getCiphertext())); + JSONObject payload = JSON.parseObject(new String(plaintext, StandardCharsets.UTF_8)); + if (payload == null || payload.isEmpty()) { + throw new BusinessException("登录安全载荷为空"); + } + if (!encryptedCredential.getNonce().equals(payload.getString("nonce"))) { + throw new BusinessException("登录安全随机数不匹配"); + } + return payload; + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + throw new BusinessException("登录安全载荷解密失败"); + } + } + + /** + * 手动触发密钥轮换。 + * + * @param emergency 是否紧急吊销旧密钥 + */ + @Override + public void rotate(boolean emergency) { + redisLockExecutor.executeWithLock(LOCK_KEY, LOCK_WAIT, LOCK_LEASE, () -> { + AuthCredentialKey active = getActiveKey(); + if (active == null) { + ensureActiveKey(); + return; + } + AuthCredentialKey newKey = createCredentialKey(active, active.getRotateSeq() + 1); + save(newKey); + if (emergency) { + markOldKey(active, CredentialKeyStatus.REVOKED, null); + } else { + markOldKey(active, CredentialKeyStatus.PREVIOUS, + new Date(System.currentTimeMillis() + safeDuration(properties.getPreviousRetain()).toMillis())); + } + }); + } + + private AuthCredentialKey ensureActiveKey() { + AuthCredentialKey active = getActiveKey(); + if (active != null) { + return active; + } + redisLockExecutor.executeWithLock(LOCK_KEY, LOCK_WAIT, LOCK_LEASE, () -> { + if (getActiveKey() == null) { + save(createCredentialKey(null, nextRotateSeq())); + } + }); + active = getActiveKey(); + if (active == null) { + throw new IllegalStateException("认证传输密钥初始化失败"); + } + return active; + } + + private AuthCredentialKey createCredentialKey(AuthCredentialKey parent, long rotateSeq) { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(RSA_KEY_SIZE); + KeyPair keyPair = generator.generateKeyPair(); + Date now = new Date(); + Date expiresAt = new Date(now.getTime() + safeDuration(properties.getLifetime()).toMillis()); + Date rotateAt = new Date(expiresAt.getTime() - safeDuration(properties.getRotateBefore()).toMillis()); + String publicKeyPem = toPem("PUBLIC KEY", keyPair.getPublic().getEncoded()); + String privateKeyPem = toPem("PRIVATE KEY", keyPair.getPrivate().getEncoded()); + AuthCredentialKey key = new AuthCredentialKey(); + key.setKeyId(UUID.randomUUID().toString().replace("-", "")); + key.setPublicKeyPem(publicKeyPem); + key.setEncryptedPrivateKey(encryptPrivateKeyPem(privateKeyPem)); + key.setFingerprint(fingerprint(keyPair.getPublic())); + key.setStatus(CredentialKeyStatus.ACTIVE); + key.setRotateSeq(rotateSeq); + key.setNotBefore(now); + key.setExpiresAt(expiresAt); + key.setRotateAt(rotateAt); + key.setCreated(now); + key.setModified(now); + if (parent != null) { + key.setParentKeyId(parent.getKeyId()); + String payload = buildTransitionPayload(parent, key); + key.setTransitionPayload(payload); + key.setTransitionSignature(signTransition(parent, payload)); + } + return key; + } catch (Exception e) { + throw new IllegalStateException("生成认证传输密钥失败", e); + } + } + + private String buildTransitionPayload(AuthCredentialKey parent, AuthCredentialKey key) { + Map payload = new LinkedHashMap<>(); + payload.put("oldKeyId", parent.getKeyId()); + payload.put("newKeyId", key.getKeyId()); + payload.put("newPublicKey", key.getPublicKeyPem()); + payload.put("newFingerprint", key.getFingerprint()); + payload.put("issuedAt", new Date().getTime()); + payload.put("expiresAt", key.getExpiresAt().getTime()); + payload.put("rotateSeq", key.getRotateSeq()); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(JSON.toJSONString(payload).getBytes(StandardCharsets.UTF_8)); + } + + private String signTransition(AuthCredentialKey parent, String payload) throws Exception { + PrivateKey privateKey = parsePrivateKey(decryptPrivateKeyPem(parent.getEncryptedPrivateKey())); + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initSign(privateKey); + signature.update(payload.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(signature.sign()); + } + + private AuthCredentialKey getActiveKey() { + QueryWrapper wrapper = QueryWrapper.create() + .eq(AuthCredentialKey::getStatus, CredentialKeyStatus.ACTIVE) + .orderBy("rotate_seq desc"); + List keys = list(wrapper); + return keys == null || keys.isEmpty() ? null : keys.get(0); + } + + private AuthCredentialKey getByKeyId(String keyId) { + QueryWrapper wrapper = QueryWrapper.create().eq(AuthCredentialKey::getKeyId, keyId); + AuthCredentialKey key = getOne(wrapper); + if (key == null) { + throw new BusinessException("登录安全密钥不存在"); + } + return key; + } + + private List getTransitions() { + QueryWrapper wrapper = QueryWrapper.create() + .isNotNull(AuthCredentialKey::getTransitionPayload) + .ne(AuthCredentialKey::getStatus, CredentialKeyStatus.REVOKED) + .orderBy("rotate_seq asc"); + List keys = list(wrapper); + List transitions = new ArrayList<>(); + if (keys == null) { + return transitions; + } + for (AuthCredentialKey key : keys) { + CredentialKeyTransitionVO transition = new CredentialKeyTransitionVO(); + transition.setFromKeyId(key.getParentKeyId()); + transition.setToKeyId(key.getKeyId()); + transition.setPayload(key.getTransitionPayload()); + transition.setSignature(key.getTransitionSignature()); + transitions.add(transition); + } + return transitions; + } + + private void markOldKey(AuthCredentialKey oldKey, String status, Date previousUntil) { + AuthCredentialKey update = new AuthCredentialKey(); + update.setId(oldKey.getId()); + update.setStatus(status); + update.setPreviousUntil(previousUntil); + update.setModified(new Date()); + updateById(update); + } + + private void retireExpiredPreviousKeys() { + QueryWrapper wrapper = QueryWrapper.create() + .eq(AuthCredentialKey::getStatus, CredentialKeyStatus.PREVIOUS) + .isNotNull(AuthCredentialKey::getPreviousUntil); + List keys = list(wrapper); + if (keys == null || keys.isEmpty()) { + return; + } + long now = System.currentTimeMillis(); + for (AuthCredentialKey key : keys) { + if (key.getPreviousUntil() != null && key.getPreviousUntil().getTime() <= now) { + markOldKey(key, CredentialKeyStatus.RETIRED, key.getPreviousUntil()); + } + } + } + + private long nextRotateSeq() { + List keys = list(); + if (keys == null || keys.isEmpty()) { + return 1L; + } + return keys.stream() + .map(AuthCredentialKey::getRotateSeq) + .filter(item -> item != null) + .max(Long::compareTo) + .orElse(0L) + 1L; + } + + private void validateEnvelope(EncryptedCredentialDTO encryptedCredential) { + if (encryptedCredential == null + || !StringUtils.hasText(encryptedCredential.getKeyId()) + || !StringUtils.hasText(encryptedCredential.getEncryptedKey()) + || !StringUtils.hasText(encryptedCredential.getIv()) + || !StringUtils.hasText(encryptedCredential.getCiphertext()) + || !StringUtils.hasText(encryptedCredential.getNonce())) { + throw new BusinessException("登录安全载荷不完整"); + } + } + + private void validateDecryptableKey(AuthCredentialKey key) { + long now = System.currentTimeMillis(); + if (CredentialKeyStatus.ACTIVE.equals(key.getStatus())) { + if (key.getExpiresAt() != null && key.getExpiresAt().getTime() <= now) { + throw new BusinessException("登录安全密钥已过期"); + } + return; + } + if (CredentialKeyStatus.PREVIOUS.equals(key.getStatus()) + && key.getPreviousUntil() != null + && key.getPreviousUntil().getTime() > now) { + return; + } + throw new BusinessException("登录安全密钥不可用"); + } + + private String issueNonce(String keyId) { + String nonce = UUID.randomUUID().toString().replace("-", ""); + stringRedisTemplate.opsForValue().set(nonceKey(nonce), keyId, safeDuration(properties.getNonceTtl())); + return nonce; + } + + private void consumeNonce(String nonce, String keyId) { + String storedKeyId = stringRedisTemplate.execute(CONSUME_NONCE_SCRIPT, + Collections.singletonList(nonceKey(nonce))); + if (!keyId.equals(storedKeyId)) { + throw new BusinessException("登录安全随机数无效或已过期"); + } + } + + private String nonceKey(String nonce) { + return NONCE_KEY_PREFIX + nonce; + } + + private byte[] loadMasterKey() { + String raw = System.getenv(MASTER_KEY_ENV); + if (!StringUtils.hasText(raw)) { + throw new IllegalStateException(MASTER_KEY_ENV + " 未配置"); + } + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(raw); + } catch (IllegalArgumentException e) { + throw new IllegalStateException(MASTER_KEY_ENV + " 必须是 Base64 编码", e); + } + if (decoded.length != 32) { + throw new IllegalStateException(MASTER_KEY_ENV + " 必须解码为 32 字节"); + } + return decoded; + } + + private String encryptPrivateKeyPem(String privateKeyPem) throws Exception { + byte[] iv = new byte[12]; + SECURE_RANDOM.nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(masterKey, "AES"), new GCMParameterSpec(AES_GCM_TAG_BITS, iv)); + byte[] ciphertext = cipher.doFinal(privateKeyPem.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(iv) + ":" + Base64.getEncoder().encodeToString(ciphertext); + } + + private String decryptPrivateKeyPem(String encryptedPrivateKey) throws Exception { + String[] parts = encryptedPrivateKey.split(":", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("私钥密文格式无效"); + } + byte[] iv = decodeBase64(parts[0]); + byte[] ciphertext = decodeBase64(parts[1]); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(masterKey, "AES"), new GCMParameterSpec(AES_GCM_TAG_BITS, iv)); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + } + + private byte[] decryptAesKey(PrivateKey privateKey, byte[] encryptedKey) throws Exception { + Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); + cipher.init(Cipher.DECRYPT_MODE, privateKey, + new javax.crypto.spec.OAEPParameterSpec("SHA-256", "MGF1", + MGF1ParameterSpec.SHA256, javax.crypto.spec.PSource.PSpecified.DEFAULT)); + return cipher.doFinal(encryptedKey); + } + + private byte[] decryptCiphertext(byte[] aesKey, byte[] iv, byte[] ciphertext) throws Exception { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, "AES"), new GCMParameterSpec(AES_GCM_TAG_BITS, iv)); + return cipher.doFinal(ciphertext); + } + + private PrivateKey parsePrivateKey(String privateKeyPem) throws Exception { + byte[] der = parsePem(privateKeyPem); + return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(der)); + } + + private String fingerprint(PublicKey publicKey) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(publicKey.getEncoded()); + StringBuilder builder = new StringBuilder(digest.length * 2); + for (byte item : digest) { + builder.append(String.format("%02x", item)); + } + return builder.toString(); + } + + private String toPem(String type, byte[] der) { + String body = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.UTF_8)).encodeToString(der); + return "-----BEGIN " + type + "-----\n" + body + "\n-----END " + type + "-----"; + } + + private byte[] parsePem(String pem) { + String base64 = pem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + return Base64.getDecoder().decode(base64); + } + + private byte[] decodeBase64(String value) { + try { + return Base64.getDecoder().decode(value); + } catch (IllegalArgumentException e) { + return Base64.getUrlDecoder().decode(value); + } + } + + private Duration safeDuration(Duration duration) { + if (duration == null || duration.isNegative() || duration.isZero()) { + throw new IllegalStateException("认证传输密钥配置必须为正数"); + } + return duration; + } +} diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/SysAccount.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/SysAccount.java index 48239798..b2b1703f 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/SysAccount.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/SysAccount.java @@ -1,14 +1,16 @@ package tech.easyflow.system.entity; import cn.hutool.core.bean.BeanUtil; -import tech.easyflow.common.entity.LoginAccount; -import tech.easyflow.system.entity.base.SysAccountBase; import com.alibaba.fastjson.annotation.JSONField; +import com.mybatisflex.annotation.Column; import com.mybatisflex.annotation.RelationManyToMany; import com.mybatisflex.annotation.Table; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.system.entity.base.SysAccountBase; import java.math.BigInteger; import java.util.List; +import java.util.Map; /** * 用户表 实体类。 @@ -38,6 +40,9 @@ public class SysAccount extends SysAccountBase { ) private List positionIds; + @Column(ignore = true) + private Map passwordCredential; + public List getRoleIds() { return roleIds; } @@ -54,6 +59,14 @@ public class SysAccount extends SysAccountBase { this.positionIds = positionIds; } + public Map getPasswordCredential() { + return passwordCredential; + } + + public void setPasswordCredential(Map passwordCredential) { + this.passwordCredential = passwordCredential; + } + @Override @JSONField(serialize = false) public String getPassword() { diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml b/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml index 852c5ea1..2e7b0707 100644 --- a/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/application.yml @@ -124,7 +124,7 @@ easyflow: analytical-db: # 是否启用分析数据库 enabled: true - url: jdbc:clickhouse://114.66.22.180:38123/easyflow_dev?jdbc_ignore_unsupported_values=true&socket_timeout=30000&compress=false&ssl=false + url: jdbc:clickhouse://110.42.53.158:38123/easyflow_dev?jdbc_ignore_unsupported_values=true&socket_timeout=30000&compress=false&ssl=false username: easyflow password: 123456 driver-class-name: com.clickhouse.jdbc.ClickHouseDriver diff --git a/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V26__mysql_auth_credential_key.sql b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V26__mysql_auth_credential_key.sql new file mode 100644 index 00000000..c36b7d62 --- /dev/null +++ b/easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/V26__mysql_auth_credential_key.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS `tb_auth_credential_key` ( + `id` BIGINT NOT NULL COMMENT 'ID', + `key_id` VARCHAR(64) NOT NULL COMMENT '密钥标识', + `public_key_pem` TEXT NOT NULL COMMENT '公钥PEM', + `encrypted_private_key` TEXT NOT NULL COMMENT '加密后的私钥PEM', + `fingerprint` VARCHAR(128) NOT NULL COMMENT '公钥指纹', + `status` VARCHAR(32) NOT NULL COMMENT '状态', + `rotate_seq` BIGINT NOT NULL COMMENT '轮换序号', + `parent_key_id` VARCHAR(64) NULL COMMENT '上一密钥标识', + `transition_payload` TEXT NULL COMMENT '轮换载荷', + `transition_signature` TEXT NULL COMMENT '轮换签名', + `not_before` DATETIME NOT NULL COMMENT '生效时间', + `expires_at` DATETIME NOT NULL COMMENT '过期时间', + `rotate_at` DATETIME NOT NULL COMMENT '计划轮换时间', + `previous_until` DATETIME NULL COMMENT '旧密钥保留解密截止时间', + `created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_auth_credential_key_id` (`key_id`), + UNIQUE KEY `uk_auth_credential_fingerprint` (`fingerprint`), + KEY `idx_auth_credential_status` (`status`), + KEY `idx_auth_credential_rotate_seq` (`rotate_seq`), + KEY `idx_auth_credential_previous_until` (`previous_until`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='认证传输密钥环'; diff --git a/easyflow-ui-admin/app/src/api/core/auth.ts b/easyflow-ui-admin/app/src/api/core/auth.ts index cf451828..6e611e4b 100644 --- a/easyflow-ui-admin/app/src/api/core/auth.ts +++ b/easyflow-ui-admin/app/src/api/core/auth.ts @@ -1,4 +1,5 @@ -import { baseRequestClient, requestClient } from '#/api/request'; +import {baseRequestClient, requestClient} from '#/api/request'; +import {type CredentialKeyMaterial, encryptCredentialPayload,} from '#/utils/credential-encryption'; export namespace AuthApi { /** 登录接口参数 */ @@ -25,11 +26,27 @@ export namespace AuthApi { } } +/** + * 获取登录加密公钥 + */ +export async function getCredentialKeyApi() { + return requestClient.get( + '/api/v1/auth/credential-key', + ); +} + /** * 登录 */ export async function loginApi(data: AuthApi.LoginParams) { - return requestClient.post('/api/v1/auth/login', data); + const encryptedPayload = await encryptCredentialPayload( + getCredentialKeyApi, + data, + ); + return requestClient.post( + '/api/v1/auth/login', + encryptedPayload, + ); } /** diff --git a/easyflow-ui-admin/app/src/utils/credential-encryption.ts b/easyflow-ui-admin/app/src/utils/credential-encryption.ts new file mode 100644 index 00000000..ac97dad7 --- /dev/null +++ b/easyflow-ui-admin/app/src/utils/credential-encryption.ts @@ -0,0 +1,234 @@ +interface CredentialKeyMaterial { + algorithm: string; + expiresAt: string; + fingerprint: string; + keyId: string; + nonce: string; + publicKey: string; + transitions?: CredentialKeyTransition[]; +} + +interface CredentialKeyPin { + fingerprint: string; + keyId: string; + publicKey: string; +} + +interface CredentialKeyTransition { + fromKeyId: string; + payload: string; + signature: string; + toKeyId: string; +} + +interface TransitionPayload { + newFingerprint: string; + newKeyId: string; + newPublicKey: string; + oldKeyId: string; +} + +const PIN_STORAGE_KEY = 'easyflow:credential-key-pin:v1'; +const SECURITY_ERROR = '登录安全校验失败,请刷新或联系管理员'; +const UNSUPPORTED_ERROR = '当前浏览器不支持登录安全加密,请升级浏览器'; + +/** + * 加密密码类请求载荷。 + * @param loadMaterial 获取后端公钥材料的方法 + * @param payload 待加密载荷 + * @returns 加密信封 + */ +export async function encryptCredentialPayload( + loadMaterial: () => Promise, + payload: Record, +) { + ensureWebCrypto(); + const material = await loadMaterial(); + await verifyAndPersistPin(material); + const aesKey = await window.crypto.subtle.generateKey( + { length: 256, name: 'AES-GCM' }, + true, + ['encrypt'], + ); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode( + JSON.stringify({ + ...payload, + nonce: material.nonce, + timestamp: Date.now(), + }), + ); + const ciphertext = await window.crypto.subtle.encrypt( + { iv, name: 'AES-GCM' }, + aesKey, + plaintext, + ); + const rawAesKey = await window.crypto.subtle.exportKey('raw', aesKey); + const publicKey = await importRsaPublicKey(material.publicKey, ['encrypt']); + const encryptedKey = await window.crypto.subtle.encrypt( + { name: 'RSA-OAEP' }, + publicKey, + rawAesKey, + ); + return { + ciphertext: bytesToBase64(new Uint8Array(ciphertext)), + encryptedKey: bytesToBase64(new Uint8Array(encryptedKey)), + iv: bytesToBase64(iv), + keyId: material.keyId, + nonce: material.nonce, + }; +} + +async function verifyAndPersistPin(material: CredentialKeyMaterial) { + const current = readPin(); + if (!current) { + writePin(material); + return; + } + if (current.fingerprint === material.fingerprint) { + writePin(material); + return; + } + const nextPin = await verifyTransitionChain(current, material); + if (!nextPin || nextPin.fingerprint !== material.fingerprint) { + throw new Error(SECURITY_ERROR); + } + writePin(material); +} + +async function verifyTransitionChain( + current: CredentialKeyPin, + material: CredentialKeyMaterial, +) { + let cursor = { ...current }; + const transitions = material.transitions || []; + const visited = new Set(); + while (cursor.fingerprint !== material.fingerprint) { + if (visited.has(cursor.keyId)) { + return null; + } + visited.add(cursor.keyId); + const transition = transitions.find((item) => item.fromKeyId === cursor.keyId); + if (!transition) { + return null; + } + const payload = decodeTransitionPayload(transition.payload); + if ( + payload.oldKeyId !== cursor.keyId || + payload.newKeyId !== transition.toKeyId + ) { + return null; + } + const verified = await verifyTransition(cursor.publicKey, transition); + if (!verified) { + return null; + } + cursor = { + fingerprint: payload.newFingerprint, + keyId: payload.newKeyId, + publicKey: payload.newPublicKey, + }; + } + return cursor; +} + +async function verifyTransition( + publicKeyPem: string, + transition: CredentialKeyTransition, +) { + const publicKey = await importSignPublicKey(publicKeyPem); + return window.crypto.subtle.verify( + { name: 'RSASSA-PKCS1-v1_5' }, + publicKey, + base64ToBytes(transition.signature), + new TextEncoder().encode(transition.payload), + ); +} + +function decodeTransitionPayload(payload: string): TransitionPayload { + const json = new TextDecoder().decode(base64UrlToBytes(payload)); + return JSON.parse(json) as TransitionPayload; +} + +async function importRsaPublicKey(publicKeyPem: string, usages: KeyUsage[]) { + return window.crypto.subtle.importKey( + 'spki', + pemToBytes(publicKeyPem), + { hash: 'SHA-256', name: 'RSA-OAEP' }, + false, + usages, + ); +} + +async function importSignPublicKey(publicKeyPem: string) { + return window.crypto.subtle.importKey( + 'spki', + pemToBytes(publicKeyPem), + { hash: 'SHA-256', name: 'RSASSA-PKCS1-v1_5' }, + false, + ['verify'], + ); +} + +function ensureWebCrypto() { + if (!window.crypto?.subtle || !window.crypto.getRandomValues) { + throw new Error(UNSUPPORTED_ERROR); + } +} + +function readPin(): CredentialKeyPin | null { + const raw = window.localStorage.getItem(PIN_STORAGE_KEY); + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as CredentialKeyPin; + } catch { + window.localStorage.removeItem(PIN_STORAGE_KEY); + return null; + } +} + +function writePin(material: CredentialKeyMaterial) { + window.localStorage.setItem( + PIN_STORAGE_KEY, + JSON.stringify({ + fingerprint: material.fingerprint, + keyId: material.keyId, + publicKey: material.publicKey, + }), + ); +} + +function pemToBytes(pem: string) { + const base64 = pem + .replace('-----BEGIN PUBLIC KEY-----', '') + .replace('-----END PUBLIC KEY-----', '') + .replace(/\s/g, ''); + return base64ToBytes(base64); +} + +function base64ToBytes(value: string) { + const binary = window.atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function base64UrlToBytes(value: string) { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + return base64ToBytes(padded); +} + +function bytesToBase64(bytes: Uint8Array) { + let binary = ''; + bytes.forEach((item) => { + binary += String.fromCharCode(item); + }); + return window.btoa(binary); +} + +export type { CredentialKeyMaterial }; diff --git a/easyflow-ui-admin/app/src/views/_core/profile/base-setting.vue b/easyflow-ui-admin/app/src/views/_core/profile/base-setting.vue index 497b569d..b3288877 100644 --- a/easyflow-ui-admin/app/src/views/_core/profile/base-setting.vue +++ b/easyflow-ui-admin/app/src/views/_core/profile/base-setting.vue @@ -1,16 +1,16 @@