在结束理论知识的学习后,荔枝开始项目学习,这个系列文章将围绕荔枝学习mall项目过程中总结的知识点来梳理。本篇文章主要涉及如何整合Spring Security和JWT实现鉴权认证的功能!希望能帮助到一起学习mall项目的小伙伴~~~
前言
一、JWT和Spring Security的整合知识点
1.1 JWTtoken的生成流程
1.1.1 什么是CSRF:
1.1.2 为什么需要JWT
1.1.3 JWTtoken的生成流程
1.2 UserDetails接口
1.3 CollUtil类
1.4 PasswordEncoder接口
1.5 为什么要实现Serializable接口
总结
这部分会根据mall_learning中的后台用户管理实现类来做系统的梳理学习,目的主要是弄清楚JWT token的生成流程以及相应的类的使用。首先我们来看一下该实现类的demo:
package com.crj.crj_mall_learning.service.impl;
import cn.hutool.core.collection.CollUtil;
import com.crj.crj_mall_learning.utils.JwtTokenUtil;
import com.crj.crj_mall_learning.domain.AdminUserDetails;
import com.crj.crj_mall_learning.domain.UmsResource;
import com.crj.crj_mall_learning.service.UmsAdminService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* @auther lzddl
* @description 后台用户管理Service实现类
*/
@Slf4j
@Service
public class UmsAdminServiceImpl implements UmsAdminService {
/**
* 存放默认用户信息
*/
private List adminUserDetailsList = new ArrayList<>();
/**
* 存放默认资源信息
*/
private List resourceList = new ArrayList<>();
@Autowired
private JwtTokenUtil jwtTokenUtil;
//spring security中的一种对密码的编译方法
@Autowired
private PasswordEncoder passwordEncoder;
// Spring IOC容器的初始化中就会执行该init方法
@PostConstruct
private void init(){
adminUserDetailsList.add(AdminUserDetails.builder()
.username("admin")
.password(passwordEncoder.encode("123456"))
.authorityList(CollUtil.toList("brand:create","brand:update","brand:delete","brand:list","brand:listAll"))
.build());
adminUserDetailsList.add(AdminUserDetails.builder()
.username("lzddl")
.password(passwordEncoder.encode("123456"))
.authorityList(CollUtil.toList("brand:listAll"))
.build());
resourceList.add(UmsResource.builder()
.id(1L)
.name("brand:create")
.url("/brand/create")
.build());
resourceList.add(UmsResource.builder()
.id(2L)
.name("brand:update")
.url("/brand/update/**")
.build());
resourceList.add(UmsResource.builder()
.id(3L)
.name("brand:delete")
.url("/brand/delete/**")
.build());
resourceList.add(UmsResource.builder()
.id(4L)
.name("brand:list")
.url("/brand/list")
.build());
resourceList.add(UmsResource.builder()
.id(5L)
.name("brand:listAll")
.url("/brand/listAll")
.build());
}
@Override
public AdminUserDetails getAdminByUsername(String username) {
//在存放默认用户对象的集合中来查找指定的用户信息,如果有就返回符合条件的第一条数据;
// 如果没有就会返回一个null
List findList = adminUserDetailsList.stream().filter(item -> item.getUsername().equals(username)).collect(Collectors.toList());
if(CollUtil.isNotEmpty(findList)){
return findList.get(0);
}
return null;
}
@Override
public List getResourceList() {
return resourceList;
}
@Override
public String login(String username, String password) {
String token = null;
try {
//这里的UserDetails是Spring Security中的一个接口,该接口实现仅仅存储用户的信息,后续会将该接口提供的用户信息封装到认证对象Authentication中去
UserDetails userDetails = getAdminByUsername(username);
if(userDetails==null){
return token;
}
if (!passwordEncoder.matches(password, userDetails.getPassword())) {
throw new BadCredentialsException("密码不正确");
}
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
token = jwtTokenUtil.generateToken(userDetails);
} catch (AuthenticationException e) {
log.warn("登录异常:{}", e.getMessage());
}
return token;
}
}
在正式了解整个认证鉴权的流程之前,我们首先需要弄清楚为什么需要使用JWT!在以往的前后端半分离的项目中我们经常使用会话来实现token值的缓存,由于前后端没有涉及过多的跨域操作,因此也就不会采用JWT来保证跨域请求的安全。那为什么跨域请求中需要采用JWT来认证授权呢?
跨站请求伪造(Cross-site request forgery),也被称为 one-click attack 或者session riding,通常缩写为 CSRF 或者 XSRF,是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。
JWT的token值主要是由三部分组成的:header.payload.signature。该token是用户访问正规网站会拿到的并缓存在用户浏览器的localStorage本地缓存里面。header里面是加密算法的信息,里面放type=jwt,alg(加密算法)=HS512;payload里面存放的是用户名、token的创建时间和过期时间;signature通过使用head中定义的加密算法和后端已经设置好的加密密钥,将head+payload这两部分加密生成一个signature签名,最终拼接所有密钥数据加起来生成一个JWT令牌也就是token。由于JWT的token存在用户浏览器的localStorage本地缓存里面,下次这个用户发送请求给网站的后台时,就会把这个JWT发给正规网站的后台进行安全性校验,而不会向之前那种模式那样使用到cookie。这就可以避免用户在浏览网站的时候被恶意链接获取到cookies,从而绕开网站的安全性校验而导致用户的信息被窃取或造成其它损失。
首先用户通过后端提供的校验授权的接口来执行登录的操作,这部分的功能主要有两个:校验和授权。首先当用户第一次登录的时候会根据账号和密码来获得JWT生成的token,在上面的介绍中我们已经知晓该token值的功能。
首先用户根据账号和密码进行校验,在Spring Security中我们可以选择在初始化接口实现类的时候就将符合条件的用户信息加载在一个UserDetails对象构成的列表中,并根据默认要求对密码采用passwordEncoder类方法进行encode。因此我们在校验密码的正误的时候也需要采用该类方法对用户输入的密码进行比较。完成身份校验后才会开始生成token,首先需要将用户信息对象UserDetails和用户的访问权限列表交给UsernamePasswordAuthenticationToken对象的构造方法构造一个UsernamePasswordAuthenticationToken对象,之后会交给一个SecurityContextHolder来管理该用户的信息,最后才会调用你自定义的token工具类生成JWT token值。
//用户校验完成,获取一个UsernamePasswordAuthenticationToken对象
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
//将UsernamePasswordAuthenticationToken对象交给SecurityContextHolder,表示用户已经通过身份验证,这一步是为了让SpringSecurity知晓用户对象是谁及其权限,并判断该用户是否有相应的访问资源的权限
SecurityContextHolder.getContext().setAuthentication(authentication);
//获取token
token = jwtTokenUtil.generateToken(userDetails);
在下面的demo中使用了一个map对象来设置主题信息,并在重载方法中通过setClaims方法来实现主题信息的声明,后续可以根据UserDetails对象的getSubject()方法来获得用户名。这个UserDetails对象的获取其实就是根据用户输入的用户名在已经缓存的用户信息List中找到对应的用户信息对象。
/**
* 根据负载生成JWT的token
*/
private String generateToken(Map claims) {
return Jwts.builder()
.setClaims(claims) // 设置JWT的声明(claims),即要在JWT中存储的信息。这个参数是一个Map,表示JWT的payload部分。
.setExpiration(generateExpirationDate()) //过期时间
.signWith(SignatureAlgorithm.HS512, secret) //根据后台设置的密钥来生成签名
.compact(); //构建并返回最终的JWT字符串
}
/**
* 根据用户信息生成token
*/
public String generateToken(UserDetails userDetails) {
Map claims = new HashMap<>();
claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
claims.put(CLAIM_KEY_CREATED, new Date());
return generateToken(claims);
}
拿到token后用户每次调用接口都在http的headert中添加一个叫Authorization的头,值为WT的token,后台程序通过对Authorization头中信息的解码及数字签名校验来获取其中的用户信息,从而实现认证和授权。
在上文中我们已经弄清楚了整个JWT的token令牌生成的流程了,接下来我们需要搞清楚如何整合Spring Security完成用户权限的授权。首先同样的我们事先需要获取到所有用户的权限以及信息,这部分内容并不是现在我们的重点。前面我们知道我们需要把用户信息存在一个个UserDetails对象,当用户成功登录时,UserDetails对象会被封装成Authentication对象,并且设置到Spring Security的安全上下文中(通常是SecurityContextHolder
),以便在后续的请求中进行鉴权和授权。首先我们需要弄清楚Spring Security的配置类:
/**
* @auther lzddl
* @description SpringSecurity的配置
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled=true)
public class SecurityConfig {
@Autowired
private RestfulAccessDeniedHandler restfulAccessDeniedHandler;
@Autowired
private RestAuthenticationEntryPoint restAuthenticationEntryPoint;
@Autowired
private IgnoreUrlsConfig ignoreUrlsConfig;
@Bean
SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
ExpressionUrlAuthorizationConfigurer.ExpressionInterceptUrlRegistry registry = httpSecurity
.authorizeRequests();
//不需要保护的资源路径允许访问
for (String url : ignoreUrlsConfig.getUrls()) {
registry.antMatchers(url).permitAll();
}
//允许跨域请求的OPTIONS请求,这是因为在跨域访问的正式请求发送前会发送一个Option请求,我们要开启跨域访问就必须允许所有的option请求
registry.antMatchers(HttpMethod.OPTIONS)
.permitAll();
httpSecurity.csrf()// 由于使用的是JWT,我们这里不需要csrf
.disable()
.sessionManagement()// 基于token,所以不需要session,下面设置session为无状态的
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.anyRequest()// 除上面外的所有请求全部需要鉴权认证
.authenticated();
// 禁用缓存
httpSecurity.headers().cacheControl();
// 添加JWT filter
httpSecurity.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
//添加自定义未授权和未登录结果返回
httpSecurity.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler)
.authenticationEntryPoint(restAuthenticationEntryPoint);
return httpSecurity.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter(){
return new JwtAuthenticationTokenFilter();
}
}
我们需要使用SecurityFilterChain来配置相关的操作,首先我们需要获取到httpSecurity对象,然后配置相应的访问路径白名单、允许跨域的Option请求、禁用csrf和session、开启除白名单外的鉴权认证功能、禁用缓存并添加JWT过滤器、添加自定义未授权和未登录结果返回。以上就是我们需要整合SpringSecurity的内容。其中鉴权部分我们需要注意的是JwtAuthenticationTokenFilter 部分,因为我们在配置类中开启了将filter放置在登录验证的功能前,因此这个功能其实就是为我们提供一个可以通过token记住用户态的功能。
/**
* @auther lzddl
* @description JWT登录授权过滤器
*/
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String authHeader = request.getHeader(this.tokenHeader);
if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
// 获取token
String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer "
// 根据token获取用户名
String username = jwtTokenUtil.getUserNameFromToken(authToken);
LOGGER.info("checking username:{}", username);
//若有,则获取用户信息并创建authentication对象
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(authToken, userDetails)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
LOGGER.info("authenticated user:{}", username);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
chain.doFilter(request, response);
}
}
真正的授权其实在用户信息对象的加载过程中就完成了!
当然了接口也需要使用@PreAuthorize注解来定义接口需要的权限,在配置类中我们还要@EnableGlobalMethodSecurity(prePostEnabled=true)注解来开启方法级的安全性!
该接口和UsernamePasswordAuthenticationToken类其实都是Spring Security中core包下管理。该接口定义了一个用户信息管理的api,要想使用该接口,我们需要自定义实现一个实现类在Spring Security中按照我们自定义的来动态权限配置列表,这里荔枝将其命名为AdminUserDetails类。
/**
* @auther lzddl
* @description SpringSecurity用户信息封装类
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Builder
public class AdminUserDetails implements UserDetails {
private String username;
private String password;
private List authorityList;
@Override
public Collection getAuthorities() {
return this.authorityList.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
this.authorityList.stream()是将authorityList列表对象转化成一个Stream方便我们操作集合对象,map()定义了authorityList中的每个字符串对象到SimpleGrantedAuthority 对象的映射关系!SimpleGrantedAuthority 是Spring Security提供的一个实现了GrantedAuthority 接口的简单权限授予类,它表示用户的权限。最后就是借助collect将SimpleGrantedAuthority 对象收集在一个List对象中。也就是说,这个List包含了用户具有的权限,可以被Spring Security用于进行身份验证和授权。
CollUtil类是Hutool插件中有关集合操作的工具类,里面封装了大量的操作集合数据的方法。
中文文档:https://www.hutool.cn/docs/#/
API手册:https://apidoc.gitee.com/dromara/hutool/
该接口是一个密码解析器,一般用来加密。Spring Security 要求容器中必须有 PasswordEncoder 实例,因此我们在用户的信息中必须使用该解析器来对密码进行解析。
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package org.springframework.security.crypto.password;
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
当然了该接口有很多实现类,我们可以创建该接口的实现类对象来指定加密的规则。但是如果我们使用的是@AutoWired注解的方式来将接口进行依赖注入,那么默认使用加密算法BCrypt。如果要修改加密算法,我们可以在配置类声明bean对象的时候修改:
/**
* @auther lzddl
* @description SpringSecurity的配置
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled=true)
public class SecurityConfig {
@Autowired
private RestfulAccessDeniedHandler restfulAccessDeniedHandler;
@Autowired
private RestAuthenticationEntryPoint restAuthenticationEntryPoint;
@Autowired
private IgnoreUrlsConfig ignoreUrlsConfig;
@Bean
SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
......
}
@Bean
public PasswordEncoder passwordEncoder() {
//使用MD4加密算法
return new Md4PasswordEncoder();
}
@Bean
public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter(){
return new JwtAuthenticationTokenFilter();
}
}
说起Serializable接口,我们一定会提到的一个名词就是序列化,那么什么是序列化呢?
Serializable接口是一个语义级别的一个接口,属于Java的io包中定义的接口,该接口没有定义任何的方法,只有实现了该接口的类才会有序列化和反序列化的状态。序列化和反序列化时Java在执行底层的IO读写操作,也就是实现对象数据和文件字节流转化的时候,告诉JVM的一种方式。实现了Serializable接口的类可以被ObjectOutputStream转换为字节流,同时也可以通过ObjectInputStream再将其解析为对象。
可序列化是一个可以被继承的状态。同时为了保证不可序列化类的子类被序列化,子类必须有一个无参的构造方法
序列化运行时与每个可序列化类关联一个版本号,称为serialVersionUlD。在反序列化期间使用它来验证序列化对象的发送方和接收方已经为该对象加载了类与序列化兼容。类的一个类对象的serialVersionUlD与相应发送方的serialVersionUlD不同时会导致在反序列化时抛出异常InvalidclassException。所以我们在实现Serializable接口的时候,一般还会要去尽量显示地定义serialVersionUID就比如:
private static final long serialVersionUID = 1L;
记住一定要是final类型的!
在正式开发项目中我个人认为可能鉴权这部分的功能会更加复杂一点,因为脚手架只是一个快速让我们接触上手的内容,因此这部分token拼接和请求头的生成其实是在Swagger中手动输入的哈哈哈。然后最重要的还是要弄清楚整个整合的流程,需要自定义的配置以及相应的鉴权流程,当然了JWTtoken的结构也是比较重要的!
今朝已然成为过去,明日依然向往未来!我是荔枝,在技术成长之路上与您相伴~~~
如果博文对您有帮助的话,可以给荔枝一键三连嘿,您的支持和鼓励是荔枝最大的动力!
如果博文内容有误,也欢迎各位大佬在下方评论区批评指正!!!