多来买:登录与 Redis 会话实战
多来买:登录与 Redis 会话实战
核心问题:用户名密码怎样建立 Redis 有状态会话,Token、TTL 与退出语义分别是什么
事实来源:user-service、user-api、common-util 当前源码
证据口径:代码已确认;未构建、未启动、未连接数据库或 Redis
本文只聚焦会话建立和生命周期。Gateway 如何验证并向下游透传身份,见同专题的《Gateway 鉴权与身份透传实战》;可信边界与演进建议,见《鉴权安全边界与演进》。
当前实现可以概括为:
name + passwd -> MD5(passwd) -> 查询 user_info -> 雪花 ID 生成 Token -> Redis 保存 userId + 登录 IP -> 固定 TTL 2 小时 -> logout 删除当前 Token Key1. 为什么它是 Redis Session,而不是 JWT
1.1 当前 Token 本身包含什么
当前 Token 来自雪花 ID:
Long snowflakeId = hutoolIdUtil.getSnowflakeId();String token = String.valueOf(snowflakeId);代码出处:
- 路径:duolaimall-user/user-service/src/main/java/com/cskaoyan/mall/user/controller/UserController.java
- 类与方法:UserController#login
这个字符串没有三段式 JWT 结构,没有 Header、Payload、Signature,也没有由 Gateway 在本地验签。
Gateway 必须用 Token 拼出 Redis Key,并读取服务端状态后才能知道用户是谁。
1.2 Redis Session 与 JWT 的判断标准
| 判断维度 | 当前多来买实现 | 自包含 JWT 的常见实现 |
|---|---|---|
| 用户身份放在哪里 | Redis value | JWT Claims |
| Gateway 是否每次查 Redis | 是 | 基础验签通常不需要 |
| 是否有签名 | 当前没有 | 有 |
| 主动退出 | 删除 Redis Key 即可 | 通常需要黑名单或额外会话状态 |
| Token 被删除后是否立即失效 | 是 | 单纯 JWT 通常不会 |
| Redis 故障影响 | 无法读取会话 | 只做本地验签时影响较小 |
| 服务端是否有状态 | 有 | 可以接近无状态 |
| 当前 Token 是否适合携带敏感数据 | 不携带数据,只做索引 | Payload 可解码,也不应放敏感数据 |
所以面试时应准确表述为:
项目采用 Redis 有状态会话。Token 是雪花 ID 字符串,Gateway 使用它查询 Redis 中的 userId 与登录 IP,而不是解析 JWT。
2. 接口契约
2.1 服务内路径与网关外部路径
当前 Controller 明确声明的服务内路径是:
| 动作 | HTTP 方法 | user-service Controller 路径 |
|---|---|---|
| 登录 | POST | /user/login |
| 退出 | GET | /user/logout |
仓库内只看到 Gateway 从 Nacos 导入 server-gateway-dev.yaml 和 common.yaml,没有看到这些外部配置的实际内容。
因此,下面两个结论必须分开:
- 代码已确认:用户服务 Controller 匹配 /user/login 与 /user/logout。
- 未验证:客户端经过 Gateway 时最终使用的外部路径、路由前缀和 StripPrefix 规则。
本文的 HTTP 示例使用 Controller 路径表达接口结构,不把未读取到的 Gateway 路由写成既定事实。
2.2 登录请求体
请求对象是 UserInfoParam。
源码节选:
- 路径:duolaimall-user/user-service/src/main/java/com/cskaoyan/mall/user/query/UserInfoParam.java
- 类:UserInfoParam
@Datapublic class UserInfoParam {
private Long id; private String loginName; private String nickName; private String passwd; private String name; private String phoneNum; private String email; private String headImg; private String userLevel;}登录实现实际只读取两个字段:
- name:作为用户名查询条件。
- passwd:先执行 MD5,再作为密码摘要查询条件。
其余字段即使出现在请求体中,也没有被 UserServiceImpl#login 使用。
这说明当前 DTO 是一个复用范围较宽的用户参数对象,而不是只包含登录字段的专用 DTO。
2.3 登录分支真值表
| 请求体或数据库情况 | 当前代码路径 | 结果 |
|---|---|---|
| body 为 null | Service 读取 userInfoParam.getName | 可能抛出空指针,由全局异常处理接管 |
| passwd 为 null | 调用 getBytes | 可能抛出空指针 |
| name 为空字符串、passwd 有值 | 仍计算 MD5 并按 name + passwd 查询 | 通常查不到并返回登录失败,实际数据未验证 |
| name 有值、passwd 为空字符串 | 计算空字符串的 MD5 并查询 | 通常查不到并返回登录失败,实际数据未验证 |
| name 或密码错误 | selectOne 返回 null | 返回 USER_LOGIN_CHECK_FAIL |
| name 与 MD5(passwd) 匹配唯一用户 | selectOne 返回 UserInfo | 写 Redis 会话并返回 UserLoginDTO |
| 查询结果超过一条 | selectOne 期待单条 | 可能抛出多结果异常 |
| Redis 写入异常 | bucket.set 未完成 | 异常向上抛出,不会返回成功 DTO |
这里没有 Bean Validation、@Valid 或显式的空值校验。
“body 为 null”和“passwd 为 null”的结果是根据当前方法直接解引用得出的候选风险,本轮没有发送请求复现。
2.4 退出契约
当前退出接口:
- 使用 GET /user/logout。
- 只从 Header 读取 token。
- 用 token 拼出 Redis Key。
- 删除对应 RBucket。
- 无论 delete 返回什么,都返回 Result.ok。
它不会读取 Cookie 中的 token,这一点与 Gateway 的 Token 提取策略不对称。
3. 端到端时序
3.1 登录并建立 Redis 会话
这个时序中有一个容易忽略的事实:
Token 在查询数据库之前就已经生成。登录失败会消耗一个雪花 ID,但不会写入 Redis。
4. 用户表与查询模型
登录查询使用 UserInfo 映射的 user_info 表,当前条件字段是 name 与 passwd。实体还继承了主键、创建时间、更新时间和逻辑删除字段;仓库没有数据库迁移脚本,因此用户名唯一约束属于未验证信息。
4.1 查询等价语义
UserInfoMapper 继承 MyBatis-Plus BaseMapper,没有自定义登录 SQL。
当前 Wrapper:
LambdaQueryWrapper<UserInfo> wrapper = new LambdaQueryWrapper<>();wrapper.eq(UserInfo::getName, userName);wrapper.eq(UserInfo::getPasswd, password);UserInfo userInfo = userInfoMapper.selectOne(wrapper);从业务语义看,它等价于:
SELECT <UserInfo 映射字段>FROM user_infoWHERE name = ? AND passwd = ? AND <MyBatis-Plus 逻辑删除条件>;
随后由 selectOne 要求映射结果最多一条。这里是查询语义伪代码,不是仓库中的 Mapper XML,也不假定具体数据库方言会自动追加 LIMIT。
selectOne 的契约意味着“相同 name + passwd 最多匹配一条记录”。仓库没有数据库迁移脚本,因此本轮不能确认 name 是否有唯一索引。
4.2 为什么同时按用户名和摘要查询
当前实现不是:
- 先按用户名查用户;
- 再在 Java 中比较密码。
而是把用户名和密码摘要都放进 SQL 条件。
这种写法的直接结果是:
- 用户不存在和密码错误都得到 null。
- Java 层不会拿到错误账号的 UserInfo。
- 无法在当前方法里进一步判断账号状态或失败次数。
- 如果以后更换 BCrypt 这类带随机盐的慢哈希,通常要改成先查用户再调用 passwordEncoder.matches。
5. Controller:HTTP 适配、IP 与 Token
5.1 登录 Controller 的真实代码
源码节选:
- 路径:duolaimall-user/user-service/src/main/java/com/cskaoyan/mall/user/controller/UserController.java
- 类与方法:UserController#login
@PostMapping("/user/login")public Result login(@RequestBody UserInfoParam userInfo, HttpServletRequest request) {
String ip = IpUtil.getIpAddress(request);
Long snowflakeId = hutoolIdUtil.getSnowflakeId(); String token = String.valueOf(snowflakeId);
UserLoginDTO userLoginDTO = userService.login(userInfo, ip, token);
if (userLoginDTO == null) { return Result.build( "登录失败", UserCodeEnum.USER_LOGIN_CHECK_FAIL ); }
return Result.ok(userLoginDTO);}Controller 依次做了四件事:
- 从 Servlet 请求提取 IP。
- 通过 HutoolIdUtil 生成雪花 ID。
- 把请求参数、IP 和 Token 一起交给 Service。
- 把 null 转成登录失败,否则返回统一成功响应。
密码校验和 Redis 写入没有放进 Controller,职责划分是清晰的。
5.2 雪花 ID 从哪里来
源码节选:
- 路径:duolaimall-common/common-util/src/main/java/com/cskaoyan/mall/common/util/HutoolIdUtil.java
- 类:HutoolIdUtil
@Component@ConfigurationProperties(prefix = "hutool.snowflake")public class HutoolIdUtil {
private long workerId; private long datacenterId; private Snowflake snowflake;
@PostConstruct private void init() { snowflake = new Snowflake(workerId, datacenterId); }
public Long getSnowflakeId() { return snowflake.nextId(); }}外部配置负责提供 workerId 和 datacenterId 的语义值。
本文不记录具体配置值。
雪花 ID 的优点是生成快、通常唯一、无需访问数据库;但“唯一 ID”不等于“安全令牌”:
- ID 有时间和节点结构。
- 它的目标是唯一性,不是不可预测性。
- 如果多个实例错误地复用相同 workerId、datacenterId,可能破坏唯一性假设。
- 如果 Token 碰撞,同一个 Redis Key 会被后一次登录覆盖。
当前没有运行或多实例配置证据,因此这些属于设计边界,不表示已发生碰撞。
6. Service:校验账号密码
6.1 Service 接口
UserService 对外暴露:
UserLoginDTO login( UserInfoParam userInfo, String ip, String token);
void logout(HttpServletRequest request);Token 由 Controller 生成后传入,Service 不负责生成 Token。
6.2 密码摘要与数据库查询
源码节选:
- 路径:duolaimall-user/user-service/src/main/java/com/cskaoyan/mall/user/service/impl/UserServiceImpl.java
- 类与方法:UserServiceImpl#login
String userName = userInfoParam.getName();
String password = DigestUtils.md5DigestAsHex( userInfoParam.getPasswd().getBytes());
LambdaQueryWrapper<UserInfo> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(UserInfo::getName, userName);lambdaQueryWrapper.eq(UserInfo::getPasswd, password);
UserInfo userInfo = userInfoMapper.selectOne(lambdaQueryWrapper);
if (userInfo == null) { return null;}当前摘要公式可以写成:
database.passwd == MD5(request.passwd bytes)代码中没有:
- 独立随机盐;
- BCrypt、Argon2 或 PBKDF2;
- 账号状态检查;
- 登录失败次数;
- 验证码;
- 登录限流;
- 多因素认证。
另外,getBytes 没有显式指定字符集。对于普通 ASCII 密码通常结果一致;对非 ASCII 输入,摘要可能受 JVM 默认字符集影响。
6.3 为什么无盐 MD5 不适合生产密码
MD5 的问题不只是存在碰撞,更重要的是它计算非常快。
对于密码存储,计算快意味着攻击者获得摘要后可以高速尝试大量候选密码。
生产系统通常选择:
- BCrypt;
- Argon2id;
- PBKDF2;
- 配合每个用户独立随机盐和合理成本参数。
同时应配合 HTTPS、登录限流、账号锁定策略、安全审计和敏感日志控制。
这些是安全演进建议,不是当前项目已经实现的能力。
7. Service:建立 Redis 会话
7.1 Redis 中保存的 DTO
源码:
- 路径:duolaimall-user/user-api/src/main/java/com/cskaoyan/mall/user/dto/UserLoginInfoDTO.java
- 类:UserLoginInfoDTO
@Data@NoArgsConstructor@AllArgsConstructorpublic class UserLoginInfoDTO {
String userId; String ip;}Redis 中不保存完整 UserInfo。
这避免了每次鉴权都携带用户昵称、手机号、密码摘要等无关字段,也降低了会话对象与用户表字段变化的耦合。
7.2 Key 规则
源码:
- 路径:duolaimall-user/user-api/src/main/java/com/cskaoyan/mall/user/consts/UserConstants.java
- 类:UserConstants
public static final String USER_LOGIN_KEY_PREFIX = "user:login:";
public static final int USERKEY_TIMEOUT = 60 * 60 * 24 * 7;
public static final String USER_LOGIN_TOKEN_HEADER = "token";真实登录代码只使用 USER_LOGIN_KEY_PREFIX。
Key 结构:
user:login:{token}概念示例:
key = user:login:<雪花 ID 字符串>value = { "userId": "<数据库用户 ID>", "ip": "<登录时提取的 IP>"}ttl = 2 hours示例使用占位符,不包含真实 Token、用户 ID 或网络地址。
7.3 写入会话的真实代码
源码节选:
- 路径:duolaimall-user/user-service/src/main/java/com/cskaoyan/mall/user/service/impl/UserServiceImpl.java
- 类与方法:UserServiceImpl#login
UserLoginInfoDTO userLoginInfoDTO = new UserLoginInfoDTO();
userLoginInfoDTO.setUserId( userInfo.getId().toString());userLoginInfoDTO.setIp(ip);
String key = UserConstants.USER_LOGIN_KEY_PREFIX + token;
RBucket<Object> bucket = redissonClient.getBucket(key);
bucket.set( userLoginInfoDTO, 2, TimeUnit.HOURS);这段代码同时表达了三件事:
- Redis Key 由固定前缀和 Token 拼接。
- Value 只含 userId 与 IP。
- 实际 TTL 是两小时。
USERKEY_TIMEOUT 的七天常量没有传给 bucket.set,因此不能把当前登录有效期写成七天。
7.4 序列化方式
common-util 的 RedissonConfig 使用 JsonJacksonCodec。
这意味着会话 DTO 以 JSON 语义序列化,而不是 Java 原生序列化。
当前写入端声明 RBucket
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!