1.先说个大概,这是一种新的学习的技术,其实这并不常见,我的理解应该是短信验证码+token下发jwt令牌+拦截器拦截校验三步实现登录(这是一个框架),与session实现登录流程是高度重合的,唯一不同的是:
区别就是在于保存用户信息到session,然后校验的时候直接看用户信息是否存在,而token下发jwt令牌是自己生成jwt令牌给用户,然后校验令牌即可。其实其他步骤token也可以实现,就比如我先发送短信验证码,然后可以短暂的保存验证码到redis或者session里面(其实我觉得用jwt令牌就保存在redis里面就行),然后和用户发过来的比对,成立则说明登录成功,我们可以保存在数据库里(或者使用异步线程保存到数据库,来处理高并发情况,这是后边讲的),并且给他下发jwt令牌即可,每次拦截jwt令牌,校验有效性即可。

基于jwt令牌实现登录注册

以上几乎完整但存在一定的漏洞,应该把手机号也保存下来,不然你第二次过来的时候光有一个验证码是不行的。。
现在流行的框架是:


2.因为在外卖已经讲过jwt令牌的流程,只不过没有短信验证码的功能,这里不实现jwt了,实现一下session,而且上面那个漏洞我还得改前端的提交数据,很蛮烦不想弄了,所以不保存手机号了:
java
public Result sendCode(@RequestParam("phone") 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);
//5.发送验证码
log.debug("发送验证码成功,验证码:{}", code);
//6.返回结果
return Result.ok(code);
}
首先就是参数里面写了HttpSession session,之后就可以保存信息到这里,然后就会自动生成一个sessionId,然后用户再请求就会把这个传递过来,我们就可以拿到这个session里的东西了
然后就是RegexUtils是我们自己定义的工具包
RandomUtil这个工具包来生成6位的随机数字
session.setAttribute("code",code);用于保存到session,其实还应该保存手机号,但是我算了,发送验证码没有实现,因为重点不在这里。。
最后写代码不要写多层if嵌套,所以就if的判断条件是结束当前业务的条件
java
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
//1.校验手机号,其实应该把第一步的手机号拿出来和传递的进行校验,但是这里不实现了,知道进行
//我们以校验合法性代替
if(RegexUtils.isPhoneInvalid(loginForm.getPhone())){
return Result.fail("手机号格式错误!");
}
//2.校验验证码
Object code = session.getAttribute("code");
if(!code.equals(loginForm.getCode())){
//3.不一样报错
return Result.fail("验证码错误!");
}
//4.根据手机号查询用户
User user = userMapper.getByPhone(loginForm.getPhone());
//5.判断是否存在,不存在创建
if(user == null){
user.setPhone(loginForm.getPhone());
userMapper.insert(user);
}
//6.保存用户
session.setAttribute("user",user);
//7.什么也不用返回,因为不是jwt模式
return Result.ok();
}
session.getAttribute("code"),就是拿数据,有人说为什么能拿到呢,就是因为你过来的有请求头,里面携带了sessionid,然后你会自动去找到这个session。
第三步就是配置拦截器了
java
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取sessioon,并且获取登录用户
HttpSession session = request.getSession();
Object user = session.getAttribute("user");
//2.判断是否存在,不存在不让访问
if(user == null){
//不存在拦截
response.setStatus(401);
return false;
}
//3.保存登录用户
UserHolder.saveUser((UserDTO) user);
//4.放行
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
UserHolder.removeUser();
}
}
首先 HttpSession session = request.getSession();从请求体里面拿到session
其次就是 response.setStatus(401);这种前面也说过,就是如果登录没成功就返回401状态码
最后就是一个数据安全性问题,其实我们一开始没必要直接把user放在session里面,这涉及到数据的安全性,所以我们把不重要的数据封装到userdto里面,这个作为保障
1
所以这个线程工具里面泛型就是UserDto了

别忘记修改第二步的代码哦
然后配置拦截器就行了。
java
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
}
}
每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
但是这种方案具有两个大问题
1、每台服务器中都有完整的一份session数据,服务器压力过大。
2、session拷贝数据时,可能会出现延迟
所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了,所以方法就几乎是是我的jwt令牌的方法的改变,把所有信息都保存到redis里边,然后拦截器只需要从redis中取出这个用户数据进行校验。但区别就在于我服务端还是得存数据,我觉得不是一个很好的方案,最好的方案还是下发jwt令牌。因为这里有一些代码第一次见,我就不优化了,其实还是能优化的
这就涉及到保存的类型和key的设计,很简单设计key的时候,需要满足
1、key要具有唯一性
2、key要方便携带
3.key尽量不要有敏感数据
如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后返回给前端,然后让前端带来这个token,然后我们从请求头中拿出来就行,就能完成我们的整体逻辑了。但是第一步的时候不能这样设计,因为第二部需要通过手机号去获取对应的验证码,所以设计要包括手机号。
然后就是value的选取,很明显验证码使用string存储把,用户信息(还是DTO)使用hashmap进行存储,这就涉及到一个对象转换为一个map,所以我要敲一遍代码。还有一个知识点我先说了把,就是如何直接从请求头获取信息


还有利用uuid生成一个不敏感数据
String token = UUID.randomUUID().toString(true),.string是必要的,因为redis的可以都是string类型的,但是这个true写法jdk15以下不支持,所以我改了一下
// 生成标准 UUID 并手动移除所有连字符 String token = UUID.randomUUID().toString().replace("-", "");
至此就可以对代码进行改造:
首先是第一步的存放验证码:
java
public Result sendCode(@RequestParam("phone") 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,5分钟有效期
redisTemplate.opsForValue().set("code:" + phone, code,5, TimeUnit.MINUTES);
//5.发送验证码
log.debug("发送验证码成功,验证码:{}", code);
//6.返回结果
return Result.ok();
redisTemplate.opsForValue().set("code:" + phone, code,5, TimeUnit.MINUTES);要设置验证码的有效期为5分钟,而且还要注意的是在第二步的时候校验成功的时候要删除校验码,以防二次使用
第二部校验代码:
java
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
//1.校验手机号,其实应该把第一步的手机号拿出来和传递的进行校验,但是这里不实现了,知道进行
//我们以校验合法性代替
if(RegexUtils.isPhoneInvalid(loginForm.getPhone())){
return Result.fail("手机号格式错误!");
}
//2.校验验证码
String code = redisTemplate.opsForValue().get("code:" + loginForm.getPhone());
if(!code.equals(loginForm.getCode())){
//3.不一样报错
return Result.fail("验证码错误!");
}
//3.验证码一致,删除
redisTemplate.delete("code:" + loginForm.getPhone());
//4.根据手机号查询用户
User user = userMapper.getByPhone(loginForm.getPhone());
//5.判断是否存在,不存在创建
if(user == null){
user = new User();
user.setPhone(loginForm.getPhone());
userMapper.insert(user);
}
//6.保存用户
//6.1生成对象
UserDTO userDTO = new UserDTO();
BeanUtils.copyProperties(user,userDTO);
Map<String, Object> stringObjectMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())
);
//6.2生成token
String token = UUID.randomUUID().toString().replace("-", "");
//6.3保存
redisTemplate.opsForHash().putAll("LOGIN_USER_KEY:" + token, stringObjectMap);
redisTemplate.expire("LOGIN_USER_KEY:" + token, 30, TimeUnit.MINUTES);
//7.返回
return Result.ok(token);
}
redisTemplate.opsForHash().putAll("LOGIN_USER_KEY:" + token, stringObjectMap);putAll方发可以一次性把一个map给他放进去
我们主要看这个代码,作用就是把一个对象变成一个map集合,当然也有对应的,拿到一个map集合变成对象的方法。
BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())
);
CopyOptions.create()就是说明我现在要控制细节了
.setIgnoreNullValue(true)表示忽略空,比如我在转的时候有一项属性没有,那就不用管
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()),里面是一个函数式编程,因为redis这里我们这个序列化器要求全是字符串

2.所以只需对另一个进行处理即可。
第三步拦截器代码
java
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// //1.获取sessioon,并且获取登录用户
// HttpSession session = request.getSession();
// Object user = session.getAttribute("user");
//1.获取登录用户
String token = request.getHeader("authorization");
//2.判断是否存在这个请求头,不存在直接放行
if (StrUtil.isBlank( token)){
return true;
}
//2.根据token查询用户
String key = "LOGIN_USER_KEY" + token;
Map<Object, Object> userMap = redisTemplate.opsForHash().entries(key);
if (userMap.isEmpty()){
return true;
}
//3.将查询到的用户转化为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//4.保存登录用户
UserHolder.saveUser(userDTO);
//5.刷新token有效期
// 7.刷新token有效期
redisTemplate.expire(key, 30, TimeUnit.MINUTES);
//6.放行
return true;
}
if (StrUtil.isBlank( token)){
return true;
}StrUtil是一个工具类
Map<Object, Object> userMap = redisTemplate.opsForHash().entries(key);
.entries(key);,这样会直接返回所有信息,成一个map
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);这就是把一个map转化为对象的方法,false表示转化的时候严格区分大小写。。
注意这里要刷新这个用户的登录时间,因为用户在进行资源阅览时,刚开始校验成功我们设置的是30min,如果不刷新的话,这里会出现我突然就下线了,这是不对的,所以要更新。。
在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的,可以这样进行改进:我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断线程池是否有东西即可。即第一个拦截器永远不会拒绝,只会放行,只是起到存放usedto和刷新时间的作用。。。现在能理解为什么上面我写的那个拦截器token和对象不存在我都放行的原因了吧,因为不影响,第二个拦截器会处理的。


注意这里无法直接注入Stringrdies....因为这个类没有交给bean管理,所以要使用构造方法

而@Configuration封装了,表示可以注入,所以我们先注入一个,然后在registry.addInterceptor(new LoginInterceptor1(redisTemplate)) .addPathPatterns("/**").order(0);使用它即可。。
最后说两个让我气死的东西,和一个改进的地方,第一个就是@Resource,你看
,以前我都写的这样,最后一直报错,调试了好长时间,发现resource是按照后面的名字先匹配的,你看
然后就是我的redis配置
我是发现也是报错redis链接不上,写了一个测试类发现redis的密码老是读取不到,不知道为啥,最后手动配置了一下,能连上了..
第三相当于一个改进吧,我发现如果我一直用一个手机号发验证码登录,就会保存很多个用户对象到redis中,并且他们存储的信息都一样(验证码不会,因为验证成功我们删除了),这是因为在验证成功后保存用户信息到redis里,没有限制会一直保存,这是一个问题,我感觉这就是能够多端登录的原因,以后可以加以限制,判断redis里的数量。所以我要实现多端登录并且登出处理,我说一下把一共有两个要存储的信息,一个就是当前登录态比如说我用token(uuid表示的),还有一个存储user这个用户的登录token的比如叫B+userid(使用set集合存储),所以现在我在查询完数据库后,我要查询这个B+userid所有的集合里的元素,完事我看有没有超过最大限制,没超过我就把这个token加进B+userid表示我这个设备可以登录,你用吧,如果超过最大限制呢(其实只可能等于,比如我限制两个设备,我登录第一个可以,第二个可以,第三个就不行了),所以我们的判断条件一定是有=的,如果等于最大限制也得减少一个最旧的,即同时删除这个最旧的token(一个用户设备删除)和删除这个key(登录态删除)。这样就行了。然后登出的话,我也是要删除两部分吧,一个是当前登录态,一个是这个用户设备也删了,而且为了安全考虑,一个用户只能删自己的信息,所以登出功能要知道当前的token(登录态)和userid(因为要删设备),所以这时拦截器就得同时把这两个必要的信息都存储起来。值得注意的是在第一层拦截器里必需对当前登录态进行更新时间,而是否对设备更新时间,取决于实际需求,我感觉一般不更新,因为如果更新那我不是一直访问资源在没有其他登录的时候我就会一直在线吗,感觉没必要
最后登出功能应该加上权限限制,就是使用这个功能的时候只能登出自己的账号,这时候传参,传入userid和当前的这个token即可,这就要求拦截器需要把这次的token也带上,不能只存用户dto,因为你登出你要删除这个token对因的登录态和存储用户token的集合中的这个token。
实现强制所有设备下线功能:只需要知道useid然后去找B+userid这个里面所有的token,这就表示登录的设备,然后我们先把这个token登录态删了,再B+userid集合清空
当然可以固定下线,实现其实一样,不说了
jwt令牌也可以实现多端登录,过程其实一样,具体再看把,懒得弄了
,以前我都写的这样,最后一直报错,调试了好长时间,发现resource是按照后面的名字先匹配的,你看





