笔记导读:
我之前做前后端分离的项目的时候在想,第一次登录一个网站过后后续登录就可以直接访问太神奇了,后来熟练了发现是后端的过滤器和拦截器实现的, 最近在跟着视频组做项目时候我发现自己有点忘记了前端是怎么配合后端完成的,本文带5分钟过一遍登录状态的校验
后端-----登录状态凭证
需求分析当
比如bilibili ,阿里云,用户登录后,游览器下一此访问用户信息时可以无需登录直接使用,这个时候就想到为什么没有登录时候页面发生跳转,以及登陆后在访问时候去却可以不需要再次登录,前端是根据什么判断用户状态跳转页面的?
拦截器完成校验
这里使用session存放数据完成验证,下一案列使用jwt完成(java web token)
controller:
** 登录功能* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码* @RequestBody json的后台解析*/@PostMapping("/login")public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){//俩个参数是因为一个是验证码登录 验证码 loginform是密码登录return userService.login(loginForm,session);}
service实现类:
@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验登录时候的手机号是否合法String phone = loginForm.getPhone();if (RegexUtils.isPhoneInvalid(phone)){return Result.fail("手机号格式错误");}
// 2. 校验验证码String scode =(String) session.getAttribute(HmConstants.CODE_BY_PHOINE);String sphone =(String) session.getAttribute(HmConstants.PH_BY_CODE);System.out.println(sphone+"|"+phone);if (!phone.equals(sphone)){//避免验证码到了 用户用另一手机号填写return Result.fail("填写手机号和发送验证码手机号不一致");}if(Objects.isNull(scode)){return Result.fail("为空");}if(!scode.equals(loginForm.getCode())){//之前使用的是!= string 和object 引用对象之间用equalsreturn Result.fail("验证码不一致");}// 3. 根据手机号在数据查询用户User one = query().eq("phone", loginForm.getPhone()).one();//判断用户是否存在if (Objects.isNull(one)){//4.如果不存在创建新用户 并且保存数据one = new User();//新申请内存one.setPhone(loginForm.getPhone());one.setPassword(UUID.randomUUID().toString());//因为是验证码登录注册 所以密码直接uuid 后期用户自己修改one.setNickName(HmConstants.USER_FIX+RandomUtil.randomString(10));
// one.setCreateTime(new Date().toString());boolean b = save(one);//添加用户System.out.println("保存结果:"+b);}//5.存在保存用户信息到session//todo 优化后期直接存取redis 以及vo对象session.setAttribute(HmConstants.USER_INFO,one);log.debug("存储的信息:"+ session.getAttribute(HmConstants.USER_INFO));
// 返回 无需返回登录凭证/*** 每一个session 都有唯一session id(访问tomcat服务器自动写在cookie中)* 请求中携带cookie(sessionid)* 游览器根据sessionid 即可放行*/return Result.ok();//前端做了放行}
用到的手机号格式判断utls
public class RegexUtils {/*** 是否是无效手机格式* @param phone 要校验的手机号* @return true:符合,false:不符合*/public static boolean isPhoneInvalid(String phone){return mismatch(phone, RegexPatterns.PHONE_REGEX);}/*** 是否是无效邮箱格式* @param email 要校验的邮箱* @return true:符合,false:不符合*/public static boolean isEmailInvalid(String email){return mismatch(email, RegexPatterns.EMAIL_REGEX);}/*** 是否是无效验证码格式* @param code 要校验的验证码* @return true:符合,false:不符合*/public static boolean isCodeInvalid(String code){return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX);}// 校验是否不符合正则格式private static boolean mismatch(String str, String regex){if (StrUtil.isBlank(str)) {return true;}return !str.matches(regex);}
}
userholder 用于存放 验证数据
/*** 工具类· 用于过滤器每次处理完handler之前校验用户是否处于登录状态*/
public class UserHolder {private static final ThreadLocal<UserVo> tl = new ThreadLocal<>();public static void saveUser(UserVo user){tl.set(user);}public static UserVo getUser(){return tl.get();}public static void removeUser(){tl.remove();}
}
逻辑梳理:
前端第一次访问后端,servlet会自动用cookie携带sessionID创建session,且客户端再次访问都会携带cookie,当后端接收到前端传递的登录信息,我们调用mysql查询等操作完成以后在session存放用户数据
既然session域里面有数据,那么前端页面是不是可以在页面初始化时候进行访问一个校验判断呢,如果访问到对应数据说明在对应页面是登录状态,否则跳转登录页面,并且拦截登录后才能访问的接口
拦截器:
对前端发送的请求进行验证
判断是否具有存入session的登录状态凭证(是否能读取到数据) 并且存入凭证
@Slf4j
@Component//交给ioc去管理配置的时候 无需new 一个内存空间 可以使用自动装配
public class loginIterceptor implements HandlerInterceptor {
// 进入controller 获取接口之前
// 每一个请求前对用户进行校验是否放行 登录状态放行@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.我们的用户信息存放在session中间的 所以我们从请求中需要获取 sessionHttpSession session = request.getSession();//2.获取session中的用户Object sessonuser = session.getAttribute(HmConstants.USER_INFO);//3.判断session的用户是否纯在if (Objects.isNull(sessonuser)){//不存在在的拦截 给前端抛出异常 或设置响应体状态码response.setStatus(401);//response是返回给客户端的 比如第一次访问的时候 sessionid// 就是以为cookie携带的 写入response 返回给客户端// 客户端的request在每次请求都会携带cookie// 服务器端根据(cookie)拿到参数session request.getSession,还是用httpsession接受都是如此2return false;//fasle 拦截 true 放行}//4.存在的话把用户信息保存到ThreadLocal (当前线程线程)UserHolder.saveUser(BeanCopyUtils.copyBean(sessonuser,UserDTO.class));
// 5. 放行return true;}
//controller执行之后@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);}// 视图渲染之后@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//每次视图渲染完 在存入THreadLoccal的线程释放 避免线程堵塞UserHolder.removeUser();}
}
拦截器写好后需要在配置类交给ioc经行注册
@Configuration//申明为配置类
public class MvcConfig implements WebMvcConfigurer {@Autowiredprivate com.hmdp.Interceptor.loginIterceptor logininterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(logininterceptor).excludePathPatterns("/user/code",
// 拦截器也是chain 可以链式编程 排除无需拦截的请求路径"/shop/**","/shop-type/**",
// "/upload/**",上传图片接口登录后使用"/voucher/**","/blog/hot","/user/login");//不用在new 拦截器 并且需要配置拦截路径 避免全部看拦截}
}
关于threadlocal
如果看过threadLocal的源码,你会发现在threadLocal中,无论是他的put方法和他的get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离
除了公用接口和login接口都经行拦截,包括校验接口(只有登录后在校验),有登录凭证再经行校验(是否session过期需要登录之类)
校验接口:
@GetMapping("/me")
public Result me(){// 获取当前校验登录的用户并返回 UserDTO user = UserHolder.getUser();//如果为null则说明需要从新登录 return Result.ok(user);
}
当然除了在session中存储 还可以在redis中完成该操作
核心思路分析:
每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
但是这种方案具有两个大问题
1、每台服务器中都有完整的一份session数据,服务器压力过大。
2、session拷贝数据时,可能会出现延迟
所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了
userholder 把用户信息存放发在线程容器中
/*** 工具类· 用于过滤器每次处理完handler之前校验用户是否处于登录状态*/
public class UserHolder {private static final ThreadLocal<UserVo> tl = new ThreadLocal<>();public static void saveUser(UserVo user){tl.set(user);}public static UserVo getUser(){return tl.get();}public static void removeUser(){tl.remove();}
}
token 保存在redis中
/*** 通过拦截器实现登录校验功能 过滤器也可以完成这个功能* springsecurity 也可以完成对应功能* controller 也叫做hander* 拦截器设置好后需要配置才能生效*/
//单一拦截器就可以做到对请求查询redis 刷新token有效期 但是连续访问的请求如果在拦截器排除的路径之外的我公共接口 时间到了以后又会过期 重而用户重新登录
// 所以我们需要把功能分成多个部分实现 特定功能
@Slf4j/*** 2. 这个拦截器的功能则是判断是否需要做拦截*/
public class loginIterceptor implements HandlerInterceptor {
// 进入controller 获取接口之前
// 每一个请求前对用户进行校验是否放行 登录状态放行//拦截器在ioc初始化之前执行 所以不能在使用的类上使用自动装配 即使加入@Compoent,Conreoller,Service,mapper等注解注入ioc也不行private StringRedisTemplate stringRedisTemplate; //所以现在我们不装配这个对象等拦截器加入配配置类的时候作为构造函数的参数注入public loginIterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//1.我们的用户信息存放在Redis中间的,token为key 所以我们从请求头中需要获取tokenString token = request.getHeader("authorization");//1.2判断是否为空boolean b = StringUtils.isEmpty(token);if (b){//这个拦截器的主要目的是刷新token数据凭证的有效期response.setStatus(401);return false;//拦截请求 未携带token 处于未登录状态}//2.获取redis中的用户 entries 包含了为空判断的方法Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(HmConstants.LOGIN_Token + token);//3.判断存储的的用户是否纯在if (map.isEmpty()){//不存在在的拦截 给前端抛出异常 或设置响应体状态码response.setStatus(401);//response是返回给客户端的 比如第一次访问的时候 sessionid// 就是以为cookie携带的 写入response 返回给客户端// 客户端的request在每次请求都会携带cookie// 服务器端根据(cookie)拿到参数session request.getSession,还是用httpsession接受都是如此2return false;//fasle 拦截 true 放行}
//4. 获取到的是map数据需要转换为user对象 所以需要bean的转化UserVo userVo= BeanUtil.fillBeanWithMap(map,new UserVo(), false);//4.存在的话把用户信息保存到ThreadLocal (当前线程线程)UserHolder.saveUser(userVo);// 6. 放行return true;}
//controller执行之后@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);}// 视图渲染之后@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//每次视图渲染完 在存入THreadLoccal的线程释放 避免线程堵塞UserHolder.removeUser();}
}
使用redis后的login
@Overridepublic Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验登录时候的手机号是否合法String phone = loginForm.getPhone();if (RegexUtils.isPhoneInvalid(phone)){return Result.fail("手机号格式错误");}
// 2. 校验验证码 因为验证码读取校验的时候是通过手机号 取数据 所以不存在验证码正常手机号对不上String scode =stringRedisTemplate.opsForValue().get(HmConstants.CODE_BY_PHOINE+phone);
// String sphone =(String) session.getAttribute(HmConstants.PH_BY_CODE);// if (!phone.equals(sphone)){//避免验证码到了 用户用另一手机号填写
// return Result.fail("填写手机号和发送验证码手机号不一致");
// }if(Objects.isNull(scode)){return Result.fail("验证码失效");}if(!scode.equals(loginForm.getCode())){//之前使用的是!= string 和object 引用对象之间用equalsreturn Result.fail("验证码不一致");}// 3. 根据手机号在数据查询用户User one = query().eq("phone", loginForm.getPhone()).one();//判断用户是否存在if (Objects.isNull(one)){//4.如果不存在创建新用户 并且保存数据User user = new User();//新申请内存
// one.setPhone(loginForm.getPhone());
// one.setPassword(UUID.randomUUID().toString());//因为是验证码登录注册 所以密码直接uuid 后期用户自己修改user.setNickName(HmConstants.USER_FIX+RandomUtil.randomString(10));user.setPhone(loginForm.getPhone());
// one.setCreateTime(new Date().toString());boolean b = save(user);//添加用户// 需要转化bean或者 BeanUtil.copyProperties(one,UserVo.class)和这个工具类一样得 来自依赖 hutool-all System.out.println("保存结果:"+b);}//5.存在保存用户信息到session/redis//5.1一个token发送给前端作为登录令牌 将userInfo转化为map 存储String token = UUID.randomUUID().toString(true);//不携带-符号
// 将前端需要的数据用hash结构
// 这里使vo属性挨个赋值给map的而不是用beanutil是因为id是long需要手动转为string striungredistemplate 操作的k v都是字符Map<String, Object> hashMap = new HashMap<>();UserVo userVo = BeanUtil.copyProperties(one, UserVo.class);hashMap=BeanUtil.beanToMap(userVo,new HashMap<>(), CopyOptions.create()//创建复制选项 自定义.setIgnoreNullValue(true).setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));//所有fieldname 对应的value转化为toString
//上面的操作用put挨个放入map 到id时候tostring 也是一样的stringRedisTemplate.opsForHash().putAll(HmConstants.LOGIN_Token+token,hashMap);//建议使用putAll 不需要服务端经行多次io流操作stringRedisTemplate.expire(HmConstants.LOGIN_Token+token,60*60,TimeUnit.SECONDS); // 设置用户在redis中有效期为1小时// 在拦截器设置用户发起一次请求更新一次有效器 让做到 只有不操作的时候才会一小时过期/*** 每一个session 都有唯一session id(访问tomcat服务器自动写在cookie中)* 请求中携带cookie(sessionid)* 游览器根据sessionid 即可放行*/return Result.ok(token);//前端做了放行 token返回给前端 使其后续访问需要登录状态的接口用token作为校验凭证}

前端实现
在需要登录后才显示的页面中加入钩子函数 页面初始化前访问校验登录状态接口

如果后端传来凭证显示该页面 否则跳转登录页面

axios拦截器
前端还可以自己写axois的拦截,根据状态码完成以上功能
(function (win) {axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'// 创建axios实例const service = axios.create({// axios中请求配置有baseURL选项,表示请求URL公共部分baseURL: '/',// 超时timeout: 10000})// request拦截器service.interceptors.request.use(config => {console.log("请求拦截器",config)// 从sessionStorage获取tokenconst token = sessionStorage.getItem('usertoken');// 如果token存在,则在请求头中添加Authorization字段if (token) {config.headers.Authorization = token;}// get请求映射params参数if (config.method === 'get' && config.params) {let url = config.url + '?';for (const propName of Object.keys(config.params)) {const value = config.params[propName];var part = encodeURIComponent(propName) + "=";if (value !== null && typeof(value) !== "undefined") {if (typeof value === 'object') {for (const key of Object.keys(value)) {let params = propName + '[' + key + ']';var subPart = encodeURIComponent(params) + "=";url += subPart + encodeURIComponent(value[key]) + "&";}} else {url += part + encodeURIComponent(value) + "&";}}}url = url.slice(0, -1);config.params = {};config.url = url;}return config}, error => {Promise.reject(error)})// 响应拦截器service.interceptors.response.use(res => {console.log('---响应拦截器---', res);if (res.data.code === 0 || res.status === 401) {// 返回登录页面window.top.location.href = '/front/page/login.html';} else {return res.data;}},error => {let { message } = error;if (message == "Network Error") {message = "后端接口连接异常";}// 状态码报错属于error不是响应拦截器能够使用的else if (error.response && error.response.status === 401) {// 返回登录页面window.top.location.href = '/front/page/login.html';}else if (message.includes("timeout")) {message = "系统接口请求超时";}else if (message.includes("Request failed with status code")) {message = "系统接口" + message.substr(message.length - 3) + "异常";}window.vant.Notify({message: message,type: 'warning',duration: 5 * 1000})//window.top.location.href = '/front/page/no-wify.html'return Promise.reject(error)})win.$axios = service
})(window);
拦截器实现的大体逻辑
过滤器实现
这个案列使用的是在Springsecurity中的jwt完成验证
login接口
@PostMapping("/login")
public ResponseResult login(@RequestBody User user){if(!StringUtils.hasText(user.getUserName())){//提示 必须要传用户名throw new SystemException(AppHttpCodeEnum.REQUIRE_USERNAME);}return blogLoginService.login(user);
}
对应实现
@Override
public ResponseResult login(User user) {//生成一个包含用户名密码的tokenUsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());Authentication authenticate = authenticationManager.authenticate(authenticationToken);//传入认证信息管理器 返回认证信息//判断是否认证通过1if(Objects.isNull(authenticate)){throw new RuntimeException("用户名或密码错误");}//获取userid 生成tokenLoginUser loginUser = (LoginUser) authenticate.getPrincipal();//login user 实现了useservice 使其不是使用内存的账户 密码String userId = loginUser.getUser().getId().toString();//用userid生成的token不建议String jwt = JwtUtil.createJWT(userId);//把用户信息存入redisredisCache.setCacheObject("bloglogin:"+userId,loginUser);//把token和userinfo封装 返回//把User转换成UserInfoVoUserInfoVo userInfoVo = BeanCopyUtils.copyBean(loginUser.getUser(), UserInfoVo.class);BlogUserLoginVo vo = new BlogUserLoginVo(jwt,userInfoVo);return ResponseResult.okResult(vo);
}
校验逻辑
登录后用户信息生成token,返回给前端,让前端携带token,后端则是使用该token生成前的值生成建K,用户信息作为V存入redis,校验的时候用于过滤请求
jwt过滤器
```java
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {@Autowiredprivate RedisCache redisCache;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {//获取请求头中的tokenString token = request.getHeader("token");if(!StringUtils.hasText(token)){//说明该接口登录状态 直接放行filterChain.doFilter(request, response);return;}//解析获取useridClaims claims = null;try {claims = JwtUtil.parseJWT(token);} catch (Exception e) {e.printStackTrace();//token超时 token非法//响应告诉前端需要重新登录ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);WebUtils.renderString(response, JSON.toJSONString(result));return;}String userId = claims.getSubject();//从redis中获取用户信息LoginUser loginUser = redisCache.getCacheObject("bloglogin:" + userId);//如果获取不到if(Objects.isNull(loginUser)){//说明登录过期 提示重新登录ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);WebUtils.renderString(response, JSON.toJSONString(result));return;}//存入SecurityContextHolderUsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser,null,null);SecurityContextHolder.getContext().setAuthentication(authenticationToken);filterChain.doFilter(request, response);}
前端页面访问需要登录状态的页面时候也同上,带着token发送请求交给过滤器经行解析;如果能查到用户数据说明登录状态,否则跳到登录页面
PS:这个案列有用到了springsecurity 所以security需要详细配置
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Override@Beanpublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}@Autowiredprivate JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;@AutowiredAuthenticationEntryPoint authenticationEntryPoint;@AutowiredAccessDeniedHandler accessDeniedHandler;@Overrideprotected void configure(HttpSecurity http) throws Exception {http//关闭csrf.csrf().disable()//不通过Session获取SecurityContext.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and().authorizeRequests()// 对于登录接口 允许 匿名访问(登录状态后不能访问).antMatchers("/login").anonymous()//注销接口需要认证才能访问.antMatchers("/logout").authenticated().antMatchers("/user/userInfo").authenticated()
// .antMatchers("/upload").authenticated()// 除上面外的所有请求全部不需要认证即可访问.anyRequest().permitAll();//登不登陆都可以访问//配置异常处理器http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint).accessDeniedHandler(accessDeniedHandler);//关闭默认的注销功能http.logout().disable();//把jwtAuthenticationTokenFilter添加到SpringSecurity的过滤器链中//过滤器铁链执行前执行http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);//允许跨域http.cors();}@Beanpublic PasswordEncoder passwordEncoder(){return new BCryptPasswordEncoder();/*** matches("用户输入明文","数据的密文");//对比数据库输入明文,和数据库加密文*encode()//对明文加密* 我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。*/}
}
关键代码
//过滤器铁链执行前执行http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
可能第二个列子比较蒙
所以接下来 用jwt +security 实现登录验证