Note 19.3. 账号密码登录:领域模型设计与完整实现 环境版本锁定 在开始构建账号密码登录体系之前,我们需要明确本章依赖的技术栈版本。
技术组件 版本号 说明 JDK 17 LTS 必须支持 Record 等新特性 Spring Boot 3.2.0 配合 Spring 6.x MyBatis-Plus 3.5.5 用于数据持久化 MySQL 8.0 用户数据存储 Redis 7.0 验证码存储 Spring Boot Starter Mail 3.2.0 邮件发送 Hutool 5.8.24 验证码生成 BCrypt Spring 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)
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' ;ALTER TABLE sys_user ADD COLUMN apple_id VARCHAR (100 ) COMMENT 'Apple ID' ;
最终表结构变成了一个 “万金油” 表 ,包含几十个字段,其中大部分字段对于单个用户来说都是 NULL。
数据示例 :
id username password mobile email wechat_openid github_id alipay_uid douyin_openid … 1 admin $2a$ 10… 13812345678 NULL NULL NULL NULL NULL … 2 NULL NULL NULL user@example.com oX4Gt5k… NULL NULL NULL … 3 NULL NULL NULL NULL NULL 12345678 NULL NULL …
问题分析 :
❌ 每个用户只使用 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)); 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+ 种时,代码会变得非常臃肿 单表设计的根本问题 问题的本质 :将 “用户主体” 和 “登录凭证” 混在一起。
正确的做法 :将 “用户主体” 和 “登录凭证” 分离存储。
19.3.2. 现代方案:账号-认证分离模型(1: N) 业界成熟的解决方案是将 “用户主体” 与 “登录凭证” 分离存储,建立一对多关系。
核心设计理念
设计理念 :
用户主体(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) 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), KEY idx_user_id (user_id) ) ENGINE= InnoDB DEFAULT CHARSET= utf8mb4 COMMENT= '用户认证凭证表' ;
关键设计点 :
设计点一:identity_type 字段
这个字段标识登录方式的类型,对应 19.2 中定义的 AuthType 枚举。
identity_type 含义 identifier 示例 credential 示例 PASSWORD 账号密码登录 admin 2a10… (BCrypt 哈希) MOBILE 手机号登录 13812345678 NULL(验证码登录无需存储密码) EMAIL 邮箱登录 user@example.com 2a10… (BCrypt 哈希) WECHAT 微信登录 oX4Gt5k… NULL(或存储微信 Token) GITHUB GitHub 登录 12345678 NULL(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 表 :
id nickname avatar status create_time 1748392847362 张三 https://cdn …/avatar.jpg1 2025-01-10 10:00:00
sys_auth 表 :
id user_id identity_type identifier credential verified create_time 1 1748392847362 PASSWORD zhangsan 2a10rQ7R8k… 1 2025-01-10 10:00:00 2 1748392847362 MOBILE 13812345678 NULL 1 2025-01-10 10:05:00 3 1748392847362 EMAIL zhangsan@example.com 2a10aB3C4d… 1 2025-01-10 10:10:00 4 1748392847362 WECHAT oX4Gt5k… NULL 1 2025-01-10 10:15:00 5 1748392847362 GITHUB 12345678 NULL 1 2025-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 > <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 > <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 > <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: issuer: pro-auth-service access-token-expire-minutes: 15 clock-skew-seconds: 30 refresh-token-expire-days: 7 public-key-resource: certs/public_key.pem private-key-resource: certs/private_key.pem auth: enabled-types: - PASSWORD - SMS 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-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 { @TableId(type = IdType.ASSIGN_ID) private Long id; private String nickname; private String avatar; 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 { @TableId(type = IdType.AUTO) private Long id; private Long userId; private String identityType; private String identifier; private String credential; 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 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 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);
这种做法是极其危险的 ,原因有三:
原因一:MD5 是哈希算法,不是加密算法
哈希算法 :单向函数,无法解密加密算法 :双向函数,可以解密MD5 的设计目的是 数据完整性校验 ,而不是密码存储。
原因二:彩虹表攻击
彩虹表(Rainbow Table)是一个预先计算好的哈希值数据库。攻击者可以通过查表的方式,快速破解 MD5 哈希。
示例 :
假设数据库泄漏,攻击者获取到以下数据:
username password_md5 admin e10adc3949ba59abbe56e057f20f883e user1 5f4dcc3b5aa765d61d8327deb882cf99
攻击者只需要在彩虹表中查询这两个哈希值:
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);
这种做法比纯 MD5 好一些 ,但仍然存在问题:
问题一:盐值存储
盐值必须存储在数据库中,否则无法验证密码。如果数据库泄漏,攻击者可以获取盐值,然后针对每个用户生成专属的彩虹表。
问题二:盐值固定
如果所有用户使用相同的盐值,攻击者只需要生成一次彩虹表,就能破解所有密码。
问题三:计算速度太快
MD5 的计算速度非常快,攻击者可以使用 GPU 进行暴力破解。现代 GPU 每秒可以计算数十亿次 MD5 哈希。
BCrypt 的三大优势 BCrypt 是一种专门为密码存储设计的哈希算法,它解决了 MD5 的所有问题。
优势一:自动加盐
BCrypt 会自动生成随机盐值,并将盐值嵌入到哈希值中。
1 2 3 String password = "123456" ;String bcryptHash = BCrypt.hashpw(password, BCrypt.gensalt());
哈希值的结构 :
1 2 3 4 5 6 $2a$10$rQ7R8kN5J6X7Z8Y9W0V1U.2Q3R4S5T6U7V8W9X0Y1Z2A3B4C5D6E7F | | | | | | | +-- 哈希值(31 字符) | | +-- 盐值(22 字符) | +-- 成本因子(10) +-- 算法版本(2a)
关键点 :盐值和哈希值存储在一起,不需要单独存储盐值。
优势二:慢哈希(Slow Hash)
BCrypt 的计算速度非常慢,这是故意设计的。
1 2 3 4 5 String md5Hash = DigestUtils.md5Hex("123456" );String bcryptHash = BCrypt.hashpw("123456" , BCrypt.gensalt(10 ));
为什么要慢?
对于正常用户:登录时只需要计算一次,慢 0.1 秒完全可以接受 对于攻击者:暴力破解需要计算数百万次,慢 0.1 秒意味着破解时间从几小时变成几年 优势三:自适应(Adaptive)
BCrypt 的成本因子(Cost Factor)可以调整,随着硬件性能的提升,可以增加成本因子,保持相同的安全性。
1 2 3 4 5 String hash10 = BCrypt.hashpw("123456" , BCrypt.gensalt(10 ));String hash12 = BCrypt.hashpw("123456" , BCrypt.gensalt(12 ));
成本因子对照表 :
成本因子 迭代次数 计算时间(单核) 适用场景 10 1024 ~0.1 秒 开发环境 12 4096 ~0.4 秒 生产环境(推荐) 14 16384 ~1.6 秒 高安全场景 16 65536 ~6.4 秒 极高安全场景
建议 :生产环境使用成本因子 12。
BCrypt 实战 引入依赖 :
📄 文件路径 :auth-core/pom.xml(追加)
1 2 3 4 5 <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;@Component public class PasswordEncoder { private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder (12 ); public String encode (String rawPassword) { return encoder.encode(rawPassword); } 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 requestsfor 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 requestspasswords = ['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 > <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 > <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;public class RedisKeyConstants { 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; public String generateCaptcha (String key) { LineCaptcha captcha = CaptchaUtil.createLineCaptcha(width, height, codeCount, lineCount); String code = captcha.getCode(); log.info("生成验证码: key={}, code={}" , key, code); String redisKey = RedisKeyConstants.CAPTCHA_PREFIX + key; redisTemplate.opsForValue().set(redisKey, code, expireMinutes, TimeUnit.MINUTES); return captcha.getImageBase64(); } public boolean verifyCaptcha (String key, String code) { String redisKey = RedisKeyConstants.CAPTCHA_PREFIX + key; String storedCode = redisTemplate.opsForValue().get(redisKey); if (storedCode == null ) { log.warn("验证码不存在或已过期: key={}" , key); return false ; } if (!storedCode.equalsIgnoreCase(code)) { log.warn("验证码错误: key={}, expected={}, actual={}" , key, storedCode, code); return false ; } 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; @GetMapping("/generate") public Result<Map<String, String>> generate () { String key = IdUtil.fastSimpleUUID(); String imageBase64 = captchaService.generateCaptcha(key); Map<String, String> data = new HashMap <>(); data.put("key" , key); data.put("image" , "data:image/png;base64," + imageBase64); return Result.ok(data); } @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:生成验证码
方法 :GETURL :http://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:验证验证码
方法 :GETURL :http://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 <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: 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 邮箱授权码?
登录 QQ 邮箱 点击 “设置” → “账户” 找到 “POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV 服务” 开启 “POP3/SMTP 服务” 或 “IMAP/SMTP 服务” 点击 “生成授权码” 将授权码复制到配置文件中 其他邮箱配置 :
激活链接生成(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;@Component public class HmacUtil { @Value("${auth.hmac.secret:default_secret_key_change_in_production}") private String secret; public String sign (String data) { return SecureUtil.hmacSha256(secret).digestHex(data); } 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: 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; 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;@Slf4j @Component @RequiredArgsConstructor public class EmailEventListener { private final JavaMailSender mailSender; @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()); 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;@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; public void sendActivationEmail (String email, Long userId) { String activationUrl = generateActivationUrl(userId); String content = buildActivationEmailContent(activationUrl); EmailEvent event = new EmailEvent (this , email, "账号激活" , content); eventPublisher.publishEvent(event); log.info("激活邮件事件已发布: email={}, userId={}" , email, userId); } private String generateActivationUrl (Long userId) { long timestamp = System.currentTimeMillis() + 24 * 60 * 60 * 1000 ; String data = userId + ":" + timestamp; String sign = hmacUtil.sign(data); return String.format("http://localhost:%s/auth/activate?userId=%d×tamp=%d&sign=%s" , serverPort, userId, timestamp, sign); } 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); } public void sendResetPasswordEmail (String email, Long userId) { String resetUrl = generateResetPasswordUrl(userId); String content = buildResetPasswordEmailContent(resetUrl); EmailEvent event = new EmailEvent (this , email, "重置密码" , content); eventPublisher.publishEvent(event); log.info("重置密码邮件事件已发布: email={}, userId={}" , email, userId); } private String generateResetPasswordUrl (Long userId) { long timestamp = System.currentTimeMillis() + 60 * 60 * 1000 ; String data = userId + ":" + timestamp; String sign = hmacUtil.sign(data); return String.format("http://localhost:%s/auth/reset-password?userId=%d×tamp=%d&sign=%s" , serverPort, userId, timestamp, sign); } 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 { @NotBlank(message = "用户名不能为空") @Pattern(regexp = "^[a-zA-Z0-9_]{4,20}$", message = "用户名格式不正确(4-20位,只能包含字母、数字、下划线)") private String username; @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; @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; @Transactional(rollbackFor = Exception.class) public Long register (RegisterRequest request) { log.info("开始注册用户: username={}, email={}" , request.getUsername(), request.getEmail()); if (!captchaService.verifyCaptcha(request.getCaptchaKey(), request.getCaptchaCode())) { throw new RuntimeException ("验证码错误或已过期" ); } Auth existingAuth = authMapper.selectOne(new LambdaQueryWrapper <Auth>() .eq(Auth::getIdentityType, AuthType.PASSWORD.name()) .eq(Auth::getIdentifier, request.getUsername())); if (existingAuth != null ) { throw new RuntimeException ("用户名已存在" ); } Auth existingEmail = authMapper.selectOne(new LambdaQueryWrapper <Auth>() .eq(Auth::getIdentityType, AuthType.EMAIL.name()) .eq(Auth::getIdentifier, request.getEmail())); if (existingEmail != null ) { throw new RuntimeException ("邮箱已被注册" ); } User user = new User (); user.setNickname(request.getUsername()); user.setAvatar("https://cdn.example.com/default-avatar.png" ); user.setStatus(2 ); userMapper.insert(user); 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 ("用户名已存在" ); } 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 ("邮箱已被注册" ); } emailService.sendActivationEmail(request.getEmail(), user.getId()); log.info("用户注册成功: userId={}, username={}" , user.getId(), request.getUsername()); return user.getId(); } @Transactional(rollbackFor = Exception.class) public void activateAccount (Long userId, Long timestamp, String sign) { log.info("开始激活账号: userId={}" , userId); String data = userId + ":" + timestamp; if (System.currentTimeMillis() > timestamp) { throw new RuntimeException ("激活链接已过期" ); } User user = userMapper.selectById(userId); if (user == null ) { throw new RuntimeException ("用户不存在" ); } if (user.getStatus() == 1 ) { throw new RuntimeException ("账号已激活,无需重复激活" ); } user.setStatus(1 ); userMapper.updateById(user); authMapper.update(null , new LambdaQueryWrapper <Auth>() .eq(Auth::getUserId, userId) .set(Auth::getVerified, 1 )); log.info("账号激活成功: userId={}" , userId); } 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 @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); 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()); } } @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:生成验证码
方法 :GETURL :http://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:注册用户
方法 :POSTURL :http://localhost:8080/auth/registerBody :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:激活账号
方法 :GETURL :http://localhost:8080/auth/activate?userId=1748392847362×tamp=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;@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("开始执行账号密码登录" ); PasswordAuthRequest passwordRequest = (PasswordAuthRequest) request; String username = passwordRequest.getUsername(); String password = passwordRequest.getPassword(); log.info("账号密码登录: username={}" , username); Auth auth = authMapper.selectOne(new LambdaQueryWrapper <Auth>() .eq(Auth::getIdentityType, AuthType.PASSWORD.name()) .eq(Auth::getIdentifier, username)); if (auth == null ) { log.warn("用户不存在: username={}" , username); throw new RuntimeException ("用户名或密码错误" ); } if (!passwordEncoder.matches(password, auth.getCredential())) { log.warn("密码错误: username={}" , username); throw new RuntimeException ("用户名或密码错误" ); } User user = userMapper.selectById(auth.getUserId()); if (user == null ) { log.error("用户主体不存在: userId={}" , auth.getUserId()); throw new RuntimeException ("用户数据异常" ); } if (user.getStatus() == 0 ) { throw new RuntimeException ("账号已被禁用,请联系管理员" ); } if (user.getStatus() == 2 ) { throw new RuntimeException ("账号未激活,请先激活邮箱" ); } 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:测试登录(账号未激活)
方法 :POSTURL :http://localhost:8080/auth/loginBody :1 2 3 4 5 { "authType" : "PASSWORD" , "username" : "testuser" , "password" : "Test1234" }
响应示例 :
1 2 3 4 5 { "code" : 400 , "message" : "账号未激活,请先激活邮箱" , "data" : null }
步骤 2:激活账号
点击邮件中的激活链接,或者调用激活接口。
步骤 3:测试登录(账号已激活)
方法 :POSTURL :http://localhost:8080/auth/loginBody :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:测试登录(密码错误)
方法 :POSTURL :http://localhost:8080/auth/loginBody :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; @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 { private Long userId; private Long timestamp; private String sign; @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 public void forgotPassword (ForgotPasswordRequest request) { log.info("开始找回密码: email={}" , request.getEmail()); if (!captchaService.verifyCaptcha(request.getCaptchaKey(), request.getCaptchaCode())) { throw new RuntimeException ("验证码错误或已过期" ); } Auth emailAuth = authMapper.selectOne(new LambdaQueryWrapper <Auth>() .eq(Auth::getIdentityType, AuthType.EMAIL.name()) .eq(Auth::getIdentifier, request.getEmail())); if (emailAuth == null ) { log.warn("邮箱不存在: email={}" , request.getEmail()); return ; } emailService.sendResetPasswordEmail(request.getEmail(), emailAuth.getUserId()); log.info("重置密码邮件已发送: email={}, userId={}" , request.getEmail(), emailAuth.getUserId()); } @Transactional(rollbackFor = Exception.class) public void resetPassword (ResetPasswordRequest request) { log.info("开始重置密码: userId={}" , request.getUserId()); if (System.currentTimeMillis() > request.getTimestamp()) { throw new RuntimeException ("重置链接已过期" ); } User user = userMapper.selectById(request.getUserId()); if (user == null ) { throw new RuntimeException ("用户不存在" ); } Auth passwordAuth = authMapper.selectOne(new LambdaQueryWrapper <Auth>() .eq(Auth::getUserId, request.getUserId()) .eq(Auth::getIdentityType, AuthType.PASSWORD.name())); if (passwordAuth == null ) { throw new RuntimeException ("该账号未设置密码" ); } 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 @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()); } } @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:生成验证码
方法 :GETURL :http://localhost:8080/captcha/generate步骤 2:找回密码(发送重置密码邮件)
方法 :POSTURL :http://localhost:8080/auth/forgot-passwordBody :1 2 3 4 5 { "email" : "test@example.com" , "captchaKey" : "a1b2c3d4e5f6g7h8" , "captchaCode" : "ABCD" }
响应示例 :
1 2 3 4 5 { "code" : 200 , "message" : "操作成功" , "data" : "重置密码邮件已发送,请查收邮件" }
步骤 3:查收重置密码邮件
登录邮箱,查看重置密码邮件,点击重置密码链接。
步骤 4:重置密码
方法 :POSTURL :http://localhost:8080/auth/reset-passwordBody :1 2 3 4 5 6 { "userId" : 1748392847362 , "timestamp" : 1735372800000 , "sign" : "a1b2c3d4e5f6g7h8" , "newPassword" : "NewPass1234" }
响应示例 :
1 2 3 4 5 { "code" : 200 , "message" : "操作成功" , "data" : "密码重置成功,请使用新密码登录" }
步骤 5:使用新密码登录
方法 :POSTURL :http://localhost:8080/auth/loginBody :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 : 调用
方法速查汇总 用户服务
类名 方法 作用 参数 返回值 UserService register用户注册 RegisterRequest Long (userId) UserService activateAccount激活账号 userId, timestamp, sign void UserService forgotPassword找回密码 ForgotPasswordRequest void UserService resetPassword重置密码 ResetPasswordRequest void
验证码服务
类名 方法 作用 参数 返回值 CaptchaService generateCaptcha生成验证码 key String (Base64) CaptchaService verifyCaptcha验证验证码 key, code boolean
邮件服务
类名 方法 作用 参数 返回值 EmailService sendActivationEmail发送激活邮件 email, userId void EmailService sendResetPasswordEmail发送重置密码邮件 email, userId void
密码加密
类名 方法 作用 参数 返回值 PasswordEncoder encode加密密码 rawPassword String (BCrypt 哈希) PasswordEncoder matches验证密码 rawPassword, encodedPassword boolean
HMAC 签名
类名 方法 作用 参数 返回值 HmacUtil sign生成签名 data String HmacUtil verify验证签名 data, sign boolean
接口速查汇总 接口 方法 作用 请求参数 响应 /captcha/generateGET 生成验证码 无 /auth/registerPOST 用户注册 RegisterRequest /auth/activateGET 激活账号 userId, timestamp, sign message /auth/loginPOST 账号密码登录 PasswordAuthRequest AuthToken /auth/forgot-passwordPOST 找回密码 ForgotPasswordRequest message /auth/reset-passwordPOST 重置密码 ResetPasswordRequest message
核心避坑指南 陷阱一:密码使用 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: 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 const captchaResponse = await axios.get ('/captcha/generate' );const captchaKey = captchaResponse.data .data .key ;const captchaImage = captchaResponse.data .data .image ;document .getElementById ('captcha-img' ).src = captchaImage;const registerResponse = await axios.post ('/auth/register' , { username : 'testuser' , password : 'Test1234' , email : 'test@example.com' , captchaKey : captchaKey, captchaCode : '用户输入的验证码' }); alert (registerResponse.data .data .message );
登录流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const loginResponse = await axios.post ('/auth/login' , { authType : 'PASSWORD' , username : 'testuser' , password : 'Test1234' }); const accessToken = loginResponse.data .data .accessToken ;const refreshToken = loginResponse.data .data .refreshToken ;localStorage .setItem ('accessToken' , accessToken);localStorage .setItem ('refreshToken' , refreshToken);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 const captchaResponse = await axios.get ('/captcha/generate' );const captchaKey = captchaResponse.data .data .key ;const captchaImage = captchaResponse.data .data .image ;document .getElementById ('captcha-img' ).src = captchaImage;const forgotResponse = await axios.post ('/auth/forgot-password' , { email : 'test@example.com' , captchaKey : captchaKey, captchaCode : '用户输入的验证码' }); alert (forgotResponse.data .data );const resetResponse = await axios.post ('/auth/reset-password' , { userId : urlParams.get ('userId' ), timestamp : urlParams.get ('timestamp' ), sign : urlParams.get ('sign' ), newPassword : 'NewPass1234' }); alert (resetResponse.data .data );
测试用例速查 注册测试
测试场景 请求参数 预期结果 正常注册 username = testuser, password = Test1234, email = test@example.com , 验证码正确 注册成功,发送激活邮件 用户名为空 username = 空, password = Test1234, email = test@example.com 400 错误:用户名不能为空 密码格式错误 username = testuser, password = 123456, email = test@example.com 400 错误:密码格式不正确 邮箱格式错误 username = testuser, password = Test1234, email = invalid 400 错误:邮箱格式不正确 验证码错误 username = testuser, password = Test1234, email = test@example.com , 验证码错误 400 错误:验证码错误或已过期 用户名已存在 username = testuser(已存在), password = Test1234, email = new@example.com 400 错误:用户名已存在 邮箱已存在 username = newuser, password = Test1234, email = test@example.com (已存在) 400 错误:邮箱已被注册
登录测试
测试场景 请求参数 预期结果 正常登录 username = testuser, password = Test1234(账号已激活) 登录成功,返回双令牌 账号未激活 username = testuser, password = Test1234(账号未激活) 400 错误:账号未激活,请先激活邮箱 用户名不存在 username = notexist, password = Test1234 400 错误:用户名或密码错误 密码错误 username = testuser, password = WrongPass 400 错误:用户名或密码错误 账号被禁用 username = testuser, password = Test1234(账号被禁用) 400 错误:账号已被禁用,请联系管理员
找回密码测试
重置密码测试
测试场景 请求参数 预期结果 正常重置密码 userId = xxx, timestamp = 未过期, sign = 正确, newPassword = NewPass1234 密码重置成功 链接已过期 userId = xxx, timestamp = 已过期, sign = 正确, newPassword = NewPass1234 400 错误:重置链接已过期 签名错误 userId = xxx, timestamp = 未过期, sign = 错误, newPassword = NewPass1234 400 错误:重置链接无效 新密码格式错误 userId = xxx, timestamp = 未过期, sign = 正确, newPassword = 123456 400 错误:密码格式不正确
性能优化建议 优化一:验证码生成优化
问题 :每次生成验证码都需要创建新的 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 { @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) 验证码生成与校验 手机号登录策略 手机号绑定功能 这套账号密码登录体系将成为整个认证系统的基石,后续的所有登录方式都将基于这个体系实现。