[SpringBoot-vue3]用户登录实现JWT单点登录/ThreadLocal保存用户信息

 ✨✨个人主页:沫洺的主页

系列专栏:  JavaWeb专栏 JavaSE专栏  Java基础专栏vue3专栏 

                           MyBatis专栏Spring专栏SpringMVC专栏SpringBoot专栏

                           Docker专栏Reids专栏MQ专栏SpringCloud专栏     

如果文章对你有所帮助请留下三连✨✨

效果图

[SpringBoot-vue3]用户登录实现JWT单点登录/ThreadLocal保存用户信息_第1张图片

简单的用户登录注册环境的搭建,目的是使用JWT(Json web token)来模拟分布式站点的单点登录(SSO)场景

[SpringBoot-vue3]用户登录实现JWT单点登录/ThreadLocal保存用户信息_第2张图片

[SpringBoot-vue3]用户登录实现JWT单点登录/ThreadLocal保存用户信息_第3张图片

[SpringBoot-vue3]用户登录实现JWT单点登录/ThreadLocal保存用户信息_第4张图片

这里模拟了三种token的情况

基于Token的身份验证步骤

  • 客户端使用用户名跟密码请求登录;
  • 服务端收到请求,去验证用户名与密码;
  • 验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端;
  • 客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里或者 Local Storage 里;
  • 客户端每次向服务端请求资源的时候需要带着服务端签发的 Token;
  • 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据。

JWT介绍

原文链接: 什么是 JWT -- JSON WEB TOKEN

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准(RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

传统的session认证:

  • 当用户经过应用认证之后,应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。
  • 如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。
  • 因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。

基于token的鉴权机制:

  • 基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。

JWT详解参考: JWT详解

总结来说就是服务无状态化,服务器不保存任何状态,而是客户端将状态带过来这种机制

JWT工具使用

Hutool工具: JWTUtil使用

JSON Web Token 入门教程

[SpringBoot-vue3]用户登录实现JWT单点登录/ThreadLocal保存用户信息_第5张图片

代码实现

建表

CREATE TABLE `scm_user` (
  `id` int NOT NULL AUTO_INCREMENT,
  `tel` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `nickName` varchar(255) DEFAULT NULL,
  `lastUpdateTime` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;;

后端接口搭建及返回token

  • 客户端使用用户名跟密码请求登录;
  • 服务端收到请求,去验证用户名与密码;
  • 验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端;

application.yml

#数据库连接信息配置
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/db5?useSSL=false&useServerPrepStmts=true&allowPublicKeyRetrieval=true
    username: root
    password: 123456
    druid:
      initial-size: 10 # 初始化时建立物理连接的个数
      min-idle: 10 # 最小连接池数量
      maxActive: 200 # 最大连接池数量
      maxWait: 60000 # 获取连接时最大等待时间,单位毫秒
#映射文件所在位置
#mybatis:
#  mapper-locations: classpath:mapper/*Mapper.xml
#  #别名
#  type-aliases-package: com.moming.entity

mybatis-plus:
  config-locations: classpath:mapper/*Mapper.xml
  #别名
  type-aliases-package: com.moming.entity

#配置日志级别
logging:
  level:
#    root: error
    com.moming: info


rest:
  controller:
    advice:
      base-packages: com.moming.controller

#配置authority-spring-boot-starter相关信息
moming:
  authority:
    enable: true
    path-patterns: /api/**
    exclude-path-patterns: /api/user/*
    #设置token密钥
  token:
    secret: moming

UserDto

@Data
public class UserDto{
    private Integer id;
    private String tel;
    private String nickName;
    private String password;
}

UserQueryDto

@Data
public class UserQueryDto{
    private Integer id;
    private String tel;
    private String password;
}

UserLoginDto

@Data
public class UserLoginDto {
    private String tel;
    private String password;
}

UserEntity

@Data
public class UserEntity  {
    private Integer id;
    private String tel;
    private String nickName;
    private String password;
    private LocalDateTime lastUpdateTime;
}

UserMapper

@Mapper
public interface UserMapper {
    List select(UserQueryDto dto);
    int insert(UserEntity entity);
    int update(UserEntity entity);
    int delete(Integer id);
}

UserMapper.xml





    
        
        
        
        
        
    

    

    
        insert into scm_user (  tel,nickName,  password)
        values (  #{tel}, #{nickName}, #{password} )
    
    
        update scm_user
        
            
                tel = #{tel},
            
            
                password = #{password},
            

        
        where id = #{id}
    

    
        delete from scm_user where id = #{id}
    

UserService

@Service
public class UserService  {

    @Autowired
    private UserMapper userMapper;

    public Integer insert(UserDto dto){
        //业务判断逻辑
        UserEntity entity = BeanUtil.copyProperties(dto, UserEntity.class);
        return userMapper.insert(entity);
    }

    public Integer update(UserDto dto){
        UserEntity entity = BeanUtil.copyProperties(dto, UserEntity.class);
        return userMapper.update(entity);
    }

    public List select(UserQueryDto dto){
        List entities = userMapper.select(dto);
        return BeanUtil.copyToList(entities,UserDto.class);
    }

}

UserController

@RestController
@RequestMapping("/api/user")
public class UserController {

    @Autowired
    private UserService userService;
    //密钥
    @Value("${moming.token.secret}")
    private String TOKEN_SECRET;

    @PutMapping
    public Integer update(@RequestBody UserDto dto){
        return  userService.update(dto);
    }

    @PostMapping("/regist")
    public Integer insert(@RequestBody UserDto dto){
        return userService.insert(dto);
    }

    @GetMapping
    public List select(UserQueryDto dto){

        return userService.select(dto);
    }
    @PostMapping("/login")
    public String login(@RequestBody UserLoginDto dto) throws Exception {

        //业务判断
        //获取请求头数据是否为空
        if(ObjectUtil.isEmpty(dto.getTel())){
            throw  new Exception("手机号不能为空");
        }
        if(ObjectUtil.isEmpty(dto.getPassword())){
            throw  new Exception("密码不能为空");
        }
        //不为空封装到对象中
        UserQueryDto queryDto =  new UserQueryDto();
        queryDto.setTel(dto.getTel());
        queryDto.setPassword(dto.getPassword());
        //查询数据库中是否匹配登录信息
        List userList = userService.select(queryDto);
        //如果查出来的是空的,则表示没有匹配上,登录失败
        if(ObjectUtil.isEmpty(userList)){
            throw  new Exception("手机号或者密码错误");
        }
        //匹配上,由于是数组对象,取第一个
        UserDto user = userList.get(0);

        //创建JWT
        Map map = new HashMap() ;
        //用户id
        map.put("userId", user.getId());
        //用户手机号
        //String tel = user.getTel();
        //tel  = tel.substring(0,3)+"******"+tel.substring(9,11);
        //map.put("tel", tel);
        map.put("tel", user.getTel());
        //用户别名
        map.put("nickName", user.getNickName());
        //过期时间
        map.put("expire_time", System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 15);
        //生成token
        String token = JWTUtil.createToken(map, TOKEN_SECRET.getBytes());

        return  token;

    }
}

当登录接口成功通过业务判断,会返回一个加密的token,格式如下

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHBpcmVfdGltZSI6MTY2ODIzMDgyNTIyMCwidGVsIjoiMTIzKioqKioqMTAiLCJ1c2VySWQiOjF9.t1E8YfUuMVWpCfAtIw_i4MWZftSpr8tmlGTHphuv-Do

在线解析JWT加密

[SpringBoot-vue3]用户登录实现JWT单点登录/ThreadLocal保存用户信息_第6张图片

前端实现接口及解析token

  • 客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里或者 Local Storage 里;

vue token解析: 解析token中携带的数据

安装插件

npm install jwt-decode --save

在需要使用的地方引入

	import jwtDecode from 'jwt-decode'
	const code = jwtDecode(res.data.data.accessToken)
	console.log(code)// 就可以解析成功了

部分核心代码如下

UserLogin.vue




UserRegist.vue




App.vue





appStore.ts

import { defineStore } from 'pinia'

export const appStore = defineStore({
  id: 'scmapp',
  state: () => {
    return {
      userInfo:{
        userId: 0,
        tel: "",
        nickName: "",
        token: "",
      },
      menuCollapse: false,
      //定义一个editableTabs数组,里边放初始化元素
      tabList: [{
        title: '主页',
        name: 'home',
        close: false,
      }],
      //当前活动标签,默认显示name:'home'的元素
      activeTabName: "home",
    }
  },
  getters: {

  },
  actions: {

  },
  // 开启数据缓存
  persist: {
    enabled: true,
    strategies: [
      {
        key: 'scm_app',
        storage: localStorage,
      }
    ]
  }
})

将用户登录不重要的信息通过pinia保存到本地存储Local Storage中,通过消息的订阅与发布这种通信模式,登录成功后UserLogin.vue作为发布者将token发给订阅者,通知App.vue订阅者去解析token

//发布者,发送消息
PubSub.publish('login-ok', token)


//订阅者接收登录成功的消息事件
  PubSub.subscribe('login-ok', (topic: string, data: any) => {
    // console.log(topic,data);
    let token = data;
    //解析token(JWT数据结构)
    const tokenObject:any = jwtDecode(token)
    //存入本地存储
    userInfo.value.token = token
    userInfo.value.userId = tokenObject.userId
    userInfo.value.tel = tokenObject.tel
    userInfo.value.nickName = tokenObject.nickName
  })

[SpringBoot-vue3]用户登录实现JWT单点登录/ThreadLocal保存用户信息_第7张图片

前端拦截器请求头存放token

JSON Web Token 入门教程

[SpringBoot-vue3]用户登录实现JWT单点登录/ThreadLocal保存用户信息_第8张图片

 http/index.ts

import axios, { AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse, } from "axios";
//弹窗图标和加载图标
import { ElMessage, ElLoading } from "element-plus";
// import { userInfo } from "os";
import { appStore } from "@/store/appStore"
import { storeToRefs } from 'pinia'
let { userInfo } = storeToRefs(appStore());
const state = {
  ok: 0,//请求成功状态码
  401: "ERR_BAD_REQUEST"
};

//返回数据规则
interface IResponseData {
  status: number;
  message?: string;
  data: T;
  code: string;
}

//默认配置,封装了一个实例对象
const config = {
  baseURL: "",
  timeout: 30 * 1000,
  withCredentials: true,
};

let loading: any = null;
//类似定义一个类
class Http {
  axiosInstance;  //定义了一个axiosInstance属性,未来它放的是一个axios实例
  constructor(config: any) {
    //实例化请求配置
    this.axiosInstance = axios.create(config);

    // 添加请求拦截器
    this.axiosInstance.interceptors.request.use(
      //在发送请求之前做些什么
      (config: AxiosRequestConfig) => {

        //弄了一个加载的过度
        loading = ElLoading.service({
          lock: true,
          text: '加载中...',
          background: 'rgba(0, 0, 0, 0.7)',
          //覆盖整个屏幕
          fullscreen: true
        })
        //处理token
        if (userInfo.value.token != "") {
          let headers = config.headers as AxiosRequestHeaders
          headers.Authorization = userInfo.value.token
        }
        return config;
      },
      (error: any) => {
        loading.close();
        // 对请求错误做些什么
        return Promise.reject(error);
      }
    );

    // 添加响应拦截器
    this.axiosInstance.interceptors.response.use(function (response) {
      // 对响应数据做点什么
      loading.close();
      let apiData = response.data;
      // console.log(apiData)
      // console.log(apiData.data)
      //将apiData的属性取出来
      const { code, message, data } = apiData;
      //取出来之后处理属性
      if (code === undefined) {
        return apiData;
      } else if (code === 0) {
        return data;
      } else {
        ElMessage.error(message)
      }
      return apiData.data;
    }, function (error) {
      // 对响应错误做点什么
      loading.close();
      return Promise.reject(error);
    });

  }

  get(url: string, params?: object, data = {}): Promise> {
    return this.axiosInstance.get(url, { params, ...data });
  }

  post(url: string, params?: object, data = {}): Promise> {
    return this.axiosInstance.post(url, params, data);
  }

  put(url: string, params?: object, data = {}): Promise> {
    return this.axiosInstance.put(url, params, data);
  }
  patch(url: string, params?: object, data = {}): Promise> {
    return this.axiosInstance.patch(url, params, data);
  }
  delete(url: string, params?: object, data = {}): Promise> {
    return this.axiosInstance.delete(url, { params, ...data });
  }
}
//类似new了一个实例
export default new Http(config);

请求拦截器

将token放到请求头的Authorization中

// 添加请求拦截器
    this.axiosInstance.interceptors.request.use(
      //在发送请求之前做些什么
      (config: AxiosRequestConfig) => {

        //弄了一个加载的过度
        loading = ElLoading.service({
          lock: true,
          text: '加载中...',
          background: 'rgba(0, 0, 0, 0.7)',
          //覆盖整个屏幕
          fullscreen: true
        })
        //处理token
        if (userInfo.value.token != "") {
          //拿到请求头
          let headers = config.headers as AxiosRequestHeaders
          //赋值
          headers.Authorization = userInfo.value.token
        }
        return config;
      },
      (error: any) => {
        loading.close();
        // 对请求错误做些什么
        return Promise.reject(error);
      }
    );

[SpringBoot-vue3]用户登录实现JWT单点登录/ThreadLocal保存用户信息_第9张图片

后端拦截器验证/解析token,通过ThreadLocal保存用户信息

  • 客户端每次向服务端请求资源的时候需要带着服务端签发的 Token;
  • 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据。

拦截器创建参考: 自定义authority-spring-boot-starter

引入坐标


  com.moming
  authority-spring-boot-starter
  14-SNAPSHOT

application.yml

#配置authority-spring-boot-starter相关信息
moming:
  authority:
    enable: true
    path-patterns: /api/**
    exclude-path-patterns: /api/user/*
    #设置token密钥
  token:
    secret: moming

UserInfo

@Data
public class UserInfo {
    private Integer userId;
    private String tel;
    private String nickName;
}

UserInfoLocal

public class UserInfoLocal {
    //线程独有变量
    private static ThreadLocal threadLocal = new InheritableThreadLocal<>();
    //给线程脑门上设置给变量
    public static void set(UserInfo userInfo){
        threadLocal.set(userInfo);
    }
    public static UserInfo get(){
        return threadLocal.get();
    }
}

AuthorityAutoConfiguration

@ConditionalOnProperty(prefix = AuthorityAutoConfiguration.AUTHORITY_PRE,value = "enable", havingValue = "true", matchIfMissing = false)
@Import(WebConfig.class)
public class AuthorityAutoConfiguration {
    public final static String AUTHORITY_PRE="moming.authority";
}

AuthorityProperties

@Data
@ConfigurationProperties(prefix = AuthorityProperties.AUTHORITY_PRE)
public class AuthorityProperties {

    public final static String AUTHORITY_PRE="moming.authority";

    /*
     是否开启
    */
    private boolean enable ;
    /*
    可以自定义配置拦截的路径
     */
    private String pathPatterns="/api/**";

    /*
     配置不拦截的路径
     */
    private String excludePathPatterns="";

}

AuthorityInteceptor拦截器通过密钥验证请求头传过来的Authorization(token)是否合法

public class AuthorityInteceptor implements HandlerInterceptor {
    //密钥
    private String TOKEN_SECRET;

    public AuthorityInteceptor(String tokenSecret){
        this.TOKEN_SECRET = tokenSecret;
    }
    @Override
    public boolean preHandle(HttpServletRequest request, javax.servlet.http.HttpServletResponse response, Object handler) throws Exception {
        String token =  request.getHeader("Authorization");
        //token为空时
        if(StrUtil.isEmpty(token)){
            //response.sendError(response.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write("{\"code\": 401,\"message\": \"token为空或已过期,请重新登录\"}");
            return false;
        }
        //验证token
        //将系统提示的异常抛出,使用自定义的提示信息message
        //校验是否正确(合法)
        boolean isTrue = false;
        try {
            //验证token,与密钥是否
            isTrue = JWTUtil.verify(token, TOKEN_SECRET.getBytes());
        } catch (Exception ex){
            ex.printStackTrace();
        }
        //验证不通过
        if (isTrue == false) {
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write("{\"code\":401,\"message\":\"您的token不正确或已过期\"}");
            return false;
        }
        //token验证通过解析token
        final JWT jwt = JWTUtil.parseToken(token);
        //System.out.println(jwt.getPayload().toString());
        //解析后获取PAYLOAD转换成UserInfo对象
        UserInfo userInfo = JSONUtil.toBean(jwt.getPayload().toString(), UserInfo.class);
        //给本地线程脑门上加个属性userInfo
        //目的是通过获取线程脑门上的属性userInfo来获取登录用户的信息
        UserInfoLocal.set(userInfo);
        //当前线程名称
        //System.out.println("111-"+Thread.currentThread().getName());

        return true;
    }
}

WebConfig

@Import(AuthorityProperties.class)
@Order(Ordered.HIGHEST_PRECEDENCE)
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private AuthorityProperties authorityProperties;

    //密钥
    @Value("${moming.token.secret}")
    private String TOKEN_SECRET;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AuthorityInteceptor(TOKEN_SECRET))
                .addPathPatterns(authorityProperties.getPathPatterns())
                .excludePathPatterns(authorityProperties.getExcludePathPatterns());
    }
}

通过对当前线程脑门上贴个东西,在controller中就可以获取到当前线程脑门上的东西

如下

    //改
    @PutMapping
    public int update(@RequestBody ProductDto productDto){
        //获取当前线程脑门上的东西userInfo 
        UserInfo userInfo = UserInfoLocal.get();
        //给更新人赋值
        productDto.setLastUpdateBy(userInfo.getNickName());
        return productService.update(productDto);
    }
    //插
    @PostMapping
    public int insert(@RequestBody ProductDto productDto){
        //获取线程脑门上的变量,给更新人赋值
        UserInfo userInfo = UserInfoLocal.get();
        productDto.setLastUpdateBy(userInfo.getNickName());
        return productService.insert(productDto);
    }

这样在执行添加,编辑这些操作时就能获取当前登录用户的别名

如下图所示

[SpringBoot-vue3]用户登录实现JWT单点登录/ThreadLocal保存用户信息_第10张图片

[SpringBoot-vue3]用户登录实现JWT单点登录/ThreadLocal保存用户信息_第11张图片

你可能感兴趣的:(SpringBoot,Vue3,spring,boot,java,前端)