2.1 基于传统Session实现的短信登录及其校验
2.1.1 基于Session登录校验的流程设计
2.1.2 实现短信验证码发送功能
请求接口 | /user/code |
请求类型 | post |
请求参数 | phone |
返回值 | 无 |
/**
* 发送手机验证码
*/
@PostMapping("/code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
log.info("发送验证码, 手机号:{}", phone);
return userService.sendCode(phone, session);
}
/**
* 发送验证码
* @param phone
* @param session
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
// 1. 校验手机号码
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号码格式错误!");
}
// 2. 生成验证码
String code = RandomUtil.randomNumbers(6);
// 3. 将验证码保存到Session中
session.setAttribute("code", code);
//TODO 4. 调用阿里云 将短信信息发送到指定手机
log.info("发送短信验证码成功,验证码:{}", code);
return Result.ok();
}
2.1.3 实现登录、注册功能
请求接口 | /user/login |
请求类型 | post |
请求参数 | LoginForm---> phone,code,[password] |
返回值 | 无 |
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
log.info("用户登录, 参数:{}", loginForm);
return userService.login(loginForm, session);
}
/**
* 登录功能
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号码格式错误!");
}
// 2. 校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if(cacheCode==null || !cacheCode.toString().equals(code)){
return Result.fail("验证码错误!");
}
// 3. 根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
// if 0 :创建新用户,保存数据库,将用户信息存储到Session
//
if(user == null){
user = createUserWithPhone(phone);
}
//else: 登录成功,将用户信息存储到Session
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
/**
* 根据手机号创建用户
* @param phone
* @return
*/
private User createUserWithPhone(String phone) {
//创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
//保存用户
save(user);
// 返回
return user;
}
2.1.4 实现登录状态校验拦截器
由于日后项目功能会越来越多,需要登录才能进行访问的界面也会越来越多,我们必须想办法将登录状态校验抽离出来形成一个前置校验的条件,再放行到后续逻辑。
1. 封装TreadLocal工具类
将用户信息保存到 TreadLocal中 并封装TreadLocal工具类用于 保存用户、获取用户、移除用户
在 urils / UserHolder
/**
* TreadLocal工具类
*/
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
// 保存用户
public static void saveUser(UserDTO user){
tl.set(user);
}
// 获取ThreadLocal中的用户
public static UserDTO getUser(){
return tl.get();
}
// 清空ThreadLocal
public static void removeUser(){
tl.remove();
}
}
2. 创建登录拦截器
在 urils / LoginInterceptor
public class LoginInterceptor implements HandlerInterceptor {
/**
* 前置拦截器
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取session
HttpSession session = request.getSession();
// 2. 获取session中的用户
Object user = session.getAttribute("user");
// 3. 判断用户是否存在
if(user == null){
response.setStatus(401);
return false;
}
// 4. 如果存在,用户信息保存到 ThreadLocal 并放行
UserHolder.saveUser((UserDTO) user);
return true;
}
/**
* 后置拦截器(移除用户)
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
3. 添加配置,生效拦截器,并配置放行路径
在 config/ MvcConfig
@Configuration
public class MvcConfig implements WebMvcConfigurer {
/**
* 添加拦截器
* @param registry
*/
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/host",
"/shop/**",
"/shop-type/**",
"/voucher/**"
);
}
}
2.1.5 实现获取用户请求
前端点击我的,发送请求到后端,获取当前登录状态,方能进入个人中心
/**
* 获取当前登录的用户
* @return
*/
@GetMapping("/me")
public Result me(){
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
2.1.6 (附加)用户信息脱敏处理
为防止出现以下这种情况(将用户隐私信息暴露过多),我们采用UserDTO对象对用户信息脱敏处理:
@Data public class UserDTO { private Long id; private String nickName; private String icon; }
并借助拷贝工具 进行对象拷贝
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
2.2 传统Session在集群环境下的弊端
Session共享问题
多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。
解决策略
1. 让Session可以共享
Tomcat提供了Session拷贝功能,但是这会增加服务器的额外内存开销,并且带来数据一致性问题
2. 【推荐】使用Redis进行替代
数据共享、内存存储(快)、key-value结构
2.3 基于Redis实现短信登录功能
2.3.1 基于Redis实现短信登录流程设计
对于验证码,使用 手机号码作为KEY,确保了正确的手机对应着正确的短信验证码。
对于用户信息唯一标识,使用 UUID生成的Token作为 KEY,而不使用手机号码,从而提高了用户数据安全性。
2.3.2 修改发送短信验证码功能
只需要在Session的基础上,将第三步保存到Redis中
格式:
key | value | TTL |
login:code:[手机号码] | [验证码] | 120S |
// // 3. 将验证码保存到Session中
// session.setAttribute("code", code);
// 3. 将验证码保存到Redis中
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
2.3.3 修改登录、注册功能
1. 手机号校验
2. 从Redis中取出验证码进行校验
3.查询用户信息
4. 将用户信息存储到Redis ---> 需要以Hash结构进行存储 ----> 需要将user对象转成 Map对象
5. 将token返回给客户端 ,充当Session的标识作用
/**
* 登录功能
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号
String phone = loginForm.getPhone();
if(RegexUtils.isPhoneInvalid(phone)){
return Result.fail("手机号码格式错误!");
}
// 2. 校验验证码 REDIS
// Object cacheCode = session.getAttribute("code");
// 2.1 从Redis中获取验证码
String redisCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
// 2.2 校验验证码
String code = loginForm.getCode();
if(redisCode==null || !redisCode.equals(code)){
return Result.fail("验证码错误!");
}
// 3. 根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
// if 0 :创建新用户,保存数据库,将用户信息存储到Session
if(user == null){
user = createUserWithPhone(phone);
}
// //else: 登录成功,将用户信息存储到Session
// session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
// 4. 将用户信息存储到Redis中
// 1. 随机生成token,作为登录令牌 ---> UUID导入工具包中的方法,不要导入java自带的
String token = UUID.randomUUID().toString(true);
// 2. 以hash结构进行存储
UserDTO userDTO = BeanUtil.copyProperties(user,UserDTO.class);
//TODO 这里报错了,因为UserDTO中有个id属性,不是字符串,在Redis序列化下报错
// Map<String,Object> userMap = BeanUtil.beanToMap(userDTO);
Map<String,Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName,fieldValue)-> fieldValue.toString()));
// 3. 存储到Redis中
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token,userMap);
// 给token设置有效期
// 超过30分钟不访问任何界面就会剔除,所以还需要设置在访问过程中不断更新token的有效期
// 实现方式: 在登录拦截器中进行处理
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 5. 返回token到客户端,客户端保存到浏览器中
return Result.ok(token);
}
2.3.4 添加刷新Token拦截器逻辑 (只做判断,不做拦截)
首先,由于需要在自定义的拦截器中使用StringRedisTemplate对象,由于不是交由spring管理的,所以我们需要自己写构造函数进行导入。同时在MvcConfig中直接交给Spring管理
其次,这里选择了新建一个专门负责刷新Token的“拦截器”,只做判断不做拦截。确保请求在经过登录校验拦截器之前,会统一先被该“拦截器”获取,并对Token进行判断,如果没有Token,则会被接下来的登录拦截器进行拦截
package com.hmdp.config;
import com.hmdp.Interceptor.LoginInterceptor;
import com.hmdp.Interceptor.RefreshTokenInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 添加拦截器
* @param registry
*/
public void addInterceptors(InterceptorRegistry registry) {
// 刷新token拦截器 全部拦截 只做判断
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**");
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/host",
"/shop/**",
"/shop-type/**",
"/voucher/**"
);
}
}
package com.hmdp.Interceptor;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_TTL;
/**
* Token缓存刷新拦截器 只会放行不会拦截
*/
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
/**
* 手动创建的对象,需要手动注入,所以需要构造方法
* @param stringRedisTemplate
*/
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// 2.基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
2.3.5 (补充)退出登录功能实现
前端点击退出登录时发送logout请求,我们只需要将TreadLocal的用户对象给清除掉,这样一来前端的请求就获取不到用户信息,强制被拦截到登录界面了
/**
* 登出功能
* @return 无
*/
@PostMapping("/logout")
public Result logout(){
// 清除用户登录状态
UserHolder.removeUser();
return Result.ok();
}
2.3.6 (补充)查看用户首页功能实现
点击用户头像,可以进入用户的首页
/**
* 根据id查询用户
* @param userId
* @return
*/
@GetMapping("/{id}")
public Result getUserById(@PathVariable("id") Long userId){
User user = userService.getById(userId);
if(user == null){
return Result.ok();
}
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
return Result.ok(userDTO);
}
2.3.7【故障排查】关于一登陆后前端立马闪退回登录界面的问题
在跟着视频练习的过程中,在所以代码开发完成后,我发现了每当自己点击登录校验成功后,前端又会重复的闪退回登录界面。对此我进行了以下排除手段:
1. 检查Redis是否 将 短信验证码及其用户token信息保存成功,有则证明这两个大环节没有问题
2. 猜测是拦截器问题:
一开始,我们模仿老师将刷新Token的逻辑一并写到了登录校验拦截器上。但是!登录校验拦截器在开发的过程中,有一些需要Token【或是说需要校验TreadLocal对象】的接口并没有被拦截器拦截下来,导致前端认为该用户操作并未携带Token【没被存储到TreadLocal中】,从而误判为未登录状态,从而剔除该用户,强制跳转到登录界面。
为此,秉承单一职责原则,对于Token,我们需要新建一个将所有界面都“拦截”的拦截器,这样子可以保证在进入后续拦截器的请求,不会再有被误判的情况出现。
而且,也确保了不管用户进行了什么操作,Token都能刷新时长
(该故障经过拆分拦截器已正常解决,但是题主并没有深刻去揪到底是拿一些请求被误判了,这个故障原因也是我分析,如有高见请分享一下)
2.4 (TODO) 基于阿里云完善验证码功能
TODO