Java框架快速入门: Spring Security+OAuth2之云服务集成与多因子认证设计

纲要

  • 云服务认证基础
    • AccessKey IDAccessKey Secret 的密钥对模型
    • 短信服务要素:签名、模板
    • 跨平台对照:阿里云、Leancloud
  • 邮件发送方案
    • SMTPWeb API 的对比与选型
    • 邮件服务中的 API Key 鉴权
  • 多因子认证(MFA)架构设计
    • 用户实体扩展:mfaEnabledtotpSecret 字段
    • TOTP 密钥生成与持久化
    • 两步验证的交互流程
    • 利用请求 ID 与缓存实现无状态关联
  • 代码实现
    • 项目结构树
    • 用户实体、TOTP 服务、MFA 服务
    • 阿里云短信客户端封装
  • 可视化流程
    • MFA 认证时序图
    • 请求 ID 与缓存映射类图

云服务认证与基础概念

绝大多数云服务都通过一对密钥 来标识调用方身份并完成请求签名。常见命名形式为 AccessKey IDAccessKey Secret,也可能称为 AppID / AppSecretAPI Key / Secret KeyID 作为公开标识,Secret 用于生成签名,两者缺一不可。

以阿里云短信服务为例,需在 RAM 控制台创建 AccessKey,获得上述密钥对。同时还需要配置:

  • 短信签名:标识发送者身份,如"XX科技",需提前审核。
  • 短信模板 :定义短信正文结构及占位符,不同业务场景使用不同模板,通过 TemplateCode 指定。

Leancloud 等 BaaS 平台同样提供 AppIDAppKey,部分还提供 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。

相关推荐
门思科技2 小时前
LoRaWAN 网络容量估算:一个 SX1302 网关到底能带多少设备
开发语言·网络·php
大草原的小灰灰2 小时前
Python基础语法
开发语言·python
xinjia_ctrl2 小时前
暑假实习总结
git·后端·spring·maven·intellij-idea
liangsheng_g2 小时前
SpringAOP拦截器链递归与事务钩子补偿源码实战
java·spring
SL_staff2 小时前
制造业私有化文档平台的技术实践:从知识孤岛到可追溯知识资产
java·spring·开源
泡泡鱼(敲代码中)3 小时前
MySQL基础学习笔记:从数据模型到DDL全掌握
开发语言·数据库·笔记·学习·mysql
SL_staff3 小时前
3天上线OKR系统:一名HR与1名工程师如何用JVS完成全栈交付
java·低代码·开源
2501_933923254 小时前
统一功能:统一返回格式+统一异常处理
spring·java-ee·状态模式·统一数据返回
wzdark5 小时前
基于链表的内存池设计与内存复用机制4
java·数据结构·链表