feat/encrypt_login #3

Merged
czm merged 2 commits from feat/encrypt_login into main 2026-06-23 16:01:56 +08:00
33 changed files with 2273 additions and 113 deletions
Showing only changes of commit e56f043483 - Show all commits

View File

@@ -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<CredentialKeyVO> credentialKey() {
return Result.ok(credentialKeyService.getCurrentCredentialKey());
}
@PostMapping("login")
public Result<LoginVO> login(@JsonBody LoginDTO loginDTO) {
public Result<LoginVO> 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<String> permissionList = StpUtil.getPermissionList();
return Result.ok(permissionList);
}
@PostMapping("credential-key/rotate")
@LogRecord("轮换认证传输密钥")
public Result<Void> 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;
}
}

View File

@@ -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<SysAccountService, SysAccount> {
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<SysAccountService,
if (count > 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<SysAccountService,
/**
* 修改密码,用于修改用户自己的密码
*
* @param password 用户的旧密码
* @param newPassword 新密码
* @param confirmPassword 确认密码
* @param encryptedCredential 加密后的旧密码、新密码与确认密码
*/
@PostMapping("/updatePassword")
public Result<Void> updatePassword(@JsonBody(value = "password", required = true) String password,
@JsonBody(value = "newPassword", required = true) String newPassword,
@JsonBody(value = "confirmPassword", required = true) String confirmPassword) {
public Result<Void> 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<SysAccountService,
return Result.ok();
}
private String decryptInitialPassword(Map<String, Object> passwordCredential) {
if (passwordCredential == null || passwordCredential.isEmpty()) {
return null;
}
JSONObject payload = credentialKeyService.decryptPayload(toEncryptedCredential(passwordCredential));
return payload.getString("password");
}
private EncryptedCredentialDTO toEncryptedCredential(Map<String, Object> 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<Void> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {

View File

@@ -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<CredentialKeyVO> credentialKey() {
return Result.ok(credentialKeyService.getCurrentCredentialKey());
}
/**
* 登录
* @param loginDTO 登录参数
* @param encryptedCredential 加密登录参数
*/
@PostMapping("login")
public Result<LoginVO> login(@JsonBody LoginDTO loginDTO) {
public Result<LoginVO> 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<String> 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;
}
}

View File

@@ -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<Void> updatePassword(@JsonBody(value = "password", required = true) String password,
@JsonBody(value = "newPassword", required = true) String newPassword,
@JsonBody(value = "confirmPassword", required = true) String confirmPassword) {
public Result<Void> 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) {

View File

@@ -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 在解密后完成。
}
}

View File

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

View File

@@ -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 <T> 响应数据类型
* @return 统一失败响应
*/
public <T> Result<T> failureResult() {
Result<T> result = new Result<>();
result.setErrorCode(CAPTCHA_ERROR_CODE);
result.setMessage(CAPTCHA_ERROR_MESSAGE);
return result;
}
}

View File

@@ -20,10 +20,18 @@
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-web</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-cache</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-system</artifactId>
</dependency>
<dependency>
<groupId>com.mybatis-flex</groupId>
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<CredentialKeyTransitionVO> 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<CredentialKeyTransitionVO> getTransitions() {
return transitions;
}
public void setTransitions(List<CredentialKeyTransitionVO> transitions) {
this.transitions = transitions;
}
}

View File

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

View File

@@ -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<AuthCredentialKey> {
}

View File

@@ -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<AuthCredentialKey> {
/**
* 获取当前前端加密材料。
*
* @return 公钥、指纹、nonce 和轮换链
*/
CredentialKeyVO getCurrentCredentialKey();
/**
* 解密前端加密信封。
*
* @param encryptedCredential 加密信封
* @return 明文 JSON 载荷
*/
JSONObject decryptPayload(EncryptedCredentialDTO encryptedCredential);
/**
* 触发一次密钥轮换。
*
* @param emergency 是否紧急吊销旧密钥
*/
void rotate(boolean emergency);
/**
* 自动轮换与旧密钥清理。
*/
void maintainKeyRing();
}

View File

@@ -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<AuthCredentialKeyMapper, AuthCredentialKey>
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<String> 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<String, Object> 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<AuthCredentialKey> 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<CredentialKeyTransitionVO> getTransitions() {
QueryWrapper wrapper = QueryWrapper.create()
.isNotNull(AuthCredentialKey::getTransitionPayload)
.ne(AuthCredentialKey::getStatus, CredentialKeyStatus.REVOKED)
.orderBy("rotate_seq asc");
List<AuthCredentialKey> keys = list(wrapper);
List<CredentialKeyTransitionVO> 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<AuthCredentialKey> 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<AuthCredentialKey> 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;
}
}

View File

@@ -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<BigInteger> positionIds;
@Column(ignore = true)
private Map<String, Object> passwordCredential;
public List<BigInteger> getRoleIds() {
return roleIds;
}
@@ -54,6 +59,14 @@ public class SysAccount extends SysAccountBase {
this.positionIds = positionIds;
}
public Map<String, Object> getPasswordCredential() {
return passwordCredential;
}
public void setPasswordCredential(Map<String, Object> passwordCredential) {
this.passwordCredential = passwordCredential;
}
@Override
@JSONField(serialize = false)
public String getPassword() {

View File

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

View File

@@ -0,0 +1,109 @@
CREATE TABLE IF NOT EXISTS `tb_skill_category` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NULL COMMENT '租户ID',
`parent_id` BIGINT NULL COMMENT '父分类ID',
`category_name` VARCHAR(128) NOT NULL COMMENT '分类名称',
`level_no` INT DEFAULT 1 COMMENT '层级',
`ancestors` VARCHAR(512) NULL COMMENT '祖级路径',
`sort_no` INT DEFAULT 0 COMMENT '排序',
`status` INT DEFAULT 1 COMMENT '状态',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`created_by` BIGINT NULL COMMENT '创建人',
`modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`modified_by` BIGINT NULL COMMENT '修改人',
PRIMARY KEY (`id`),
KEY `idx_skill_category_tenant_parent` (`tenant_id`, `parent_id`, `status`, `sort_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill 分类';
CREATE TABLE IF NOT EXISTS `tb_skill` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NULL COMMENT '租户ID',
`dept_id` BIGINT NULL COMMENT '部门ID',
`category_id` BIGINT NULL COMMENT '分类ID',
`name` VARCHAR(128) NOT NULL COMMENT 'Skill 名称',
`display_name` VARCHAR(128) NULL COMMENT '展示名称',
`description` VARCHAR(1024) NULL COMMENT '描述',
`metadata_json` JSON NULL COMMENT '元数据',
`skill_content` MEDIUMTEXT NULL COMMENT 'SKILL.md 内容',
`enabled` TINYINT(1) DEFAULT 1 COMMENT '是否启用',
`visibility_scope` VARCHAR(32) NULL COMMENT '可见范围',
`source_type` VARCHAR(32) NULL COMMENT '来源类型',
`package_hash` VARCHAR(128) NULL COMMENT '包 hash',
`reference_count` INT DEFAULT 0 COMMENT '引用文档数量',
`script_count` INT DEFAULT 0 COMMENT '脚本数量',
`asset_count` INT DEFAULT 0 COMMENT '资产数量',
`publish_status` VARCHAR(32) DEFAULT 'DRAFT' COMMENT '发布状态',
`current_approval_instance_id` BIGINT NULL COMMENT '当前审批实例ID',
`published_snapshot_json` JSON NULL COMMENT '已发布快照',
`published_at` DATETIME NULL COMMENT '发布时间',
`published_by` BIGINT NULL COMMENT '发布人',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`created_by` BIGINT NULL COMMENT '创建人',
`modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`modified_by` BIGINT NULL COMMENT '修改人',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_skill_tenant_name` (`tenant_id`, `name`),
KEY `idx_skill_tenant_category` (`tenant_id`, `category_id`, `enabled`),
KEY `idx_skill_publish_status` (`publish_status`),
KEY `idx_skill_created_by` (`created_by`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill';
CREATE TABLE IF NOT EXISTS `tb_skill_reference` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NULL COMMENT '租户ID',
`skill_id` BIGINT NOT NULL COMMENT 'Skill ID',
`path` VARCHAR(512) NOT NULL COMMENT '逻辑路径',
`name` VARCHAR(255) NULL COMMENT '文件名',
`content` MEDIUMTEXT NULL COMMENT '内容',
`content_hash` VARCHAR(128) NULL COMMENT '内容 hash',
`size` BIGINT DEFAULT 0 COMMENT '大小',
`metadata_json` JSON NULL COMMENT '元数据',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_skill_reference_path` (`skill_id`, `path`),
KEY `idx_skill_reference_skill` (`skill_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill reference';
CREATE TABLE IF NOT EXISTS `tb_skill_script` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NULL COMMENT '租户ID',
`skill_id` BIGINT NOT NULL COMMENT 'Skill ID',
`path` VARCHAR(512) NOT NULL COMMENT '逻辑路径',
`language` VARCHAR(32) NULL COMMENT '脚本语言',
`content` MEDIUMTEXT NULL COMMENT '内容',
`content_hash` VARCHAR(128) NULL COMMENT '内容 hash',
`size` BIGINT DEFAULT 0 COMMENT '大小',
`metadata_json` JSON NULL COMMENT '元数据',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_skill_script_path` (`skill_id`, `path`),
KEY `idx_skill_script_skill` (`skill_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill script';
CREATE TABLE IF NOT EXISTS `tb_skill_asset` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NULL COMMENT '租户ID',
`skill_id` BIGINT NOT NULL COMMENT 'Skill ID',
`path` VARCHAR(512) NOT NULL COMMENT '逻辑路径',
`name` VARCHAR(255) NULL COMMENT '文件名',
`media_type` VARCHAR(128) NULL COMMENT '媒体类型',
`content_ref` VARCHAR(128) NOT NULL COMMENT '内容引用',
`content_hash` VARCHAR(128) NULL COMMENT '内容 hash',
`size` BIGINT DEFAULT 0 COMMENT '大小',
`metadata_json` JSON NULL COMMENT '元数据',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_skill_asset_path` (`skill_id`, `path`),
KEY `idx_skill_asset_skill` (`skill_id`),
KEY `idx_skill_asset_content_ref` (`content_ref`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill asset';
CREATE TABLE IF NOT EXISTS `tb_skill_asset_content` (
`content_ref` VARCHAR(128) NOT NULL COMMENT '内容引用',
`content_hash` VARCHAR(128) NOT NULL COMMENT '内容 hash',
`file_path` VARCHAR(1024) NOT NULL COMMENT '文件路径',
`media_type` VARCHAR(128) NULL COMMENT '媒体类型',
`size` BIGINT DEFAULT 0 COMMENT '大小',
`ref_count` INT DEFAULT 0 COMMENT '引用数',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`content_ref`),
KEY `idx_skill_asset_content_hash` (`content_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill asset 内容索引';

View File

@@ -0,0 +1,172 @@
SET NAMES utf8mb4;
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367400000000000001, 0, 0, '技能管理', '/ai/skill', '/ai/skill/SkillList', 'lucide:badge-check',
1, '', 3, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, '管理端 Skill 管理菜单'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000001
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367400000000000011, 367400000000000001, 1, '查询', '', '', '',
0, '/api/v1/skill/query', 1, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-查询'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000011
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367400000000000012, 367400000000000001, 1, '详情', '', '', '',
0, '/api/v1/skill/getDetail', 2, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-详情'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000012
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367400000000000013, 367400000000000001, 1, '保存', '', '', '',
0, '/api/v1/skill/save', 3, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-保存'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000013
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367400000000000014, 367400000000000001, 1, '更新', '', '', '',
0, '/api/v1/skill/update', 4, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-更新'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000014
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367400000000000015, 367400000000000001, 1, '删除', '', '', '',
0, '/api/v1/skill/remove', 5, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-删除'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000015
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367400000000000016, 367400000000000001, 1, '发布', '', '', '',
0, '/api/v1/skill/submitPublishApproval', 6, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-发布审批'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000016
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367400000000000017, 367400000000000001, 1, '下线', '', '', '',
0, '/api/v1/skill/submitOfflineApproval', 7, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-下线审批'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000017
);
INSERT INTO `tb_sys_menu` (
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
)
SELECT
367400000000000018, 367400000000000001, 1, '删除审批', '', '', '',
0, '/api/v1/skill/submitDeleteApproval', 8, 0, '2026-06-05 20:00:00', 1, '2026-06-05 20:00:00', 1, 'Skill-删除审批'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000018
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000101, 1, 367400000000000001
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000001
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000111, 1, 367400000000000011
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000011
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000112, 1, 367400000000000012
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000012
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000113, 1, 367400000000000013
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000013
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000114, 1, 367400000000000014
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000014
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000115, 1, 367400000000000015
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000015
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000116, 1, 367400000000000016
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000016
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000117, 1, 367400000000000017
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000017
);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000118, 1, 367400000000000018
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000018
);

View File

@@ -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='认证传输密钥环';

View File

@@ -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<CredentialKeyMaterial>(
'/api/v1/auth/credential-key',
);
}
/**
* 登录
*/
export async function loginApi(data: AuthApi.LoginParams) {
return requestClient.post<AuthApi.LoginResult>('/api/v1/auth/login', data);
const encryptedPayload = await encryptCredentialPayload(
getCredentialKeyApi,
data,
);
return requestClient.post<AuthApi.LoginResult>(
'/api/v1/auth/login',
encryptedPayload,
);
}
/**

View File

@@ -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<CredentialKeyMaterial>,
payload: Record<string, any>,
) {
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<string>();
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 };

View File

@@ -1,16 +1,16 @@
<script setup lang="ts">
import type { EasyFlowFormSchema } from '#/adapter/form';
import type {EasyFlowFormSchema} from '#/adapter/form';
import { computed, markRaw, onMounted, ref } from 'vue';
import {computed, markRaw, nextTick, onMounted, ref} from 'vue';
import { ProfileBaseSetting } from '@easyflow/common-ui';
import {ProfileBaseSetting} from '@easyflow/common-ui';
import { ElMessage } from 'element-plus';
import {ElMessage} from 'element-plus';
import { api } from '#/api/request';
import {api} from '#/api/request';
import Cropper from '#/components/upload/Cropper.vue';
import { $t } from '#/locales';
import { useAuthStore } from '#/store';
import {$t} from '#/locales';
import {useAuthStore} from '#/store';
const { fetchUserInfo } = useAuthStore();
const profileBaseSettingRef = ref();
@@ -48,9 +48,16 @@ onMounted(async () => {
});
async function getInfo() {
loading.value = true;
const data = await fetchUserInfo();
await profileBaseSettingRef.value.getFormApi().setValues(data);
loading.value = false;
try {
const data = await fetchUserInfo();
await nextTick();
const formApi = profileBaseSettingRef.value?.getFormApi?.();
if (formApi) {
await formApi.setValues(data);
}
} finally {
loading.value = false;
}
}
const loading = ref(false);
const updateLoading = ref(false);

View File

@@ -1,19 +1,21 @@
<script setup lang="ts">
import type { EasyFlowFormSchema } from '#/adapter/form';
import type {EasyFlowFormSchema} from '#/adapter/form';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {computed, ref} from 'vue';
import {useRoute, useRouter} from 'vue-router';
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
import { preferences } from '@easyflow/preferences';
import { useUserStore } from '@easyflow/stores';
import {ProfilePasswordSetting, z} from '@easyflow/common-ui';
import {preferences} from '@easyflow/preferences';
import {useUserStore} from '@easyflow/stores';
import { ElMessage } from 'element-plus';
import {ElMessage} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import { useAuthStore } from '#/store';
import { isStrongPassword } from '#/utils/password-policy';
import {getCredentialKeyApi} from '#/api';
import {api} from '#/api/request';
import {$t} from '#/locales';
import {useAuthStore} from '#/store';
import {encryptCredentialPayload} from '#/utils/credential-encryption';
import {isStrongPassword} from '#/utils/password-policy';
const profilePasswordSettingRef = ref();
const authStore = useAuthStore();
@@ -87,7 +89,14 @@ const updateLoading = ref(false);
async function handleSubmit(values: any) {
updateLoading.value = true;
try {
const res = await api.post('/api/v1/sysAccount/updatePassword', values);
const encryptedPayload = await encryptCredentialPayload(
getCredentialKeyApi,
values,
);
const res = await api.post(
'/api/v1/sysAccount/updatePassword',
encryptedPayload,
);
if (res.errorCode === 0) {
ElMessage.success($t('message.success'));
const userInfo = await authStore.fetchUserInfo();

View File

@@ -1,18 +1,19 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import type {FormInstance} from 'element-plus';
import {ElForm, ElFormItem, ElInput, ElMessage} from 'element-plus';
import { onMounted, ref, watch } from 'vue';
import {onMounted, ref, watch} from 'vue';
import { EasyFlowFormModal, EasyFlowInputPassword } from '@easyflow/common-ui';
import {EasyFlowFormModal, EasyFlowInputPassword} from '@easyflow/common-ui';
import { ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import {getCredentialKeyApi} from '#/api';
import {api} from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue';
// import Cropper from '#/components/upload/Cropper.vue';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales';
import { isStrongPassword } from '#/utils/password-policy';
import {$t} from '#/locales';
import {encryptCredentialPayload} from '#/utils/credential-encryption';
import {isStrongPassword} from '#/utils/password-policy';
const emit = defineEmits(['reload']);
// vue
@@ -107,26 +108,33 @@ function openDialog(row: any) {
dialogVisible.value = true;
}
function save() {
saveForm.value?.validate((valid) => {
saveForm.value?.validate(async (valid) => {
if (valid) {
btnLoading.value = true;
const { confirmPassword: _confirmPassword, ...payload } = entity.value;
api
.post(
try {
const {
confirmPassword: _confirmPassword,
password,
...payload
} = entity.value;
if (isAdd.value) {
payload.passwordCredential = await encryptCredentialPayload(
getCredentialKeyApi,
{ password },
);
}
const res = await api.post(
isAdd.value ? 'api/v1/sysAccount/save' : 'api/v1/sysAccount/update',
payload,
)
.then((res) => {
btnLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
})
.catch(() => {
btnLoading.value = false;
});
);
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
} finally {
btnLoading.value = false;
}
}
});
}

View File

@@ -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 {
/** 登录接口参数 */
@@ -20,13 +21,26 @@ export namespace AuthApi {
}
}
/**
* 获取登录加密公钥
*/
export async function getCredentialKeyApi() {
return requestClient.get<CredentialKeyMaterial>(
'/userCenter/auth/credential-key',
);
}
/**
* 登录
*/
export async function loginApi(data: AuthApi.LoginParams) {
const encryptedPayload = await encryptCredentialPayload(
getCredentialKeyApi,
data,
);
return requestClient.post<AuthApi.LoginResult>(
'/userCenter/auth/login',
data,
encryptedPayload,
);
}

View File

@@ -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<CredentialKeyMaterial>,
payload: Record<string, any>,
) {
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<string>();
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 };

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import type { BasicOption } from '@easyflow/types';
import type {BasicOption} from '@easyflow/types';
import type { EasyFlowFormSchema } from '#/adapter/form';
import type {EasyFlowFormSchema} from '#/adapter/form';
import { computed, onMounted, ref } from 'vue';
import {computed, nextTick, onMounted, ref} from 'vue';
import { ProfileBaseSetting } from '@easyflow/common-ui';
import {ProfileBaseSetting} from '@easyflow/common-ui';
import { getUserInfoApi } from '#/api';
import {getUserInfoApi} from '#/api';
const profileBaseSettingRef = ref();
@@ -57,7 +57,11 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
onMounted(async () => {
const data = await getUserInfoApi();
profileBaseSettingRef.value.getFormApi().setValues(data);
await nextTick();
const formApi = profileBaseSettingRef.value?.getFormApi?.();
if (formApi) {
formApi.setValues(data);
}
});
</script>
<template>

View File

@@ -1,19 +1,21 @@
<script setup lang="ts">
import type { EasyFlowFormSchema } from '#/adapter/form';
import type {EasyFlowFormSchema} from '#/adapter/form';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {computed, ref} from 'vue';
import {useRoute, useRouter} from 'vue-router';
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
import { preferences } from '@easyflow/preferences';
import { useUserStore } from '@easyflow/stores';
import {ProfilePasswordSetting, z} from '@easyflow/common-ui';
import {preferences} from '@easyflow/preferences';
import {useUserStore} from '@easyflow/stores';
import { ElMessage } from 'element-plus';
import {ElMessage} from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import { useAuthStore } from '#/store';
import { isStrongPassword } from '#/utils/password-policy';
import {getCredentialKeyApi} from '#/api';
import {api} from '#/api/request';
import {$t} from '#/locales';
import {useAuthStore} from '#/store';
import {encryptCredentialPayload} from '#/utils/credential-encryption';
import {isStrongPassword} from '#/utils/password-policy';
const profilePasswordSettingRef = ref();
const authStore = useAuthStore();
@@ -83,7 +85,14 @@ const updateLoading = ref(false);
async function handleSubmit(values: any) {
updateLoading.value = true;
try {
const res = await api.post('/userCenter/sysAccount/updatePassword', values);
const encryptedPayload = await encryptCredentialPayload(
getCredentialKeyApi,
values,
);
const res = await api.post(
'/userCenter/sysAccount/updatePassword',
encryptedPayload,
);
if (res.errorCode === 0) {
ElMessage.success($t('message.success'));
const userInfo = await authStore.fetchUserInfo();