feat: 登录信息加密传输
This commit is contained in:
@@ -20,10 +20,18 @@
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-common-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-common-cache</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-module-system</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mybatis-flex</groupId>
|
||||
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package tech.easyflow.auth.config;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@AutoConfiguration
|
||||
@EnableScheduling
|
||||
@ComponentScan("tech.easyflow.auth")
|
||||
@MapperScan("tech.easyflow.auth.mapper")
|
||||
public class AuthModuleConfig {
|
||||
|
||||
public AuthModuleConfig() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.system.entity.base.SysAccountBase;
|
||||
import com.alibaba.fastjson.annotation.JSONField;
|
||||
import com.mybatisflex.annotation.Column;
|
||||
import com.mybatisflex.annotation.RelationManyToMany;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.system.entity.base.SysAccountBase;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户表 实体类。
|
||||
@@ -38,6 +40,9 @@ public class SysAccount extends SysAccountBase {
|
||||
)
|
||||
private List<BigInteger> positionIds;
|
||||
|
||||
@Column(ignore = true)
|
||||
private Map<String, Object> passwordCredential;
|
||||
|
||||
public List<BigInteger> getRoleIds() {
|
||||
return roleIds;
|
||||
}
|
||||
@@ -54,6 +59,14 @@ public class SysAccount extends SysAccountBase {
|
||||
this.positionIds = positionIds;
|
||||
}
|
||||
|
||||
public Map<String, Object> getPasswordCredential() {
|
||||
return passwordCredential;
|
||||
}
|
||||
|
||||
public void setPasswordCredential(Map<String, Object> passwordCredential) {
|
||||
this.passwordCredential = passwordCredential;
|
||||
}
|
||||
|
||||
@Override
|
||||
@JSONField(serialize = false)
|
||||
public String getPassword() {
|
||||
|
||||
Reference in New Issue
Block a user