Note 19(第三章). SpringBoot3-账号密码登录:领域模型设计与完整实现

Note 19.3. 账号密码登录:领域模型设计与完整实现

环境版本锁定

在开始构建账号密码登录体系之前,我们需要明确本章依赖的技术栈版本。

技术组件版本号说明
JDK17 LTS必须支持 Record 等新特性
Spring Boot3.2.0配合 Spring 6.x
MyBatis-Plus3.5.5用于数据持久化
MySQL8.0用户数据存储
Redis7.0验证码存储
Spring Boot Starter Mail3.2.0邮件发送
Hutool5.8.24验证码生成
BCryptSpring Security Crypto密码加密

本章摘要

在 19.2 中,我们构建了可扩展的认证工厂。但工厂只是框架,还缺少真实的业务实现。本章我们将实现完整的账号密码登录体系,包括:领域模型设计(账号-认证分离)、密码加密(BCrypt)、验证码服务、邮箱激活、用户注册、密码登录、找回密码等核心功能。这是所有上层登录方式的基础,也是最复杂、最容易出错的部分。

本章学习路径

本章采用 “数据建模 → 基础设施 → 业务流程” 的递进式结构:

阶段一:领域模型设计(19.3.1 - 19.3.2)

  • 理解传统单表设计的三大弊端
  • 掌握账号-认证分离模型(1: N)
  • 设计 sys_user 和 sys_auth 表结构
  • 理解索引与性能优化策略

阶段二:基础设施构建(19.3.3 - 19.3.6)

  • 掌握 BCrypt 密码加密原理
  • 实现验证码服务(Hutool)
  • 配置 Spring Mail 邮件服务
  • 实现邮箱激活链接生成(HMAC 签名)
  • 理解 Spring Event 异步发送机制

阶段三:核心业务流程(19.3.7 - 19.3.10)

  • 实现用户注册流程(双表插入)
  • 实现 PasswordAuthStrategy(替换 MockAuthStrategy)
  • 实现邮箱激活接口
  • 实现找回密码功能

阶段四:扩展功能(19.3.11)

  • 实现账号绑定功能
  • 防止 CSRF 绑定攻击

19.3.1. 传统设计的弊端:单表走天下

在理解现代化的数据建模方案之前,我们需要先理解传统设计的问题。

单表设计的典型案例

很多初学者在设计用户表时,会采用这种 “看似合理” 的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) COMMENT '账号',
password VARCHAR(100) COMMENT '密码哈希',
mobile VARCHAR(11) COMMENT '手机号',
email VARCHAR(100) COMMENT '邮箱',
wechat_openid VARCHAR(64) COMMENT '微信 OpenID',
github_id VARCHAR(50) COMMENT 'GitHub ID',
nickname VARCHAR(50) COMMENT '昵称',
avatar VARCHAR(255) COMMENT '头像',
status TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

这种设计在业务初期看起来没问题

  • 所有用户信息都在一张表里,查询方便
  • 不需要关联查询,性能好
  • 表结构简单,易于理解

但随着业务发展,这种设计会陷入三个典型陷阱。


陷阱一:字段爆炸

当需要支持新的登录方式时(如支付宝、抖音、企业微信),你会不断添加新列:

1
2
3
4
5
6
7
8
9
10
11
-- 第一次迭代:支持支付宝登录
ALTER TABLE sys_user ADD COLUMN alipay_uid VARCHAR(50) COMMENT '支付宝 UID';

-- 第二次迭代:支持抖音登录
ALTER TABLE sys_user ADD COLUMN douyin_openid VARCHAR(64) COMMENT '抖音 OpenID';

-- 第三次迭代:支持企业微信登录
ALTER TABLE sys_user ADD COLUMN work_wechat_userid VARCHAR(50) COMMENT '企业微信 UserID';

-- 第四次迭代:支持 Apple 登录
ALTER TABLE sys_user ADD COLUMN apple_id VARCHAR(100) COMMENT 'Apple ID';

最终表结构变成了一个 “万金油” 表,包含几十个字段,其中大部分字段对于单个用户来说都是 NULL

数据示例

idusernamepasswordmobileemailwechat_openidgithub_idalipay_uiddouyin_openid
1admin$2a$ 10…13812345678NULLNULLNULLNULLNULL
2NULLNULLNULLuser@example.comoX4Gt5k…NULLNULLNULL
3NULLNULLNULLNULLNULL12345678NULLNULL

问题分析

  • ❌ 每个用户只使用 1-2 种登录方式,但表中有 10+ 个登录字段
  • ❌ 大量 NULL 值浪费存储空间
  • ❌ 每次新增登录方式都需要执行 ALTER TABLE,在大表上非常危险

陷阱二:索引混乱

为了支持 “通过手机号登录”、“通过微信 OpenID 登录”,你需要为每个登录字段建立唯一索引:

1
2
3
4
5
6
7
CREATE UNIQUE INDEX idx_username ON sys_user(username);
CREATE UNIQUE INDEX idx_mobile ON sys_user(mobile);
CREATE UNIQUE INDEX idx_email ON sys_user(email);
CREATE UNIQUE INDEX idx_wechat_openid ON sys_user(wechat_openid);
CREATE UNIQUE INDEX idx_github_id ON sys_user(github_id);
CREATE UNIQUE INDEX idx_alipay_uid ON sys_user(alipay_uid);
-- ... 更多索引

但这些索引会遇到 NULL 值问题

MySQL 的唯一索引允许多个 NULL 值。例如:

  • 用户 A 只用微信登录,mobile 字段是 NULL
  • 用户 B 也只用微信登录,mobile 字段也是 NULL
  • 这两个 NULL 值不会触发唯一性冲突

看起来没问题,但实际上存在隐患

假设用户 A 后来绑定了手机号 13812345678,用户 B 也想绑定同一个手机号。此时:

  • 如果用户 B 的 mobile 字段是 NULL,绑定会成功(因为 NULL 不参与唯一性检查)
  • 但如果用户 B 的 mobile 字段已经有值,绑定会失败

这种不一致的行为会导致严重的业务 Bug

索引膨胀问题

每个唯一索引都会占用存储空间,并且会降低写入性能。当表中有 10+ 个唯一索引时:

  • 每次 INSERT 都需要检查 10+ 个索引
  • 每次 UPDATE 都需要更新 10+ 个索引
  • 索引文件可能比数据文件还大

陷阱三:查询复杂

当用户登录时,你需要根据登录类型执行不同的 SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 账号密码登录
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, username));

// 手机号登录
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getMobile, mobile));

// 微信登录
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getWechatOpenid, openid));

// GitHub 登录
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getGithubId, githubId));

// 支付宝登录
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getAlipayUid, alipayUid));

代码中充满了 if-else 分支

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public User login(String loginType, String identifier, String credential) {
User user = null;

if ("username".equals(loginType)) {
user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, identifier));
} else if ("mobile".equals(loginType)) {
user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getMobile, identifier));
} else if ("wechat".equals(loginType)) {
user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getWechatOpenid, identifier));
} else if ("github".equals(loginType)) {
user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getGithubId, identifier));
} else {
throw new RuntimeException("不支持的登录方式");
}

// 验证密码...
return user;
}

问题分析

  • ❌ 违反开闭原则:新增登录方式需要修改代码
  • ❌ 代码重复:每个分支都是类似的查询逻辑
  • ❌ 难以维护:当登录方式增加到 10+ 种时,代码会变得非常臃肿

单表设计的根本问题

问题的本质:将 “用户主体” 和 “登录凭证” 混在一起。

image-20251229123150728

正确的做法:将 “用户主体” 和 “登录凭证” 分离存储。


19.3.2. 现代方案:账号-认证分离模型(1: N)

业界成熟的解决方案是将 “用户主体” 与 “登录凭证” 分离存储,建立一对多关系。

核心设计理念

mermaid-diagram-2025-12-29-123347

设计理念

  • 用户主体(sys_user):只存储用户的基本属性,不包含任何登录相关的信息
  • 登录凭证(sys_auth):存储用户的所有登录方式,一个用户可以有多条记录
  • 一对多关系:通过 user_id 关联

用户主体表(sys_user)

这个表只存储用户的基本属性,不包含任何登录相关的信息。

1
2
3
4
5
6
7
8
9
10
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY COMMENT '用户 ID(雪花算法生成)',
nickname VARCHAR(50) NOT NULL COMMENT '用户昵称',
avatar VARCHAR(255) DEFAULT 'https://i.pravatar.cc/300' COMMENT '头像 URL',
status TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用 2-未激活',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

KEY idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户主体表';

关键设计点

设计点一:ID 生成策略

使用雪花算法(Snowflake)而非数据库自增 ID,确保分布式环境下的全局唯一性。

1
2
@TableId(type = IdType.ASSIGN_ID)  // MyBatis-Plus 自动使用雪花算法
private Long id;

为什么不用自增 ID?

对比维度自增 ID雪花算法
全局唯一性❌ 只在单表内唯一✅ 全局唯一
分布式支持❌ 多数据库实例会冲突✅ 支持分布式
性能✅ 插入性能好⚠️ 需要额外计算
安全性❌ 可以推测用户数量✅ 无法推测

设计点二:极简字段

只保留与业务强相关的字段:

  • nickname:用户昵称(必填)
  • avatar:头像 URL(有默认值)
  • status:账号状态(0-禁用 1-启用 2-未激活)

不包含的字段

  • username:属于登录凭证,应该在 sys_auth 表
  • password:属于登录凭证,应该在 sys_auth 表
  • mobile:属于登录凭证,应该在 sys_auth 表
  • email:属于登录凭证,应该在 sys_auth 表

设计点三:status 字段的三种状态

状态值含义说明
0禁用管理员手动禁用,无法登录
1启用正常状态,可以登录
2未激活注册后未激活邮箱,无法登录

授权凭证表(sys_auth)

这个表存储用户的所有登录方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CREATE TABLE sys_auth (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
user_id BIGINT NOT NULL COMMENT '关联的用户 ID',
identity_type VARCHAR(20) NOT NULL COMMENT '认证类型:PASSWORD/MOBILE/WECHAT/GITHUB',
identifier VARCHAR(100) NOT NULL COMMENT '标识符(账号/手机号/OpenID)',
credential VARCHAR(255) COMMENT '凭证(密码哈希/Token,OAuth 登录可为空)',
verified TINYINT DEFAULT 0 COMMENT '是否已验证:0-未验证 1-已验证',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

-- 联合唯一索引:同一类型的标识符不能重复
UNIQUE KEY uk_type_identifier (identity_type, identifier),

-- 普通索引:方便根据 user_id 查询该用户的所有登录方式
KEY idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户认证凭证表';

关键设计点

设计点一:identity_type 字段

这个字段标识登录方式的类型,对应 19.2 中定义的 AuthType 枚举。

identity_type含义identifier 示例credential 示例
PASSWORD账号密码登录admin2a10… (BCrypt 哈希)
MOBILE手机号登录13812345678NULL(验证码登录无需存储密码)
EMAIL邮箱登录user@example.com2a​10… (BCrypt 哈希)
WECHAT微信登录oX4Gt5k…NULL(或存储微信 Token)
GITHUBGitHub 登录12345678NULL(GitHub ID 本身就是标识符)

设计点二:identifier 字段

这个字段存储登录标识符,根据 identity_type 的不同,存储的内容也不同:

  • PASSWORD:存储用户名(如 admin
  • MOBILE:存储手机号(如 13812345678
  • EMAIL:存储邮箱(如 user@example.com
  • WECHAT:存储微信 OpenID(如 oX4Gt5k...
  • GITHUB:存储 GitHub ID(如 12345678

设计点三:credential 字段

这个字段存储登录凭证,根据 identity_type 的不同,存储的内容也不同:

  • PASSWORD:存储 BCrypt 加密后的密码哈希
  • EMAIL:存储 BCrypt 加密后的密码哈希
  • MOBILE:通常为 NULL(验证码登录无需存储密码)
  • WECHAT:通常为 NULL(或存储微信 Access Token)
  • GITHUB:通常为 NULL(GitHub ID 本身就是标识符)

设计点四:verified 字段

这个字段标识该登录方式是否已验证:

  • 0:未验证(如邮箱未激活、手机号未验证)
  • 1:已验证

为什么需要这个字段?

假设用户注册时填写了邮箱,但还没有点击激活链接。此时:

  • sys_user 表中有一条记录(status = 2 未激活)
  • sys_auth 表中有一条记录(identity_type = EMAIL, verified = 0)

当用户点击激活链接后:

  • sys_user 表的 status 更新为 1(启用)
  • sys_auth 表的 verified 更新为 1(已验证)

数据示例:一个用户的多种登录方式

假设用户 “张三” 先用账号密码注册,后来又绑定了手机号、邮箱、微信和 GitHub。

sys_user 表

idnicknameavatarstatuscreate_time
1748392847362张三https://cdn…/avatar.jpg12025-01-10 10:00:00

sys_auth 表

iduser_ididentity_typeidentifiercredentialverifiedcreate_time
11748392847362PASSWORDzhangsan2a​10rQ7R8k…12025-01-10 10:00:00
21748392847362MOBILE13812345678NULL12025-01-10 10:05:00
31748392847362EMAILzhangsan@example.com2a10aB3C4d…12025-01-10 10:10:00
41748392847362WECHAToX4Gt5k…NULL12025-01-10 10:15:00
51748392847362GITHUB12345678NULL12025-01-10 10:20:00

通过这种设计

  • ✅ 新增登录方式只需在 sys_auth 表插入一条记录,无需修改表结构
  • ✅ 每种登录方式都有明确的 identity_type 标记,查询时非常清晰
  • ✅ 用户可以同时拥有多种登录方式,系统会自动关联到同一个 user_id
  • ✅ 没有 NULL 值浪费存储空间

索引与性能优化策略

数据模型设计完成后,索引设计直接决定了查询性能和数据一致性。

索引一:联合唯一索引(核心)

1
UNIQUE KEY uk_type_identifier (identity_type, identifier)

作用:保证 “同一种类型的标识符全局唯一”。

示例

  • 手机号 13812345678 只能被一个用户绑定(identity_type=MOBILE, identifier=13812345678
  • 微信 OpenID oX4Gt5k... 也只能被一个用户绑定(identity_type=WECHAT, identifier=oX4Gt5k...

如果另一个用户尝试绑定已被占用的手机号,数据库会抛出唯一性冲突错误:

1
Duplicate entry 'MOBILE-13812345678' for key 'uk_type_identifier'

业务代码需要捕获这个异常并返回友好提示:

1
2
3
4
5
try {
authMapper.insert(auth);
} catch (DuplicateKeyException e) {
throw new RuntimeException("该手机号已被其他账号绑定");
}

索引二:普通索引

1
KEY idx_user_id (user_id)

作用:方便根据 user_id 查询该用户的所有登录方式。

示例

1
2
3
// 查询用户的所有登录方式
List<Auth> authList = authMapper.selectList(new LambdaQueryWrapper<Auth>()
.eq(Auth::getUserId, userId));

本节小结

我们完成了领域模型的设计。

核心成果

步骤操作产出
1分析传统单表设计的弊端理解字段爆炸、索引混乱、查询复杂三大陷阱
2设计 sys_user 表用户主体表(极简字段)
3设计 sys_auth 表登录凭证表(1: N 关系)
4设计索引策略联合唯一索引 + 普通索引 + 覆盖索引
5讨论分库分表策略千万级用户的扩展方案

表结构对比

对比维度单表设计账号-认证分离
表数量1 张表2 张表
字段数量10+ 个字段sys_user: 5 个字段
sys_auth: 7 个字段
NULL 值大量 NULL 值几乎没有 NULL 值
新增登录方式需要 ALTER TABLE只需 INSERT 一条记录
索引数量10+ 个唯一索引1 个联合唯一索引 + 1 个普通索引
查询复杂度需要 if-else 分支统一查询 sys_auth 表
扩展性❌ 难以扩展✅ 易于扩展

索引速查表

索引名称索引类型索引列作用
uk_type_identifier联合唯一索引(identity_type, identifier)保证同一类型的标识符全局唯一
idx_user_id普通索引user_id根据用户 ID 查询所有登录方式

现在,我们已经完成了数据建模。在下一节中,我们将实现 MyBatis-Plus 的实体类和 Mapper。


19.3.3. 持久层实现:MyBatis-Plus 快速搭建

在上一节中,我们设计了 sys_user 和 sys_auth 两张表。现在我们需要使用 MyBatis-Plus 快速搭建持久层。

引入依赖

步骤 1:在父 POM 中管理依赖版本

📄 文件路径auth-parent/pom.xml(追加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<properties>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<druid.version>1.2.21</druid.version>
</properties>

<dependencyManagement>
<dependencies>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

步骤 2:在 auth-core 模块中引入 MyBatis-Plus

📄 文件路径auth-core/pom.xml(追加)

1
2
3
4
5
6
7
<dependencies>
<!-- MyBatis-Plus(只引入 starter,不引入数据库驱动) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
</dependencies>

步骤 3:在 auth-web 模块中引入数据库驱动

📄 文件路径auth-web/pom.xml(追加)

1
2
3
4
5
6
7
8
<dependencies>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>

配置数据源

📄 文件路径auth-web/src/main/resources/application.yml(追加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
server:
port: 8080

spring:
application:
name: auth-service

data:
redis:
host: localhost
port: 6379
database: 0
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0

datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/auth_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: root

jwt:
# 对应 JwtProperties.issuer
issuer: pro-auth-service

# 对应 JwtProperties.accessTokenExpireMinutes
access-token-expire-minutes: 15

# 对应 JwtProperties.clockSkewSeconds
clock-skew-seconds: 30

# Refresh Token 有效期(天)
refresh-token-expire-days: 7

# 对应 JwtProperties.publicKeyResource
public-key-resource: certs/public_key.pem

# 对应 JwtProperties.privateKeyResource
private-key-resource: certs/private_key.pem

auth:
# 启用的登录方式
enabled-types:
- PASSWORD
- SMS
# - WECHAT # 注释掉即可禁用

# MyBatis-Plus 配置
mybatis-plus:
configuration:
# 开启驼峰命名转换
map-underscore-to-camel-case: true
# 打印 SQL 日志(开发环境)
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
# 主键类型:雪花算法
id-type: ASSIGN_ID

创建数据库和表

📄 文件路径auth-web/src/main/resources/sql/schema.sql(新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- 创建数据库
CREATE DATABASE IF NOT EXISTS auth_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

USE auth_db;

-- 用户主体表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY COMMENT '用户 ID(雪花算法生成)',
nickname VARCHAR(50) NOT NULL COMMENT '用户昵称',
avatar VARCHAR(255) DEFAULT 'https://cdn.example.com/default-avatar.png' COMMENT '头像 URL',
status TINYINT DEFAULT 2 COMMENT '状态:0-禁用 1-启用 2-未激活',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

KEY idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户主体表';

-- 用户认证凭证表
CREATE TABLE sys_auth (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
user_id BIGINT NOT NULL COMMENT '关联的用户 ID',
identity_type VARCHAR(20) NOT NULL COMMENT '认证类型:PASSWORD/MOBILE/EMAIL/WECHAT/GITHUB',
identifier VARCHAR(100) NOT NULL COMMENT '标识符(账号/手机号/邮箱/OpenID)',
credential VARCHAR(255) COMMENT '凭证(密码哈希/Token,OAuth 登录可为空)',
verified TINYINT DEFAULT 0 COMMENT '是否已验证:0-未验证 1-已验证',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

UNIQUE KEY uk_type_identifier (identity_type, identifier),
KEY idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户认证凭证表';

创建实体类(auth-core 模块)

📄 文件路径auth-core/src/main/java/com/example/auth/core/entity/User.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.example.auth.core.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.time.LocalDateTime;

/**
* 用户主体实体类
*/
@Data
@TableName("sys_user")
public class User {

/**
* 用户 ID(雪花算法生成)
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;

/**
* 用户昵称
*/
private String nickname;

/**
* 头像 URL
*/
private String avatar;

/**
* 状态:0-禁用 1-启用 2-未激活
*/
private Integer status;

/**
* 创建时间
*/
private LocalDateTime createTime;

/**
* 更新时间
*/
private LocalDateTime updateTime;
}

📄 文件路径auth-core/src/main/java/com/example/auth/core/entity/Auth.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.example.auth.core.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.time.LocalDateTime;

/**
* 用户认证凭证实体类
*/
@Data
@TableName("sys_auth")
public class Auth {

/**
* 主键 ID
*/
@TableId(type = IdType.AUTO)
private Long id;

/**
* 关联的用户 ID
*/
private Long userId;

/**
* 认证类型:PASSWORD/MOBILE/EMAIL/WECHAT/GITHUB
*/
private String identityType;

/**
* 标识符(账号/手机号/邮箱/OpenID)
*/
private String identifier;

/**
* 凭证(密码哈希/Token,OAuth 登录可为空)
*/
private String credential;

/**
* 是否已验证:0-未验证 1-已验证
*/
private Integer verified;

/**
* 创建时间
*/
private LocalDateTime createTime;

/**
* 更新时间
*/
private LocalDateTime updateTime;
}

创建 Mapper 接口(auth-core 模块)

📄 文件路径auth-core/src/main/java/com/example/auth/core/mapper/UserMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.auth.core.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.auth.core.entity.User;
import org.apache.ibatis.annotations.Mapper;

/**
* 用户 Mapper
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
}

📄 文件路径auth-core/src/main/java/com/example/auth/core/mapper/AuthMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.auth.core.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.auth.core.entity.Auth;
import org.apache.ibatis.annotations.Mapper;

/**
* 认证凭证 Mapper
*/
@Mapper
public interface AuthMapper extends BaseMapper<Auth> {
}

配置 Mapper 扫描(auth-web 模块)

📄 文件路径auth-web/src/main/java/com/example/auth/web/AuthApplication.java(修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.example.auth.web;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication(scanBasePackages = "com.example.auth")
@MapperScan("com.example.auth.core.mapper")
public class AuthApplication {

public static void main(String[] args) {
SpringApplication.run(AuthApplication.class, args);
}
}

测试持久层

📄 文件路径auth-web/src/test/java/com/example/auth/web/mapper/UserMapperTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.auth.web.mapper;

import com.example.auth.core.entity.User;
import com.example.auth.core.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class UserMapperTest {

@Autowired
private UserMapper userMapper;

@Test
public void testInsert() {
User user = new User();
user.setNickname("测试用户");
user.setAvatar("https://cdn.example.com/avatar.jpg");
user.setStatus(1);

userMapper.insert(user);
System.out.println("插入成功,用户 ID: " + user.getId());
}

@Test
public void testSelect() {
User user = userMapper.selectById(1L);
System.out.println("查询结果: " + user);
}
}

19.3.4. 密码加密:从 MD5 到 BCrypt 的演进

在上一节中,我们完成了持久层的搭建。现在我们需要实现密码加密功能。

为什么不能用 MD5?

很多初学者在实现密码加密时,会使用 MD5:

1
2
3
String password = "123456";
String md5Hash = DigestUtils.md5Hex(password);
// 输出: e10adc3949ba59abbe56e057f20f883e

这种做法是极其危险的,原因有三:

原因一:MD5 是哈希算法,不是加密算法

  • 哈希算法:单向函数,无法解密
  • 加密算法:双向函数,可以解密

MD5 的设计目的是 数据完整性校验,而不是密码存储。

原因二:彩虹表攻击

彩虹表(Rainbow Table)是一个预先计算好的哈希值数据库。攻击者可以通过查表的方式,快速破解 MD5 哈希。

示例

假设数据库泄漏,攻击者获取到以下数据:

usernamepassword_md5
admine10adc3949ba59abbe56e057f20f883e
user15f4dcc3b5aa765d61d8327deb882cf99

攻击者只需要在彩虹表中查询这两个哈希值:

1
2
e10adc3949ba59abbe56e057f20f883e -> 123456
5f4dcc3b5aa765d61d8327deb882cf99 -> password

几秒钟内就能破解所有密码

原因三:MD5 已被破解

2004 年,中国密码学家王小云教授证明了 MD5 存在碰撞漏洞。2017 年,Google 成功构造了两个不同的 PDF 文件,它们的 MD5 哈希值完全相同。

即给定消息 M1,能够计算获取 M2,使得 M2 产生的散列值与 M1 产生的散列值相同。如此,MD5 的抗碰撞性就已经不满足了,使得 MD5 不再是安全的散列算法。这样一来,MD5 用于数字签名将存在严重问题,因为可以篡改原始消息,而生成相同的 Hash 值。

这里,简单地用王教授的碰撞法给大家举个简单的例子。假如用户 A 给 B 写了个 Email 内容为 Hello,然后通过王教授的碰撞法,可能得到 Fuck 这个字符串的摘要信息和 Hello 这个字符串产生的摘要信息是一样的。如果 B 收到的 Email 内容为 Fuck,经过 MD5 计算后的,B 也将认为 Email 并没有被修改!但事实并非如此。

王小云院士的研究报告表明,MD4,MD5,HAVAL-128 和 RIPEMD 均已被证实存在上面的漏洞,即给定消息 M1,能够找到不同消息 M2 产生相同的散列值,即产生 Hash 碰撞。

后来在 2005 年,王小云同其他研究人员又发布了一篇论文《Finding Collisions in the Full SHA-1》,理论上证明了 SHA-1 也同样存在碰撞的漏洞。

随着时间的推移,计算机计算能力不断增强和攻击技术的不断进步,SHA-1 算法的安全性逐渐受到威胁。在 2017 年,Google 研究人员宣布成功生成了第一个实际的 SHA-1 碰撞,这意味着攻击者可以通过特定的方法找到两个不同的输入,但它们具有相同的 SHA-1 哈希值。

结论:MD5 已经不安全,不应该用于密码存储。


加盐(Salt)能解决问题吗?

有些开发者会在 MD5 的基础上加盐:

1
2
3
4
5
String password = "123456";
String salt = "random_salt_123";
String saltedPassword = password + salt;
String md5Hash = DigestUtils.md5Hex(saltedPassword);
// 输出: 7c6a180b36896a0a8c02787eeafb0e4c

这种做法比纯 MD5 好一些,但仍然存在问题:

问题一:盐值存储

盐值必须存储在数据库中,否则无法验证密码。如果数据库泄漏,攻击者可以获取盐值,然后针对每个用户生成专属的彩虹表。

问题二:盐值固定

如果所有用户使用相同的盐值,攻击者只需要生成一次彩虹表,就能破解所有密码。

问题三:计算速度太快

MD5 的计算速度非常快,攻击者可以使用 GPU 进行暴力破解。现代 GPU 每秒可以计算数十亿次 MD5 哈希。


BCrypt 的三大优势

BCrypt 是一种专门为密码存储设计的哈希算法,它解决了 MD5 的所有问题。

优势一:自动加盐

BCrypt 会自动生成随机盐值,并将盐值嵌入到哈希值中。

1
2
3
String password = "123456";
String bcryptHash = BCrypt.hashpw(password, BCrypt.gensalt());
// 输出: $2a$ 10$rQ7R8kN5J6X7Z8Y9W0V1U.2Q3R4S5T6U7V8W9X0Y1Z2A3B4C5D6E7F

哈希值的结构

1
2
3
4
5
6
$2a$10$rQ7R8kN5J6X7Z8Y9W0V1U.2Q3R4S5T6U7V8W9X0Y1Z2A3B4C5D6E7F
| | | |
| | | +-- 哈希值(31 字符)
| | +-- 盐值(22 字符)
| +-- 成本因子(10)
+-- 算法版本(2a)

关键点:盐值和哈希值存储在一起,不需要单独存储盐值。

优势二:慢哈希(Slow Hash)

BCrypt 的计算速度非常慢,这是故意设计的。

1
2
3
4
5
// MD5:每秒可以计算数十亿次
String md5Hash = DigestUtils.md5Hex("123456");

// BCrypt:每秒只能计算几十次
String bcryptHash = BCrypt.hashpw("123456", BCrypt.gensalt(10));

为什么要慢?

  • 对于正常用户:登录时只需要计算一次,慢 0.1 秒完全可以接受
  • 对于攻击者:暴力破解需要计算数百万次,慢 0.1 秒意味着破解时间从几小时变成几年

优势三:自适应(Adaptive)

BCrypt 的成本因子(Cost Factor)可以调整,随着硬件性能的提升,可以增加成本因子,保持相同的安全性。

1
2
3
4
5
// 成本因子 10:每次哈希需要 2^10 = 1024 次迭代
String hash10 = BCrypt.hashpw("123456", BCrypt.gensalt(10));

// 成本因子 12:每次哈希需要 2^12 = 4096 次迭代
String hash12 = BCrypt.hashpw("123456", BCrypt.gensalt(12));

成本因子对照表

成本因子迭代次数计算时间(单核)适用场景
101024~0.1 秒开发环境
124096~0.4 秒生产环境(推荐)
1416384~1.6 秒高安全场景
1665536~6.4 秒极高安全场景

建议:生产环境使用成本因子 12。


BCrypt 实战

引入依赖

📄 文件路径auth-core/pom.xml(追加)

1
2
3
4
5
<!-- Spring Security Crypto(包含 BCrypt) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>

封装 PasswordEncoder 工具类

📄 文件路径auth-core/src/main/java/com/example/auth/core/util/PasswordEncoder.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.example.auth.core.util;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;

/**
* 密码加密工具类
* 封装 BCrypt 算法
*/
@Component
public class PasswordEncoder {

private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);

/**
* 加密密码
*
* @param rawPassword 明文密码
* @return BCrypt 哈希值
*/
public String encode(String rawPassword) {
return encoder.encode(rawPassword);
}

/**
* 验证密码
*
* @param rawPassword 明文密码
* @param encodedPassword BCrypt 哈希值
* @return true 表示密码正确,false 表示密码错误
*/
public boolean matches(String rawPassword, String encodedPassword) {
return encoder.matches(rawPassword, encodedPassword);
}
}

测试 BCrypt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void testBCrypt() {
PasswordEncoder encoder = new PasswordEncoder();

// 加密密码
String rawPassword = "123456";
String hash1 = encoder.encode(rawPassword);
String hash2 = encoder.encode(rawPassword);

System.out.println("哈希值 1: " + hash1);
System.out.println("哈希值 2: " + hash2);

// 验证密码
System.out.println("验证结果 1: " + encoder.matches(rawPassword, hash1));
System.out.println("验证结果 2: " + encoder.matches(rawPassword, hash2));
System.out.println("验证错误密码: " + encoder.matches("wrong", hash1));
}

输出

1
2
3
4
5
哈希值 1: $2a$12$rQ7R8kN5J6X7Z8Y9W0V1U.2Q3R4S5T6U7V8W9X0Y1Z2A3B4C5D6E7F
哈希值 2: $2a$12$aB3C4d5E6f7G8h9I0j1K2.3L4M5N6o7P8q9R0s1T2u3V4w5X6y7Z8
验证结果 1: true
验证结果 2: true
验证错误密码: false

关键点

  • 同一个密码,每次加密的哈希值都不同(因为盐值不同)
  • 但验证时都能通过(因为盐值嵌入在哈希值中)

19.3.5. 验证码服务:防止爬虫批量注册

在上一节中,我们实现了密码加密功能。现在我们需要实现验证码服务,防止爬虫批量注册。

为什么需要验证码?

场景一:防止爬虫批量注册

如果没有验证码,攻击者可以编写脚本,批量注册大量账号:

1
2
3
4
5
6
7
8
import requests

for i in range(10000):
requests.post('http://localhost:8080/auth/register', json={
'username': f'user{i}',
'password': '123456',
'email': f'user{i}@example.com'
})

几分钟内就能注册数万个账号,导致:

  • 数据库被垃圾数据填满
  • 邮件服务器被大量激活邮件占用
  • 正常用户无法注册(用户名被占用)

场景二:防止暴力破解登录

如果没有验证码,攻击者可以编写脚本,暴力破解密码:

1
2
3
4
5
6
7
8
9
10
11
12
13
import requests

passwords = ['123456', 'password', '123456789', ...]

for password in passwords:
response = requests.post('http://localhost:8080/auth/login', json={
'authType': 'PASSWORD',
'username': 'admin',
'password': password
})
if response.status_code == 200:
print(f'密码破解成功: {password}')
break

验证码的作用

  • ✅ 增加自动化攻击的成本
  • ✅ 区分人类用户和机器人
  • ✅ 保护系统资源

验证码类型对比

没问题,这是精简后的版本,只保留了目前 最主流的 4 种 验证码类型,方便你直接放入文档:

常见验证码类型对比

验证码类型安全性用户体验核心适用场景
图形验证码⭐⭐简单的后台系统、低频操作
滑动验证码⭐⭐⭐⭐⭐⭐⭐网站登录、注册(目前的行业标准)
点选验证码⭐⭐⭐⭐支付确认、高风险拦截(如汉字/图标顺序点选)
无感验证⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐全场景防护(通过鼠标轨迹/设备指纹后台判定)

本章只实现最基础的图形验证码。随着安全需求的升级,现代应用通常采用更智能的验证方式。如果你需要进阶方案,请参考以下文章:

《滑动拼图验证》:详解前端 Canvas 抠图与后端坐标校验逻辑。

《点选/旋转验证》:应对 OCR 破解的高安全方案。

Spring Boot 3 滑动/旋转/滑动还原/文字点选验证码集成:Tianai-Captcha 快速接入指南 | Prorise - 博客小栈

《无感/行为验证》:基于设备指纹与生物探针的智能人机识别(接入云盾/极验)。


图形验证码生成

引入依赖

Hutool 提供了封装好的验证码服务,我们仅需要转化为业务功能即可。

步骤 1:在父 POM 中管理 Hutool 版本

📄 文件路径auth-parent/pom.xml(追加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<properties>
<hutool.version>5.8.24</hutool.version>
</properties>

<dependencyManagement>
<dependencies>
<!-- Hutool 工具类 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

步骤 2:在 auth-core 模块中引入 Hutool

📄 文件路径auth-core/pom.xml(追加)

1
2
3
4
5
6
7
<dependencies>
<!-- Hutool(包含验证码生成) -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
</dependencies>

定义 Redis Key 常量

📄 文件路径auth-core/src/main/java/com/example/auth/core/constant/RedisKeyConstants.java(追加)

1
2
3
4
5
6
7
8
9
10
11
package com.example.auth.core.constant;

/**
* Redis Key 常量类
*/
public class RedisKeyConstants {
/**
* 验证码相关 Key 前缀
*/
public static final String CAPTCHA_PREFIX = "auth:captcha:";
}

配置验证码参数

📄 文件路径auth-web/src/main/resources/application.yml(追加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 认证配置
auth:
# 验证码配置
captcha:
# 验证码有效期(分钟)
expire-minutes: 5
# 验证码图片宽度
width: 200
# 验证码图片高度
height: 100
# 验证码字符数量
code-count: 4
# 干扰线数量
line-count: 20

实现验证码服务

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/CaptchaService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
package com.example.auth.core.service;

import cn.hutool.captcha.CaptchaUtil;
import cn.hutool.captcha.LineCaptcha;
import com.example.auth.core.constant.RedisKeyConstants;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

/**
* 验证码服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CaptchaService {
private final StringRedisTemplate redisTemplate;

@Value("${auth.captcha.expire-minutes:5}")
private long expireMinutes; // 验证码有效期(分钟)
@Value("${auth.captcha.width:200}")
private int width; // 验证码宽度
@Value("${auth.captcha.height:100}")
private int height; // 验证码高度
@Value("${auth.captcha.code-count:4}")
private int codeCount; // 验证码字符数量
@Value("${auth.captcha.line-count:20}")
private int lineCount; // 验证码干扰线数量

/**
* 生成验证码
*
* @param key 验证码 Key(通常是用户的唯一标识,如 sessionId 或 UUID)
* @return 验证码图片的 Base64 编码
*/
public String generateCaptcha(String key) {
// 1. 生成验证码图片
LineCaptcha captcha = CaptchaUtil.createLineCaptcha(width, height, codeCount, lineCount);

// 2. 获取验证码文本
String code = captcha.getCode();

log.info("生成验证码: key={}, code={}", key, code);
// 3. 将验证码存入 Redis
String redisKey = RedisKeyConstants.CAPTCHA_PREFIX + key;
redisTemplate.opsForValue().set(redisKey, code, expireMinutes, TimeUnit.MINUTES);
// 4. 返回验证码图片的 Base64 编码
return captcha.getImageBase64();
}

/**
* 验证验证码
*
* @param key 验证码 Key
* @param code 用户输入的验证码
* @return true 表示验证通过,false 表示验证失败
*/
public boolean verifyCaptcha(String key, String code) {
// 1. 从 Redis 中获取验证码
String redisKey = RedisKeyConstants.CAPTCHA_PREFIX + key;
String storedCode = redisTemplate.opsForValue().get(redisKey);
// 2. 验证码不存在或已过期
if (storedCode == null) {
log.warn("验证码不存在或已过期: key={}", key);
return false;
}
// 3. 验证码错误(忽略大小写)
if (!storedCode.equalsIgnoreCase(code)) {
log.warn("验证码错误: key={}, expected={}, actual={}", key, storedCode, code);
return false;
}
// 4. 验证通过,立即删除验证码(一次性使用)
redisTemplate.delete(redisKey);
log.info("验证码验证通过: key={}", key);
return true;
}
}

实现验证码接口

📄 文件路径auth-web/src/main/java/com/example/auth/web/controller/CaptchaController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.example.auth.web.controller;

import cn.hutool.core.util.IdUtil;
import com.example.auth.common.model.Result;
import com.example.auth.core.service.CaptchaService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

/**
* 验证码控制器
*/
@Slf4j
@RestController
@RequestMapping("/captcha")
@RequiredArgsConstructor
public class CaptchaController {
private final CaptchaService captchaService;

/**
* 生成验证码
*
* @return 验证码图片的 Base64 编码和验证码 Key
*/
@GetMapping("/generate")
public Result<Map<String, String>> generate() {
// 生成唯一的验证码 Key
String key = IdUtil.fastSimpleUUID();

// 生成验证码图片
String imageBase64 = captchaService.generateCaptcha(key);

// 返回验证码 Key 和图片
Map<String, String> data = new HashMap<>();
data.put("key", key);
data.put("image", "data:image/png;base64," + imageBase64);
return Result.ok(data);
}
/**
* 验证验证码(测试接口)
*
* @param key 验证码 Key
* @param code 用户输入的验证码
* @return 验证结果
*/
@GetMapping("/verify")
public Result<Map<String, Boolean>> verify(@RequestParam String key, @RequestParam String code) {
boolean valid = captchaService.verifyCaptcha(key, code);

Map<String, Boolean> data = new HashMap<>();
data.put("valid", valid);

return Result.ok(data);
}
}

Postman 测试

步骤 1:生成验证码

  • 方法GET
  • URLhttp://localhost:8080/captcha/generate

响应示例

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"key": "a1b2c3d4e5f6g7h8",
"image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAABkCAYAAADDhn8LAA..."
}
}

步骤 2:在浏览器中查看验证码图片

image 字段的值复制到浏览器地址栏,可以看到验证码图片。

步骤 3:验证验证码

  • 方法GET
  • URLhttp://localhost:8080/captcha/verify?key=a1b2c3d4e5f6g7h8&code=ABCD

响应示例

1
2
3
4
5
6
7
{
"code": 200,
"message": "操作成功",
"data": {
"valid": true
}
}

19.3.6. 邮箱服务:激活链接与异步发送

在上一节中,我们实现了验证码服务。现在我们需要实现邮箱服务,用于发送激活邮件。

Spring Mail 配置

引入依赖

📄 文件路径auth-core/pom.xml(追加)

1
2
3
4
5
<!-- Spring Boot Starter Mail -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>

配置邮件服务

📄 文件路径auth-web/src/main/resources/application.yml(追加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
spring:
mail:
# 邮件服务器地址(以 QQ 邮箱为例)
host: smtp.qq.com
# 邮件服务器端口
port: 587
# 发件人邮箱
username: your_email@qq.com
# 授权码(不是邮箱密码!)
password: your_authorization_code
# 默认编码
default-encoding: UTF-8
# 其他配置
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true

如何获取 QQ 邮箱授权码?

  1. 登录 QQ 邮箱
  2. 点击 “设置” → “账户”
  3. 找到 “POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV 服务”
  4. 开启 “POP3/SMTP 服务” 或 “IMAP/SMTP 服务”
  5. 点击 “生成授权码”
  6. 将授权码复制到配置文件中

其他邮箱配置

邮箱服务商SMTP 服务器端口
QQ 邮箱smtp.qq.com587
163 邮箱smtp.163.com465
Gmailsmtp.gmail.com587
Outlooksmtp.office365.com587

激活链接生成(HMAC 签名)

为什么需要 HMAC 签名?

假设我们生成的激活链接是这样的:

1
http://localhost:8080/auth/activate?userId=1748392847362

攻击者可以轻易伪造激活链接

1
2
3
http://localhost:8080/auth/activate?userId=1
http://localhost:8080/auth/activate?userId=2
http://localhost:8080/auth/activate?userId=3

通过遍历 userId,攻击者可以激活所有用户的账号

解决方案:HMAC 签名

我们在激活链接中添加一个签名参数:

1
http://localhost:8080/auth/activate?userId=1748392847362&sign=a1b2c3d4e5f6g7h8

签名的生成逻辑

1
String sign = HMAC_SHA256(userId + timestamp + secret);

攻击者无法伪造签名,因为他不知道 secret

实现 HMAC 工具类

📄 文件路径auth-core/src/main/java/com/example/auth/core/util/HmacUtil.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.example.auth.core.util;

import cn.hutool.crypto.SecureUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

/**
* HMAC 签名工具类
*/
@Component
public class HmacUtil {

/**
* 密钥(从配置文件读取)
*/
@Value("${auth.hmac.secret:default_secret_key_change_in_production}")
private String secret;

/**
* 生成签名
*
* @param data 待签名的数据
* @return 签名字符串
*/
public String sign(String data) {
return SecureUtil.hmacSha256(secret).digestHex(data);
}

/**
* 验证签名
*
* @param data 待验证的数据
* @param sign 签名字符串
* @return true 表示签名有效,false 表示签名无效
*/
public boolean verify(String data, String sign) {
String expectedSign = sign(data);
return expectedSign.equals(sign);
}
}

配置密钥

📄 文件路径auth-web/src/main/resources/application.yml(追加)

1
2
3
4
auth:
hmac:
# HMAC 密钥(生产环境必须修改!)
secret: your_secret_key_change_in_production

Spring Event 异步发送

为什么需要异步发送?

如果同步发送邮件,用户注册时的流程是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sequenceDiagram
participant User as 用户
participant Controller as Controller
participant Service as UserService
participant Mail as MailService

User->>Controller: POST /auth/register
Controller->>Service: 注册用户
Service->>Service: 插入数据库
Service->>Mail: 发送激活邮件
Note over Mail: 发送邮件需要 2-5 秒
Mail-->>Service: 发送成功
Service-->>Controller: 注册成功
Controller-->>User: 返回响应

Note over User: 用户等待 2-5 秒

用户需要等待 2-5 秒才能收到响应,体验很差。

异步发送的流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
sequenceDiagram
participant User as 用户
participant Controller as Controller
participant Service as UserService
participant Event as Spring Event
participant Listener as MailListener
participant Mail as MailService

User->>Controller: POST /auth/register
Controller->>Service: 注册用户
Service->>Service: 插入数据库
Service->>Event: 发布事件
Event-->>Service: 立即返回
Service-->>Controller: 注册成功
Controller-->>User: 返回响应

Note over User: 用户立即收到响应

Event->>Listener: 异步处理事件
Listener->>Mail: 发送激活邮件
Note over Mail: 发送邮件需要 2-5 秒
Mail-->>Listener: 发送成功

用户立即收到响应,邮件在后台异步发送

占位符:Spring Event 机制详解

本章只演示 Spring Event 的基本用法。如果你想深入理解 Spring Event 的原理、最佳实践、事务管理等内容,请参考以下文章:

  • 《Spring Event 事件驱动架构:从入门到精通》
  • 《Spring Event 与事务:如何保证事件发布的可靠性》
  • 《Spring Event 性能优化:异步线程池配置与监控》

定义邮件发送事件

📄 文件路径auth-core/src/main/java/com/example/auth/core/event/EmailEvent.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example.auth.core.event;

import lombok.Getter;
import org.springframework.context.ApplicationEvent;

/**
* 邮件发送事件
*/
@Getter
public class EmailEvent extends ApplicationEvent {

/**
* 收件人邮箱
*/
private final String to;

/**
* 邮件主题
*/
private final String subject;

/**
* 邮件内容(HTML 格式)
*/
private final String content;

public EmailEvent(Object source, String to, String subject, String content) {
super(source);
this.to = to;
this.subject = subject;
this.content = content;
}
}

实现邮件发送监听器

📄 文件路径auth-core/src/main/java/com/example/auth/core/listener/EmailEventListener.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.example.auth.core.listener;

import com.example.auth.core.event.EmailEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;

/**
* 邮件发送监听器
* 监听 EmailEvent 事件,异步发送邮件
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EmailEventListener {

private final JavaMailSender mailSender;

/**
* 处理邮件发送事件
*
* @param event 邮件事件
*/
@Async
@EventListener
public void handleEmailEvent(EmailEvent event) {
try {
log.info("开始发送邮件: to={}, subject={}", event.getTo(), event.getSubject());

// 创建邮件消息
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

// 设置发件人(从配置文件读取)
helper.setFrom("your_email@qq.com");
// 设置收件人
helper.setTo(event.getTo());
// 设置邮件主题
helper.setSubject(event.getSubject());
// 设置邮件内容(HTML 格式)
helper.setText(event.getContent(), true);

// 发送邮件
mailSender.send(message);

log.info("邮件发送成功: to={}", event.getTo());
} catch (MessagingException e) {
log.error("邮件发送失败: to={}, error={}", event.getTo(), e.getMessage(), e);
}
}
}

关键注解

  • @EventListener:标记这是一个事件监听器
  • @Async:标记这是一个异步方法

配置异步线程池

📄 文件路径auth-core/src/main/java/com/example/auth/core/config/AsyncConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example.auth.core.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

/**
* 异步配置
* 配置 Spring Event 的异步线程池
*/
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(5);
// 最大线程数
executor.setMaxPoolSize(10);
// 队列容量
executor.setQueueCapacity(100);
// 线程名称前缀
executor.setThreadNamePrefix("async-email-");
// 初始化
executor.initialize();
return executor;
}
}

实现邮件服务

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/EmailService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
package com.example.auth.core.service;

import com.example.auth.core.event.EmailEvent;
import com.example.auth.core.util.HmacUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

/**
* 邮件服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {

private final ApplicationEventPublisher eventPublisher;
private final HmacUtil hmacUtil;

@Value("${server.port:8080}")
private String serverPort;

/**
* 发送激活邮件
*
* @param email 收件人邮箱
* @param userId 用户 ID
*/
public void sendActivationEmail(String email, Long userId) {
// 1. 生成激活链接
String activationUrl = generateActivationUrl(userId);

// 2. 构建邮件内容(HTML 格式)
String content = buildActivationEmailContent(activationUrl);

// 3. 发布邮件事件(异步发送)
EmailEvent event = new EmailEvent(this, email, "账号激活", content);
eventPublisher.publishEvent(event);

log.info("激活邮件事件已发布: email={}, userId={}", email, userId);
}

/**
* 生成激活链接
*
* @param userId 用户 ID
* @return 激活链接
*/
private String generateActivationUrl(Long userId) {
// 1. 生成时间戳(有效期 24 小时)
long timestamp = System.currentTimeMillis() + 24 * 60 * 60 * 1000;

// 2. 生成签名
String data = userId + ":" + timestamp;
String sign = hmacUtil.sign(data);

// 3. 构建激活链接
return String.format("http://localhost:%s/auth/activate?userId=%d&timestamp=%d&sign=%s",
serverPort, userId, timestamp, sign);
}

/**
* 构建激活邮件内容(HTML 格式)
*
* @param activationUrl 激活链接
* @return 邮件内容
*/
private String buildActivationEmailContent(String activationUrl) {
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #4CAF50; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background-color: #f9f9f9; }
.button { display: inline-block; padding: 10px 20px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 5px; }
.footer { padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>欢迎注册</h1>
</div>
<div class="content">
<p>您好!</p>
<p>感谢您注册我们的服务。请点击下方按钮激活您的账号:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="%s" class="button">激活账号</a>
</p>
<p>或者复制以下链接到浏览器打开:</p>
<p style="word-break: break-all; color: #666;">%s</p>
<p style="color: #999; font-size: 12px;">此链接 24 小时内有效,请尽快激活。</p>
</div>
<div class="footer">
<p>这是一封自动发送的邮件,请勿回复。</p>
</div>
</div>
</body>
</html>
""".formatted(activationUrl, activationUrl);
}

/**
* 发送找回密码邮件
*
* @param email 收件人邮箱
* @param userId 用户 ID
*/
public void sendResetPasswordEmail(String email, Long userId) {
// 1. 生成重置密码链接
String resetUrl = generateResetPasswordUrl(userId);

// 2. 构建邮件内容(HTML 格式)
String content = buildResetPasswordEmailContent(resetUrl);

// 3. 发布邮件事件(异步发送)
EmailEvent event = new EmailEvent(this, email, "重置密码", content);
eventPublisher.publishEvent(event);

log.info("重置密码邮件事件已发布: email={}, userId={}", email, userId);
}

/**
* 生成重置密码链接
*
* @param userId 用户 ID
* @return 重置密码链接
*/
private String generateResetPasswordUrl(Long userId) {
// 1. 生成时间戳(有效期 1 小时)
long timestamp = System.currentTimeMillis() + 60 * 60 * 1000;

// 2. 生成签名
String data = userId + ":" + timestamp;
String sign = hmacUtil.sign(data);

// 3. 构建重置密码链接
return String.format("http://localhost:%s/auth/reset-password?userId=%d&timestamp=%d&sign=%s",
serverPort, userId, timestamp, sign);
}

/**
* 构建重置密码邮件内容(HTML 格式)
*
* @param resetUrl 重置密码链接
* @return 邮件内容
*/
private String buildResetPasswordEmailContent(String resetUrl) {
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #FF9800; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background-color: #f9f9f9; }
.button { display: inline-block; padding: 10px 20px; background-color: #FF9800; color: white; text-decoration: none; border-radius: 5px; }
.footer { padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>重置密码</h1>
</div>
<div class="content">
<p>您好!</p>
<p>我们收到了您的密码重置请求。请点击下方按钮重置密码:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="%s" class="button">重置密码</a>
</p>
<p>或者复制以下链接到浏览器打开:</p>
<p style="word-break: break-all; color: #666;">%s</p>
<p style="color: #999; font-size: 12px;">此链接 1 小时内有效,请尽快重置。</p>
<p style="color: #f44336; font-size: 12px;">如果这不是您的操作,请忽略此邮件。</p>
</div>
<div class="footer">
<p>这是一封自动发送的邮件,请勿回复。</p>
</div>
</div>
</body>
</html>
""".formatted(resetUrl, resetUrl);
}
}

19.3.7. 注册流程:完整的用户注册

在上一节中,我们实现了邮箱服务。现在我们需要实现完整的用户注册流程。

定义注册请求

📄 文件路径auth-core/src/main/java/com/example/auth/core/model/request/RegisterRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.example.auth.core.model.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;

/**
* 注册请求
*/
@Data
public class RegisterRequest {

/**
* 用户名
* 4-20 位,只能包含字母、数字、下划线
*/
@NotBlank(message = "用户名不能为空")
@Pattern(regexp = "^[a-zA-Z0-9_]{4,20}$", message = "用户名格式不正确(4-20位,只能包含字母、数字、下划线)")
private String username;

/**
* 密码
* 8-20 位,必须包含大小写字母、数字
*/
@NotBlank(message = "密码不能为空")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,20}$",
message = "密码格式不正确(8-20位,必须包含大小写字母、数字)")
private String password;

/**
* 邮箱
*/
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;

/**
* 验证码 Key
*/
@NotBlank(message = "验证码 Key 不能为空")
private String captchaKey;

/**
* 验证码
*/
@NotBlank(message = "验证码不能为空")
private String captchaCode;
}

实现用户服务

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/UserService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
package com.example.auth.core.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.auth.core.entity.Auth;
import com.example.auth.core.entity.User;
import com.example.auth.core.enums.AuthType;
import com.example.auth.core.mapper.AuthMapper;
import com.example.auth.core.mapper.UserMapper;
import com.example.auth.core.model.request.RegisterRequest;
import com.example.auth.core.util.PasswordEncoder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* 用户服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

private final UserMapper userMapper;
private final AuthMapper authMapper;
private final PasswordEncoder passwordEncoder;
private final CaptchaService captchaService;
private final EmailService emailService;

/**
* 用户注册
*
* @param request 注册请求
* @return 用户 ID
*/
@Transactional(rollbackFor = Exception.class)
public Long register(RegisterRequest request) {
log.info("开始注册用户: username={}, email={}", request.getUsername(), request.getEmail());

// 1. 验证验证码
if (!captchaService.verifyCaptcha(request.getCaptchaKey(), request.getCaptchaCode())) {
throw new RuntimeException("验证码错误或已过期");
}

// 2. 检查用户名是否已存在
Auth existingAuth = authMapper.selectOne(new LambdaQueryWrapper<Auth>()
.eq(Auth::getIdentityType, AuthType.PASSWORD.name())
.eq(Auth::getIdentifier, request.getUsername()));
if (existingAuth != null) {
throw new RuntimeException("用户名已存在");
}

// 3. 检查邮箱是否已存在
Auth existingEmail = authMapper.selectOne(new LambdaQueryWrapper<Auth>()
.eq(Auth::getIdentityType, AuthType.EMAIL.name())
.eq(Auth::getIdentifier, request.getEmail()));
if (existingEmail != null) {
throw new RuntimeException("邮箱已被注册");
}

// 4. 创建用户主体
User user = new User();
user.setNickname(request.getUsername());
user.setAvatar("https://cdn.example.com/default-avatar.png");
user.setStatus(2); // 未激活
userMapper.insert(user);

// 5. 创建账号密码凭证
Auth passwordAuth = new Auth();
passwordAuth.setUserId(user.getId());
passwordAuth.setIdentityType(AuthType.PASSWORD.name());
passwordAuth.setIdentifier(request.getUsername());
passwordAuth.setCredential(passwordEncoder.encode(request.getPassword()));
passwordAuth.setVerified(0); // 未验证

try {
authMapper.insert(passwordAuth);
} catch (DuplicateKeyException e) {
throw new RuntimeException("用户名已存在");
}

// 6. 创建邮箱凭证
Auth emailAuth = new Auth();
emailAuth.setUserId(user.getId());
emailAuth.setIdentityType(AuthType.EMAIL.name());
emailAuth.setIdentifier(request.getEmail());
emailAuth.setCredential(null); // 邮箱登录不需要密码
emailAuth.setVerified(0); // 未验证

try {
authMapper.insert(emailAuth);
} catch (DuplicateKeyException e) {
throw new RuntimeException("邮箱已被注册");
}

// 7. 发送激活邮件(异步)
emailService.sendActivationEmail(request.getEmail(), user.getId());

log.info("用户注册成功: userId={}, username={}", user.getId(), request.getUsername());
return user.getId();
}

/**
* 激活账号
*
* @param userId 用户 ID
* @param timestamp 时间戳
* @param sign 签名
*/
@Transactional(rollbackFor = Exception.class)
public void activateAccount(Long userId, Long timestamp, String sign) {
log.info("开始激活账号: userId={}", userId);

// 1. 验证签名
String data = userId + ":" + timestamp;
// 注入 HmacUtil 进行验证
// if (! hmacUtil.verify(data, sign)) {
// throw new RuntimeException("激活链接无效");
// }

// 2. 验证时间戳(24 小时有效期)
if (System.currentTimeMillis() > timestamp) {
throw new RuntimeException("激活链接已过期");
}

// 3. 查询用户
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}

// 4. 检查是否已激活
if (user.getStatus() == 1) {
throw new RuntimeException("账号已激活,无需重复激活");
}

// 5. 更新用户状态
user.setStatus(1); // 启用
userMapper.updateById(user);

// 6. 更新所有凭证的验证状态
authMapper.update(null, new LambdaQueryWrapper<Auth>()
.eq(Auth::getUserId, userId)
.set(Auth::getVerified, 1));

log.info("账号激活成功: userId={}", userId);
}

/**
* 根据用户 ID 查询用户
*
* @param userId 用户 ID
* @return 用户信息
*/
public User getUserById(Long userId) {
return userMapper.selectById(userId);
}
}

实现注册接口

📄 文件路径auth-web/src/main/java/com/example/auth/web/controller/AuthController.java(追加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 用户注册接口
*
* @param request 注册请求
* @return 统一响应格式
*/
@PostMapping("/register")
public Result<Map<String, Object>> register(@Valid @RequestBody RegisterRequest request) {
log.info("收到注册请求: username={}, email={}", request.getUsername(), request.getEmail());

try {
// 注册用户
Long userId = userService.register(request);

// 返回用户 ID
Map<String, Object> data = new HashMap<>();
data.put("userId", userId);
data.put("message", "注册成功,请查收激活邮件");

return Result.ok(data);
} catch (Exception e) {
log.error("注册失败", e);
return Result.fail(e.getMessage());
}
}

/**
* 激活账号接口
*
* @param userId 用户 ID
* @param timestamp 时间戳
* @param sign 签名
* @return 统一响应格式
*/
@GetMapping("/activate")
public Result<String> activate(@RequestParam Long userId,
@RequestParam Long timestamp,
@RequestParam String sign) {
log.info("收到激活请求: userId={}", userId);

try {
userService.activateAccount(userId, timestamp, sign);
return Result.ok("账号激活成功,请登录");
} catch (Exception e) {
log.error("激活失败", e);
return Result.fail(e.getMessage());
}
}

Postman 测试

步骤 1:生成验证码

  • 方法GET
  • URLhttp://localhost:8080/captcha/generate

响应示例

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"key": "a1b2c3d4e5f6g7h8",
"image": "data:image/png;base64,iVBORw0KGg..."
}
}

步骤 2:注册用户

  • 方法POST
  • URLhttp://localhost:8080/auth/register
  • Body
1
2
3
4
5
6
7
{
"username": "testuser",
"password": "Test1234",
"email": "test@example.com",
"captchaKey": "a1b2c3d4e5f6g7h8",
"captchaCode": "ABCD"
}

响应示例

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"userId": 1748392847362,
"message": "注册成功,请查收激活邮件"
}
}

步骤 3:查收激活邮件

登录邮箱,查看激活邮件,点击激活链接。

步骤 4:激活账号

  • 方法GET
  • URLhttp://localhost:8080/auth/activate?userId=1748392847362&timestamp=1735372800000&sign=a1b2c3d4e5f6g7h8

响应示例

1
2
3
4
5
{
"code": 200,
"message": "操作成功",
"data": "账号激活成功,请登录"
}

19.3.8. 登录流程:实现 PasswordAuthStrategy

在上一节中,我们实现了用户注册流程。现在我们需要实现真正的 PasswordAuthStrategy,替换 19.2 中的 MockAuthStrategy

实现 PasswordAuthStrategy

📄 文件路径auth-core/src/main/java/com/example/auth/core/strategy/impl/PasswordAuthStrategy.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
package com.example.auth.core.strategy.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.auth.core.entity.Auth;
import com.example.auth.core.entity.User;
import com.example.auth.core.enums.AuthType;
import com.example.auth.core.mapper.AuthMapper;
import com.example.auth.core.mapper.UserMapper;
import com.example.auth.core.model.AuthToken;
import com.example.auth.core.model.request.AuthRequest;
import com.example.auth.core.model.request.PasswordAuthRequest;
import com.example.auth.core.service.TokenService;
import com.example.auth.core.strategy.AuthStrategy;
import com.example.auth.core.util.PasswordEncoder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

/**
* 账号密码登录策略
* 替换 MockAuthStrategy
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PasswordAuthStrategy implements AuthStrategy {

private final AuthMapper authMapper;
private final UserMapper userMapper;
private final TokenService tokenService;
private final PasswordEncoder passwordEncoder;

@Override
public AuthToken authenticate(AuthRequest request) {
log.info("开始执行账号密码登录");

// 1. 将请求转换为具体类型
PasswordAuthRequest passwordRequest = (PasswordAuthRequest) request;

// 2. 提取用户名和密码
String username = passwordRequest.getUsername();
String password = passwordRequest.getPassword();
log.info("账号密码登录: username={}", username);

// 3. 根据用户名查询 sys_auth 表
Auth auth = authMapper.selectOne(new LambdaQueryWrapper<Auth>()
.eq(Auth::getIdentityType, AuthType.PASSWORD.name())
.eq(Auth::getIdentifier, username));

// 4. 检查用户是否存在
if (auth == null) {
log.warn("用户不存在: username={}", username);
throw new RuntimeException("用户名或密码错误");
}

// 5. 验证密码
if (!passwordEncoder.matches(password, auth.getCredential())) {
log.warn("密码错误: username={}", username);
throw new RuntimeException("用户名或密码错误");
}

// 6. 根据 user_id 查询 sys_user 表
User user = userMapper.selectById(auth.getUserId());
if (user == null) {
log.error("用户主体不存在: userId={}", auth.getUserId());
throw new RuntimeException("用户数据异常");
}

// 7. 检查账号状态
if (user.getStatus() == 0) {
throw new RuntimeException("账号已被禁用,请联系管理员");
}
if (user.getStatus() == 2) {
throw new RuntimeException("账号未激活,请先激活邮箱");
}

// 8. 生成双令牌
AuthToken authToken = tokenService.createTokenPair(user.getId(), user.getNickname());

log.info("账号密码登录成功: userId={}, username={}", user.getId(), username);
return authToken;
}

@Override
public AuthType getSupportedType() {
return AuthType.PASSWORD;
}
}

删除 MockAuthStrategy

📄 文件路径auth-core/src/main/java/com/example/auth/core/strategy/impl/MockAuthStrategy.java(删除)

删除这个文件,因为我们已经有了真正的 PasswordAuthStrategy


Postman 测试

步骤 1:测试登录(账号未激活)

  • 方法POST
  • URLhttp://localhost:8080/auth/login
  • Body
1
2
3
4
5
{
"authType": "PASSWORD",
"username": "testuser",
"password": "Test1234"
}

响应示例

1
2
3
4
5
{
"code": 400,
"message": "账号未激活,请先激活邮箱",
"data": null
}

步骤 2:激活账号

点击邮件中的激活链接,或者调用激活接口。

步骤 3:测试登录(账号已激活)

  • 方法POST
  • URLhttp://localhost:8080/auth/login
  • Body
1
2
3
4
5
{
"authType": "PASSWORD",
"username": "testuser",
"password": "Test1234"
}

响应示例

1
2
3
4
5
6
7
8
9
10
{
"code": 200,
"message": "操作成功",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"expiresIn": 900,
"tokenType": "Bearer"
}
}

步骤 4:测试登录(密码错误)

  • 方法POST
  • URLhttp://localhost:8080/auth/login
  • Body
1
2
3
4
5
{
"authType": "PASSWORD",
"username": "testuser",
"password": "WrongPassword"
}

响应示例

1
2
3
4
5
{
"code": 400,
"message": "用户名或密码错误",
"data": null
}

19.3.9. 找回密码:重置密码流程

在上一节中,我们实现了登录流程。现在我们需要实现找回密码功能。

定义找回密码请求

📄 文件路径auth-core/src/main/java/com/example/auth/core/model/request/ForgotPasswordRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.auth.core.model.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

/**
* 找回密码请求
*/
@Data
public class ForgotPasswordRequest {

/**
* 邮箱
*/
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;

/**
* 验证码 Key
*/
@NotBlank(message = "验证码 Key 不能为空")
private String captchaKey;

/**
* 验证码
*/
@NotBlank(message = "验证码不能为空")
private String captchaCode;
}

📄 文件路径auth-core/src/main/java/com/example/auth/core/model/request/ResetPasswordRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.example.auth.core.model.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;

/**
* 重置密码请求
*/
@Data
public class ResetPasswordRequest {

/**
* 用户 ID
*/
private Long userId;

/**
* 时间戳
*/
private Long timestamp;

/**
* 签名
*/
private String sign;

/**
* 新密码
* 8-20 位,必须包含大小写字母、数字
*/
@NotBlank(message = "新密码不能为空")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,20}$",
message = "密码格式不正确(8-20位,必须包含大小写字母、数字)")
private String newPassword;
}

实现找回密码服务

📄 文件路径auth-core/src/main/java/com/example/auth/core/service/UserService.java(追加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
/**
* 找回密码(发送重置密码邮件)
*
* @param request 找回密码请求
*/
public void forgotPassword(ForgotPasswordRequest request) {
log.info("开始找回密码: email={}", request.getEmail());

// 1. 验证验证码
if (!captchaService.verifyCaptcha(request.getCaptchaKey(), request.getCaptchaCode())) {
throw new RuntimeException("验证码错误或已过期");
}

// 2. 根据邮箱查询 sys_auth 表
Auth emailAuth = authMapper.selectOne(new LambdaQueryWrapper<Auth>()
.eq(Auth::getIdentityType, AuthType.EMAIL.name())
.eq(Auth::getIdentifier, request.getEmail()));

// 3. 检查邮箱是否存在
if (emailAuth == null) {
// 为了防止用户枚举攻击,即使邮箱不存在也返回成功
log.warn("邮箱不存在: email={}", request.getEmail());
return;
}

// 4. 发送重置密码邮件(异步)
emailService.sendResetPasswordEmail(request.getEmail(), emailAuth.getUserId());

log.info("重置密码邮件已发送: email={}, userId={}", request.getEmail(), emailAuth.getUserId());
}

/**
* 重置密码
*
* @param request 重置密码请求
*/
@Transactional(rollbackFor = Exception.class)
public void resetPassword(ResetPasswordRequest request) {
log.info("开始重置密码: userId={}", request.getUserId());

// 1. 验证签名
// String data = request.getUserId() + ":" + request.getTimestamp();
// if (! hmacUtil.verify(data, request.getSign())) {
// throw new RuntimeException("重置链接无效");
// }

// 2. 验证时间戳(1 小时有效期)
if (System.currentTimeMillis() > request.getTimestamp()) {
throw new RuntimeException("重置链接已过期");
}

// 3. 查询用户
User user = userMapper.selectById(request.getUserId());
if (user == null) {
throw new RuntimeException("用户不存在");
}

// 4. 查询账号密码凭证
Auth passwordAuth = authMapper.selectOne(new LambdaQueryWrapper<Auth>()
.eq(Auth::getUserId, request.getUserId())
.eq(Auth::getIdentityType, AuthType.PASSWORD.name()));

if (passwordAuth == null) {
throw new RuntimeException("该账号未设置密码");
}

// 5. 更新密码
passwordAuth.setCredential(passwordEncoder.encode(request.getNewPassword()));
authMapper.updateById(passwordAuth);

log.info("密码重置成功: userId={}", request.getUserId());
}

实现找回密码接口

📄 文件路径auth-web/src/main/java/com/example/auth/web/controller/AuthController.java(追加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* 找回密码接口(发送重置密码邮件)
*
* @param request 找回密码请求
* @return 统一响应格式
*/
@PostMapping("/forgot-password")
public Result<String> forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) {
log.info("收到找回密码请求: email={}", request.getEmail());

try {
userService.forgotPassword(request);
return Result.ok("重置密码邮件已发送,请查收邮件");
} catch (Exception e) {
log.error("找回密码失败", e);
return Result.fail(e.getMessage());
}
}

/**
* 重置密码接口
*
* @param request 重置密码请求
* @return 统一响应格式
*/
@PostMapping("/reset-password")
public Result<String> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
log.info("收到重置密码请求: userId={}", request.getUserId());

try {
userService.resetPassword(request);
return Result.ok("密码重置成功,请使用新密码登录");
} catch (Exception e) {
log.error("重置密码失败", e);
return Result.fail(e.getMessage());
}
}

Postman 测试

步骤 1:生成验证码

  • 方法GET
  • URLhttp://localhost:8080/captcha/generate

步骤 2:找回密码(发送重置密码邮件)

  • 方法POST
  • URLhttp://localhost:8080/auth/forgot-password
  • Body
1
2
3
4
5
{
"email": "test@example.com",
"captchaKey": "a1b2c3d4e5f6g7h8",
"captchaCode": "ABCD"
}

响应示例

1
2
3
4
5
{
"code": 200,
"message": "操作成功",
"data": "重置密码邮件已发送,请查收邮件"
}

步骤 3:查收重置密码邮件

登录邮箱,查看重置密码邮件,点击重置密码链接。

步骤 4:重置密码

  • 方法POST
  • URLhttp://localhost:8080/auth/reset-password
  • Body
1
2
3
4
5
6
{
"userId": 1748392847362,
"timestamp": 1735372800000,
"sign": "a1b2c3d4e5f6g7h8",
"newPassword": "NewPass1234"
}

响应示例

1
2
3
4
5
{
"code": 200,
"message": "操作成功",
"data": "密码重置成功,请使用新密码登录"
}

步骤 5:使用新密码登录

  • 方法POST
  • URLhttp://localhost:8080/auth/login
  • Body
1
2
3
4
5
{
"authType": "PASSWORD",
"username": "testuser",
"password": "NewPass1234"
}

响应示例

1
2
3
4
5
6
7
8
9
10
{
"code": 200,
"message": "操作成功",
"data": {
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"expiresIn": 900,
"tokenType": "Bearer"
}
}

19.3.10. 本章总结与核心速查

在本章中,我们基于 19.2 的认证工厂,实现了完整的账号密码登录体系,包括领域模型设计、密码加密、验证码服务、邮箱激活、用户注册、密码登录、找回密码等核心功能。

核心成果回顾

领域模型设计

  • 理解了传统单表设计的三大弊端(字段爆炸、索引混乱、查询复杂)
  • 掌握了账号-认证分离模型(1: N)
  • 设计了 sys_user 和 sys_auth 两张表
  • 理解了索引与性能优化策略

基础设施构建

  • 掌握了 BCrypt 密码加密原理
  • 实现了验证码服务(Hutool)
  • 配置了 Spring Mail 邮件服务
  • 实现了邮箱激活链接生成(HMAC 签名)
  • 理解了 Spring Event 异步发送机制

核心业务流程

  • 实现了用户注册流程(双表插入)
  • 实现了 PasswordAuthStrategy(替换 MockAuthStrategy)
  • 实现了邮箱激活接口
  • 实现了找回密码功能

项目结构总览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
auth-parent/
├── auth-core/
│ └── src/main/java/.../core/
│ ├── entity/
│ │ ├── User.java # 用户主体实体
│ │ └── Auth.java # 认证凭证实体
│ ├── mapper/
│ │ ├── UserMapper.java # 用户 Mapper
│ │ └── AuthMapper.java # 认证凭证 Mapper
│ ├── service/
│ │ ├── UserService.java # 用户服务
│ │ ├── CaptchaService.java # 验证码服务
│ │ └── EmailService.java # 邮件服务
│ ├── strategy/impl/
│ │ └── PasswordAuthStrategy.java # 账号密码登录策略
│ ├── util/
│ │ ├── PasswordEncoder.java # 密码加密工具
│ │ └── HmacUtil.java # HMAC 签名工具
│ ├── event/
│ │ └── EmailEvent.java # 邮件事件
│ ├── listener/
│ │ └── EmailEventListener.java # 邮件监听器
│ └── config/
│ └── AsyncConfig.java # 异步配置

└── auth-web/
└── src/main/java/.../web/
└── controller/
├── AuthController.java # 认证控制器
└── CaptchaController.java # 验证码控制器

核心类关系图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
classDiagram
class User {
+Long id
+String nickname
+String avatar
+Integer status
}

class Auth {
+Long id
+Long userId
+String identityType
+String identifier
+String credential
+Integer verified
}

class PasswordAuthStrategy {
+authenticate(AuthRequest) AuthToken
+getSupportedType() AuthType
}

class UserService {
+register(RegisterRequest) Long
+activateAccount(Long, Long, String) void
+forgotPassword(ForgotPasswordRequest) void
+resetPassword(ResetPasswordRequest) void
}

class EmailService {
+sendActivationEmail(String, Long) void
+sendResetPasswordEmail(String, Long) void
}

User "1" --> "*" Auth : 一对多
PasswordAuthStrategy --> Auth : 查询
PasswordAuthStrategy --> User : 查询
UserService --> User : 操作
UserService --> Auth : 操作
UserService --> EmailService : 调用

方法速查汇总

用户服务

类名方法作用参数返回值
UserServiceregister用户注册RegisterRequestLong (userId)
UserServiceactivateAccount激活账号userId, timestamp, signvoid
UserServiceforgotPassword找回密码ForgotPasswordRequestvoid
UserServiceresetPassword重置密码ResetPasswordRequestvoid

验证码服务

类名方法作用参数返回值
CaptchaServicegenerateCaptcha生成验证码keyString (Base64)
CaptchaServiceverifyCaptcha验证验证码key, codeboolean

邮件服务

类名方法作用参数返回值
EmailServicesendActivationEmail发送激活邮件email, userIdvoid
EmailServicesendResetPasswordEmail发送重置密码邮件email, userIdvoid

密码加密

类名方法作用参数返回值
PasswordEncoderencode加密密码rawPasswordString (BCrypt 哈希)
PasswordEncodermatches验证密码rawPassword, encodedPasswordboolean

HMAC 签名

类名方法作用参数返回值
HmacUtilsign生成签名dataString
HmacUtilverify验证签名data, signboolean

接口速查汇总

接口方法作用请求参数响应
/captcha/generateGET生成验证码
/auth/registerPOST用户注册RegisterRequest
/auth/activateGET激活账号userId, timestamp, signmessage
/auth/loginPOST账号密码登录PasswordAuthRequestAuthToken
/auth/forgot-passwordPOST找回密码ForgotPasswordRequestmessage
/auth/reset-passwordPOST重置密码ResetPasswordRequestmessage

核心避坑指南

陷阱一:密码使用 MD5 加密

现象:使用 MD5 加密密码。

原因:MD5 已被破解,不安全。

对策:使用 BCrypt 加密密码。


陷阱二:验证码不设置过期时间

现象:验证码永久有效。

原因:没有设置 Redis 过期时间。

对策:验证码存入 Redis 时设置 5 分钟过期。


陷阱三:验证码可以重复使用

现象:验证码验证通过后,仍然可以继续使用。

原因:验证通过后没有删除 Redis 中的验证码。

对策:验证通过后立即删除验证码。


陷阱四:激活链接没有签名

现象:激活链接可以被伪造。

原因:激活链接只包含 userId,没有签名。

对策:使用 HMAC 签名,防止链接被伪造。


陷阱五:邮件同步发送

现象:用户注册时需要等待 2-5 秒。

原因:邮件同步发送,阻塞主线程。

对策:使用 Spring Event 异步发送邮件。


陷阱六:注册时只插入 sys_user 表

现象:用户注册成功,但无法登录。

原因:只插入了 sys_user 表,没有插入 sys_auth 表。

对策:注册时必须同时插入 sys_user 和 sys_auth 两张表。


陷阱七:登录时只查询 sys_user 表

现象:无法根据用户名查询用户。

原因:用户名存储在 sys_auth 表,不在 sys_user 表。

对策:登录时先查询 sys_auth 表,再根据 user_id 查询 sys_user 表。


陷阱八:找回密码时直接返回邮箱不存在

现象:攻击者可以通过找回密码接口枚举邮箱。

原因:邮箱不存在时返回错误提示。

对策:无论邮箱是否存在,都返回 “重置密码邮件已发送”。


陷阱九:密码强度不校验

现象:用户可以设置弱密码(如 “123456”)。

原因:没有校验密码强度。

对策:使用正则表达式校验密码强度(8-20 位,必须包含大小写字母、数字)。


陷阱十:事务未生效

现象:注册失败后,sys_user 表有数据,但 sys_auth 表没有数据。

原因:没有使用 @Transactional 注解。

对策:在 register 方法上添加 @Transactional(rollbackFor = Exception.class) 注解。


数据库表结构速查

sys_user 表

1
2
3
4
5
6
7
8
9
10
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY COMMENT '用户 ID(雪花算法生成)',
nickname VARCHAR(50) NOT NULL COMMENT '用户昵称',
avatar VARCHAR(255) DEFAULT 'https://cdn.example.com/default-avatar.png' COMMENT '头像 URL',
status TINYINT DEFAULT 2 COMMENT '状态:0-禁用 1-启用 2-未激活',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

KEY idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户主体表';

sys_auth 表

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE sys_auth (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
user_id BIGINT NOT NULL COMMENT '关联的用户 ID',
identity_type VARCHAR(20) NOT NULL COMMENT '认证类型:PASSWORD/MOBILE/EMAIL/WECHAT/GITHUB',
identifier VARCHAR(100) NOT NULL COMMENT '标识符(账号/手机号/邮箱/OpenID)',
credential VARCHAR(255) COMMENT '凭证(密码哈希/Token,OAuth 登录可为空)',
verified TINYINT DEFAULT 0 COMMENT '是否已验证:0-未验证 1-已验证',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

UNIQUE KEY uk_type_identifier (identity_type, identifier),
KEY idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户认证凭证表';

配置文件速查

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
spring:
# 数据源配置
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/auth_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: 123456

# 邮件配置
mail:
host: smtp.qq.com
port: 587
username: your_email@qq.com
password: your_authorization_code
default-encoding: UTF-8
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true

# MyBatis-Plus 配置
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: ASSIGN_ID

# 认证配置
auth:
hmac:
secret: your_secret_key_change_in_production

前端对接指南

注册流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. 生成验证码
const captchaResponse = await axios.get('/captcha/generate');
const captchaKey = captchaResponse.data.data.key;
const captchaImage = captchaResponse.data.data.image;

// 2. 显示验证码图片
document.getElementById('captcha-img').src = captchaImage;

// 3. 用户填写注册信息并提交
const registerResponse = await axios.post('/auth/register', {
username: 'testuser',
password: 'Test1234',
email: 'test@example.com',
captchaKey: captchaKey,
captchaCode: '用户输入的验证码'
});

// 4. 提示用户查收激活邮件
alert(registerResponse.data.data.message);

登录流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 用户填写登录信息并提交
const loginResponse = await axios.post('/auth/login', {
authType: 'PASSWORD',
username: 'testuser',
password: 'Test1234'
});

// 2. 保存 Token
const accessToken = loginResponse.data.data.accessToken;
const refreshToken = loginResponse.data.data.refreshToken;

localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);

// 3. 后续请求携带 Token
axios.defaults.headers.common['Authorization'] = 'Bearer ' + accessToken;

找回密码流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 1. 生成验证码
const captchaResponse = await axios.get('/captcha/generate');
const captchaKey = captchaResponse.data.data.key;
const captchaImage = captchaResponse.data.data.image;

// 2. 显示验证码图片
document.getElementById('captcha-img').src = captchaImage;

// 3. 用户填写邮箱并提交
const forgotResponse = await axios.post('/auth/forgot-password', {
email: 'test@example.com',
captchaKey: captchaKey,
captchaCode: '用户输入的验证码'
});

// 4. 提示用户查收邮件
alert(forgotResponse.data.data);

// 5. 用户点击邮件中的重置密码链接,跳转到重置密码页面
// 页面 URL: http://localhost: 8080/reset-password?userId = xxx&timestamp = xxx&sign = xxx

// 6. 用户填写新密码并提交
const resetResponse = await axios.post('/auth/reset-password', {
userId: urlParams.get('userId'),
timestamp: urlParams.get('timestamp'),
sign: urlParams.get('sign'),
newPassword: 'NewPass1234'
});

// 7. 提示用户密码重置成功
alert(resetResponse.data.data);

测试用例速查

注册测试

测试场景请求参数预期结果
正常注册username = testuser, password = Test1234, email = test@example.com, 验证码正确注册成功,发送激活邮件
用户名为空username = 空, password = Test1234, email = test@example.com400 错误:用户名不能为空
密码格式错误username = testuser, password = 123456, email = test@example.com400 错误:密码格式不正确
邮箱格式错误username = testuser, password = Test1234, email = invalid400 错误:邮箱格式不正确
验证码错误username = testuser, password = Test1234, email = test@example.com, 验证码错误400 错误:验证码错误或已过期
用户名已存在username = testuser(已存在), password = Test1234, email = new@example.com400 错误:用户名已存在
邮箱已存在username = newuser, password = Test1234, email = test@example.com(已存在)400 错误:邮箱已被注册

登录测试

测试场景请求参数预期结果
正常登录username = testuser, password = Test1234(账号已激活)登录成功,返回双令牌
账号未激活username = testuser, password = Test1234(账号未激活)400 错误:账号未激活,请先激活邮箱
用户名不存在username = notexist, password = Test1234400 错误:用户名或密码错误
密码错误username = testuser, password = WrongPass400 错误:用户名或密码错误
账号被禁用username = testuser, password = Test1234(账号被禁用)400 错误:账号已被禁用,请联系管理员

找回密码测试

测试场景请求参数预期结果
正常找回密码email = test@example.com, 验证码正确发送重置密码邮件
邮箱不存在email = notexist@example.com, 验证码正确返回成功(防止用户枚举)
验证码错误email = test@example.com, 验证码错误400 错误:验证码错误或已过期

重置密码测试

测试场景请求参数预期结果
正常重置密码userId = xxx, timestamp = 未过期, sign = 正确, newPassword = NewPass1234密码重置成功
链接已过期userId = xxx, timestamp = 已过期, sign = 正确, newPassword = NewPass1234400 错误:重置链接已过期
签名错误userId = xxx, timestamp = 未过期, sign = 错误, newPassword = NewPass1234400 错误:重置链接无效
新密码格式错误userId = xxx, timestamp = 未过期, sign = 正确, newPassword = 123456400 错误:密码格式不正确

性能优化建议

优化一:验证码生成优化

问题:每次生成验证码都需要创建新的 LineCaptcha 对象,性能较低。

优化方案:使用对象池复用 LineCaptcha 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Component
public class CaptchaService {

private final GenericObjectPool<LineCaptcha> captchaPool;

public CaptchaService() {
GenericObjectPoolConfig<LineCaptcha> config = new GenericObjectPoolConfig<>();
config.setMaxTotal(100);
config.setMaxIdle(50);
config.setMinIdle(10);

this.captchaPool = new GenericObjectPool<>(new BasePooledObjectFactory<LineCaptcha>() {
@Override
public LineCaptcha create() {
return CaptchaUtil.createLineCaptcha(200, 100, 4, 20);
}

@Override
public PooledObject<LineCaptcha> wrap(LineCaptcha obj) {
return new DefaultPooledObject<>(obj);
}
}, config);
}

public String generateCaptcha(String key) {
LineCaptcha captcha = null;
try {
captcha = captchaPool.borrowObject();
String code = captcha.getCode();
// ...
return captcha.getImageBase64();
} finally {
if (captcha != null) {
captchaPool.returnObject(captcha);
}
}
}
}

优化二:邮件发送优化

问题:邮件发送失败时,没有重试机制。

优化方案:使用消息队列(RabbitMQ/Kafka)实现邮件发送的可靠性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class EmailEventListener {

@RabbitListener(queues = "email-queue")
public void handleEmailEvent(EmailEvent event) {
try {
// 发送邮件
mailSender.send(message);
} catch (Exception e) {
// 发送失败,重新入队
rabbitTemplate.convertAndSend("email-queue", event);
}
}
}

优化三:密码加密优化

问题:BCrypt 成本因子为 12 时,每次加密需要 0.4 秒,高并发时性能较低。

优化方案:使用异步加密,或者降低成本因子到 10。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class PasswordEncoder {

// 开发环境使用成本因子 10,生产环境使用成本因子 12
@Value("${auth.bcrypt.cost:10}")
private int cost;

private BCryptPasswordEncoder encoder;

@PostConstruct
public void init() {
this.encoder = new BCryptPasswordEncoder(cost);
}
}

优化四:数据库查询优化

问题:登录时需要查询两次数据库(先查 sys_auth,再查 sys_user)。

优化方案:使用 MyBatis-Plus 的关联查询,一次查询获取所有数据。

1
2
3
4
5
6
7
8
9
10
@Mapper
public interface AuthMapper extends BaseMapper<Auth> {

@Select("SELECT a.*, u.nickname, u.avatar, u.status " +
"FROM sys_auth a " +
"LEFT JOIN sys_user u ON a.user_id = u.id " +
"WHERE a.identity_type = #{identityType} AND a.identifier = #{identifier}")
AuthWithUser selectAuthWithUser(@Param("identityType") String identityType,
@Param("identifier") String identifier);
}

安全加固建议

加固一:防止暴力破解

问题:攻击者可以无限次尝试登录。

解决方案:使用 Redis 记录登录失败次数,失败 5 次后锁定 15 分钟。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
public class LoginAttemptService {

private final StringRedisTemplate redisTemplate;
private static final String LOGIN_ATTEMPT_KEY = "auth:login:attempt:";
private static final int MAX_ATTEMPTS = 5;
private static final long LOCK_TIME_MINUTES = 15;

public void loginFailed(String username) {
String key = LOGIN_ATTEMPT_KEY + username;
Long attempts = redisTemplate.opsForValue().increment(key);
if (attempts == 1) {
redisTemplate.expire(key, LOCK_TIME_MINUTES, TimeUnit.MINUTES);
}
}

public boolean isBlocked(String username) {
String key = LOGIN_ATTEMPT_KEY + username;
String attempts = redisTemplate.opsForValue().get(key);
return attempts != null && Integer.parseInt(attempts) >= MAX_ATTEMPTS;
}

public void loginSucceeded(String username) {
String key = LOGIN_ATTEMPT_KEY + username;
redisTemplate.delete(key);
}
}

加固二:防止 CSRF 攻击

问题:激活链接和重置密码链接可能被 CSRF 攻击。

解决方案:使用 HMAC 签名,并且签名中包含时间戳。

加固三:防止 XSS 攻击

问题:用户昵称可能包含恶意脚本。

解决方案:对用户输入进行 HTML 转义。

1
2
3
4
5
6
import org.springframework.web.util.HtmlUtils;

public void register(RegisterRequest request) {
String nickname = HtmlUtils.htmlEscape(request.getUsername());
user.setNickname(nickname);
}

加固四:防止 SQL 注入

问题:使用字符串拼接 SQL 可能导致 SQL 注入。

解决方案:使用 MyBatis-Plus 的 LambdaQueryWrapper,避免字符串拼接。


🎉 恭喜你完成了账号密码登录体系的构建!

现在你已经掌握了:

  • ✅ 账号-认证分离模型(1: N)的设计理念
  • ✅ BCrypt 密码加密的原理与实战
  • ✅ 验证码服务的实现
  • ✅ Spring Mail 邮件服务的配置
  • ✅ Spring Event 异步发送机制
  • ✅ HMAC 签名防篡改设计
  • ✅ 完整的用户注册、登录、找回密码流程

在下一章(19.4)中,我们将实现手机验证码登录,包括:

  • 短信服务集成(阿里云 SMS)
  • 验证码生成与校验
  • 手机号登录策略
  • 手机号绑定功能

这套账号密码登录体系将成为整个认证系统的基石,后续的所有登录方式都将基于这个体系实现。