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

纲要

  • 云服务认证基础
    • 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。

相关推荐
QQ_21696290962 小时前
基于C#(Asp.net)电竞陪玩信息管理系统的设计与实现
java·大数据·spring boot·微信小程序·c#·云计算·asp.net
сокол2 小时前
【Python-基础-环境搭建】Ubuntu Python 开发环境搭建:编译、pipenv、pyenv、远程连接
开发语言·python·ubuntu
vx_Biye_Design2 小时前
springbootNBA数据分析系统平台42434-计算机课程设计、毕业设计
java·vue.js·spring boot·python·django·课程设计·express
Q26433650232 小时前
【有源码】基于springboot的知识产权管理系统-知识产权咨询问答管理系统-知识产权价值评估管理系统
java·spring boot·mysql·spring·毕业设计·javaweb·课程设计
liangshanbo12154 小时前
JavaScript 面试题:Object.keys() 的返回顺序是无序的吗?
开发语言·javascript·ecmascript
朝朝辞暮i10 小时前
C++ 第 23 课:class —— 开始真正进入面向对象
开发语言·c++·算法
leikooo11 小时前
用餐厅和盘子理解栈和堆
java·数据结构·链表·rust
小师兄吃牛肉11 小时前
C语言篇:宏
c语言·开发语言
郑同学的笔记11 小时前
【c++随笔28】strcmp 和 memcmp对比
开发语言·c++
言乐612 小时前
Python根据无法识别搜索词找出可能输入内容模型
开发语言·python·django·virtualenv·pygame