纲要
- 云服务认证基础
AccessKey ID与AccessKey Secret的密钥对模型- 短信服务要素:签名、模板
- 跨平台对照:阿里云、Leancloud
- 邮件发送方案
SMTP与Web API的对比与选型- 邮件服务中的
API Key鉴权
- 多因子认证(MFA)架构设计
- 用户实体扩展:
mfaEnabled与totpSecret字段 TOTP密钥生成与持久化- 两步验证的交互流程
- 利用请求 ID 与缓存实现无状态关联
- 用户实体扩展:
- 代码实现
- 项目结构树
- 用户实体、TOTP 服务、MFA 服务
- 阿里云短信客户端封装
- 可视化流程
- MFA 认证时序图
- 请求 ID 与缓存映射类图
云服务认证与基础概念
绝大多数云服务都通过一对密钥 来标识调用方身份并完成请求签名。常见命名形式为 AccessKey ID 与 AccessKey Secret,也可能称为 AppID / AppSecret 或 API Key / Secret Key。ID 作为公开标识,Secret 用于生成签名,两者缺一不可。
以阿里云短信服务为例,需在 RAM 控制台创建 AccessKey,获得上述密钥对。同时还需要配置:
- 短信签名:标识发送者身份,如"XX科技",需提前审核。
- 短信模板 :定义短信正文结构及占位符,不同业务场景使用不同模板,通过
TemplateCode指定。
Leancloud 等 BaaS 平台同样提供 AppID 与 AppKey,部分还提供 MasterKey 用于管理操作。其短信服务也要求签名和模板,并建议绑定自定义 API 域名以避免默认域名的限流问题。
邮件服务:SMTP 与 Web API
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
SMTP |
直连邮件服务器端口,按协议投递 | 标准化、通用性强 | 连接耗时、资源占用高 |
Web API |
调用 RESTful 接口,异步发送 | 响应快、解耦业务系统 | 依赖服务商 API 稳定性 |
生产环境推荐 Web API。服务商(如 SendGrid)通常提供一个 API Key,在 SMTP 场景作为密码,在 API 场景作为 Bearer Token 使用。这种方式交互时间短,不会阻塞应用线程。
多因子认证(MFA)设计
用户模型扩展
在用户实体中增加两个关键字段:
mfaEnabled(布尔型):标识是否启用多因子认证。可由用户自主开启,也可由管理员统一控制,灵活性高。totpSecret(字符串):存储该用户的 TOTP 共享密钥,每个用户唯一。密钥生成后持久化到数据库,用于每次验证码校验。
两步验证流程
用户使用用户名/密码完成第一级认证后,若 mfaEnabled=true,服务端不直接颁发令牌,而是返回一个特殊响应:
http
HTTP 401 Unauthorized
X-Auth-Identity: MFA_REQUIRED, requestId=<uuid>
其中 requestId 是一个临时凭证,服务端将其与已认证的用户信息绑定存入缓存(如 Redis),设置较短过期时间。客户端引导用户输入 TOTP 验证码,再次请求验证接口,并附上 requestId 和验证码。服务端根据 requestId 从缓存取出用户主体,校验 TOTP,成功后生成正式令牌并清除缓存。整个过程无需在第二次请求中重复传递密码,保持了无状态服务的简洁性。
项目结构树
dir
src/main/java/com/example/security/
├── config/
│ ├── SecurityConfig.java
│ └── RedisConfig.java
├── entity/
│ └── User.java
├── repository/
│ └── UserRepository.java
├── service/
│ ├── TotpService.java
│ ├── SmsService.java
│ └── MfaService.java
├── filter/
│ └── MfaAuthenticationFilter.java
└── dto/
├── MfaRequiredResponse.java
└── MfaVerifyRequest.java
核心代码实现
用户实体
java
package com.example.security.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private boolean mfaEnabled = false;
@Column(length = 64)
private String totpSecret;
// 省略 getter/setter
}
TOTP 密钥生成与验证服务
java
package com.example.security.service;
import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator;
import org.apache.commons.codec.binary.Base32;
import javax.crypto.spec.SecretKeySpec;
import java.security.Key;
import java.security.SecureRandom;
import java.time.Instant;
public class TotpService {
private static final TimeBasedOneTimePasswordGenerator totp =
new TimeBasedOneTimePasswordGenerator();
public String generateSecret() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[20];
random.nextBytes(bytes);
return new Base32().encodeToString(bytes);
}
public boolean validateCode(String secret, String code) {
try {
byte[] decodedKey = new Base32().decode(secret);
Key key = new SecretKeySpec(decodedKey, "HmacSHA1");
String expected = totp.generateOneTimePassword(key, Instant.now());
return expected.equals(code);
} catch (Exception e) {
return false;
}
}
}
MFA 服务与缓存交互
java
package com.example.security.service;
import com.example.security.dto.MfaRequiredResponse;
import com.example.security.entity.User;
import com.example.security.repository.UserRepository;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public class MfaService {
private final StringRedisTemplate redisTemplate;
private final TotpService totpService;
public MfaService(StringRedisTemplate redisTemplate, TotpService totpService) {
this.redisTemplate = redisTemplate;
this.totpService = totpService;
}
public MfaRequiredResponse requireMfa(User user) {
String requestId = UUID.randomUUID().toString();
redisTemplate.opsForValue()
.set("mfa:" + requestId, user.getUsername(), 5, TimeUnit.MINUTES);
return new MfaRequiredResponse("MFA_REQUIRED", requestId);
}
public boolean verifyMfa(String requestId, String code, UserRepository userRepo) {
String username = redisTemplate.opsForValue().get("mfa:" + requestId);
if (username == null) {
return false;
}
User user = userRepo.findByUsername(username).orElse(null);
if (user == null || !user.isMfaEnabled()) {
return false;
}
boolean valid = totpService.validateCode(user.getTotpSecret(), code);
if (valid) {
redisTemplate.delete("mfa:" + requestId);
}
return valid;
}
}
阿里云短信客户端示例
java
package com.example.security.service;
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.aliyun.teaopenapi.models.Config;
public class SmsService {
private final Client client;
private final String signName;
private final String templateCode;
public SmsService(String accessKeyId, String accessKeySecret,
String signName, String templateCode) throws Exception {
Config config = new Config()
.setAccessKeyId(accessKeyId)
.setAccessKeySecret(accessKeySecret);
config.endpoint = "dysmsapi.aliyuncs.com";
this.client = new Client(config);
this.signName = signName;
this.templateCode = templateCode;
}
public void send(String phoneNumber, String code) {
SendSmsRequest request = new SendSmsRequest()
.setPhoneNumbers(phoneNumber)
.setSignName(signName)
.setTemplateCode(templateCode)
.setTemplateParam("{\"code\":\"" + code + "\"}");
client.sendSms(request);
}
}
流程可视化
MFA 认证时序图
Redis AuthServer Client Redis AuthServer Client #mermaid-svg-ZNC3A56Ft6EBftCP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZNC3A56Ft6EBftCP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZNC3A56Ft6EBftCP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZNC3A56Ft6EBftCP .error-icon{fill:#552222;}#mermaid-svg-ZNC3A56Ft6EBftCP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZNC3A56Ft6EBftCP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZNC3A56Ft6EBftCP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZNC3A56Ft6EBftCP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZNC3A56Ft6EBftCP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZNC3A56Ft6EBftCP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZNC3A56Ft6EBftCP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZNC3A56Ft6EBftCP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZNC3A56Ft6EBftCP .marker.cross{stroke:#333333;}#mermaid-svg-ZNC3A56Ft6EBftCP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZNC3A56Ft6EBftCP p{margin:0;}#mermaid-svg-ZNC3A56Ft6EBftCP .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZNC3A56Ft6EBftCP text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ZNC3A56Ft6EBftCP .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZNC3A56Ft6EBftCP .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ZNC3A56Ft6EBftCP .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ZNC3A56Ft6EBftCP .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ZNC3A56Ft6EBftCP #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ZNC3A56Ft6EBftCP .sequenceNumber{fill:white;}#mermaid-svg-ZNC3A56Ft6EBftCP #sequencenumber{fill:#333;}#mermaid-svg-ZNC3A56Ft6EBftCP #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ZNC3A56Ft6EBftCP .messageText{fill:#333;stroke:none;}#mermaid-svg-ZNC3A56Ft6EBftCP .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZNC3A56Ft6EBftCP .labelText,#mermaid-svg-ZNC3A56Ft6EBftCP .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ZNC3A56Ft6EBftCP .loopText,#mermaid-svg-ZNC3A56Ft6EBftCP .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ZNC3A56Ft6EBftCP .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZNC3A56Ft6EBftCP .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ZNC3A56Ft6EBftCP .noteText,#mermaid-svg-ZNC3A56Ft6EBftCP .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ZNC3A56Ft6EBftCP .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZNC3A56Ft6EBftCP .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZNC3A56Ft6EBftCP .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZNC3A56Ft6EBftCP .actorPopupMenu{position:absolute;}#mermaid-svg-ZNC3A56Ft6EBftCP .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ZNC3A56Ft6EBftCP .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZNC3A56Ft6EBftCP .actor-man circle,#mermaid-svg-ZNC3A56Ft6EBftCP line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ZNC3A56Ft6EBftCP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt校验通过校验失败 alt用户未启用 MFA用户已启用 MFA POST /login (username, password)验证用户名/密码200 返回 access_tokenSET mfa:{requestId} = username (TTL 5min)401 X-Auth-Identity: MFA_REQUIRED, requestId弹出二次验证输入框POST /auth/mfa/verify (requestId, code)GET mfa:{requestId}username加载用户, 校验 TOTPDEL mfa:{requestId}200 返回 access_token403 验证码错误
请求 ID 与缓存键结构
#mermaid-svg-tVsKyLBjZZRqgM0m{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-tVsKyLBjZZRqgM0m .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tVsKyLBjZZRqgM0m .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tVsKyLBjZZRqgM0m .error-icon{fill:#552222;}#mermaid-svg-tVsKyLBjZZRqgM0m .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tVsKyLBjZZRqgM0m .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tVsKyLBjZZRqgM0m .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tVsKyLBjZZRqgM0m .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tVsKyLBjZZRqgM0m .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tVsKyLBjZZRqgM0m .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tVsKyLBjZZRqgM0m .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tVsKyLBjZZRqgM0m .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tVsKyLBjZZRqgM0m .marker.cross{stroke:#333333;}#mermaid-svg-tVsKyLBjZZRqgM0m svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tVsKyLBjZZRqgM0m p{margin:0;}#mermaid-svg-tVsKyLBjZZRqgM0m g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-tVsKyLBjZZRqgM0m g.classGroup text .title{font-weight:bolder;}#mermaid-svg-tVsKyLBjZZRqgM0m .cluster-label text{fill:#333;}#mermaid-svg-tVsKyLBjZZRqgM0m .cluster-label span{color:#333;}#mermaid-svg-tVsKyLBjZZRqgM0m .cluster-label span p{background-color:transparent;}#mermaid-svg-tVsKyLBjZZRqgM0m .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-tVsKyLBjZZRqgM0m .cluster text{fill:#333;}#mermaid-svg-tVsKyLBjZZRqgM0m .cluster span{color:#333;}#mermaid-svg-tVsKyLBjZZRqgM0m .nodeLabel,#mermaid-svg-tVsKyLBjZZRqgM0m .edgeLabel{color:#131300;}#mermaid-svg-tVsKyLBjZZRqgM0m .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-tVsKyLBjZZRqgM0m .label text{fill:#131300;}#mermaid-svg-tVsKyLBjZZRqgM0m .labelBkg{background:#ECECFF;}#mermaid-svg-tVsKyLBjZZRqgM0m .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-tVsKyLBjZZRqgM0m .classTitle{font-weight:bolder;}#mermaid-svg-tVsKyLBjZZRqgM0m .node rect,#mermaid-svg-tVsKyLBjZZRqgM0m .node circle,#mermaid-svg-tVsKyLBjZZRqgM0m .node ellipse,#mermaid-svg-tVsKyLBjZZRqgM0m .node polygon,#mermaid-svg-tVsKyLBjZZRqgM0m .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-tVsKyLBjZZRqgM0m .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-tVsKyLBjZZRqgM0m g.clickable{cursor:pointer;}#mermaid-svg-tVsKyLBjZZRqgM0m g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-tVsKyLBjZZRqgM0m g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-tVsKyLBjZZRqgM0m .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-tVsKyLBjZZRqgM0m .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-tVsKyLBjZZRqgM0m .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-tVsKyLBjZZRqgM0m .dashed-line{stroke-dasharray:3;}#mermaid-svg-tVsKyLBjZZRqgM0m .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-tVsKyLBjZZRqgM0m #compositionStart,#mermaid-svg-tVsKyLBjZZRqgM0m .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-tVsKyLBjZZRqgM0m #compositionEnd,#mermaid-svg-tVsKyLBjZZRqgM0m .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-tVsKyLBjZZRqgM0m #dependencyStart,#mermaid-svg-tVsKyLBjZZRqgM0m .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-tVsKyLBjZZRqgM0m #dependencyStart,#mermaid-svg-tVsKyLBjZZRqgM0m .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-tVsKyLBjZZRqgM0m #extensionStart,#mermaid-svg-tVsKyLBjZZRqgM0m .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-tVsKyLBjZZRqgM0m #extensionEnd,#mermaid-svg-tVsKyLBjZZRqgM0m .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-tVsKyLBjZZRqgM0m #aggregationStart,#mermaid-svg-tVsKyLBjZZRqgM0m .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-tVsKyLBjZZRqgM0m #aggregationEnd,#mermaid-svg-tVsKyLBjZZRqgM0m .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-tVsKyLBjZZRqgM0m #lollipopStart,#mermaid-svg-tVsKyLBjZZRqgM0m .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-tVsKyLBjZZRqgM0m #lollipopEnd,#mermaid-svg-tVsKyLBjZZRqgM0m .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-tVsKyLBjZZRqgM0m .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-tVsKyLBjZZRqgM0m .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-tVsKyLBjZZRqgM0m .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-tVsKyLBjZZRqgM0m .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-tVsKyLBjZZRqgM0m :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 写入
读取并校验
加载
LoginStep1
+String username
+String password
MfaRequiredResponse
+String header: X-Auth-Identity
+String requestId
RedisCache
+key: "mfa:" + requestId
+value: username
+ttl: 5min
MfaVerifyRequest
+String requestId
+String code
User
+String totpSecret
+boolean mfaEnabled
总结
本文梳理了云服务集成中的认证术语与 MFA 架构设计思路。通过扩展用户实体、引入 TOTP 密钥,并借助请求 ID 与 Redis 缓存,可在 Spring Security + OAuth2 体系下实现无状态的两步验证流程。
文中给出的阿里云短信封装、TOTP 校验等代码可作为实际开发的起点,开发者可根据具体云平台接入对应的 SDK。