发布 v1.10 #5
@@ -2,15 +2,22 @@ package tech.easyflow.admin.controller.auth;
|
|||||||
|
|
||||||
import cn.dev33.satoken.annotation.SaIgnore;
|
import cn.dev33.satoken.annotation.SaIgnore;
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import tech.easyflow.auth.entity.LoginDTO;
|
import tech.easyflow.auth.entity.*;
|
||||||
import tech.easyflow.auth.entity.LoginVO;
|
import tech.easyflow.auth.service.AuthCredentialKeyService;
|
||||||
import tech.easyflow.auth.service.AuthService;
|
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.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.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.log.annotation.LogRecord;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -21,9 +28,23 @@ public class AuthController {
|
|||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private AuthService authService;
|
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")
|
@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);
|
LoginVO res = authService.login(loginDTO);
|
||||||
return Result.ok(res);
|
return Result.ok(res);
|
||||||
}
|
}
|
||||||
@@ -45,4 +66,23 @@ public class AuthController {
|
|||||||
List<String> permissionList = StpUtil.getPermissionList();
|
List<String> permissionList = StpUtil.getPermissionList();
|
||||||
return Result.ok(permissionList);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package tech.easyflow.admin.controller.system;
|
|||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import cn.hutool.crypto.digest.BCrypt;
|
import cn.hutool.crypto.digest.BCrypt;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import com.mybatisflex.core.paginate.Page;
|
import com.mybatisflex.core.paginate.Page;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
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.EnumAccountType;
|
||||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||||
import tech.easyflow.common.domain.Result;
|
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.service.SysAccountService;
|
||||||
import tech.easyflow.system.util.SysPasswordPolicy;
|
import tech.easyflow.system.util.SysPasswordPolicy;
|
||||||
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户表 控制层。
|
* 用户表 控制层。
|
||||||
@@ -43,8 +47,11 @@ import java.util.List;
|
|||||||
@RestController("sysAccountController")
|
@RestController("sysAccountController")
|
||||||
@RequestMapping("/api/v1/sysAccount")
|
@RequestMapping("/api/v1/sysAccount")
|
||||||
public class SysAccountController extends BaseCurdController<SysAccountService, SysAccount> {
|
public class SysAccountController extends BaseCurdController<SysAccountService, SysAccount> {
|
||||||
public SysAccountController(SysAccountService service) {
|
private final AuthCredentialKeyService credentialKeyService;
|
||||||
|
|
||||||
|
public SysAccountController(SysAccountService service, AuthCredentialKeyService credentialKeyService) {
|
||||||
super(service);
|
super(service);
|
||||||
|
this.credentialKeyService = credentialKeyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -67,7 +74,7 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
|||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
return Result.fail(1, "用户名已存在");
|
return Result.fail(1, "用户名已存在");
|
||||||
}
|
}
|
||||||
String password = entity.getPassword();
|
String password = decryptInitialPassword(entity.getPasswordCredential());
|
||||||
if (!StringUtil.hasText(password)) {
|
if (!StringUtil.hasText(password)) {
|
||||||
return Result.fail(1, "密码不能为空");
|
return Result.fail(1, "密码不能为空");
|
||||||
}
|
}
|
||||||
@@ -143,14 +150,14 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
|||||||
/**
|
/**
|
||||||
* 修改密码,用于修改用户自己的密码
|
* 修改密码,用于修改用户自己的密码
|
||||||
*
|
*
|
||||||
* @param password 用户的旧密码
|
* @param encryptedCredential 加密后的旧密码、新密码与确认密码
|
||||||
* @param newPassword 新密码
|
|
||||||
* @param confirmPassword 确认密码
|
|
||||||
*/
|
*/
|
||||||
@PostMapping("/updatePassword")
|
@PostMapping("/updatePassword")
|
||||||
public Result<Void> updatePassword(@JsonBody(value = "password", required = true) String password,
|
public Result<Void> updatePassword(@JsonBody EncryptedCredentialDTO encryptedCredential) {
|
||||||
@JsonBody(value = "newPassword", required = true) String newPassword,
|
JSONObject payload = credentialKeyService.decryptPayload(encryptedCredential);
|
||||||
@JsonBody(value = "confirmPassword", required = true) String confirmPassword) {
|
String password = payload.getString("password");
|
||||||
|
String newPassword = payload.getString("newPassword");
|
||||||
|
String confirmPassword = payload.getString("confirmPassword");
|
||||||
BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId();
|
BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId();
|
||||||
SysAccount record = service.getById(loginAccountId);
|
SysAccount record = service.getById(loginAccountId);
|
||||||
if (record == null) {
|
if (record == null) {
|
||||||
@@ -174,6 +181,28 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
|||||||
return Result.ok();
|
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")
|
@PostMapping("/resetPassword")
|
||||||
@SaCheckPermission("/api/v1/sysAccount/save")
|
@SaCheckPermission("/api/v1/sysAccount/save")
|
||||||
public Result<Void> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {
|
public Result<Void> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
package tech.easyflow.usercenter.controller.auth;
|
package tech.easyflow.usercenter.controller.auth;
|
||||||
|
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
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.LoginDTO;
|
||||||
import tech.easyflow.auth.entity.LoginVO;
|
import tech.easyflow.auth.entity.LoginVO;
|
||||||
|
import tech.easyflow.auth.service.AuthCredentialKeyService;
|
||||||
import tech.easyflow.auth.service.AuthService;
|
import tech.easyflow.auth.service.AuthService;
|
||||||
|
import tech.easyflow.common.captcha.tainai.CaptchaVerificationService;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
|
||||||
@@ -20,13 +28,30 @@ public class UcAuthController {
|
|||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private AuthService authService;
|
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")
|
@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);
|
LoginVO res = authService.login(loginDTO);
|
||||||
return Result.ok(res);
|
return Result.ok(res);
|
||||||
}
|
}
|
||||||
@@ -48,4 +73,11 @@ public class UcAuthController {
|
|||||||
List<String> permissionList = StpUtil.getPermissionList();
|
List<String> permissionList = StpUtil.getPermissionList();
|
||||||
return Result.ok(permissionList);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package tech.easyflow.usercenter.controller.system;
|
package tech.easyflow.usercenter.controller.system;
|
||||||
|
|
||||||
import cn.hutool.crypto.digest.BCrypt;
|
import cn.hutool.crypto.digest.BCrypt;
|
||||||
|
import com.alibaba.fastjson2.JSONObject;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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.domain.Result;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
@@ -29,6 +32,8 @@ public class UcSysAccountController {
|
|||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private SysAccountService service;
|
private SysAccountService service;
|
||||||
|
@Resource
|
||||||
|
private AuthCredentialKeyService credentialKeyService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户的信息
|
* 获取用户的信息
|
||||||
@@ -61,14 +66,14 @@ public class UcSysAccountController {
|
|||||||
/**
|
/**
|
||||||
* 修改密码
|
* 修改密码
|
||||||
*
|
*
|
||||||
* @param password 用户的旧密码
|
* @param encryptedCredential 加密后的旧密码、新密码与确认密码
|
||||||
* @param newPassword 新密码
|
|
||||||
* @param confirmPassword 确认密码
|
|
||||||
*/
|
*/
|
||||||
@PostMapping("/updatePassword")
|
@PostMapping("/updatePassword")
|
||||||
public Result<Void> updatePassword(@JsonBody(value = "password", required = true) String password,
|
public Result<Void> updatePassword(@JsonBody EncryptedCredentialDTO encryptedCredential) {
|
||||||
@JsonBody(value = "newPassword", required = true) String newPassword,
|
JSONObject payload = credentialKeyService.decryptPayload(encryptedCredential);
|
||||||
@JsonBody(value = "confirmPassword", required = true) String confirmPassword) {
|
String password = payload.getString("password");
|
||||||
|
String newPassword = payload.getString("newPassword");
|
||||||
|
String confirmPassword = payload.getString("confirmPassword");
|
||||||
BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId();
|
BigInteger loginAccountId = SaTokenUtil.getLoginAccount().getId();
|
||||||
SysAccount record = service.getById(loginAccountId);
|
SysAccount record = service.getById(loginAccountId);
|
||||||
if (record == null) {
|
if (record == null) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package tech.easyflow.common.captcha.tainai;
|
package tech.easyflow.common.captcha.tainai;
|
||||||
|
|
||||||
import jakarta.annotation.Resource;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
@@ -8,14 +7,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|||||||
@Configuration
|
@Configuration
|
||||||
public class CaptchaMvcConfig implements WebMvcConfigurer {
|
public class CaptchaMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
@Resource
|
/**
|
||||||
private CaptchaValidInterceptor interceptor;
|
* 加密登录请求在控制器解密后校验验证码,避免明文 token 出现在请求体顶层。
|
||||||
|
*
|
||||||
|
* @param registry 拦截器注册器
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
registry.addInterceptor(interceptor)
|
// 保留扩展点,当前登录验证码校验由 AuthController / UcAuthController 在解密后完成。
|
||||||
.order(1)
|
|
||||||
.addPathPatterns("/api/v1/auth/login")
|
|
||||||
.addPathPatterns("/userCenter/auth/login");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package tech.easyflow.common.captcha.tainai;
|
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 com.alibaba.fastjson.JSONObject;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.servlet.HandlerInterceptor;
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
import tech.easyflow.common.domain.Result;
|
|
||||||
import tech.easyflow.common.util.RequestUtil;
|
import tech.easyflow.common.util.RequestUtil;
|
||||||
import tech.easyflow.common.util.ResponseUtil;
|
import tech.easyflow.common.util.ResponseUtil;
|
||||||
|
|
||||||
@@ -16,26 +13,20 @@ import tech.easyflow.common.util.ResponseUtil;
|
|||||||
public class CaptchaValidInterceptor implements HandlerInterceptor {
|
public class CaptchaValidInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ImageCaptchaApplication application;
|
private CaptchaVerificationService captchaVerificationService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||||
JSONObject jsonObject = (JSONObject) RequestUtil.readJsonObjectOrArray(request);
|
JSONObject jsonObject = (JSONObject) RequestUtil.readJsonObjectOrArray(request);
|
||||||
String validToken = jsonObject.getString("validToken");
|
String validToken = jsonObject.getString("validToken");
|
||||||
if (validToken == null || validToken.isEmpty()) {
|
if (!captchaVerificationService.verify(validToken)) {
|
||||||
renderNotLogin(response);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
boolean valid = ((SecondaryVerificationApplication) application).secondaryVerification(validToken);
|
|
||||||
if (!valid) {
|
|
||||||
renderNotLogin(response);
|
renderNotLogin(response);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void renderNotLogin(HttpServletResponse response) {
|
private void renderNotLogin(HttpServletResponse response) {
|
||||||
Result result = Result.fail(99, "验证失败,请重试!");
|
ResponseUtil.renderJson(response, captchaVerificationService.failureResult());
|
||||||
ResponseUtil.renderJson(response, result);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,10 +20,18 @@
|
|||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-common-web</artifactId>
|
<artifactId>easyflow-common-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>tech.easyflow</groupId>
|
||||||
|
<artifactId>easyflow-common-cache</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-module-system</artifactId>
|
<artifactId>easyflow-module-system</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mybatis-flex</groupId>
|
||||||
|
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>junit</groupId>
|
<groupId>junit</groupId>
|
||||||
<artifactId>junit</artifactId>
|
<artifactId>junit</artifactId>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package tech.easyflow.auth.config;
|
package tech.easyflow.auth.config;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
import org.springframework.context.annotation.ComponentScan;
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
|
@EnableScheduling
|
||||||
@ComponentScan("tech.easyflow.auth")
|
@ComponentScan("tech.easyflow.auth")
|
||||||
|
@MapperScan("tech.easyflow.auth.mapper")
|
||||||
public class AuthModuleConfig {
|
public class AuthModuleConfig {
|
||||||
|
|
||||||
public AuthModuleConfig() {
|
public AuthModuleConfig() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
package tech.easyflow.system.entity;
|
package tech.easyflow.system.entity;
|
||||||
|
|
||||||
import cn.hutool.core.bean.BeanUtil;
|
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.alibaba.fastjson.annotation.JSONField;
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
import com.mybatisflex.annotation.RelationManyToMany;
|
import com.mybatisflex.annotation.RelationManyToMany;
|
||||||
import com.mybatisflex.annotation.Table;
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.system.entity.base.SysAccountBase;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户表 实体类。
|
* 用户表 实体类。
|
||||||
@@ -38,6 +40,9 @@ public class SysAccount extends SysAccountBase {
|
|||||||
)
|
)
|
||||||
private List<BigInteger> positionIds;
|
private List<BigInteger> positionIds;
|
||||||
|
|
||||||
|
@Column(ignore = true)
|
||||||
|
private Map<String, Object> passwordCredential;
|
||||||
|
|
||||||
public List<BigInteger> getRoleIds() {
|
public List<BigInteger> getRoleIds() {
|
||||||
return roleIds;
|
return roleIds;
|
||||||
}
|
}
|
||||||
@@ -54,6 +59,14 @@ public class SysAccount extends SysAccountBase {
|
|||||||
this.positionIds = positionIds;
|
this.positionIds = positionIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getPasswordCredential() {
|
||||||
|
return passwordCredential;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPasswordCredential(Map<String, Object> passwordCredential) {
|
||||||
|
this.passwordCredential = passwordCredential;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@JSONField(serialize = false)
|
@JSONField(serialize = false)
|
||||||
public String getPassword() {
|
public String getPassword() {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ easyflow:
|
|||||||
analytical-db:
|
analytical-db:
|
||||||
# 是否启用分析数据库
|
# 是否启用分析数据库
|
||||||
enabled: true
|
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
|
username: easyflow
|
||||||
password: 123456
|
password: 123456
|
||||||
driver-class-name: com.clickhouse.jdbc.ClickHouseDriver
|
driver-class-name: com.clickhouse.jdbc.ClickHouseDriver
|
||||||
|
|||||||
@@ -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='认证传输密钥环';
|
||||||
@@ -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 {
|
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) {
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
234
easyflow-ui-admin/app/src/utils/credential-encryption.ts
Normal file
234
easyflow-ui-admin/app/src/utils/credential-encryption.ts
Normal 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 };
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<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 Cropper from '#/components/upload/Cropper.vue';
|
||||||
import { $t } from '#/locales';
|
import {$t} from '#/locales';
|
||||||
import { useAuthStore } from '#/store';
|
import {useAuthStore} from '#/store';
|
||||||
|
|
||||||
const { fetchUserInfo } = useAuthStore();
|
const { fetchUserInfo } = useAuthStore();
|
||||||
const profileBaseSettingRef = ref();
|
const profileBaseSettingRef = ref();
|
||||||
@@ -48,9 +48,16 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
async function getInfo() {
|
async function getInfo() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const data = await fetchUserInfo();
|
try {
|
||||||
await profileBaseSettingRef.value.getFormApi().setValues(data);
|
const data = await fetchUserInfo();
|
||||||
loading.value = false;
|
await nextTick();
|
||||||
|
const formApi = profileBaseSettingRef.value?.getFormApi?.();
|
||||||
|
if (formApi) {
|
||||||
|
await formApi.setValues(data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const updateLoading = ref(false);
|
const updateLoading = ref(false);
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EasyFlowFormSchema } from '#/adapter/form';
|
import type {EasyFlowFormSchema} from '#/adapter/form';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import {computed, ref} from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import {useRoute, useRouter} from 'vue-router';
|
||||||
|
|
||||||
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
|
import {ProfilePasswordSetting, z} from '@easyflow/common-ui';
|
||||||
import { preferences } from '@easyflow/preferences';
|
import {preferences} from '@easyflow/preferences';
|
||||||
import { useUserStore } from '@easyflow/stores';
|
import {useUserStore} from '@easyflow/stores';
|
||||||
|
|
||||||
import { ElMessage } from 'element-plus';
|
import {ElMessage} from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import {getCredentialKeyApi} from '#/api';
|
||||||
import { $t } from '#/locales';
|
import {api} from '#/api/request';
|
||||||
import { useAuthStore } from '#/store';
|
import {$t} from '#/locales';
|
||||||
import { isStrongPassword } from '#/utils/password-policy';
|
import {useAuthStore} from '#/store';
|
||||||
|
import {encryptCredentialPayload} from '#/utils/credential-encryption';
|
||||||
|
import {isStrongPassword} from '#/utils/password-policy';
|
||||||
|
|
||||||
const profilePasswordSettingRef = ref();
|
const profilePasswordSettingRef = ref();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
@@ -87,7 +89,14 @@ const updateLoading = ref(false);
|
|||||||
async function handleSubmit(values: any) {
|
async function handleSubmit(values: any) {
|
||||||
updateLoading.value = true;
|
updateLoading.value = true;
|
||||||
try {
|
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) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success($t('message.success'));
|
ElMessage.success($t('message.success'));
|
||||||
const userInfo = await authStore.fetchUserInfo();
|
const userInfo = await authStore.fetchUserInfo();
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<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 {getCredentialKeyApi} from '#/api';
|
||||||
|
import {api} from '#/api/request';
|
||||||
import { api } from '#/api/request';
|
|
||||||
import DictSelect from '#/components/dict/DictSelect.vue';
|
import DictSelect from '#/components/dict/DictSelect.vue';
|
||||||
// import Cropper from '#/components/upload/Cropper.vue';
|
// import Cropper from '#/components/upload/Cropper.vue';
|
||||||
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
||||||
import { $t } from '#/locales';
|
import {$t} from '#/locales';
|
||||||
import { isStrongPassword } from '#/utils/password-policy';
|
import {encryptCredentialPayload} from '#/utils/credential-encryption';
|
||||||
|
import {isStrongPassword} from '#/utils/password-policy';
|
||||||
|
|
||||||
const emit = defineEmits(['reload']);
|
const emit = defineEmits(['reload']);
|
||||||
// vue
|
// vue
|
||||||
@@ -107,26 +108,33 @@ function openDialog(row: any) {
|
|||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
}
|
}
|
||||||
function save() {
|
function save() {
|
||||||
saveForm.value?.validate((valid) => {
|
saveForm.value?.validate(async (valid) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
btnLoading.value = true;
|
btnLoading.value = true;
|
||||||
const { confirmPassword: _confirmPassword, ...payload } = entity.value;
|
try {
|
||||||
api
|
const {
|
||||||
.post(
|
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',
|
isAdd.value ? 'api/v1/sysAccount/save' : 'api/v1/sysAccount/update',
|
||||||
payload,
|
payload,
|
||||||
)
|
);
|
||||||
.then((res) => {
|
if (res.errorCode === 0) {
|
||||||
btnLoading.value = false;
|
ElMessage.success(res.message);
|
||||||
if (res.errorCode === 0) {
|
emit('reload');
|
||||||
ElMessage.success(res.message);
|
closeDialog();
|
||||||
emit('reload');
|
}
|
||||||
closeDialog();
|
} finally {
|
||||||
}
|
btnLoading.value = false;
|
||||||
})
|
}
|
||||||
.catch(() => {
|
|
||||||
btnLoading.value = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ export function useTabbar() {
|
|||||||
|
|
||||||
// 点击tab,跳转路由
|
// 点击tab,跳转路由
|
||||||
const handleClick = (key: string) => {
|
const handleClick = (key: string) => {
|
||||||
const { fullPath, path } = tabbarStore.getTabByKey(key);
|
const tab = tabbarStore.getTabByKey(key);
|
||||||
|
if (!tab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { fullPath, path } = tab;
|
||||||
router.push(fullPath || path);
|
router.push(fullPath || path);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import type {useSvelteFlow} from '@xyflow/svelte';
|
|||||||
import {componentName} from './consts';
|
import {componentName} from './consts';
|
||||||
import {store} from './store/stores.svelte';
|
import {store} from './store/stores.svelte';
|
||||||
import type {TinyflowData, TinyflowOptions, TinyflowTheme} from './types';
|
import type {TinyflowData, TinyflowOptions, TinyflowTheme} from './types';
|
||||||
|
import {installTinyflowBrowserCompat} from './utils/compat';
|
||||||
|
import {createTinyflowNodeNormalizer} from './utils/nodeInteraction';
|
||||||
|
|
||||||
|
installTinyflowBrowserCompat();
|
||||||
|
|
||||||
type FlowInstance = ReturnType<typeof useSvelteFlow>;
|
type FlowInstance = ReturnType<typeof useSvelteFlow>;
|
||||||
|
|
||||||
@@ -102,6 +106,7 @@ export class Tinyflow {
|
|||||||
|
|
||||||
const currentViewport = flow.getViewport();
|
const currentViewport = flow.getViewport();
|
||||||
const currentNodes = flow.getNodes();
|
const currentNodes = flow.getNodes();
|
||||||
|
const normalizeNode = createTinyflowNodeNormalizer(this.options);
|
||||||
const currentNodePositions = new Map(
|
const currentNodePositions = new Map(
|
||||||
currentNodes.map((node) => [node.id, node.position]),
|
currentNodes.map((node) => [node.id, node.position]),
|
||||||
);
|
);
|
||||||
@@ -109,11 +114,12 @@ export class Tinyflow {
|
|||||||
options?.preserveViewport === true
|
options?.preserveViewport === true
|
||||||
? (data.nodes || currentNodes).map((node) => {
|
? (data.nodes || currentNodes).map((node) => {
|
||||||
const currentPosition = currentNodePositions.get(node.id);
|
const currentPosition = currentNodePositions.get(node.id);
|
||||||
return currentPosition
|
const nextNode = currentPosition
|
||||||
? { ...node, position: { ...currentPosition } }
|
? { ...node, position: { ...currentPosition } }
|
||||||
: node;
|
: node;
|
||||||
|
return normalizeNode(nextNode);
|
||||||
})
|
})
|
||||||
: data.nodes || currentNodes;
|
: (data.nodes || currentNodes).map((node) => normalizeNode(node));
|
||||||
store.setNodes(nextNodes);
|
store.setNodes(nextNodes);
|
||||||
store.setEdges(data.edges || flow.getEdges());
|
store.setEdges(data.edges || flow.getEdges());
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import {store} from '#store/stores.svelte';
|
import {store} from '#store/stores.svelte';
|
||||||
import type {TinyflowData, TinyflowOptions} from '#types';
|
import type {TinyflowData, TinyflowOptions} from '#types';
|
||||||
import {setContext} from 'svelte';
|
import {setContext} from 'svelte';
|
||||||
|
import {createTinyflowNodeNormalizer} from '../utils/nodeInteraction';
|
||||||
|
|
||||||
const props = $props<{
|
const props = $props<{
|
||||||
options: TinyflowOptions,
|
options: TinyflowOptions,
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
}) as TinyflowOptions;
|
}) as TinyflowOptions;
|
||||||
const data = parseData(getOptions().data);
|
const data = parseData(getOptions().data);
|
||||||
const initialViewport = data?.viewport || null;
|
const initialViewport = data?.viewport || null;
|
||||||
|
store.setNodeNormalizer(createTinyflowNodeNormalizer(getOptions()));
|
||||||
store.init(
|
store.init(
|
||||||
data?.nodes || [],
|
data?.nodes || [],
|
||||||
data?.edges || [],
|
data?.edges || [],
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<div style={rest.style} class="tf-collapse {rest.class}">
|
<div style={rest.style} class="tf-collapse {rest.class}">
|
||||||
{#each items as item, index}
|
{#each items as item, index}
|
||||||
<div class="tf-collapse-item">
|
<div class="tf-collapse-item">
|
||||||
<div class="tf-collapse-item-title" role="button" tabindex={index}
|
<div class="tf-collapse-item-title tf-node-drag-handle" role="button" tabindex={index}
|
||||||
onclick={() => handlerOnChange(item)}
|
onclick={() => handlerOnChange(item)}
|
||||||
onkeydown={(event) => {
|
onkeydown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
@@ -112,6 +112,14 @@
|
|||||||
background: var(--tf-bg-hover);
|
background: var(--tf-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tf-node-drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.svelte-flow__node.dragging) .tf-node-drag-handle {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
.tf-collapse-item-title-help::after {
|
.tf-collapse-item-title-help::after {
|
||||||
content: attr(data-help);
|
content: attr(data-help);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { installTinyflowBrowserCompat } from './utils/compat';
|
||||||
|
|
||||||
|
installTinyflowBrowserCompat();
|
||||||
|
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './Tinyflow';
|
export * from './Tinyflow';
|
||||||
export * from './components/TinyflowComponent.svelte';
|
export * from './components/TinyflowComponent.svelte';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type Edge, type Node, type Viewport } from '@xyflow/svelte';
|
import { type Edge, type Node, type Viewport } from '@xyflow/svelte';
|
||||||
|
import type { TinyflowNodeNormalizer } from '../utils/nodeInteraction';
|
||||||
|
|
||||||
const DEFAULT_VIEWPORT: Viewport = { x: 250, y: 100, zoom: 1 };
|
const DEFAULT_VIEWPORT: Viewport = { x: 250, y: 100, zoom: 1 };
|
||||||
|
|
||||||
@@ -6,20 +7,27 @@ const createStore = () => {
|
|||||||
let nodesInternal = $state.raw([] as Node[]);
|
let nodesInternal = $state.raw([] as Node[]);
|
||||||
let edgesInternal = $state.raw([] as Edge[]);
|
let edgesInternal = $state.raw([] as Edge[]);
|
||||||
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
|
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
|
||||||
|
let normalizeNode: TinyflowNodeNormalizer = (node) => node;
|
||||||
|
|
||||||
|
const normalizeNodes = (nodes: Node[]) => nodes.map(normalizeNode);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// nodes: nodesInternal,
|
// nodes: nodesInternal,
|
||||||
// edges: edgesInternal,
|
// edges: edgesInternal,
|
||||||
// viewport,
|
// viewport,
|
||||||
|
setNodeNormalizer: (normalizer: TinyflowNodeNormalizer) => {
|
||||||
|
normalizeNode = normalizer;
|
||||||
|
nodesInternal = normalizeNodes(nodesInternal);
|
||||||
|
},
|
||||||
init: (nodes: Node[], edges: Edge[], nextViewport?: Viewport | null) => {
|
init: (nodes: Node[], edges: Edge[], nextViewport?: Viewport | null) => {
|
||||||
nodesInternal = nodes;
|
nodesInternal = normalizeNodes(nodes);
|
||||||
edgesInternal = edges;
|
edgesInternal = edges;
|
||||||
viewport = nextViewport ? { ...nextViewport } : { ...DEFAULT_VIEWPORT };
|
viewport = nextViewport ? { ...nextViewport } : { ...DEFAULT_VIEWPORT };
|
||||||
},
|
},
|
||||||
|
|
||||||
getNodes: () => nodesInternal,
|
getNodes: () => nodesInternal,
|
||||||
setNodes: (nodes: Node[]) => {
|
setNodes: (nodes: Node[]) => {
|
||||||
nodesInternal = nodes;
|
nodesInternal = normalizeNodes(nodes);
|
||||||
},
|
},
|
||||||
getEdges: () => edgesInternal,
|
getEdges: () => edgesInternal,
|
||||||
setEdges: (edges: Edge[]) => {
|
setEdges: (edges: Edge[]) => {
|
||||||
@@ -32,18 +40,18 @@ const createStore = () => {
|
|||||||
|
|
||||||
getNode: (id: string) => nodesInternal.find((node) => node.id === id),
|
getNode: (id: string) => nodesInternal.find((node) => node.id === id),
|
||||||
addNode: (node: Node) => {
|
addNode: (node: Node) => {
|
||||||
nodesInternal = [...nodesInternal, node];
|
nodesInternal = [...nodesInternal, normalizeNode(node)];
|
||||||
},
|
},
|
||||||
removeNode: (id: string) => {
|
removeNode: (id: string) => {
|
||||||
nodesInternal = nodesInternal.filter((node) => node.id !== id);
|
nodesInternal = nodesInternal.filter((node) => node.id !== id);
|
||||||
},
|
},
|
||||||
updateNode: (id: string, node: Node) => {
|
updateNode: (id: string, node: Node) => {
|
||||||
nodesInternal = nodesInternal.map((n) =>
|
nodesInternal = nodesInternal.map((n) =>
|
||||||
n.id === id ? { ...n, ...node } : n,
|
n.id === id ? normalizeNode({ ...n, ...node }) : n,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
updateNodes: (update: (nodes: Node[]) => Node[]) => {
|
updateNodes: (update: (nodes: Node[]) => Node[]) => {
|
||||||
nodesInternal = update(nodesInternal);
|
nodesInternal = normalizeNodes(update(nodesInternal));
|
||||||
},
|
},
|
||||||
updateNodeData: (id: string, data: Node['data']) => {
|
updateNodeData: (id: string, data: Node['data']) => {
|
||||||
nodesInternal = nodesInternal.map((n) =>
|
nodesInternal = nodesInternal.map((n) =>
|
||||||
|
|||||||
22
easyflow-ui-admin/packages/tinyflow-ui/src/utils/compat.ts
Normal file
22
easyflow-ui-admin/packages/tinyflow-ui/src/utils/compat.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
type StructuredCloneFallback = <T>(value: T) => T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs browser compatibility shims required by Tinyflow.
|
||||||
|
*/
|
||||||
|
export function installTinyflowBrowserCompat() {
|
||||||
|
if (
|
||||||
|
typeof globalThis.structuredClone === 'function' ||
|
||||||
|
typeof JSON === 'undefined'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback: StructuredCloneFallback = (value) =>
|
||||||
|
value == null ? value : JSON.parse(JSON.stringify(value));
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'structuredClone', {
|
||||||
|
configurable: true,
|
||||||
|
value: fallback,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Node } from '@xyflow/svelte';
|
||||||
|
import type { CustomNode, TinyflowOptions } from '../types';
|
||||||
|
|
||||||
|
export const DEFAULT_NODE_DRAG_HANDLE = '.tf-node-drag-handle';
|
||||||
|
|
||||||
|
export type TinyflowNodeNormalizer = (node: Node) => Node;
|
||||||
|
|
||||||
|
function shouldUseDefaultDragHandle(
|
||||||
|
node: Node,
|
||||||
|
customNodes?: Record<string, CustomNode>,
|
||||||
|
) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(node, 'dragHandle')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const customNode = node.type ? customNodes?.[node.type] : undefined;
|
||||||
|
return customNode?.presentation !== 'plain';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTinyflowNodeNormalizer(
|
||||||
|
options?: TinyflowOptions,
|
||||||
|
): TinyflowNodeNormalizer {
|
||||||
|
const readonly = options?.readonly === true;
|
||||||
|
const nodesDraggable = options?.nodesDraggable ?? !readonly;
|
||||||
|
const nodesConnectable = options?.nodesConnectable ?? !readonly;
|
||||||
|
const elementsSelectable = options?.elementsSelectable ?? !readonly;
|
||||||
|
|
||||||
|
return (node) => {
|
||||||
|
const nextNode = { ...node };
|
||||||
|
|
||||||
|
if (nodesDraggable) {
|
||||||
|
if (nextNode.draggable === false) {
|
||||||
|
delete nextNode.draggable;
|
||||||
|
}
|
||||||
|
if (shouldUseDefaultDragHandle(nextNode, options?.customNodes)) {
|
||||||
|
nextNode.dragHandle = DEFAULT_NODE_DRAG_HANDLE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextNode.draggable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodesConnectable) {
|
||||||
|
if (nextNode.connectable === false) {
|
||||||
|
delete nextNode.connectable;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextNode.connectable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elementsSelectable) {
|
||||||
|
if (nextNode.selectable === false) {
|
||||||
|
delete nextNode.selectable;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextNode.selectable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextNode;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
|||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
minify: true,
|
minify: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
target: 'chrome90',
|
||||||
lib: {
|
lib: {
|
||||||
entry: resolve(__dirname, 'src/index.ts'),
|
entry: resolve(__dirname, 'src/index.ts'),
|
||||||
cssFileName: 'index',
|
cssFileName: 'index',
|
||||||
|
|||||||
@@ -5,9 +5,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {Tinyflow as TinyflowNative, TinyflowOptions} from '@tinyflow-ai/ui';
|
import {Tinyflow as TinyflowNative, TinyflowOptions} from '@tinyflow-ai/ui';
|
||||||
import '@tinyflow-ai/ui/dist/index.css';
|
import '@tinyflow-ai/ui/dist/index.css';
|
||||||
import {nextTick, onMounted, onUnmounted, ref, useAttrs, watch} from 'vue';
|
import {
|
||||||
|
getCurrentInstance,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
useAttrs,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
type TinyflowDataOption = Exclude<TinyflowOptions['data'], string | undefined>;
|
type TinyflowDataOption = Exclude<TinyflowOptions['data'], string | undefined>;
|
||||||
|
type StructuredCloneFn = <T>(value: T) => T;
|
||||||
|
|
||||||
const props = defineProps<
|
const props = defineProps<
|
||||||
{
|
{
|
||||||
@@ -18,9 +27,26 @@ const props = defineProps<
|
|||||||
|
|
||||||
const divRef = ref<HTMLDivElement | null>(null);
|
const divRef = ref<HTMLDivElement | null>(null);
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
|
const instance = getCurrentInstance();
|
||||||
let tinyflow: TinyflowNative | null = null;
|
let tinyflow: TinyflowNative | null = null;
|
||||||
let mountedDataReady = false;
|
let mountedDataReady = false;
|
||||||
let lastAppliedDataSignature = '';
|
let lastAppliedDataSignature = '';
|
||||||
|
const optionalBooleanOptionKeys = new Set([
|
||||||
|
'readonly',
|
||||||
|
'hideBottomDock',
|
||||||
|
'hideEdgePanel',
|
||||||
|
'hideMiniMap',
|
||||||
|
'hideNodeHandles',
|
||||||
|
'hideNodeToolbar',
|
||||||
|
'hideNodePicker',
|
||||||
|
'hideNodeSetting',
|
||||||
|
'hideEdgeMarkers',
|
||||||
|
'edgeAnimated',
|
||||||
|
'nodesDraggable',
|
||||||
|
'nodesConnectable',
|
||||||
|
'elementsSelectable',
|
||||||
|
'dropEnabled',
|
||||||
|
]);
|
||||||
|
|
||||||
function normalizeOptionKey(key: string) {
|
function normalizeOptionKey(key: string) {
|
||||||
return key.replace(/-([a-z])/g, (_match: string, letter: string) =>
|
return key.replace(/-([a-z])/g, (_match: string, letter: string) =>
|
||||||
@@ -37,21 +63,49 @@ function normalizeOptions(source: Record<string, unknown>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getProvidedPropKeys() {
|
||||||
|
return new Set(
|
||||||
|
Object.keys(instance?.vnode.props || {}).map((key) => normalizeOptionKey(key)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProps(source: Record<string, unknown>) {
|
||||||
|
const providedKeys = getProvidedPropKeys();
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(source).filter(([key, value]) => {
|
||||||
|
if (!optionalBooleanOptionKeys.has(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return value !== false || providedKeys.has(key);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 安全深拷贝工具函数
|
// 安全深拷贝工具函数
|
||||||
function safeDeepClone<T>(obj: T): T {
|
function safeDeepClone<T>(obj: T): T {
|
||||||
if (obj === null || typeof obj !== 'object') return obj;
|
if (obj === null || typeof obj !== 'object') return obj;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return structuredClone(obj);
|
const clone = (globalThis as { structuredClone?: StructuredCloneFn })
|
||||||
|
.structuredClone;
|
||||||
|
if (clone) {
|
||||||
|
return clone(obj);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to JSON cloning for browsers without native structuredClone.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(obj));
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(JSON.stringify(obj));
|
|
||||||
} catch {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
'Failed to clone object, returning original (may cause issues)',
|
'Failed to clone object, returning original (may cause issues)',
|
||||||
obj,
|
obj,
|
||||||
);
|
);
|
||||||
return obj;
|
return obj;
|
||||||
|
} catch {
|
||||||
|
return obj;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +138,7 @@ onMounted(() => {
|
|||||||
// 净化 props.data,避免响应式对象或函数污染
|
// 净化 props.data,避免响应式对象或函数污染
|
||||||
const cleanedProps = {
|
const cleanedProps = {
|
||||||
...normalizeOptions(attrs),
|
...normalizeOptions(attrs),
|
||||||
...props,
|
...normalizeProps(props),
|
||||||
} as any;
|
} as any;
|
||||||
if ('data' in cleanedProps && cleanedProps.data != null) {
|
if ('data' in cleanedProps && cleanedProps.data != null) {
|
||||||
cleanedProps.data = cloneDataIfChanged(cleanedProps.data);
|
cleanedProps.data = cloneDataIfChanged(cleanedProps.data);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
|||||||
minify: true,
|
minify: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
cssCodeSplit: true,
|
cssCodeSplit: true,
|
||||||
|
target: 'chrome90',
|
||||||
lib: {
|
lib: {
|
||||||
entry: resolve(__dirname, 'src/index.ts'),
|
entry: resolve(__dirname, 'src/index.ts'),
|
||||||
cssFileName: 'index',
|
cssFileName: 'index',
|
||||||
|
|||||||
@@ -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 {
|
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) {
|
export async function loginApi(data: AuthApi.LoginParams) {
|
||||||
|
const encryptedPayload = await encryptCredentialPayload(
|
||||||
|
getCredentialKeyApi,
|
||||||
|
data,
|
||||||
|
);
|
||||||
return requestClient.post<AuthApi.LoginResult>(
|
return requestClient.post<AuthApi.LoginResult>(
|
||||||
'/userCenter/auth/login',
|
'/userCenter/auth/login',
|
||||||
data,
|
encryptedPayload,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
234
easyflow-ui-usercenter/app/src/utils/credential-encryption.ts
Normal file
234
easyflow-ui-usercenter/app/src/utils/credential-encryption.ts
Normal 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 };
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<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();
|
const profileBaseSettingRef = ref();
|
||||||
|
|
||||||
@@ -57,7 +57,11 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const data = await getUserInfoApi();
|
const data = await getUserInfoApi();
|
||||||
profileBaseSettingRef.value.getFormApi().setValues(data);
|
await nextTick();
|
||||||
|
const formApi = profileBaseSettingRef.value?.getFormApi?.();
|
||||||
|
if (formApi) {
|
||||||
|
formApi.setValues(data);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EasyFlowFormSchema } from '#/adapter/form';
|
import type {EasyFlowFormSchema} from '#/adapter/form';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import {computed, ref} from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import {useRoute, useRouter} from 'vue-router';
|
||||||
|
|
||||||
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
|
import {ProfilePasswordSetting, z} from '@easyflow/common-ui';
|
||||||
import { preferences } from '@easyflow/preferences';
|
import {preferences} from '@easyflow/preferences';
|
||||||
import { useUserStore } from '@easyflow/stores';
|
import {useUserStore} from '@easyflow/stores';
|
||||||
|
|
||||||
import { ElMessage } from 'element-plus';
|
import {ElMessage} from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import {getCredentialKeyApi} from '#/api';
|
||||||
import { $t } from '#/locales';
|
import {api} from '#/api/request';
|
||||||
import { useAuthStore } from '#/store';
|
import {$t} from '#/locales';
|
||||||
import { isStrongPassword } from '#/utils/password-policy';
|
import {useAuthStore} from '#/store';
|
||||||
|
import {encryptCredentialPayload} from '#/utils/credential-encryption';
|
||||||
|
import {isStrongPassword} from '#/utils/password-policy';
|
||||||
|
|
||||||
const profilePasswordSettingRef = ref();
|
const profilePasswordSettingRef = ref();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
@@ -83,7 +85,14 @@ const updateLoading = ref(false);
|
|||||||
async function handleSubmit(values: any) {
|
async function handleSubmit(values: any) {
|
||||||
updateLoading.value = true;
|
updateLoading.value = true;
|
||||||
try {
|
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) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success($t('message.success'));
|
ElMessage.success($t('message.success'));
|
||||||
const userInfo = await authStore.fetchUserInfo();
|
const userInfo = await authStore.fetchUserInfo();
|
||||||
|
|||||||
Reference in New Issue
Block a user