Bootstrap

一文搞懂SpringSecurity+JWT前后端分离~

简介

SpringSecurity属于Spring家族中的一款安全管理框架,,它提供了一套Web应用安全性的完整解决方案。主要的功能是认证授权

认证 :验证当前访问系统的是不是本系统的用户,并且要确定具体是哪个用户。
授权 :经过认证后判断当前用户是否有权限进行某个操作。

1、快速入门

1.1、创建一个SpringBoot工程

1、先创建一个最基本的SpringBoot工程,配置好相关数据库,并且编写一个Controller进行测试。

① 导入依赖

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
         <version>2.3.7.RELEASE</version>
    </parent>
	<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.2</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
   </dependencies>

② yml文件配置

server:
  port: 8080
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://xxxxxxxx:3306/security?characterEncoding=utf-8&serverTimezone=UTC
    username: root
    password: xxxxxx

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true
    call-setters-on-nulls: true

③创建数据库表编写启动类、实体类、Mapper

CREATE TABLE `sys_user` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
  `nick_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
  `password` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
  PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'
import java.io.Serializable;
import java.util.Date;


/**
 * 用户表(User)实体类
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "sys_user")
public class User implements Serializable {
    private static final long serialVersionUID = -40356785423868312L;
    @TableId
    private Long id;
    private String userName;
    private String nickName;
    private String password;
}
@Mapper
public interface UserMapper extends BaseMapper<User> {
}

④Controller

@RestController
public class HelloController {

    @Autowired
    private UserMapper userMapper;

    @GetMapping("/hello")
    public List hello(){
        List<User> list = userMapper.selectList(null);
        return list;
    }
}

⑤随便插入一条数据然后 测试
在这里插入图片描述

2.1、引入SpringSecurity

①引入依赖

   <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-security</artifactId>
   </dependency>

②重新访问hello接口测试
在这里插入图片描述

请求会被SpringSecurity拦截,然后跳转到SpringSecurity的登录页面,默认的用户名为user, 默认的密码会控制台显示

在这里插入图片描述
输入user和密码即可登录,然后就会请求到hello接口。

2、认证

2.1、原理初探

2.1.1、SpringSecurity完整流程

SpringSecurity的原理其实就是一个过滤器链,内部包含了各种各样的过滤器,入门案例的过滤器链如图所示。
在这里插入图片描述

UsernamePasswordAuthenticationFilter:负责处理我们登录页填写了用户名密码后的登录请求。入门案例中的认证工作主要又他负责。
ExceptionTranslationFilter:处理过滤器中抛出的任何AccessDeniedException(权限异常) 和AuthenticationException(认证异常)。
FilterSecurityInterceptor:负责权限校验的过滤器。

2.2.2、认证流程详解(登录)

就是 SpringSecurity 登录的这个功能是如何实现的。
由上面的图上首先经过UsernamePasswordAuthenticationFilter 那么他做了哪些动作呢?首先他会接受到默认页面提交的/login 的from中action请求,然后走认证的流程。

具体流程如下:
图片来之B站Up主 “三更草堂”
说明:
Authentication:它他的实现类表示当前访问系统的用户,封装了用户的相关信息。
AuthenticationManager: 这个接口中定义了Authentication的方法。(就是说可以直接调用AuthenticationManager中的方法认证用户的信息是否正确)。
UserDetailsService:这个接口里面定义了一个查询用户信息的方法,将用户的信息封装成一个UserDetails对象返回,在然后会在Provider中对比前面传递过来的Authentication和后面返回的Authentication。如果正确则会给Authentication设置上权限信息然后返回。

问题:
①在前后端分离的情况下,我们肯定不能用系统默认的UsernamePasswordAuthenticationFilter 去接收username和password然后它调用AuthenticationManager中的方法继续去走认证流程
②我们也不能使用系统默认的用户名和密码进行登录,而是应该是用户输入后我们去数据库查询才对。
③登录完之后UsernamePasswordAuthenticationFilter 会将Authentication存入SecurityContextHolder的上下文之中,表示我们确实登录了,然后才可以访问其他接口

2.2、解决问题

2.2.1、思路分析

根据上述的问题,我们则可以自定义封路接口然后手动调用AuthenticationManager中的方法进行校验,并且手动编写UserDetails接口,让它能从数据库中查询数据。然后登陆成功后,访问其他接口的时候我们得手动去设置SecurityContextHolder的上下文

登录
① 自定义登录接口
调用ProviderManager的方法进行认证,如果认证通过则生成JWT
(JWT中包含用户的userId)并且为了之后减少数据库的交互,我们把查到的用户信息存入redis
② 自定义UserDetailService
在我们自己的实现类中查询数据库

校验
当登录成功后,我们前端请求其他接口的时候就需要在请求头中携带token

① 自定义Jwt认证过滤器
获取token,解析token拿到用户userId,查询redis获取到用户的信息,然后手动存入SecurityContextHolder的上下文中。

2.2.2、准备工作

①添加依赖

        <!--redis依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!--fastjson依赖-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.33</version>
        </dependency>
        <!--jwt依赖-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.12.0</version>
        </dependency>

②添加redis的序列化规则配置(使用json格式的序列化)

注意:因为添加了redis所以记得要在yml中配置redis的连接哦


import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import com.alibaba.fastjson.parser.ParserConfig;
import org.springframework.util.Assert;
import java.nio.charset.Charset;

/**
 * Redis使用FastJson序列化
 * 
 * @author sg
 */
public class FastJsonRedisSerializer<T> implements RedisSerializer<T>
{

    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

    private Class<T> clazz;

    static
    {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
    }

    public FastJsonRedisSerializer(Class<T> clazz)
    {
        super();
        this.clazz = clazz;
    }

    @Override
    public byte[] serialize(T t) throws SerializationException
    {
        if (t == null)
        {
            return new byte[0];
        }
        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException
    {
        if (bytes == null || bytes.length <= 0)
        {
            return null;
        }
        String str = new String(bytes, DEFAULT_CHARSET);

        return JSON.parseObject(str, clazz);
    }


    protected JavaType getJavaType(Class<?> clazz)
    {
        return TypeFactory.defaultInstance().constructType(clazz);
    }
}

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    @SuppressWarnings(value = { "unchecked", "rawtypes" })
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
    {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);

        // 使用StringRedisSerializer来序列化和反序列化redis的key值
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);

        // Hash的key也采用StringRedisSerializer的序列化方式
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }
}

③ 前后端分离,所以使用统一响应类

import com.fasterxml.jackson.annotation.JsonInclude;


@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseResult<T> {
     private Integer code;
     private String msg;
     private T data;

    public ResponseResult(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
    }
   public ResponseResult(Integer code, T data) {
        this.code = code;
        this.data = data;
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }

    public ResponseResult(Integer code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }
}

④JWT的工具类


import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;

/**
 * JWT工具类
 */
public class JwtUtil {

    //有效期为
    public static final Long JWT_TTL = 60 * 60 *1000L;// 60 * 60 *1000  一个小时
    //设置秘钥明文
    public static final String JWT_KEY = "yzh";

    public static String getUUID(){
        String token = UUID.randomUUID().toString().replaceAll("-", "");
        return token;
    }
    
    /**
     * 生成jtw
     * @param subject token中要存放的数据(json格式)
     * @return
     */
    public static String createJWT(String subject) {
        JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间
        return builder.compact();
    }

    /**
     * 生成jtw
     * @param subject token中要存放的数据(json格式)
     * @param ttlMillis token超时时间
     * @return
     */
    public static String createJWT(String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
        return builder.compact();
    }

    private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        SecretKey secretKey = generalKey();
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        if(ttlMillis==null){
            ttlMillis=JwtUtil.JWT_TTL;
        }
        long expMillis = nowMillis + ttlMillis;
        Date expDate = new Date(expMillis);
        return Jwts.builder()
                .setId(uuid)              //唯一的ID
                .setSubject(subject)   // 主题  可以是JSON数据
                .setIssuer("yzh")     // 签发者
                .setIssuedAt(now)      // 签发时间
                .signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
                .setExpiration(expDate);
    }

    /**
     * 创建token
     * @param id
     * @param subject
     * @param ttlMillis
     * @return
     */
    public static String createJWT(String id, String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
        return builder.compact();
    }

    public static void main(String[] args) throws Exception {
        String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJjYWM2ZDVhZi1mNjVlLTQ0MDAtYjcxMi0zYWEwOGIyOTIwYjQiLCJzdWIiOiJzZyIsImlzcyI6InNnIiwiaWF0IjoxNjM4MTA2NzEyLCJleHAiOjE2MzgxMTAzMTJ9.JVsSbkP94wuczb4QryQbAke3ysBDIL5ou8fWsbt_ebg";
        Claims claims = parseJWT(token);
        System.out.println(claims);
    }

    /**
     * 生成加密后的秘钥 secretKey
     * @return
     */
    public static SecretKey generalKey() {
        byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
        SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
        return key;
    }
    
    /**
     * 解析
     *
     * @param jwt
     * @return
     * @throws Exception
     */
    public static Claims parseJWT(String jwt) throws Exception {
        SecretKey secretKey = generalKey();
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();
    }


}

⑤redis的工具类


import java.util.*;
import java.util.concurrent.TimeUnit;

@SuppressWarnings(value = { "unchecked", "rawtypes" })
@Component
public class RedisCache
{
    @Autowired
    public RedisTemplate redisTemplate;

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     */
    public <T> void setCacheObject(final String key, final T value)
    {
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     * @param timeout 时间
     * @param timeUnit 时间颗粒度
     */
    public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit)
    {
        redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout)
    {
        return expire(key, timeout, TimeUnit.SECONDS);
    }

    /**
     * 设置有效时间
     *
     * @param key Redis键
     * @param timeout 超时时间
     * @param unit 时间单位
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout, final TimeUnit unit)
    {
        return redisTemplate.expire(key, timeout, unit);
    }

    /**
     * 获得缓存的基本对象。
     *
     * @param key 缓存键值
     * @return 缓存键值对应的数据
     */
    public <T> T getCacheObject(final String key)
    {
        ValueOperations<String, T> operation = redisTemplate.opsForValue();
        return operation.get(key);
    }

    /**
     * 删除单个对象
     *
     * @param key
     */
    public boolean deleteObject(final String key)
    {
        return redisTemplate.delete(key);
    }

    /**
     * 删除集合对象
     *
     * @param collection 多个对象
     * @return
     */
    public long deleteObject(final Collection collection)
    {
        return redisTemplate.delete(collection);
    }

    /**
     * 缓存List数据
     *
     * @param key 缓存的键值
     * @param dataList 待缓存的List数据
     * @return 缓存的对象
     */
    public <T> long setCacheList(final String key, final List<T> dataList)
    {
        Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
        return count == null ? 0 : count;
    }

    /**
     * 获得缓存的list对象
     *
     * @param key 缓存的键值
     * @return 缓存键值对应的数据
     */
    public <T> List<T> getCacheList(final String key)
    {
        return redisTemplate.opsForList().range(key, 0, -1);
    }

    /**
     * 缓存Set
     *
     * @param key 缓存键值
     * @param dataSet 缓存的数据
     * @return 缓存数据的对象
     */
    public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet)
    {
        BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
        Iterator<T> it = dataSet.iterator();
        while (it.hasNext())
        {
            setOperation.add(it.next());
        }
        return setOperation;
    }

    /**
     * 获得缓存的set
     *
     * @param key
     * @return
     */
    public <T> Set<T> getCacheSet(final String key)
    {
        return redisTemplate.opsForSet().members(key);
    }

    /**
     * 缓存Map
     *
     * @param key
     * @param dataMap
     */
    public <T> void setCacheMap(final String key, final Map<String, T> dataMap)
    {
        if (dataMap != null) {
            redisTemplate.opsForHash().putAll(key, dataMap);
        }
    }

    /**
     * 获得缓存的Map
     *
     * @param key
     * @return
     */
    public <T> Map<String, T> getCacheMap(final String key)
    {
        return redisTemplate.opsForHash().entries(key);
    }

    /**
     * 往Hash中存入数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @param value 值
     */
    public <T> void setCacheMapValue(final String key, final String hKey, final T value)
    {
        redisTemplate.opsForHash().put(key, hKey, value);
    }

    /**
     * 获取Hash中的数据
     *
     * @param key Redis键
     * @param hKey Hash键
     * @return Hash中的对象
     */
    public <T> T getCacheMapValue(final String key, final String hKey)
    {
        HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
        return opsForHash.get(key, hKey);
    }

    /**
     * 删除Hash中的数据
     * 
     * @param key
     * @param hkey
     */
    public void delCacheMapValue(final String key, final String hkey)
    {
        HashOperations hashOperations = redisTemplate.opsForHash();
        hashOperations.delete(key, hkey);
    }

    /**
     * 获取多个Hash中的数据
     *
     * @param key Redis键
     * @param hKeys Hash键集合
     * @return Hash对象集合
     */
    public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys)
    {
        return redisTemplate.opsForHash().multiGet(key, hKeys);
    }

    /**
     * 获得缓存的基本对象列表
     *
     * @param pattern 字符串前缀
     * @return 对象列表
     */
    public Collection<String> keys(final String pattern)
    {
        return redisTemplate.keys(pattern);
    }
}

2.2.3、UserDetailsService接口实现

创建UserDetailsService的实现类重写其中的方法,因为需要返回一个UserDetails接口对象,所以我们得先定义一个实现类

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {

    private User user;
    
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null; //该用户有哪些权限
    }

    @Override
    public String getPassword() {
        return this.user.getPassword();
    }

    @Override
    public String getUsername() {
        return this.user.getUserName();
    }

    @Override
    public boolean isAccountNonExpired() {//帐号是不是没有过期
        return true;
    }

    @Override
    public boolean isAccountNonLocked() { //是不是没有被锁定
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() { //凭证是不是没有过期
        return true;
    }

    @Override
    public boolean isEnabled() {  //是否可用
        return true;
    }
}

UserDetailsService的实现

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if(StringUtils.isBlank(username)){
            throw new RuntimeException("请输入用户名");
        }
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        wrapper.eq("user_name",username).last("limit 1");
        User user = userMapper.selectOne(wrapper);
        if(Objects.isNull(user)){
            throw new RuntimeException("用户名不存在");
        }

        return new LoginUser(user);
    }
}

注意,如果你现在想测试是否能使用自定义的UserDetailsService的逻辑进行登录的话,因为在密码比较中SpringSecurity会使用默认的PasswordEncoder编码加密将用户传递的密码加上{noop},所以想测试的话数据库密码记得修改

在这里插入图片描述

2.2.4、密码加密存储

实际过程中我们是不会使用明文进行加密的,所以我们需要将PasswordEncoder进行替换,在SpringSecurity我们一般使用BCryptPasswordEncoder进行加密

编写SpringSecurity的配置

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

测试 (之后我们数据库中存储的密码都得是这种加密后的数据)

在这里插入图片描述

2.2.5、登录接口

我们需要自定义登录的接口

@RestController
@RequestMapping("/user")
public class LoginController {

    @Autowired
    private LoginService loginService;

    @PostMapping("/login")
    public ResponseResult login(@RequestBody User user){
        return loginService.login(user);
    }
}

我们需要对这个接口进行放行,并且我们在接口中需要手动去调用AuthenticationManager的authenticate的方法,所以我们还需要在将AuthenticationManager注入到IOC容器。


@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
          //关闭csrf
          .csrf().disable()
          //不通过Session获取SecurityContext
          .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
          .and()
          .authorizeRequests()
          // 对于登录接口 允许匿名访问
          .antMatchers("/user/login").anonymous() //表示匿名可访问
          // 除上面外的所有请求全部需要鉴权认证
          .anyRequest().authenticated();
    }
    
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

登录接口具体实现


@Service
public class LoginServiceImpl implements LoginService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisCache redisCache;

    @Override
    public ResponseResult login(User user) {

        //使用Authentication的实现类
        Authentication authentication = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());

        //手动调用方法去认证 他会自动调用UserDetailsService查 然后对比啥的
        Authentication authenticate = authenticationManager.authenticate(authentication);
        if(Objects.isNull(authenticate)){ //说明输入错误
            throw new  RuntimeException("用户名或密码错误");
        }
        //拿到用户信息 然后生成jwt返回给前端,并且将用户的信息存入redis
        LoginUser loginUser = (LoginUser)authenticate.getPrincipal(); // 这个其实就是UserDetails 也就是LoginUser
        String userId = loginUser.getUser().getId().toString();


        String jwt = JwtUtil.createJWT(userId);
        redisCache.setCacheObject("login:"+userId,loginUser);//将用户信息直接存入redis

        Map<String, String> map = new HashMap<>();
        map.put("token",jwt);
        return new ResponseResult(200,map);
    }
}

测试
在这里插入图片描述
到这里,我们的登录接口就完事了,前端就获取到了token,之后前端如果想访问其他接口,那么就直接请求头中携带上token即可

2.2.6、认证过滤器

回顾:
③登录完之后UsernamePasswordAuthenticationFilter 会将Authentication存入SecurityContextHolder的上下文之中,表示我们确实登录了,然后才可以访问其他接口

解决:
因为现在是前后端分离 所以在访问其他接口之前我们需要手动在SecurityContextHolder设置上下文。这样SpringSecurity才会认为他确实登录了。才会选择放行


@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private RedisCache redisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //我们先拿到请求头中的token
        String token = request.getHeader("token");
        if(StringUtils.isBlank(token)){
            //说明没有携带token 那么直接放行 之后的过滤器肯定会报错,那么就说明用户没有登录
            filterChain.doFilter(request,response);
            return;
        }
        //解析token
        String userid;
        try {
            Claims claims = JwtUtil.parseJWT(token);
            userid  = claims.getSubject();
        } catch (Exception e) {
            e.printStackTrace();
            //就说明token失效了 或者是token无效
            throw new RuntimeException("token无效");
        }
        //从redis中拿到用户的信息,给SecurityContextHolder设置上下文
        LoginUser loginUser = (LoginUser)redisCache.getCacheObject("login:" + userid);
        if(Objects.isNull(loginUser)){
            throw new RuntimeException("用户未登录");
        }
        //存入SecurityContextHolder上下文当中  注意 这里必须得使用三个参数的authentication
        //第三个参数为授权 也就是用户是啥身份 先不管
        Authentication authentication = new UsernamePasswordAuthenticationToken(loginUser,null,null);
        SecurityContextHolder.getContext().setAuthentication(authentication);
        //放行
        filterChain.doFilter(request,response); //那么就正常的请求接口去啦!!!
    }
}

我们还需要SpringSecurity中配置上该过滤器

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private  JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
          //关闭csrf
          .csrf().disable()
          //不通过Session获取SecurityContext
          .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
          .and()
          .authorizeRequests()
          // 对于登录接口 允许匿名访问
          .antMatchers("/user/login").anonymous() //表示匿名可访问
          // 除上面外的所有请求全部需要鉴权认证
          .anyRequest().authenticated();

          http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

测试 登录成功之后携带token再请求hello接口

在这里插入图片描述

2.2.7、退出登录

退出接口

    @PostMapping("/logout")
    public ResponseResult logout(){
        return loginService.logout();
    }

具体实现 直接删除redis中的数据即可

    @Override
    public ResponseResult logout() {
        //因为这个方法 是通过了jwt过滤器执行到这里的 所以SecurityContextHolder上下文是一样的
        LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        //拿到用户id删除redis中的数据
        String userId  = loginUser.getUser().getId().toString();
        redisCache.deleteObject("login:"+userId);
        return new ResponseResult(200,"退出成功");
    }

测试
在这里插入图片描述
再次访问hello接口,就会提示没有权限
在这里插入图片描述

3、授权

在很多系统中,比如xx管理系统,不同的角色所具备权限也不一样,比如管理员能删除或者修改信息或者查询,而阅读者只能查询信息。
虽然说前端可以动态的显示菜单,但如果有人拿到了接口,是不是也同样可以做其他操作呢?所以我们可以对接口进行设置上响应的权限,然后带有该权限的用户才能访问

3.1、授权的流程

根据最上面的调用API接口过滤器的流程,FilterSecurityInterceptor会判断用户是否具有响应的权限,他会从SecurityContextHolder中拿到Authentication中拿到用户用户所具有的权限,

3.2、授权实现

3.2.1、先给具体的资源设置上所需权限

SpringSecurity给我们提供了基于注解的权限控制,我们先开启相关配置

@EnableGlobalMethodSecurity(prePostEnabled = true)

之后就可以使用注解了

    @PreAuthorize("hasAuthority('test')")
    @GetMapping("/hello")
    public List hello(){
        List<User> list = userMapper.selectList(null);
        return list;
    }

访问接口测试,会发现没有权限
在这里插入图片描述

3.2.2、配置权限信息

先写死做测试,我们现在LoginUser中加入两个参数

注意,之所以不直接传递authorities,而是传递permission,在getAuthorities 时动态的使用permission封装authorities,那是因为redis对于 List<GrantedAuthority>没法序列化。反序列化时会报错。

@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {

    private User user;

    private List<String> permission;

    public LoginUser(User user,List<String> permission) {
        this.user = user;
        this.permission=permission;
    }

    @JSONField(serialize = false)
    private List<GrantedAuthority>  authorities;
	
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        authorities=new ArrayList<>();
        //注意 为什么这里不直接返回
        for (String perm : permission) {
            authorities.add(new SimpleGrantedAuthority(perm));
        }
        return authorities; //该用户有哪些权限
    }

    @Override
    public String getPassword() {
        return this.user.getPassword();
    }

    @Override
    public String getUsername() {
        return this.user.getUserName();
    }

    @Override
    public boolean isAccountNonExpired() {//帐号是不是没有过期
        return true;
    }

    @Override
    public boolean isAccountNonLocked() { //是不是没有被锁定
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() { //凭证是不是没有过期
        return true;
    }

    @Override
    public boolean isEnabled() {  //是否可用
        return true;
    }
}

接着我们在UserDetailsService中传递所具有的权限

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if(StringUtils.isBlank(username)){
            throw new RuntimeException("请输入用户名");
        }
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        wrapper.eq("user_name",username).last("limit 1");
        User user = userMapper.selectOne(wrapper);
        if(Objects.isNull(user)){
            throw new RuntimeException("用户名不存在");
        }

		//传递用户所具有的权限
        List<String> list = new ArrayList<>(Arrays.asList("test", "admin"));
        return new LoginUser(user,list);
    }
}

最后在过滤器中设置SecurityContextHolder上下文时也设置上权限,那么后面的权限过滤器就会拿到对应的权限去判断我们是否有权限

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private RedisCache redisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //我们先拿到请求头中的token
        String token = request.getHeader("token");
        if(StringUtils.isBlank(token)){
            //说明没有携带token 那么直接放行 之后的过滤器肯定会报错,那么就说明用户没有登录
            filterChain.doFilter(request,response);
            return;
        }
        //解析token
        String userid;
        try {
            Claims claims = JwtUtil.parseJWT(token);
            userid  = claims.getSubject();
        } catch (Exception e) {
            e.printStackTrace();
            //就说明token失效了 或者是token无效
            throw new RuntimeException("token无效");
        }
        //从redis中拿到用户的信息,给SecurityContextHolder设置上下文
        LoginUser loginUser = (LoginUser)redisCache.getCacheObject("login:" + userid);
        if(Objects.isNull(loginUser)){
            throw new RuntimeException("用户未登录");
        }
        //存入SecurityContextHolder上下文当中  注意 这里必须得使用三个参数的authentication 
        //第三个参数则为权限

        Authentication authentication = new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authentication);
        //放行
        filterChain.doFilter(request,response); //那么就正常的请求接口去啦!!!
    }
}

测试
在这里插入图片描述

3.2.3、从数据库中查询信息

在这里插入图片描述
这里就不演示了,写累了,相信可爱的你一定能搞定…

4、自定义异常处理器

从上面测试的例子中我们可以看到,在如果认证失败或者是没有权限的话,就会返回对应的json数据,但是这与我们统一返回的json格式不一致,所以我们需要自定义异常处理器

{
    "timestamp": "2022-04-27T08:49:11.475+00:00",
    "status": 403,
    "error": "Forbidden",
    "message": "",
    "path": "/login"
}

准备工作,我们可以先导入一个WebUtil工具类,以便向前端写出json数据


import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class WebUtils
{
    /**
     * 将字符串渲染到客户端
     *
     * @param response 渲染对象
     * @param string 待渲染的字符串
     * @return null
     */
    public static String renderString(HttpServletResponse response, String string) {
        try
        {
            response.setStatus(200);
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().print(string);
        }
        catch (IOException e)
        {
            e.printStackTrace();
        }
        return null;
    }
}

4.1、自定义异常处理

在SpringSecurity中,认证或授权出现异常会被ExceptionTranslationFilter捕获到,他会去判断是认证失败还是授权失败出现异常。
如果是认证过程中出现异常,那么异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint的方法对异常进行处理。
如果是授权过程中出现异常,会被封装成AccessDeniedException然后调用AccessDeniedHandler的方法对异常进行处理。

所以,对于自定义AuthenticationEntryPointAccessDeniedHandler然后配置给SpringSecurity即可。

①自定义AuthenticationEntryPoint实现类

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());//401 表示没有授权
        ResponseResult result = new ResponseResult(401,"认证失败请重新登录");
        WebUtils.renderString(response, JSON.toJSONString(result));
    }
}

②自定义AccessDeniedHandler实现类

@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
        response.setStatus(HttpStatus.FORBIDDEN.value()); //403
        ResponseResult result = new ResponseResult(403, "权限不足无法访问");
        WebUtils.renderString(response, JSON.toJSONString(result));
    }
}

配置给SpringSecurity

    @Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;
    
    @Autowired
    private AccessDeniedHandler accessDeniedHandler;

	http.exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler);

测试,如果帐号和密码输入错误
在这里插入图片描述
如果权限不足
在这里插入图片描述

5、跨域

即使我们通过Cors解决了跨域,但我们的资源都会被SpringSecurity进行保护,所以还是会存在跨域
解决跨域需要对SpringSecurity进行配置

①先配置SpringBoot的跨域

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
      // 设置允许跨域的路径
        registry.addMapping("/**")
          .allowedOriginPatterns("*")// 设置允许跨域请求的域名
          .allowCredentials(true)   // 是否允许cookie
          .allowedMethods("GET", "POST", "DELETE", "PUT") // 设置允许的请求方式
          .allowedHeaders("*") // 设置允许的header属性
          .maxAge(3600);// 跨域允许时间
    }
}

②开启SpringSecurity的跨域

直接在SpringSecurity的配置中加上一行代码即可

http.cors();

到这里,SpringSecurity+Jwt前后端分离就完结了,就是这么简单,完结撒花。

6、后记

生活朗朗,万物可爱,人间值得。。未来可期。

;