Spring Security+JWT实现前后端分离认证授权

一、Spring Security是什么?

Spring Security是一个基于Spring框架的安全性解决方案,用于保护Java应用程序的安全性。它提供了一套全面的安全性功能,包括身份验证、授权、密码管理、会话管理等。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富。

​ 一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。一般Web应用的需要进行认证和授权,​ 而认证和授权也是SpringSecurity作为安全框架的核心功能:

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

Spring Security的主要特点包括:

  1. 身份验证和授权:Spring Security提供了多种身份验证和授权的方式,包括基于表单、基于HTTP基本认证、基于LDAP等。它还支持细粒度的授权控制,可以根据用户的角色和权限来限制访问。

  2. 安全性过滤器链:Spring Security使用一系列过滤器来处理安全性相关的任务,例如身份验证、授权、会话管理等。这些过滤器可以按照特定的顺序组成过滤器链,以便按需执行。

  3. 集成Spring框架:Spring Security与Spring框架紧密集成,可以方便地与其他Spring组件一起使用。它可以与Spring MVC、Spring Boot等框架无缝集成,提供全面的安全性解决方案。

  4. 可扩展性:Spring Security提供了丰富的扩展点和自定义选项,可以根据应用程序的需求进行定制。开发人员可以自定义身份验证逻辑、授权策略、密码加密算法等。

Spring Security是一个功能强大、灵活可扩展的安全性解决方案,可以帮助开发人员轻松地实现应用程序的安全性需求。

二、快速入门

2.1.环境准备

我们先搭建一个简单的SpringBoot工程,添加Spring Security的依赖:

        <!--安全框架springsecurity依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

2.2.编写测试代码

创建controller包,在下面创建代码如下:

@RestController
public class HelloController {

    @RequestMapping("/hello")
    public String hello(){
        return "hello";
    }
}

配置application.yaml这里默认即可

引入依赖后我们在尝试去访问之前的接口就会自动跳转到一个SpringSecurity的默认登陆页面,默认用户名是user,密码会输出在控制台。 必须登陆之后才能对接口进行访问。

三、 认证

3.1.登陆校验流程

流程如下:

JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在网络应用间传递信息的一种基于JSON的安全性传输方式。它由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。

  • 头部(Header)包含了关于令牌的元数据信息,例如令牌的类型(JWT)、签名算法等。
  • 载荷(Payload)包含了实际传输的数据,可以包含一些标准的声明(例如iss(签发者)、exp(过期时间)、sub(主题)等)以及自定义的声明。
  • 签名(Signature)用于验证令牌的真实性和完整性。它由头部、载荷、密钥和指定的签名算法生成,可以防止令牌被篡改。

JWT的优点包括:

  1. 简洁性:JWT使用JSON格式进行数据传输,结构简单清晰,易于理解和使用。

  2. 安全性:JWT使用签名进行验证,可以防止令牌被篡改。同时,由于令牌中包含了用户的身份信息,可以减少对服务器的查询次数,提高安全性。

  3. 可扩展性:JWT可以包含自定义的声明,可以根据应用程序的需求进行扩展。

  4. 无状态性:由于JWT包含了所有必要的信息,服务器不需要在后续请求中保存用户的状态,可以减轻服务器的负担。

JWT是一种简洁、安全、可扩展的身份验证和授权机制,广泛应用于Web应用程序和API的安全性传输中。

3.2.实现原理说明

要知道如何实现登录校验,就必须要先知道入门案例中SpringSecurity的流程。

3.2.1 SpringSecurity完整流程

SpringSecurity的本质上就是一个过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器:

图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。

  • UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。上述案例的认证工作主要有它负责。
  • ExceptionTranslationFilter: 处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。
  • FilterSecurityInterceptor: 负责权限校验的过滤器

启动springboot项目查看当前系统中SpringSecurity过滤器链中有15个过滤器,按照下面的先后顺序执行:

上述的过滤器含义如下:

过滤器名称 作用
WebAsyncManagerIntegrationFilter 将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成。
SecurityContextPersistenceFilter 在每次请求处理之前将该请求相关的安全上下文信息加载到 SecurityContextHolder 中,然后在该次请求处理完成之后,将SecurityContextHolder 中关于这次请求的信息存储到一个“仓储”中,然后将SecurityContextHolder 中的信息清除,例如在 Session 中维护一个用户的安全信息就是这个过滤器处理的。
HeaderWriterFilter 用于将头信息加入响应中
CsrfFilter 用于处理跨站请求伪造。
LogoutFilter 用于处理退出登录
UsernamePasswordAuthenticationFilter 用于处理基于表单的登录请求,从表单中获取用户名和密码。默认情况下处理来自 /login 的请求。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的usernameParameter 和 passwordParameter 两个参数的值进行修改。
DefaultLoginPageGeneratingFilter 如果没有配置登录页面,那系统初始化时就会配置这个过滤器,并且用于在需要进行登录时生成一个登录表单页面
BasicAuthenticationFilter 检测和处理 http basic 认证
RequestCacheAwareFilter 用来处理请求的缓存
SecurityContextHolderAwareRequestFilter 主要是包装请求对象 request。
AnonymousAuthenticationFilter 检测 SecurityContextHolder 中是否存在Authentication 对象,如果不存在为其提供一个匿名 Authentication。
AnonymousAuthenticationFilter 检测 SecurityContextHolder 中是否存在Authentication 对象,如果不存在为其提供一个匿名 Authentication。
AnonymousAuthenticationFilter 检测 SecurityContextHolder 中是否存在Authentication 对象,如果不存在为其提供一个匿名 Authentication。
FilterSecurityInterceptor 可以看做过滤器链的出口。
RememberMeAuthenticationFilter 当用户没有登录而直接访问资源时, 从 cookie里找出用户的信息, 如果 Spring Security 能够识别出用户提供的 remember me cookie,用户将不必填写用户名和密码, 而是直接登录进入系统,该过滤器默认不开启。

3.2.2.认证流程详解

上图中场景方法说明:

  • Authentication接口:其实现类表示当前访问系统的用户,封装了用户相关信息。
  • AuthenticationManager接口:定义了认证Authentication的方法
  • UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。
  • UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。

3.3.问题解决

3.3.1 思路分析

登录

​ ①自定义登录接口

  • ​ 调用ProviderManager的方法进行认证,如果认证通过生成jwt
  • ​ 把用户信息存入redis中

​ ②自定义UserDetailsService

  • ​ 在实现类中去查询数据库

校验:

​ ①定义Jwt认证过滤器

  • ​ 获取token
  • ​ 解析token获取其中的userid
  • ​ 从redis中获取用户信息
  • ​ 存入SecurityContextHolder

3.3.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>

②响应类

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;
    }
}

③ 工具类

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;
    }
}

3.3.3 实现

3.3.3.1.数据库校验用户

从之前的分析得知可以自定义一个UserDetailsService,让SpringSecurity使用我们的UserDetailsService。我们自己的UserDetailsService可以从数据库中查询用户名和密码。​

  • 我们先创建一个用户表, 建表语句如下:
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 '密码',
  `status` CHAR(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
  `email` VARCHAR(64) DEFAULT NULL COMMENT '邮箱',
  `phonenumber` VARCHAR(32) DEFAULT NULL COMMENT '手机号',
  `sex` CHAR(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
  `avatar` VARCHAR(128) DEFAULT NULL COMMENT '头像',
  `user_type` CHAR(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
  `create_by` BIGINT(20) DEFAULT NULL COMMENT '创建人的用户id',
  `create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
  `update_by` BIGINT(20) DEFAULT NULL COMMENT '更新人',
  `update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
  `del_flag` INT(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
  PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'
  • ​在springboot项目中引入依赖如下:
    <dependencies>
        <!--安全框架springsecurity依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        
        <!--springboot启动器-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <!--lombok-->
        <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>

        <!--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>

        <!--mybatis-plus依赖-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.0-RELEASE</version>
        </dependency>

        <!--添加druid连接池-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.10</version>
        </dependency>

        <!--MySQL数据库连接的依赖-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.28</version>
        </dependency>

    </dependencies>
  • 设置application.yaml配置文件如下:
spring:
  datasource:
    # 使用阿里的Druid连接池
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    # 填写你数据库的url、登录名、密码和数据库名
    url: jdbc:mysql://127.0.0.1:3306/mydb?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
  druid:
    # 连接池的配置信息
    # 初始化大小,最小,最大
    initial-size: 5
    min-idle: 5
    maxActive: 20
    # 配置获取连接等待超时的时间
    maxWait: 60000
    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
    timeBetweenEvictionRunsMillis: 60000
    # 配置一个连接在池中最小生存的时间,单位是毫秒
    minEvictableIdleTimeMillis: 300000
    validationQuery: SELECT 1
    testWhileIdle: true
    testOnBorrow: false
    testOnReturn: false
    # 打开PSCache,并且指定每个连接上PSCache的大小
    poolPreparedStatements: true
    maxPoolPreparedStatementPerConnectionSize: 20
    # 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
    filters: stat,wall,slf4j
    # 通过connectProperties属性来打开mergeSql功能;慢SQL记录
    connectionProperties: druid.stat.mergeSql\=true;druid.stat.slowSqlMillis\=5000
    # 配置DruidStatFilter
    web-stat-filter:
      enabled: true
      url-pattern: "/*"
      exclusions: "*.js,*.gif,*.jpg,*.bmp,*.png,*.css,*.ico,/druid/*"
    # 配置DruidStatViewServlet
    stat-view-servlet:
      url-pattern: "/druid/*"
      # IP白名单(没有配置或者为空,则允许所有访问)
      allow: 127.0.0.1,192.168.8.109
      # IP黑名单 (存在共同时,deny优先于allow)
      deny: 192.168.1.188
      #  禁用HTML页面上的“Reset All”功能
      reset-enable: false
      # 登录名
      login-username: admin
      # 登录密码
      login-password: 123456
  servlet:
    multipart:
      #设置文件上传单个文件的大小
      max-file-size: 10MB
      #设置多个文件上传总文件的大小
      file-size-threshold: 100MB
server:
  port: 8080
  servlet:
    context-path: /springboot06
  • 在pojo包下创建User实体类

类名上加@TableName(value = "sys_user") ,id字段上加 @TableId 注解:

import java.io.Serializable;
import java.util.Date;
 
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
    private static final long serialVersionUID = -40356785423868312L;
    
    /**
    * 主键
    */
    private Long id;
    /**
    * 用户名
    */
    private String userName;
    /**
    * 昵称
    */
    private String nickName;
    /**
    * 密码
    */
    private String password;
    /**
    * 账号状态(0正常 1停用)
    */
    private String status;
    /**
    * 邮箱
    */
    private String email;
    /**
    * 手机号
    */
    private String phonenumber;
    /**
    * 用户性别(0男,1女,2未知)
    */
    private String sex;
    /**
    * 头像
    */
    private String avatar;
    /**
    * 用户类型(0管理员,1普通用户)
    */
    private String userType;
    /**
    * 创建人的用户id
    */
    private Long createBy;
    /**
    * 创建时间
    */
    private Date createTime;
    /**
    * 更新人
    */
    private Long updateBy;
    /**
    * 更新时间
    */
    private Date updateTime;
    /**
    * 删除标志(0代表未删除,1代表已删除)
    */
    private Integer delFlag;
}
  • 在mapper包下定义UserMapper接口

这里主要是利用mybatis-plus查询数据库中用户信息

package com.augus.mapper;

import com.augus.pojo.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

public interface UserMapper extends BaseMapper<User> {
}
  • 在sprinboot项目启动类配置扫描mapper包
@SpringBootApplication
@MapperScan("com.augus.mapper") //配置mybatis-plus包扫描
public class Springboot06Application {

    public static void main(String[] args) {
        SpringApplication.run(Springboot06Application.class, args);
    }
}
  • ​创建测试方法,测试mybatis-plus中是否能正常使用
@SpringBootTest
class Springboot06ApplicationTests {
    @Autowired
    private UserMapper userMapper;

    @Test
    public void testUserMapper() {
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        wrapper.eq("user_name", "zhangsan");

        //查询登录的用户信息是否存在
        User user = userMapper.selectOne(wrapper);
        System.out.println(user);
    }
}
  • 在service包中创建一个类实现UserDetailsService接口,重写其中的方法。作用呢就是根据用户名从数据库中查询用户信息(核心)
package com.augus.service.impl;

import com.augus.domain.LoginUser;
import com.augus.mapper.UserMapper;
import com.augus.pojo.User;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Objects;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;


    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        //根据用户名查询用户信息
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        wrapper.eq("user_name",s);

        User user = userMapper.selectOne(wrapper);

        System.out.println(user);
        //如果查询不到数据就通过抛出异常来给出提示
        if(Objects.isNull(user)){
            throw new RuntimeException("用户名或者密码错误");
        }

        //TODO 根据用户查询权限信息 添加到LoginUser中

        //封装成UserDetails对象返回
        return new LoginUser(user);
    }
}
  • 在domain包下创建LoginUser实现UserDetails

UserDetailsServiceImpl方法的返回值是UserDetails类型,UserDetails是一个接口所以需要定义一个实现类,实现该接口,把用户信息封装在其中。注意下面的User是我们自己的用户信息user

package com.augus.domain;

import com.augus.pojo.User;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {

    private User user;


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

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

    @Override
    public String getUsername() {
        return 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;
    }
}
  • 测试

如果测试需要往用户表中写入用户数据,并且如果你想让用户的密码是明文存储,需要在密码前加{noop},例如:

在浏览器访问控制器,http://localhost:8080/springboot06/hello 会直接跳转到登录页面,然后使用数据库中的账户张三登录即可,密码为123456

3.3.3.2 密码加密存储

实际项目中密码不可能明文存储在数据库中。Spring Security中默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式。但是我们一般不会采用这种方式。所以就需要替换PasswordEncoder。一般使用SpringSecurity为我们提供的BCryptPasswordEncoder。只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。我们可以定义一个SpringSecurity的配置类,SpringSecurity要求这个配置类要继承WebSecurityConfigurerAdapter。

在config包下创建SecurityConfig类代码如下:

package com.augus.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

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

spring security中的BCryptPasswordEncoder方法采用SHA-256 +随机盐+密钥对密码进行加密。SHA系列是Hash算法,不是加密算法,使用加密算法意味着可以解密(这个与编码/解码一样),但是采用Hash处理,其过程是不可逆的。(不可逆加密SHA:基本原理:加密过程中不需要使用密钥,输入明文后由系统直接经过加密算法处理成密文,这种加密后的数据是无法被解密的,无法根据密文推算出明文。RSA算法历史:底层-欧拉函数),有两个方法:

  • 加密(encode):注册用户时,使用SHA-256+随机盐+密钥把用户输入的密码进行hash处理,得到密码的hash值,然后将其存入数据库中。
  • 密码匹配(matches):用户登录时,密码匹配阶段并没有进行密码解密(因为密码经过Hash处理,是不可逆的),而是使用相同的算法把用户输入的密码进行hash处理,得到密码的hash值,然后将其与从数据库中查询到的密码hash值进行比较。如果两者相同,说明用户输入的密码正确。

测试代码如下:

    //可以通过注入直接使用
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Test
    public void testPasswordEncoder(){

        //BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        //通过encode方法可以进行加密
        String password1 = passwordEncoder.encode("123456");
        String password2 = passwordEncoder.encode("123456");

        //同一个明文加密多次结果是不同的
        System.out.println(password1);
        System.out.println(password2);

        //明文和密码校验,模拟登录
        boolean flage = passwordEncoder.matches(
                "123456",
                "$2a$10$XdsSquwuwKZj7rbld7D3keHsMcAJT9PyvEwpOFTrrw0YoxZ1BU5aO");

        System.out.println(flage);
    }

3.3.3.3 登陆接口(先把章节四学习后再来做)

下面需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。在接口中我们通过AuthenticationManager的authenticate方法来进行用户认证,需要在SecurityConfig中配置把AuthenticationManager注入容器。认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把用户id作为key。

  • 修改config包中配置类:SecurityConfig,如下:
package com.augus.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    /**
     * 配置HTTP请求的安全性规则,禁用CSRF保护,设置会话管理策略为无状态,并指定某些路径可以匿名访问,其他路径需要进行身份验证
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //禁用CSRF(跨站请求伪造)保护,因为在无状态的JWT认证中,不需要使用CSRF保护。
        http.csrf().disable()
                //设置会话管理策略为无状态,即不创建和使用会话。
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                //开始配置请求的授权规则
                .authorizeRequests()
                //指定/user/login路径可以匿名访问,即不需要身份验证
                .antMatchers("/user/login").anonymous()
                //指定其他所有请求都需要进行身份验证。
                .anyRequest().authenticated();
    }

    @Bean
    /**
     * 获取AuthenticationManager对象,以便在其他地方使用该对象进行身份验证
     * @return
     * @throws Exception
     */
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        //调用super.authenticationManagerBean()方法,该方法是WebSecurityConfigurerAdapter类中的一个方法,用于获取AuthenticationManager对象。
        return super.authenticationManagerBean();
    }
}
  • 在controller包中定义LoginController,作为自定义的登录接口使用代码如下:
@RestController
public class LoginController {
    @Autowired
    private LoginService loginService;

    @PostMapping("/user/login")
    public ResponseResult login(@RequestBody User user){
        //登录
        return loginService.login(user);
    }
}
  • 在service包下定义 LoginService接口:
public interface LoginService {
    /**
     * 登录的账户密码
     * @param user
     * @return
     */
    ResponseResult login(User user);
}
  • 在service.impl下定义LoginService接口的实现类LoginServiceImpl,代码如下:
package com.augus.service.impl;

import com.augus.domain.LoginUser;
import com.augus.domain.ResponseResult;
import com.augus.pojo.User;
import com.augus.service.LoginService;
import com.augus.utils.JwtUtil;
import com.augus.utils.RedisCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Objects;

@Service
public class LoginServiceImpl implements LoginService {
    @Autowired
    private AuthenticationManager authenticationManager;


    @Autowired
    private RedisCache redisCache;

    @Override
    public ResponseResult login(User user) {
        //创建一个UsernamePasswordAuthenticationToken对象,将用户的用户名和密码作为参数传入。
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());

        //调用authenticationManager.authenticate()方法对用户进行身份验证,返回一个Authentication对象。
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);

        //如果authenticate对象为空,表示用户名或密码错误,抛出一个运行时异常。
        if(Objects.isNull(authenticate)){
            throw new RuntimeException("用户名或者密码错误");
        }

        //从authenticate对象中获取登录用户的信息。
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();

        //获取用户id
        String userId = loginUser.getUser().getId().toString();

        //使用用户的ID生成一个JWT。
        String jwt = JwtUtil.createJWT(userId);

        //将登录用户的信息存入Redis缓存中,以便后续验证用户的身份。
        redisCache.setCacheObject("login:"+userId,loginUser);

        //创建一个包含JWT的响应结果,并返回给前端。
        HashMap<String, String> map = new HashMap<>();
        map.put("token",jwt);

        return new ResponseResult(200,"登录成功",map);
    }
}
  • 上面由于需要使用redis存储用户信息,后期进行验证,所以这里需要导入redis相关的依赖(基于springboot2.2.2.RELEASE)。在pom.xml中导入内容如下:
    <dependencies>
        <!--安全框架springsecurity依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!--springboot启动器-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!--lombok-->
        <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>

        <!--redis依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</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>

        <!--mybatis-plus依赖-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.0-RELEASE</version>
        </dependency>

        <!--添加druid连接池-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.10</version>
        </dependency>

        <!--MySQL数据库连接的依赖-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.17</version>
        </dependency>

    </dependencies>
  • 在utils包下创建FastJsonRedisSerializer类
/**
 * 自定义的Redis序列化器,使用FastJson库将对象序列化为字节数组,并将字节数组反序列化为对象。
 * 实现了RedisSerializer接口,用于将对象序列化为字节数组和将字节数组反序列化为对象。
 * 使用FastJson库将对象转换为JSON字符串,并将JSON字符串转换为字节数组。
 * 在序列化时,使用SerializerFeature.WriteClassName参数将对象的类名写入JSON字符串中,以便在反序列化时能够正确地恢复对象的类型。
 * 在反序列化时,将字节数组转换为字符串,并使用JSON.parseObject方法将字符串转换为指定类型的对象。
 * 通过构造方法传入要序列化/反序列化的对象的Class对象,以便在反序列化时能够正确地恢复对象的类型。
 */
public class FastJsonRedisSerializer<T> implements RedisSerializer<T>
{
    // public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

    private Class<T> clazz;

    //下面是一个静态代码块,它的作用是在类加载时执行一次,用于设置FastJson的全局配置
    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);
    }
}
  • 在utils包下创建RedisCache类
@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);
    }
}
  • 在config包下创建RedisConfig配置类
@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;
    }
}
  • 部署启动redis

下面我利用docker创建了一个redis,命令如下:

docker run -itd --name redis-01 -p 6379:6379 redis  --requirepass 123456

创建完成后,先尝试登录登录无误后再进行后续操作:

  • 在application.yaml配置文件中添加redis的连接
spring:
  datasource:
    # 使用阿里的Druid连接池
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    # 填写你数据库的url、登录名、密码和数据库名
    url: jdbc:mysql://127.0.0.1:3306/mydb?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
  druid:
    # 连接池的配置信息
    # 初始化大小,最小,最大
    initial-size: 5
    min-idle: 5
    maxActive: 20
    # 配置获取连接等待超时的时间
    maxWait: 60000
    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
    timeBetweenEvictionRunsMillis: 60000
    # 配置一个连接在池中最小生存的时间,单位是毫秒
    minEvictableIdleTimeMillis: 300000
    validationQuery: SELECT 1
    testWhileIdle: true
    testOnBorrow: false
    testOnReturn: false
    # 打开PSCache,并且指定每个连接上PSCache的大小
    poolPreparedStatements: true
    maxPoolPreparedStatementPerConnectionSize: 20
    # 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
    filters: stat,wall,slf4j
    # 通过connectProperties属性来打开mergeSql功能;慢SQL记录
    connectionProperties: druid.stat.mergeSql\=true;druid.stat.slowSqlMillis\=5000
    # 配置DruidStatFilter
    web-stat-filter:
      enabled: true
      url-pattern: "/*"
      exclusions: "*.js,*.gif,*.jpg,*.bmp,*.png,*.css,*.ico,/druid/*"
    # 配置DruidStatViewServlet
    stat-view-servlet:
      url-pattern: "/druid/*"
      # IP白名单(没有配置或者为空,则允许所有访问)
      allow: 127.0.0.1,192.168.8.109
      # IP黑名单 (存在共同时,deny优先于allow)
      deny: 192.168.1.188
      #  禁用HTML页面上的“Reset All”功能
      reset-enable: false
      # 登录名
      login-username: admin
      # 登录密码
      login-password: 123456
  servlet:
    multipart:
      #设置文件上传单个文件的大小
      max-file-size: 10MB
      #设置多个文件上传总文件的大小
      file-size-threshold: 100MB
  # ========================redis单机=====================
  redis:
    database: 0
    host: 192.168.42.136
    password: 123456
    port: 6379
    lettuce:
      pool:
        max-idle: 8
        max-active: 8
        max-wait: -1
        min-idle: 0
server:
  port: 8080
  servlet:
    context-path: /springboot06

这里通过postman模拟发出登录接口请求,如下:

 如上图,发起请求后,返回了响应的token,查看redis,存入了用户信息如下:

3.3.3.4 认证过滤器

自定义一个过滤器,这个过滤器会去获取请求头中的token,对请求头中的token进行解析取出其中的userid。使用userid去redis中获取对应的LoginUser对象。然后封装Authentication对象存入SecurityContextHolder

创建filter包,在里面自定义过滤器:

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Autowired
    private RedisCache redisCache;


    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        //获取请求头中的token:从HttpServletRequest对象中获取名为"token"的请求头信息。
        String token = httpServletRequest.getHeader("token");

        //检查token是否存在:如果token不存在,则直接放行请求,继续执行后续的过滤器或处理器。
        if(!StringUtils.hasText(token)){
            //放行操作
            filterChain.doFilter(httpServletRequest,httpServletResponse);
            return;
        }

        //解析token:使用JwtUtil工具类解析token,获取其中的用户ID(userid)。
        String userId;

        try {
            Claims claims = JwtUtil.parseJWT(token);
            userId = claims.getSubject();
        } catch (Exception exception) {
            exception.printStackTrace();
            throw new RuntimeException("非法的token");
        }

        //从Redis中获取用户信息:根据userId从Redis缓存中获取用户信息。
        LoginUser loginUser = redisCache.getCacheObject("login:" + userId);

        //检查用户是否登录:如果获取的用户信息为空,则抛出异常,表示用户未登录。
        if(Objects.isNull(loginUser)){
            throw new RuntimeException("用户未登录");
        }



        /**
         * 需要有一个Authentication类型的数据,所以先转换类型
         * UsernamePasswordAuthenticationToken参数解析如下:
         * principal:表示身份验证的主体,通常是用户的唯一标识,比如用户名、用户ID等。
         * credentials:表示身份验证的凭证,通常是用户的密码或其他认证信息。
         * authorities:表示用户的权限集合,即用户被授予的权限列表
         */
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser,null,null);

        //存入SecurityContextHolder:将获取到的用户信息存入SecurityContextHolder中,以便后续的权限验证和授权操作。
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        //放行
        filterChain.doFilter(httpServletRequest,httpServletResponse);
    }
}

修改SecurityConfig,把自定义的token校验过滤器添加到过滤器链中,代码如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

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

    /**
     * 配置HTTP请求的安全性规则,禁用CSRF保护,设置会话管理策略为无状态,并指定某些路径可以匿名访问,其他路径需要进行身份验证
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //禁用CSRF(跨站请求伪造)保护,因为在无状态的JWT认证中,不需要使用CSRF保护。
        http.csrf().disable()
                //设置会话管理策略为无状态,即不创建和使用会话。
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                //开始配置请求的授权规则
                .authorizeRequests()
                //指定/user/login路径可以匿名访问,即不需要身份验证
                .antMatchers("/user/login").anonymous()
                //指定其他所有请求都需要进行身份验证。
                .anyRequest().authenticated();

        //把token校验过滤器添加到过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    /**
     * 获取AuthenticationManager对象,以便在其他地方使用该对象进行身份验证
     * @return
     * @throws Exception
     */
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        //调用super.authenticationManagerBean()方法,该方法是WebSecurityConfigurerAdapter类中的一个方法,用于获取AuthenticationManager对象。
        return super.authenticationManagerBean();
    }
}

使用postman进行测试,登录接口获取oken

而后面的其他接口,没有token则会被拦截:

复制之前登录后响应返回的token,手动添加给请求头,请求即可成功,如下:

3.3.3.5 退出登陆

对于退出只需要定义一个登陆接口,然后获取SecurityContextHolder中的认证信息,删除redis中对应的数据即可。

在LoginController中添加退出方法:

@RestController
public class LoginController {
    @Autowired
    private LoginService loginService;

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

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

在service包下LoginService中添加接口

public interface LoginService {
    /**
     * 登录的账户密码
     * @param user
     * @return
     */
    ResponseResult login(User user);

    ResponseResult logout();
}

在service.impl包中的LoginServiceImpl下添加内容如下:

@Service
public class LoginServiceImpl implements LoginService {
    @Autowired
    private AuthenticationManager authenticationManager;


    @Autowired
    private RedisCache redisCache;

    @Override
    public ResponseResult login(User user) {
        //创建一个UsernamePasswordAuthenticationToken对象,将用户的用户名和密码作为参数传入。
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());

        //调用authenticationManager.authenticate()方法对用户进行身份验证,返回一个Authentication对象。
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);

        //如果authenticate对象为空,表示用户名或密码错误,抛出一个运行时异常。
        if(Objects.isNull(authenticate)){
            throw new RuntimeException("用户名或者密码错误");
        }

        //从authenticate对象中获取登录用户的信息。
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();

        //获取用户id
        String userId = loginUser.getUser().getId().toString();

        //使用用户的ID生成一个JWT。
        String jwt = JwtUtil.createJWT(userId);

        //将登录用户的信息存入Redis缓存中,以便后续验证用户的身份。
        redisCache.setCacheObject("login:"+userId,loginUser);

        //创建一个包含JWT的响应结果,并返回给前端。
        HashMap<String, String> map = new HashMap<>();
        map.put("token",jwt);

        return new ResponseResult(200,"登录成功",map);
    }

    @Override
    public ResponseResult logout() {
        //获取SecurityContextHolder中的用户信息
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();

        //获取用户id
        String userId = loginUser.getUser().getId().toString();


        //从redis中删除用户信息即可
        redisCache.deleteObject("login:"+userId);

        return new ResponseResult(200,"退出成功");
    }
}

利用postman先发起登陆请求,然后发出退出请求(注意需要手动在请求头中添加token),如下:

查看redis中保存的用户信息被删除了

然后利用刚刚被登录的token,请求登录之后的请求,发现请求也是无法成功,

四、jwt

4.1.什么是jwt?

官网地址: https://jwt.io/introduction/

JWT是Json Web Token,也就是通过JSON的形式作为web应用中的令牌,用于在各方之间安全的将信息作为JSON对象传输,在传输过程中还可以完成数据加密、签名等相关处理。

4.2.JWT能做什么?

授权:一旦用户登录以后,每个后续请求将包含JWT。单点登录是当今广泛使用jwt’的一项功能,因为他的开销很小,并且可以在不同的域中轻松使用。

信息交换:在各方之间安全的传输信息,通过对jwt进行签名(使用公钥/私钥对),所以可以确保发件人是他们所说的人,此外由于签名是使用标头和有效负载计算的,因此您还可以验证内容是否遭到篡改.

session的缺点:我们知道,http协议是无状态的协议(不会保持用户的登录状态),为了防止每一次请求跳转的时候都要重新登录,前期我们是通过session来保持用户的登录状态。

但是通过session保持用户的登录状态有以下几个缺点:

  • session是保存在服务器端的内存当中,随着登录用户的不断增多,服务器端需要的内存会比较大,造成成本增加。
  • 如果是分布式项目还要涉及到分布式session方案的设计。
  • 由于session是通过cookie传输sessionid来进行工作的,如果cookie被截取,用户很容易遭受到跨站请求伪造的攻击。
  • 在前后端分离的情况下,前端的请求会经过很多的中间件,每次请求转发都会到服务器验证,造成服务器压力增大

4.3.基于jwt的认证流程和优势

流程如下图:

认证流程:

①前端通过Web表单将自己的用户名和密码发送到后端的接口。这一过程一般是一个HTTP POST请求。建议的方式是通过SSL加密的传输(https协议),从而避免敏感信息被嗅探。
②后端核对用户名和密码成功后,将用户的id等其他信息作为JWT Payload(负载),将其与头部分别进行Base64编码拼接后签名,形成一个JWT(Token)。形成的JWT就是一个形同lll.zzz.xxx的字符串。 token head.payload.singurater
③后端将JWT字符串作为登录成功的返回结果返回给前端。前端可以将返回的结果保存在localStorage或sessionStorage上,退出登录时前端删除保存的JWT即可。
④前端在每次请求时将JWT放入HTTP Header中的Authorization位。(解决XSS和XSRF问题) HEADER
⑤后端检查是否存在,如存在验证JWT的有效性。例如,检查签名是否正确;检查Token是否过期;检查Token的接收方是否是自己(可选)。
⑥验证通过后后端使用JWT中包含的用户信息进行其他逻辑操作,返回相应结果。

优势:

①简洁(Compact): 可以通过URL,POST参数或者在HTTP header发送,因为数据量小,传输速度也很快
②自包含(Self-contained):负载中包含了所有用户所需要的信息,避免了多次查询数据库
③因为Token是以JSON加密的形式保存在客户端的,所以JWT是跨语言的,原则上任何web形式都支持。
④不需要在服务端保存会话信息,特别适用于分布式微服务。

4.4.jwt的结构

jwt是由标头、有效载荷、签名组成。说明如下:

4.4.1.标头

标头通常由两部分组成:令牌的类型(即JWT)和所使用的签名算法,例如HMAC SHA256或RSA。它会使用 Base64 编码组成 JWT 结构的第一部分。注意:Base64是一种编码,也就是说,它是可以被翻译回原来的样子来的。它并不是一种加密过程。例如:

{
  "alg": "HS256",
  "typ": "JWT"
}

4.4.2.有效载荷(不要放用户的敏感信息)

令牌的第二部分是有效负载,其中包含声明。声明是有关实体,(通常是用户)和其他数据的声明。同样的,它会使用Base64 编码组成 JWT 结构的第二部分

{
  "sub": "123456",
  "name": "Augus",
  "admin": true
}

4.4.3.签名

前面两部分都是使用 Base64 进行编码的,即前端可以解开知道里面的信息。Signature 需要使用编码后的 header 和 payload以及我们提供的一个密钥,然后使用 header 中指定的签名算法(HS256)进行签名。签名的作用是保证 JWT 没有被篡改过

最后一步签名的过程,实际上是对头部以及负载内容进行签名,防止内容被窜改。如果有人对头部以及负载的内容解码之后进行修改,再进行编码,最后加上之前的签名组合形成新的JWT的话,那么服务器端会判断出新的头部和负载形成的签名和JWT附带上的签名是不一样的。如果要对新的头部和负载进行签名,在不知道服务器加密时用的密钥的话,得出来的签名也是不一样的。

在这里大家一定会问一个问题:Base64是一种编码,是可逆的,那么我的信息不就被暴露了吗

是的。所以,在JWT中,不应该在负载里面加入任何敏感的数据。在上面的例子中,我们传输的是用户的User ID。这个值实际上不是什么敏 感内容,一般情况下被知道也是安全的。但是像密码这样的内容就不能被放在JWT中了。如果将用户的密码放在了JWT中,那么怀有恶意的第 三方通过Base64解码就能很快地知道你的密码了。因此JWT适合用于向Web应用传递一些非敏感信息。JWT还经常用于设计用户认证和授权系 统,甚至实现Web应用的单点登录。

以上显示的是未编码的信息,编码后的jwt是:xxxxx.yyyyy.zzzzz

4.5.JWT的简单使用

JJWT是一个提供端到端的JWT创建和验证的Java库。永远免费和开源(Apache License,版本2.0),JJWT很容易使用和理解。它被设计成一个以建筑为中心的流畅界面,隐藏了它的大部分复杂性。

  • JJWT的目标是最容易使用和理解用于在JVM上创建和验证JSON Web令牌(JWTs)的库。
  • JJWT是基于JWT、JWS、JWE、JWK和JWA RFC规范的Java实现。
  • JJWT还添加了一些不属于规范的便利扩展,比如JWT压缩和索赔强制。

4.5.1.导入依赖:

        <!--jwt依赖-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.0</version>
        </dependency>

注意:JJWT依赖Jackson 2.x,低版本将报错。

4.5.2.生成JWT token:

核心代码如下:

    @Test
    public void testJJWT(){
        //创建一个空的HashMap对象stringObjectMap,用于存储JWT的头部信息。在这个例子中,头部信息中只包含一个键值对,键为"type",值为"1"。
        HashMap<String, Object> map = new HashMap<>();
        //设置值
        map.put("type", 1);

        //创建一个字符串payload,用于存储JWT的负载信息。在这个例子中,负载信息是一个JSON字符串,包含两个键值对,分别是"user_id"和"expire_time"。这些键值对表示用户ID和过期时间。
        String payload = "{\"user_id\":\"1541137\", \"expire_time\":\"2023-09-11 0:00:00\"}";

        //这里使用sha512算法,所以需要一个密钥。这样就生成了一个固定的密钥:javastack
        Key KEY = new SecretKeySpec("javastack".getBytes(), SignatureAlgorithm.HS512.getJcaName());

        /**
         * 接下来,使用Jwts.builder()创建一个JWT构建器,并通过setHeader()方法将头部信息设置为stringObjectMap。然后,通过setPayload()方法将负载信息设置为payload。
         * 最后,通过signWith()方法将JWT使用HS512算法进行签名,并传入一个密钥KEY。最后,通过compact()方法生成最终的JWT字符串,并将其存储在compactJws变量中。
         * 生成的JWT字符串可以用于身份验证和授权等场景,客户端可以将其包含在请求的头部或请求体中发送给服务器进行验证。服务器可以使用相同的密钥进行解密和验证JWT的有效性,并提取其中的信息。
         */
        String compact = Jwts.builder().setHeader(map).setPayload(payload).signWith(SignatureAlgorithm.HS512, KEY).compact();

        System.out.println("jwt key:" + new String(KEY.getEncoded()));
        System.out.println("jwt payload:" + payload);
        System.out.println("jwt encoded:" + compact);
    }

注意:header可以不用设置,claims不能和payload同时设置。输出结果:

4.5.3.解密JWT token内容

还是在上面的方法中加入解密的代码,将加密之后的内容解码出来:

    @Test
    public void testJJWT(){
        //创建一个空的HashMap对象stringObjectMap,用于存储JWT的头部信息。在这个例子中,头部信息中只包含一个键值对,键为"type",值为"1"。
        HashMap<String, Object> map = new HashMap<>();
        //设置值
        map.put("type", 1);

        //创建一个字符串payload,用于存储JWT的负载信息。在这个例子中,负载信息是一个JSON字符串,包含两个键值对,分别是"user_id"和"expire_time"。这些键值对表示用户ID和过期时间。
        String payload = "{\"user_id\":\"1541137\", \"expire_time\":\"2023-09-11 0:00:00\"}";

        //这里使用sha512算法,所以需要一个密钥。这样就生成了一个固定的密钥:javastack
        Key KEY = new SecretKeySpec("javastack".getBytes(), SignatureAlgorithm.HS512.getJcaName());

        /**
         * 接下来,使用Jwts.builder()创建一个JWT构建器,并通过setHeader()方法将头部信息设置为stringObjectMap。然后,通过setPayload()方法将负载信息设置为payload。
         * 最后,通过signWith()方法将JWT使用HS512算法进行签名,并传入一个密钥KEY。最后,通过compact()方法生成最终的JWT字符串,并将其存储在compactJws变量中。
         * 生成的JWT字符串可以用于身份验证和授权等场景,客户端可以将其包含在请求的头部或请求体中发送给服务器进行验证。服务器可以使用相同的密钥进行解密和验证JWT的有效性,并提取其中的信息。
         */
        String compact = Jwts.builder().setHeader(map).setPayload(payload).signWith(SignatureAlgorithm.HS512, KEY).compact();

        System.out.println("jwt key:" + new String(KEY.getEncoded()));
        System.out.println("jwt payload:" + payload);
        System.out.println("jwt encoded:" + compact);

        //解密

        /**
         * 首先,使用Jwts.parser()创建一个JWT解析器,并通过setSigningKey()方法设置解析器的签名密钥为KEY。
         * 然后,通过parseClaimsJws()方法将JWT字符串compact传递给解析器进行解析和验证。解析后的结果是一个Jws<Claims>对象,其中包含了JWT的头部和负载信息。
         */
        Jws<Claims> claimsJws = Jwts.parser().setSigningKey(KEY).parseClaimsJws(compact);
        //通过getHeader()方法从Jws<Claims>对象中获取JWT的头部信息,并将其存储在header变量中。
        JwsHeader header = claimsJws.getHeader();
        //通过getBody()方法从Jws<Claims>对象中获取JWT的负载信息,并将其存储在body变量中。
        Claims body = claimsJws.getBody();

        System.out.println("jwt header:" + header);
        System.out.println("jwt body:" + body);
        System.out.println("jwt body user-id:" + body.get("user_id", String.class));
    }

输出结果:

4.6.工具类

 在utils包下创建JwtUtil工具类,用户创建和解析生成的JWT,代码如下:

package com.augus.utils;

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 = "ls";

    /**
     * 生成一个随机的UUID(Universally Unique Identifier)字符串,并将其中的"-"字符移除后返回。
     * UUID是一个128位的数字,通常用32位的十六进制字符串表示。它是由计算机系统中的各种元素(如MAC地址、时间戳等)生成的唯一标识符,可以用于在分布式系统中唯一标识实体。
     * 在这段代码中,使用UUID类的randomUUID()方法生成一个随机的UUID对象,然后使用toString()方法将其转换为字符串。由于UUID字符串中包含了"-"字符,所以使用replaceAll()方法将其移除。最后将处理后的字符串作为结果返回。
     * @return
     */
    public static String getUUID(){
        String token = UUID.randomUUID().toString().replaceAll("-", "");
        return token;
    }

    /**
     * 创建一个JWT(JSON Web Token)字符串。
     * @param subject token中要存放的数据(json格式)
     * @return
     */
    public static String createJWT(String subject) {
        //首先调用了一个名为getJwtBuilder()的方法,该方法返回一个JwtBuilder对象。然后,使用该对象设置JWT的主题(subject)、过期时间和一个随机生成的UUID作为JWT的唯一标识符。最后,调用JwtBuilder对象的compact()方法将JWT构建为一个字符串,并将其作为结果返回。
        //需要注意的是,这段代码中并没有设置签名,所以生成的JWT是未签名的。在实际应用中,通常需要使用密钥对JWT进行签名,以确保其真实性和完整性。
        JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间

        return builder.compact();
    }

    /**
     * 创建一个带有过期时间的JWT(JSON Web Token)字符串。
     * @param subject token中要存放的数据(json格式)
     * @param ttlMillis 用于设置JWT的过期时间。ttlMillis是一个Long类型的参数,表示JWT的有效期限,单位为毫秒。
     * @return
     */
    public static String createJWT(String subject, Long ttlMillis) {
        //首先调用了一个名为getJwtBuilder()的方法,该方法返回一个JwtBuilder对象。然后,使用该对象设置JWT的主题(subject)、过期时间(ttlMillis)和一个随机生成的UUID作为JWT的唯一标识符。最后,调用JwtBuilder对象的compact()方法将JWT构建为一个字符串,并将其作为结果返回。\
        //需要注意的是,这段代码中并没有设置签名,所以生成的JWT是未签名的。在实际应用中,通常需要使用密钥对JWT进行签名,以确保其真实性和完整性。
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
        return builder.compact();
    }

    /**
     * JwtBuilder对象,用于构建JWT(JSON Web Token)
     * @param subject
     * @param ttlMillis
     * @param uuid
     * @return
     */
    private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
        //首先定义了签名算法为HS256(HMAC SHA-256)
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        //然后调用generalKey()方法生成一个密钥(SecretKey)
        SecretKey secretKey = generalKey();
        //获取当前时间的毫秒数,
        long nowMillis = System.currentTimeMillis();
        //将其转换为Date对象表示当前时间
        Date now = new Date(nowMillis);
        //如果传入的过期时间(ttlMillis)为null,则使用默认的过期时间(JwtUtil.JWT_TTL)
        if(ttlMillis==null){
            ttlMillis=JwtUtil.JWT_TTL;
        }

        //计算过期时间的毫秒数
        long expMillis = nowMillis + ttlMillis;
        //将其转换为Date对象表示过期时间
        Date expDate = new Date(expMillis);
        //使用Jwts.builder()方法创建一个JwtBuilder对象。
        // 接下来,通过调用JwtBuilder对象的一系列方法设置JWT的各个属性,包括唯一ID(setId())、主题(setSubject())、签发者(setIssuer())、签发时间(setIssuedAt())、签名算法和密钥(signWith())以及过期时间(setExpiration())。
        return Jwts.builder()
                .setId(uuid)              //唯一的ID
                .setSubject(subject)   // 主题  可以是JSON数据
                .setIssuer("ls")     // 签发者
                .setIssuedAt(now)      // 签发时间
                .signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
                .setExpiration(expDate);
    }

    /**
     * 创建一个带有自定义ID、主题和过期时间的JWT(JSON Web Token)字符串
     * @param id 参数id,用于设置JWT的唯一ID
     * @param subject 主题
     * @param ttlMillis 过期时间
     * @return
     */
    public static String createJWT(String id, String subject, Long ttlMillis) {
        //用了一个名为getJwtBuilder()的方法,该方法返回一个JwtBuilder对象。然后,使用该对象设置JWT的唯一ID(id)、主题(subject)和过期时间(ttlMillis)。
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
        //调用JwtBuilder对象的compact()方法将JWT构建为一个字符串,并将其作为结果返回。
        return builder.compact();
    }

    public static void main(String[] args) throws Exception {
        String jwt = createJWT("132456");
        System.out.println(jwt);
    }

    /**
     * 生成一个密钥(SecretKey)用于JWT的签名。
     * @return
     */
    public static SecretKey generalKey() {
        //首先通过Base64解码将字符串JwtUtil.JWT_KEY转换为字节数组encodedKey
        byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
        //使用SecretKeySpec类将字节数组encodedKey转换为一个SecretKey对象。这里使用的是AES对称加密算法。最后,返回生成的密钥对象。
        //这段代码中使用的密钥是从字符串JwtUtil.JWT_KEY解码而来的,实际应用中可能需要根据具体情况进行调整,例如从配置文件中读取密钥或使用其他方式生成密钥。
        SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
        return key;
    }

    /**
     * 解析JWT(JSON Web Token)字符串,并返回其中的Claims对象。
     * @param jwt
     * @return
     * @throws Exception
     */
    public static Claims parseJWT(String jwt) throws Exception {
        //首先调用generalKey()方法获取密钥(SecretKey)
        SecretKey secretKey = generalKey();
        //然后,使用Jwts.parser()创建一个JwtParser对象,并通过调用setSigningKey()方法设置解析器的签名密钥为获取到的密钥。
        //接下来,调用JwtParser对象的parseClaimsJws()方法,传入JWT字符串jwt进行解析。该方法会返回一个Jws对象,通过调用getBody()方法获取其中的Claims对象。
        //最后,返回解析得到的Claims对象。
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();
    }
}

五、授权

5.1 权限系统的作用

权限系统的作用是确保系统中的用户只能访问其所需的资源和执行其所需的操作。它可以帮助管理者对系统中的用户进行身份验证和授权,以确保用户只能访问其具有权限的资源和执行其具有权限的操作。权限系统还可以帮助管理者对用户的权限进行管理和调整,以适应不同用户的需求和角色。通过权限系统,可以提高系统的安全性,防止未经授权的访问和操作,保护系统中的敏感数据和资源。此外,权限系统还可以提供审计功能,记录用户的访问和操作,以便进行安全审计和追踪。总之,权限系统的作用是确保系统的安全性和可控性,保护系统中的资源和数据。

例如图书管理系统,如果是普通学生登录就能看到借阅相关的功能,不会让他看到能够使用添加书籍信息,删除书籍信息等功能。但是如果是一个图书馆管理员的账号登录了,就允许看到并使用添加书籍信息,删除书籍信息等功能,让不同的用户可以使用不同的功能。这就是权限系统要去实现的效果。

在编码的时候不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮,展示哪些功能。如果这样做,如果有恶意攻击者知道了对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。所以需要在后台进行用户权限的判断,判断当前用户具有哪些权限,根据权限展示相应的功能,才能进行相应的操作。

5.2.实现授权基本流程

在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。所以我们在项目中只需要把当前登录用户的权限信息也存入Authentication。然后设置我们的资源所需要的权限即可。

5.3.授权实现

5.3.1 限制访问资源所需权限

SpringSecurity提供了基于注解的权限控制方案,这是项目中主要采用的方式。我们可以使用注解去指定访问对应的资源所需的权限。​ 但是要使用它我们需要先开启相关配置。在config包下的SecurityConfig类上添加如下注解

@EnableGlobalMethodSecurity(prePostEnabled = true)

说明:

  • @EnableGlobalMethodSecurity(prePostEnabled = true)是Spring Security提供的一个注解,用于启用方法级别的权限控制。
  • 它的作用是在应用程序中启用基于方法的权限控制。通过在配置类上添加该注解,可以在方法级别上使用Spring Security的注解来控制方法的访问权限。
  • 在哪里使用该注解取决于具体的应用程序架构和需求。通常,它可以在Spring Security的配置类上使用,例如一个继承了WebSecurityConfigurerAdapter的配置类。在这种情况下,可以将@EnableGlobalMethodSecurity(prePostEnabled = true)注解添加到配置类上,以启用方法级别的权限控制。
  • 启用方法级别的权限控制后,可以在方法上使用Spring Security提供的注解,如@PreAuthorize、@PostAuthorize、@PreFilter和@PostFilter来定义方法的访问权限。这些注解可以在方法执行前或执行后对方法的访问进行验证和过滤。

然后使用对应的注解,给控制器方法添加权限控制,利用@PreAuthorize注解实现如下:

@RestController
public class HelloController {

    @RequestMapping("/hello")
    @PreAuthorize("hasAuthority('test')")
    public String hello(){
        return "hello";
    }
}

说明:

  • @PreAuthorize("hasAuthority(“test'))是Spring Security提供的一个注解,用于在方法执行前对方法的访问权限进行验证。
  • 具体含义是,只有具有'test"权限的用户才能访问被该注解标记的方法。如果当前用户没有"test"权限,将会抛出Access Denied Exception异常,阻止方法的执行。
  • 这个注解可以用于方法级别的权限控制,通过在方法上添加该注解,可以限制只有具有特定权限的用户才能调用该方法。
  • 在使用该注解时,需要确保已经启用了方法级别的权限控制,可以通过在配置类上添加@Enable Global Method Security(pre Post Enabled=true)注解来启用。

5.3.2 封装权限信息

之前编写UserDetailsServiceImpl的时候,遗留下了在查询出用户后还要获取对应的权限信息,封装到UserDetails中返回。先直接把权限信息写死封装到UserDetails中进行测试。之前定义了UserDetails的实现类LoginUser,想要让其能封装权限信息就要对其进行修改。

  • 用户登录时将权限信息保存到SecurityContextHolder,修改LoginUser如下:

 参考具体代码

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

    private User user;

    //存储权限信息
    private List<String> permissions;

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

    /**
     * 存储SpringSecurity所需要的权限信息的集合
     * redis为了安全考虑,不会序列化spring中的对象,
     * 表示在将该对象转换为JSON字符串时,不包含该字段的内容
    */
    @JSONField(serialize = false)
    private List<GrantedAuthority> authorities;

    /**
     * getAuthorities()方法是UserDetails接口中的一个方法,用于获取用户的权限信息。
     * 在Spring Security中,权限信息被封装成GrantedAuthority对象,该对象表示用户所拥有的权限。getAuthorities()方法返回一个Collection类型的对象,其中包含了用户的权限信息。
     * 通常情况下,getAuthorities()方法会返回一个包含用户权限的集合,每个权限都被封装成GrantedAuthority对象。这些权限可以是用户在系统中被授予的角色,也可以是用户被授予的特定权限。
     * 通过调用getAuthorities()方法,我们可以获取用户的权限信息,并在系统中进行相应的权限验证和授权操作。
     * @return
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        //首先判断authorities是否为null。如果为null,则表示权限信息还未被封装,需要进行封装。不为null则直接返回已有的使用
        if(authorities == null){
            authorities = new ArrayList<>();
            //将permissions中String类型的权限信息封装成SimpleGrantedAuthority(这是GrantedAuthority接口的实现类)对象
            for (String permission : permissions) {
                SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permission);
                //封装成类型后,添加到List集合中
                authorities.add(authority);
            }
        }
        return authorities;
    }

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

    @Override
    public String getUsername() {
        return 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;
    }
}
  • 修改UserDetailsServiceImpl 把权限信息封装到LoginUser中了,目前把这个写死权限进行测试,后面我们再从数据库中查询权限信息。

 代码如下:

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;


    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        //根据用户名查询用户信息
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        wrapper.eq("user_name",s);

        User user = userMapper.selectOne(wrapper);

        //如果查询不到数据就通过抛出异常来给出提示
        if(Objects.isNull(user)){
            throw new RuntimeException("用户名或者密码错误");
        }

        //根据用户查询权限信息 添加到LoginUser中,所以需要让LoginUser中可以接受用户权限信息
        //Arrays.asList(“test","admin”)的作用是将给定的多个元素转换为一个List对象。
        ArrayList<String> strings = new ArrayList<>(Arrays.asList("test"));

        //封装成UserDetails对象返回
        return new LoginUser(user,strings);
    }
}

修改filter包中定义的JwtAuthenticationTokenFilter过滤器,

使用postman进行测试,注意先登录,然后再访问控制器/hello,注意需要把登录接口的token在请求头上携带,由于/hello对应的控制器方法需要test权限,权限符合所以可以正确的访问

在控制器方法,修改控制器方法的用户权限,如下:

这里控制器需test123权限,而我们之前给传入的用户只有test权限,这时候就会出现访问失败的情况,如下:

 

5.3.3 从数据库查询权限信息

5.2.3.1 RBAC权限模型

RBAC(Role-Based Access Control)权限模型是一种常用的访问控制模型,用于管理和控制系统中的用户权限。RBAC模型基于角色的概念,将用户分配到不同的角色,每个角色具有一组特定的权限。

RBAC模型的核心概念包括:

  1. 用户(User):系统中的实体,可以是个人用户或者组织机构。

  2. 角色(Role):一组具有相似权限的用户集合。角色可以根据用户的职责、职位或其他属性进行定义。

  3. 权限(Permission):系统中的操作或资源,如访问某个功能、查看某个数据等。

  4. 授权(Authorization):将角色与权限进行关联,即将某个角色赋予特定的权限。

  5. 访问控制(Access Control):根据用户的角色和权限,控制用户对系统资源的访问。

RBAC模型的优点包括:

  1. 简化权限管理:通过将用户分配到角色,可以简化权限管理,只需管理角色的权限,而不需要为每个用户单独分配权限。

  2. 提高系统安全性:RBAC模型可以限制用户只能访问其所需的权限,减少了潜在的安全风险。

  3. 灵活性和可扩展性:RBAC模型可以根据系统需求进行灵活的角色和权限定义,方便系统的扩展和维护。

RBAC模型在许多系统中得到广泛应用,特别是在大型组织和企业中,用于管理和控制用户的访问权限

5.2.3.2 准备工作

准备建表SQL如下:

DROP TABLE IF EXISTS `sys_menu`;
 
CREATE TABLE `sys_menu` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `menu_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名',
  `path` varchar(200) DEFAULT NULL COMMENT '路由地址',
  `component` varchar(255) DEFAULT NULL COMMENT '组件路径',
  `visible` char(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',
  `status` char(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
  `perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
  `icon` varchar(100) DEFAULT '#' COMMENT '菜单图标',
  `create_by` bigint(20) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_by` bigint(20) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `del_flag` int(11) DEFAULT '0' COMMENT '是否删除(0未删除 1已删除)',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='菜单表';
 
/*Table structure for table `sys_role` */
 
DROP TABLE IF EXISTS `sys_role`;
 
CREATE TABLE `sys_role` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(128) DEFAULT NULL,
  `role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符串',
  `status` char(1) DEFAULT '0' COMMENT '角色状态(0正常 1停用)',
  `del_flag` int(1) DEFAULT '0' COMMENT 'del_flag',
  `create_by` bigint(200) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_by` bigint(200) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
 
/*Table structure for table `sys_role_menu` */
 
DROP TABLE IF EXISTS `sys_role_menu`;
 
CREATE TABLE `sys_role_menu` (
  `role_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
  `menu_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '菜单id',
  PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
 
/*Table structure for table `sys_user` */
 
DROP TABLE IF EXISTS `sys_user`;
 
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 '密码',
  `status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
  `email` varchar(64) DEFAULT NULL COMMENT '邮箱',
  `phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',
  `sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
  `avatar` varchar(128) DEFAULT NULL COMMENT '头像',
  `user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建人的用户id',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `del_flag` int(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
 
/*Table structure for table `sys_user_role` */
 
DROP TABLE IF EXISTS `sys_user_role`;
 
CREATE TABLE `sys_user_role` (
  `user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id',
  `role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id',
  PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

编写查询的根据用户id查询权限的SQL语句如下:

SELECT DISTINCT sys_menu.perms FROM sys_user_role 
    LEFT JOIN sys_role ON sys_user_role.role_id= sys_role.id
    LEFT JOIN sys_role_menu ON sys_role.id = sys_role_menu.role_id
    LEFT JOIN sys_menu ON sys_role_menu.role_id = sys_menu.id
WHERE 
    user_id=2 
    AND sys_role.`status`=0 
    AND sys_menu.`status`=0

准备sys_menu的实体类,如下:

@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("sys_menu")
//用于指定在将Java对象序列化为JSON字符串时,只有属性值不为null的字段才会被包含在JSON字符串中。
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Menu implements Serializable {

    @TableId
    private Long id;
    /**
     * 菜单名
     */
    private String menuName;
    /**
     * 路由地址
     */
    private String path;
    /**
     * 组件路径
     */
    private String component;
    /**
     * 菜单状态(0显示 1隐藏)
     */
    private String visible;
    /**
     * 菜单状态(0正常 1停用)
     */
    private String status;
    /**
     * 权限标识
     */
    private String perms;
    /**
     * 菜单图标
     */
    private String icon;

    private Long createBy;

    private Date createTime;

    private Long updateBy;

    private Date updateTime;
    /**
     * 是否删除(0未删除 1已删除)
     */
    private Integer delFlag;
    /**
     * 备注
     */
    private String remark;
}

5.2.3.4 代码实现

需要根据用户id去查询到其所对应的权限信息。所以先定义个mapper,其中提供一个方法可以根据userid查询权限信息。创建MenuMapper内容如下

public interface MenuMapper extends BaseMapper<Menu> {
    List<String> findPermsByUserId(Long id);
}

在resources下创建mapper目录,在里面创建MenuMapper.xml文件内容如下:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.augus.mapper.MenuMapper">
    <select id="findPermsByUserId" resultType="java.lang.String">
        SELECT DISTINCT sys_menu.perms FROM sys_user_role
            LEFT JOIN sys_role ON sys_user_role.role_id= sys_role.id
            LEFT JOIN sys_role_menu ON sys_role.id = sys_role_menu.role_id
            LEFT JOIN sys_menu ON sys_role_menu.role_id = sys_menu.id
        WHERE
            user_id=#{id}
            AND sys_role.`status`=0
            AND sys_menu.`status`=0
    </select>
</mapper>

在application.yaml中添加映射文件路径

mybatis-plus:
  mapper-locations:  classpath*:/mapper/**/*.xml

修改service下的UserDetailsServiceImpl实现类,调用上面定义的接口,从数据库中查询权限信息,代码如下:

 使用postman进行测试,发起登录接口,然后获取token,在后续接口携带,由于目前/hello要求用户权限是test123,但是查数据库中权限是system:dept:index,这样用户没有权限访问会失败,如下图

修改控制器方法的权限认证,如下:

重启项目,再次使用postman进行测试,发起登录接口,然后获取token,在后续接口携带,发现/hello请求已经可以成功

六、自定义失败处理机制

实际项目中需要在认证失败或者是授权失败的情况下也能和普通的接口一样返回相同结构的json,这样便于让前端对响应进行统一的处理。SpringSecurity的异常处理机制中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。

  • 如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint 对象的方法去进行异常处理。
  • 如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。

所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可。

6.1.自定义失败处理类

创建handle包,在下面创建:AccessDeniedHandlerImpl类,实现权限校验失败处理:

@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {

        //使用定义的响应实体类创建对象返回
        ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), "用户权限不足"); 

     //转换成json格式
     String s = JSON.toJSONString(result);

     //设置授权失败响应
     WebUtils.renderString(response,s);
} }

创建handle包,在下面创建:AuthenticationEntryPointImpl类,实现认证失败处理:

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

        ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "认证失败请重新登录");

        //转换为JSON
        String s = JSON.toJSONString(result);

        //响应给前端
        WebUtils.renderString(response,s);
    }
}

6.2.SpringSecurity配置类中添加自定义失败

修改config包下的SecurityConfig,内容如下:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    private AccessDeniedHandler accessDeniedHandler;

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

    /**
     * 配置HTTP请求的安全性规则,禁用CSRF保护,设置会话管理策略为无状态,并指定某些路径可以匿名访问,其他路径需要进行身份验证
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //禁用CSRF(跨站请求伪造)保护,因为在无状态的JWT认证中,不需要使用CSRF保护。
        http.csrf().disable()
                //设置会话管理策略为无状态,即不创建和使用会话。
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                //开始配置请求的授权规则
                .authorizeRequests()
                .antMatchers("/hello").permitAll() //不登录也可以访问
                //指定/user/login路径可以匿名访问,即不需要身份验证  permitAll()全部允许 anonymous()匿名的
                .antMatchers("/user/login").anonymous()
                //指定其他所有请求都需要进行身份验证。
                .anyRequest().authenticated();

        //把token校验过滤器添加到过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        //然后我们可以使用HttpSecurity对象的方法去配置失败处理
        http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint).accessDeniedHandler(accessDeniedHandler);
    }

    @Bean
    /**
     * 获取AuthenticationManager对象,以便在其他地方使用该对象进行身份验证
     * @return
     * @throws Exception
     */
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        //调用super.authenticationManagerBean()方法,该方法是WebSecurityConfigurerAdapter类中的一个方法,用于获取AuthenticationManager对象。
        return super.authenticationManagerBean();
    }
}

6.3.测试

 使用postman进行操作,发起登录请求,故意输错用户名或者密码,如下:

然后输入正确用户名密码,登录成功后,将登录的token添加给后续的/hello请求,由于权限在上个案例中改成要求是test123和数据库中权限不一致,所以如下:

然后将控制器方法中权限修改正确

重启项目,成功登录后,添加token,访问即可成功

七、跨域

7.1.什么是跨域?

跨域(Cross-Origin)是指在浏览器中,当一个网页的JavaScript代码向不同源(域名、协议或端口)的服务器发送请求时,就会发生跨域请求。同源策略(Same-Origin Policy)是浏览器的一种安全机制,它限制了不同源之间的交互,以防止恶意网站窃取用户的信息。

同源策略要求请求的协议、域名和端口都相同,只有在同源的情况下,浏览器才会允许JavaScript代码访问其他网页的数据。如果请求的目标与当前网页的源不同,就会触发跨域请求,浏览器会阻止该请求,以保护用户的安全。

跨域请求可以分为以下几种情况:

  1. 跨域AJAX请求:当使用XMLHttpRequest或Fetch API发送AJAX请求时,如果请求的目标与当前网页的源不同,就会触发跨域请求。

  2. 跨域资源共享(CORS):CORS是一种机制,允许服务器在响应中设置一些特殊的HTTP头部,告诉浏览器该服务器允许哪些源进行跨域访问。

  3. JSONP:JSONP是一种利用<script>标签的跨域技术,通过动态创建<script>标签,将跨域请求的数据作为回调函数的参数返回到当前网页。

  4. 代理服务器:通过在同源服务器上设置一个代理服务器,将跨域请求转发到目标服务器,然后再将响应返回给浏览器。

跨域问题在前端开发中经常遇到,前后端分离项目,前端项目和后端项目一般都不是同源的,会存在跨域请求的问题。所以需要处理一下,让前端能进行跨域请求。

7.2.对SpringBoot配置,允许跨域请求

在config包下创建CorsConfig类需要实现WebMvcConfigurer 接口,代码如下:

@Configuration
public class CorsConfig implements WebMvcConfigurer {

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

上面的代码中如果springboot的版本太低会出现一下错误:

Cannot resolve method 'allowedOriginPatterns' in 'CorsRegistration'

解决这个问题的方法是升级你的Spring版本,确保使用的是支持allowedOriginPatterns方法的版本。allowedOriginPatterns方法是在Spring 5.2版本中引入的,如果你的Spring版本低于这个版本,你可以考虑升级到最新的稳定版本。如果升级Spring版本不是一个可行的解决方案,你可以尝试使用allowedOrigins方法来设置允许的跨域请求的源。allowedOrigins方法接受一个字符串数组参数,可以指定多个允许的源

7.3.SpringSecurity开启跨域请求

由于项目后续的资源都会受到SpringSecurity的保护,想要跨域访问还需要让SpringSecurity运行跨域访问。

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    private AccessDeniedHandler accessDeniedHandler;

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

    /**
     * 配置HTTP请求的安全性规则,禁用CSRF保护,设置会话管理策略为无状态,并指定某些路径可以匿名访问,其他路径需要进行身份验证
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //禁用CSRF(跨站请求伪造)保护,因为在无状态的JWT认证中,不需要使用CSRF保护。
        http.csrf().disable()
                //设置会话管理策略为无状态,即不创建和使用会话。
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                //开始配置请求的授权规则
                .authorizeRequests()
                .antMatchers("/hello").permitAll() //不登录也可以访问
                //指定/user/login路径可以匿名访问,即不需要身份验证  permitAll()全部允许 anonymous()匿名的
                .antMatchers("/user/login").anonymous()
                //指定其他所有请求都需要进行身份验证。
                .anyRequest().authenticated();

        //把token校验过滤器添加到过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        //然后我们可以使用HttpSecurity对象的方法去配置失败处理
        http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint).accessDeniedHandler(accessDeniedHandler);

        //开启跨域请求
        http.cors();
    }

    @Bean
    /**
     * 获取AuthenticationManager对象,以便在其他地方使用该对象进行身份验证
     * @return
     * @throws Exception
     */
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        //调用super.authenticationManagerBean()方法,该方法是WebSecurityConfigurerAdapter类中的一个方法,用于获取AuthenticationManager对象。
        return super.authenticationManagerBean();
    }
}

7.4.测试

这里的使用postman无法模拟跨域问题,需要有一个前端工程,暂时不用测试,等到后期前后端分离项目在直接配置使用即可

八、权限校验补充

8.1.内置权限角色定义方式说明

根据 @PreAuthorize("hasAuthority('system:dept:index')")注解会发现,核心的是hasAnyAuthorityName方法中的内容,

    //判断当前用户是否具有指定的角色权限。
    //方法接受两个参数:prefix和roles。prefix是一个字符串,用于指定角色名称的前缀,roles是一个可变参数,表示需要判断的角色名称。
    private boolean hasAnyAuthorityName(String prefix, String... roles) {
        
        //方法首先通过调用getAuthoritySet()方法获取当前用户的角色集合,然后遍历传入的角色名称数组roles。
        Set<String> roleSet = getAuthoritySet();

        for (String role : roles) {
            //调用getRoleWithDefaultPrefix方法,将prefix和当前遍历到的角色名称拼接起来,得到一个完整的角色名称。
            String defaultedRole = getRoleWithDefaultPrefix(prefix, role);
            //方法会检查当前用户的角色集合中是否包含这个完整的角色名称。如果包含,则表示当前用户具有该角色权限,方法会立即返回true。
            //如果遍历完所有的角色名称后,仍然没有找到匹配的角色权限,方法会返回false。
            if (roleSet.contains(defaultedRole)) {
                return true;
            }
        }

        return false;
    }
  • hasAuthority每次只能传入一个权限,权限满足返回true就执行,不满足返回false就拒绝处理
@RestController
public class HelloController {

    @RequestMapping("/hello")
    @PreAuthorize("hasAuthority('system:dept:index')")
    public String hello(){
        return "hello";
    }
}
  • hasAnyAuthority方法可以传入多个权限,只有用户有其中任意一个权限都可以访问对应资源。
@RestController
public class HelloController {

    @RequestMapping("/hello")
    //@PreAuthorize("hasAuthority('system:dept:index')")
    @PreAuthorize("hasAnyAuthority('test','admin','system:dept:index')")
    public String hello(){
        return "hello";
    }
}
  • hasRole要求有对应的角色才可以访问(只能有一个权限),但是它内部会把我们传入的参数拼接上 ROLE_ 后再去比较。这要求用户对应的数据库中的权限也要有 ROLE_ 这个前缀才可以。
@RestController
public class HelloController {

    @RequestMapping("/hello")
    //@PreAuthorize("hasAuthority('system:dept:index')")
    //@PreAuthorize("hasAnyAuthority('test','admin','system:dept:index')")
    @PreAuthorize("hasRole('system:dept:index')")
    public String hello(){
        return "hello";
    }
}
  • hasAnyRole 有任意的角色就可以访问。它内部也会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。代码如下:
@RestController
public class HelloController {

    @RequestMapping("/hello")
    //@PreAuthorize("hasAuthority('system:dept:index')")
    //@PreAuthorize("hasAnyAuthority('test','admin','system:dept:index')")
    //@PreAuthorize("hasRole('system:dept:index')")
    @PreAuthorize("hasAnyRole('test','admin','system:dept:index')")
    public String hello(){
        return "hello";
    }
}

8.2.自定义权限校验方法

在实际工作的时候可以定义自己的权限校验方法,在@PreAuthorize注解中使用自己定义的方法。在expression下定义ExpressionRoot类如下:

@Component("er")//起个别名,后续使用的时候比较方便
public class ExpressionRoot {

    public final boolean hasAuthority(String authority){
        //获取当前用户的权限
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        List<String> permissions = loginUser.getPermissions();
        //判断数据库中的用户权限是否包含所需要的用户权限,包含则返回true
        return permissions.contains(authority);
    }
}

在SPEL表达式中使用 @er相当于获取容器中bean的名字未ex的对象。然后再调用这个对象的hasAuthority方法

@RestController
public class HelloController {

    @RequestMapping("/hello")
    //@PreAuthorize("hasAuthority('system:dept:index')")
    //@PreAuthorize("hasAnyAuthority('test','admin','system:dept:index')")
    //@PreAuthorize("hasRole('system:dept:index')")
    //@PreAuthorize("hasAnyRole('test','admin','system:dept:index')")
    @PreAuthorize("@er.hasAuthority('system:dept:index')")
    public String hello(){
        return "hello";
    }
}

8.3.基于配置的权限控制

之前我们实现权限控制,都是基于注解来完成,其实还有一种方式就是基于配置类实现,使用配置类的方式对资源进行权限控制。

先把请求/hello上的权限控制删除或者注销

在SecurityConfig配置类中添加权限控制

九、CSRF

9.1.什么CSRF攻击?

CSRF(Cross-Site Request Forgery)跨站请求伪造,也被称为“一次性令牌”攻击,是一种常见的Web安全漏洞。它利用了Web应用程序对用户发出的请求没有进行充分验证的漏洞,攻击者可以通过伪造请求来执行未经授权的操作。

CSRF攻击的原理是攻击者诱导用户在受信任的网站上执行恶意操作,而不是直接攻击网站本身。攻击者通常会通过诱导用户点击恶意链接、访问恶意网页或者在受感染的网站上执行恶意脚本等方式,来触发用户的浏览器发送伪造的请求。

当用户在受信任的网站上登录并保持会话时,浏览器会自动携带相应的身份验证凭证(如Cookie)发送请求。攻击者可以通过伪造请求,利用用户的身份验证凭证来执行未经授权的操作,如修改用户信息、发起资金转账等。

9.2.SpringSecurity如何去防止CSRF?

​ SpringSecurity去防止CSRF攻击的方式就是通过csrf_token。后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。

​ 我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是token,而token并不是存储中cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。

十、认证成功、认证失败和登出成功处理器

10.1.认证成功

这里我们换一种实现方式,区别于之前的方式,在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果登录成功了是会调用AuthenticationSuccessHandler的方法进行认证成功后的处理的。AuthenticationSuccessHandler就是登录成功处理器。

UsernamePasswordAuthenticationFilter是SpringSecurity官方用于用户认证的过滤器,但是我们前几节学的时候,自定义了登录认证接口,通过我们自定义的登录接口来实现认证,所以在我们自定义登录接口的项目中是没UsernamePasswordAuthenticationFilter,使用了自定义的登录接口替换了UsernamePasswordAuthenticationFilter,也就不能使用认证成功处理器。所以新创建一个模块,使用默认案例演示即可,下面我们使用自定义的

创建handle包,然后创建 SuccessHandler,实现认证成功处理器代码如下:

@Component
public class SuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        System.out.println("认证成功!!!");
    }
}

创建config包,在下面创建 SecurityConfig内容如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private AuthenticationSuccessHandler successHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //配置登录成功处理器
        http.formLogin().successHandler(successHandler);

        //允许所有请求进行匿名访问,无需进行身份验证
        http.authorizeRequests().anyRequest().anonymous();
    }
}

测试,通过浏览器访问登录页面,登录成功会执行认证成功处理器

10.2.认证失败处理器​

实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果认证失败了是会调用AuthenticationFailureHandler的方法进行认证失败后的处理的。AuthenticationFailureHandler就是登录失败处理器。也可以自己去自定义失败处理器进行失败后的相应处理。

在handle包下创建FailureHandler作为认证失败处理器,代码如下:

@Component
public class FailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        System.out.println("认证失败!!!");
    }
}

修改config包下的 SecurityConfig添加认证失败的处理,代码如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private AuthenticationSuccessHandler successHandler;
    @Autowired
    private AuthenticationFailureHandler failureHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //配置认证成功处理器
        http.formLogin().successHandler(successHandler)
                //配置认证失败处理器
                .failureHandler(failureHandler);
        
        //允许所有请求进行匿名访问,无需进行身份验证
        http.authorizeRequests().anyRequest().anonymous();
    }
}

重启项目,然后输入错误的用户名或者密码访问,结果如下,触发了认证失败处理器的执行

10.3.退出成功处理器

创建退出成功处理器,代码如下:、

@Component
public class LsLogoutSuccessHandler implements LogoutSuccessHandler {

    @Override
    public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        System.out.println("退出成功!!!");
    }
}

修改config包下的 SecurityConfig添加退出成功的处理,代码如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private AuthenticationSuccessHandler successHandler;
    @Autowired
    private AuthenticationFailureHandler failureHandler;
    @Autowired
    private LsLogoutSuccessHandler logoutSuccessHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //配置认证成功处理器
        http.formLogin().successHandler(successHandler)
                //配置认证失败处理器
                .failureHandler(failureHandler);

        //配置注销成功处理器
        http.logout().logoutSuccessHandler(logoutSuccessHandler);

        //允许所有请求进行匿名访问,无需进行身份验证
        http.authorizeRequests().anyRequest().anonymous();
    }
}

测试,直接退出内容如下:

posted @ 2023-08-24 17:29  酒剑仙*  阅读(5769)  评论(0)    收藏  举报