项目流程
- 客户端 向 NGINX 请求静态页面
- NGINX 向 tomcat 服务端请求数据
- 数据库集群将数据返回给 tomcat
- comcat 将数据返回给 NGINX
- NGINX 将数据返回给客户端的页面
相关工具类
DTO
- 功能:DataTransferObject,隐藏用户的敏感信息,将含有用户敏感信息的User对象转化成没有敏感信息的UserDto对象
- 实现:BeanUtils.copyProperties( user, UserDTP.class)
- 注:此方法不要求源对象和目标类之间有继承或接口实现关系,只要属性名相同且类型兼容即可
HttpSession
-
功能:在服务器端管理用户会话,用于在多个 HTTP 请求之间保存用户状态信息
-
常用方法
方法 功能 1. setAttribute(”attributeName”, attributeValue); 设置(添加)属性 2. getAttribute(”attributeName”, attributeValue); 获取属性 3. removeAttribute(“attributeName”); 移除属性 4. String getId() 获取会话 ID 5. setMaxInactiveInterval(30 * 60) 设置最大不活动间隔 6. invalidate() 使会话无效
UserHolder
- 功能:通过 ThreadLocal 维持一个用户线程
-
实现:通过 java.lang 的 ThreadLocal 实现
public class UserHolder { private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>(); public static void saveUser(UserDTO user){ tl.set(user); } public static UserDTO getUser(){ return tl.get(); } public static void removeUser(){ tl.remove(); } }
登录功能 (MySQL)
- 功能:接收客户端请求,验证用户手机号和验证码,返回登录结果
发送短信验证码
UserController
- 创建函数:sendCode(@RequestParam(”phone”) String phone, HttpSession session)
- 调用 Service 的服务:userService.sendCode(phone, session);
- 返回 Service 的结果
UserService
- 创建方法:Result getCode(String phone, HttpSession session)
UserServiceImpl
- 实现方法:Result getCode(String phone, HttpSession session)
- 校验手机号格式 (错误则提前退出,返回 Result.fail(”格式错误!”)
- 生成验证码
- 验证码保存至 Session
- 发送验证码
- 返回 Result.ok()
短信验证码登录 & 注册
UserController
- 创建函数: login(@RequestBody LoginFormDTO loginForm, HttpSession session)
- 调用 Service 的服务:userService.login( loginForm, session);
- 返回 Service 的结果
UserService
- 创建函数 Result login( String code, HttpSession session )
UserServiceImpl
- 实现函数 Result login( LoginFormDTO loginForm, HttpSession session)
- 校验 logForm 中的手机号格式 (不符合则提前退出,返回 Result.fail(”手机号格式错误!”)
- 校验 session 中的验证码是否和用户填写的 code 相符合 (不符合则提前退出,返回 Result.fail(”验证码错误!”)
- 查询手机用户:User user = query().eq( “phone”, phone).one(); ( 注:query() 来自 mybatisPlus)
- 不存在则创建用户 user = createUserWithPhone(phone); ( 注:createUserWithPhone() 需要自己实现)
- 保存用户信息到 Session 中
- 返回 Result.ok()
校验登录状态
LoginInterceptor
- 实现 HandlerInterceptor 接口类
- 实现 preHandle( HttpServletRequest request, HttpServletResponse response, Object handler) 方法
- 获取 Session:HttpSession session = request.getSession();
- 校验 Session 中的用户 Object user = session.getAttribute(”user”);
- 用户不存在则给 Response 写入 401 状态码 ( response.setStatus(401) ),并返回 false
- 用户存在则将其添加到 ThreadHolder 中,其中 ThreadHolder 用 UserHolder 管理
- 实现 afterCompletion( HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) 方法
- 释放用户连接:UserHolder.removeUser()
MvcConfig
- 放置在 config 包目录下
- 注解为 @Configuration
- 实现 WebMvcConfigurer 接口
- 重写 addInterceptors( InterceptorRegistry registry) 方法
- 添加 Interceptor:registry.addInterceptor( new LoginInterceptor() ).excludePathPatterns( “/**”, …)
登录数据共享(Redis)
集群 Session 共享
问题引入
:多台 Tomcat 服务器不共享 session 存储空间,请求切换到不同 Tomcat 服务器会导致数据丢失目标
:通过共享 Redis 数据,解决 Session 在不同 Tomcat 服务器之间 Session 信息无法共享的问题途径
:使用 Redis 共享数据 (之前是在 Tomcat 服务器中存储 Session 信息,现在存储在 Redis 中)
重点
数据结构
- String:每个对象的键都会单独存储,多占用一点空间,如果要修改单个键的值则需要全部重新输入
- HashMap:每个对象只会存储数据本身,方便修改单个键的值
key 结构
:”prefix” + “randomString”- 唯一性:随机生成 UUID 作为 Redis 查询的 Key
- 安全性:手机号唯一但是存在隐私问题,故不采用
存储粒度
:传输不含隐私信息的 FormDTO(Data Transform Object)
校验登录状态 ( Session → Redis )
-
修改 login( LoginFormDTO loginForm, HttpSession, session)
-
🆕 保存用户登录信息
-
生成 token 作为登录令牌
String token = UUID.randomUUID().toString(true);
-
保护隐私信息 & 转换存储格式
:转换 User 类为 HashMap 存储,方便存入 RedisstringRedisTemplate 无法处理非 String 属性,需要自定义 CopyOptions 将 HashMap 中的 value 转为 String
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); Map<String, Object> userMap = BeanUtil.beanToMap( userDTO, new HashMap<>(), CopyOptions.create(). setIgnoreNullValue(true). setFieldValueEditor((fieldName, fieldValue) → fieldValue.toString()));
-
存储 key-value 到 Redis 中
String tokenKey = LOGIN_USER_KEY + token; stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
-
设置 token 有效期
:有效期内验证登录状态可以查询到 token,实现逻辑登录校验stringRedisTemplate.expire( tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
-
-
返回 token 给客户端 (不需要 prefix ),后续通过查询 Redis 中是否含有 token 来判断登录状态
return Result.ok(token);
问:为什么 tokenKey 要加上一个 LOGIN_USER_KEY 前缀?
答:因为 Redis 是公共存储空间,所有数据都加上前缀防止误用
问:为什么要在 util 包下创建 RredisConstants 类?
答:为了代码更加优雅,防止过多的硬编码
流程图
刷新登录状态
问题引入
:用户只访问不需要拦截的路径,那么拦截器不会生效,令牌刷新的动作不会执行目标
:用户所有操作都会刷新 Session 有效期途径
:添加全局拦截器,用户操作任何操作都刷新 Session 有效期
RefreshTokenInterceptor
-
创建类
:创建 RefreshTokenInterceptor 类,实现 HandlerInterceptor 接口 -
依赖注入
:注入 StringRedisTemplate 依赖 (用钩子函数创建)private StringRedisTemplate stringRedisTemplate; public LoginInterceptor(StringRedisTemplate stringRedisTemplate){ this.stringRedisTemplate = stringRedisTemplate; }
-
获取登录信息
:基于 token 获取 Redis 中的用户String token = request.getHeader("authorization"); if( StrUtil.isBlank(token) ) return true; // 如果不存在token则不需要刷新,直接放行
-
判断用户是否存在 (不存在则提前退出,返回 true)
String tokenKey = LOGIN_USER_KEY + token; Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key); if( userMap.isEmpty()) return true;
-
映射查询到的 hash 数据为 UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap( userMap, new UserDTO(), false);
-
保存用户信息到 ThreadLocal ( UserHolder )
UserHolder.saveUser(userDTO);
-
刷新 token 有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
-
返回 true;
LoginInterceptor
-
修改 LoginInterceptor 类,实现 HandlerInterceptor 接口
-
修改 preHndle 方法
-
判断 UserHolder 中是否有 User,没有则拦截,否则放行
@Override public boolean preHandle(HtttpServletRequest request, HttpServletResponse response, Object handler) throws Exception{ if( UserHolder.getUser() == null){ response.setStatus(401); return false; } return true; }
配置拦截器
-
修改 MvcConfig 类
-
注入 StringRedisTemplate 对象
@Resource private StringRedisTemplate stringRedisTemplate;
-
修改 addInterceptors 方法
-
添加 RefreshTokenInterceptor 拦截器到 registry 中
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)) .addPathPatterns("/**").order(0);