短信登录
导入项目
基于session实现登录
流程图

发送短信验证码
controller:

serviceimpl:
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
//2.不符合返回错误信息
return Result.fail("手机号错误");
}
//3.符合生成验证码
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到session
session.setAttribute("code",code);
//TODO 5.发送验证码
log.debug("发送成功,验证码:{}",code);
//6.返回ok
return Result.ok();
}
}

短信验证码登录与注册
controller:

impl:
为什么不需要返回一个登录凭证:
因为session原理就是cookie,每一个session都有一个唯一的id,会自动写进session。
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.检验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
//2.不符合返回错误信息
return Result.fail("手机号错误");}
//2.校验验证码
Object cachecode = session.getAttribute("code");
String code = loginForm.getCode();
//3.不一致报错
if (cachecode==null ||!cachecode.toString().equals(code)){
return Result.fail("验证码错误");}
//4.一致用手机号查询用户
User user = query().eq("phone", phone).one();
//5.没找到注册新用户
if (user==null){
//不存在则创建
user= createUserWithPhone(phone);
}
//6.保存信息到session
session.setAttribute("user",user);
return Result.ok();
}
private User createUserWithPhone(String phone) {
//创建用户
User user =new User();
user.setPhone(phone);
user.setNickName("user"+RandomUtil.randomString(10));
//保存
save(user);
return user;
}
}

登录校验拦截器
一个项目有很多controller,比如登录业务就是前端向usercontroller发送请求,在usecontroller中编写业务逻辑,但是随着业务开发,越来越多的业务都需要去校验用户的登录,不可能在每个都写一堆校验登录的业务逻辑。
为了节省,使用拦截器,所有请求都必须经过拦截器,让拦截器判断是否放行。
但是还是有一个问题,拦截器是可以帮助我们进行业务校验,但是后续业务有的时需要业务信息的,还需要一种方案将拦截器里的用户信息传递到controller中。安全问题用ThreadLocal解决。因为ThreadLocal是一个线程对象,每一个进入tomcat请求都是独立进程。


拦截器使用前置拦截和返回用户之前拦截。
前置拦截:
@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){
//4.不存在拦截,拦截就是返回false
response.setStatus(401);//这是给用户提示,报错401
return false;
}
//5.存在保存用户信息到ThreadLocal
//ThreadLocal不用自己写,放在了utils.UserHolder中
UserHolder.saveUser((UserDTO) user);
//6,放行
return true;
}
其中,ThreadLocal放在了utils.UserHolder中:

其实这样只是写好了还并没有让拦截器生效,想要生效的话需要进行配置拦截器:


public class MvcConfig implements WebMvcConfigurer {
@Override
//顾名思义:添加拦截器
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInteerceptor())
//excludePathPatterns排除参数,排除不需要拦截的路径
.excludePathPatterns(
"/shop/**",
"/shop-type/**",
"/blog/hot",
"/user/code",
"/user/login"
)
}
}
完成拦截器后,请求会到controller中,登录校验使用是user/me的接口,需要将当前登录的用户信息返回到前端,因为已经将user放到了userholder中:

@GetMapping("/me")
public Result me(){
// 获取当前登录的用户并返回
//因为将拦截器放到了usercoder中,只需直接取用
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
隐藏用户敏感信息
进行测试发现,前端返回的信息有点多:

一般来说。查询当前登录用户的时候,其实只需要返回id,头像信息就够了。
时间密码等敏感信息不需要返回。
为什么会返回这么多信息呢:

因为这个方法,直接返回了从userholder中保存的完整user信息,而userHolder信息又是从拦截器中
获得的。
这说明从session取出来的就是完整信息,而session是tomcat的内存空间,往这里存储的信息越多,对服务的压力也就越大。
而又是谁在给session中存储信息:
是登录业务时,从数据库查出user直接传给了session

从一开始我们就应该给session传递的是部分信息,虽然说存储的越完整将来使用越方便。但是有内存问题和隐私问题,如何解决:
首先,我们只需要给前端传递id,名字,头像这三个信息,所以直接创建一个userdto:

在传递给session之前先将user传递给dto:

所以从拦截器中取出来的其实也是dto对象:

有时候(userdto)user会发现有错误,这是因为userholder里面是user,需要修改:

又因为userholder中的getuser方法在有些地方使用过

集群的session共享问题

同一个用户,他可能登录是通过第一个tomcat,但是做其他业务被分配到了其他tomcat,会发现其他的session是空的,这就是session不共享带来的问题。
事实上为了解决这个问题,tomcat提供了数据拷贝,但是这样会有问题,多台 tomcat互相拷贝数据就是浪费内存。然后拷贝数据卷=也是有延迟的,在延迟中请求还是请求不到。
于是有了替代session的方案:redis.

基于redis实现共享session登录
使用redis的话,key使用手机号,这样可以避免重复和冲突,有助于获取验证码进行验证
商户查询缓存

使用redis的话:


前端如图:


redis代替session
发送短信验证码流程发生变化:就是保存验证码不再保存到session而是保存到redis/
先进行注入:

@Resource:作用:这是 Java 提供的依赖注入注解,告诉 Spring 框架自动查找并注入一个 StringRedisTemplate 类型的 Bean
查找方式:默认按类型(byType)注入,如果找到多个则按字段名(byName)注
为什么选择 @Resource 而不是 @Autowired?
@Resource:Java 标准注解,按名称注入优先
@Autowired:Spring 提供,按类型注入优先
两者在这个场景下效果相同,都是 Spring 支持的注入方式
StringRedisTemplate 是 Spring Data Redis 提供的核心类,用于操作 Redis 数据库:
特点:专门处理 String 类型:所有的 key 和 value 都会被序列化为 String 格式
基于 Spring 封装:简化了 Redis 的原生命令操作
线程安全:可以在多线程环境中安全使用
代码:userimpl,发送验证码逻辑:
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
//2.不符合返回错误信息
return Result.fail("手机号错误");}
//3.符合生成验证码
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到session
//session.setAttribute("code",code);
//4.保存验证码到redis,key设置有效期:2, TimeUnit.MINUTES)两分钟
stringRedisTemplate.opsForValue().set("login:code:"+phone,code,2, TimeUnit.MINUTES);
//TODO 5.发送验证码
log.debug("发送成功,验证码:{}",code);
//6.返回ok
return Result.ok();
}
/*

短信登录和注册:
分为从redis中获取验证码。
验证码经过校验后将user登录信息保存到redis中,分为几步:
生成token作为登录令牌
转成hashmap对象
设置有效期
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.检验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
//2.不符合返回错误信息
return Result.fail("手机号错误");}
//2.校验验证码
// Object cachecode = session.getAttribute("code");//获得code
// String code = loginForm.getCode();//前端提交的
//从redis获取验证码
String cachecode = stringRedisTemplate.opsForValue().get("login:code:" + phone);
String code = loginForm.getCode();//前端提交的
//3.不一致报错
if (cachecode==null ||!cachecode.toString().equals(code)){
return Result.fail("验证码错误");}
//4.一致用手机号查询用户
User user = query().eq("phone", phone).one();
//5.没找到注册新用户
if (user==null){
//不存在则创建
user= createUserWithPhone(phone);
}
// //6.保存信息到session
// session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
// return Result.ok();
//6保存到redis
//6.1生成token作为登录令牌
String token = UUID.randomUUID().toString(true);
//将user对象转为hashMap储存
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
//存储,存储时不能设置有效期,先存储再设置
stringRedisTemplate.opsForHash().putAll("login:token:"+token,userMap);
//设置有效期
stringRedisTemplate.expire("login:token:"+token,30,TimeUnit.MINUTES);
//还需要注意,如果客户一直在访问那么30分钟就无效肯定是不合理的,需要让用户再访问期间一直有token,一直更新redis
//怎么知道用户再访问我?
//使用登录拦截校验,只要经过了校验就能知道用户在登录和访问
//7.返回token
return Result.ok(token);
}

这样做完,还需要注意,因为我们设置了30分钟,超过会删除数据,但如果用户一直在访问,无疑30分钟就删除是不合理的,如果想在用户 访问期间一直有token,让redis一直更新。
如何知道用户是在访问:那就是登录拦截校验,只要经过校验就能知道用户在登录和访问
即添加一个更新token有效期的逻辑 。
拦截器代码:


如图,为什么LoginInteerceptor类中不能注解进行注入而是使用构造方法注入:
因为LoginInteerceptor的拦截器对象是手动new出来的不是通过注解构建的,也就是说这个对象不是由spring构建的。所以用构造函数注入,至于谁来帮我们进行注入,谁用了谁进行。
// 在配置类中,我们是这样创建拦截器的
public class MvcConfig {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// ❌ 手动创建对象
registry.addInterceptor(new LoginInteerceptor(stringRedisTemplate));
}
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// //1.获取session
// HttpSession session = request.getSession();
//获取请求头中的token,头名称authorization前端写的
String token = request.getHeader("authorization");
//判断空,strutil是工具类
if (StrUtil.isBlank(token)){
response.setStatus(401);
return false;
}
// //2.获取session中用户
// Object user = session.getAttribute("user");
///基于token获取redis用户
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries("login:token:" + token);
//3.判断用户是否存在
//if (user==null){
if (userMap.isEmpty()){
//4.不存在拦截,拦截就是返回false
response.setStatus(401);//这是给用户提示,报错401
return false;
}
//将查询到的数据转成userdto对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//5.存在保存用户信息到ThreadLocal
//ThreadLocal不用自己写,放在了utils.UserHolder中
// UserHolder.saveUser((UserDTO) user);
UserHolder.saveUser((UserDTO) userDTO);
//刷新token有效期
stringRedisTemplate.expire("login:token:" + token,30, TimeUnit.MINUTES);
//6,放行
return true;
}

运行报错:

类型转换异常在userserviceimpl中:

说类型转换long不能转成string。
而long类型在userdto中只有id。
也就是说long类型的id无法存到redis中。
为什么会有这样一个错误:
因为我们使用的template是stringtemplate,其特点是要求所有的key和value都是string结构。
如何解决:

存储数据到map中时确保每一个值都是以string形式:
方法1:不用这个工具类,自己new一个map,然后将userdto对象里面的字段名作为key,将字段值作为值。
方法2:还是使用这个工具,而且这个工具其实是可以自定义的。默认情况下值是什么类型就是什么类型,但是可以自定义

修改后:

登录拦截器的优化
登录是基于拦截器做的校验,用户请求进入拦截器后会尝试去获取请求头上的token。
而拦截器因为目前拦截的都是登录的路径,因此会出现一个问题,如果用户一直访问的不是登录路径就会造成拦截器不生效,这样就不会刷新token。
所以加一个拦截器拦截所有路径:

新拦截器复制粘贴原先拦截器,并在此基础上进行修改:

public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate=stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取请求头中的token,头名称authorization前端写的
String token = request.getHeader("authorization");
//判断空,strutil是工具类
if (StrUtil.isBlank(token)){
//新的拦截器不用做拦截,为空直接放行即可
//若return false,第一次访问登录页面时就会被拦截;
// 若return true,第一次访问登录页会进入Login拦截器,由于登录页为放行路径,放行
return true;
}
///基于token获取redis用户
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries("login:token:" + token);
//3.判断用户是否存在
if (userMap.isEmpty()){
//4.这里也是,不存在放行
// response.setStatus(401);//这是给用户提示,报错401
return true;
}
//将查询到的数据转成userdto对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//5.存在保存用户信息到ThreadLocal
UserHolder.saveUser((UserDTO) userDTO);
//刷新token有效期
stringRedisTemplate.expire("login:token:" + token,30, TimeUnit.MINUTES);
//6,放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
}
然后进行分工优化,旧拦截器唯一要做的其实就是要不要做拦截:

和之前的login拦截器区别太多了,修改过后只有几行代码。
接下来就是配置mvcconfig:
