Spring Boot整合Spring Security实现认证鉴权

Spring Security

本文仅针对前后端分离项目采用的jwt方案来实现认证授权
相关概念来源网上查找(本文主要是涉及到使用,不会有太深的概念东西)
Spring Security是一个基于Spring框架的安全性框架,可以为Web应用程序提供身份验证(Authentication)、授权(Authorization)、攻击防御等安全功能。Spring Security框架提供了一整套的身份验证、授权、ACL(访问控制列表)等模块和类库,还提供了一系列的安全过滤器、安全标签等,可以方便地实现常见的安全性控制。

Spring Security的核心组件如下:

Authentication:身份验证组件,负责用户身份认证。
Authorization:权限授权组件,负责用户权限的授权管理。
Access Control、ACL:访问控制列表组件,负责资源的访问控制和权限的分配控制。
Session Management:会话管理组件,负责管理用户的会话,如Session ID管理等。
Web Security:使用Spring Security保护Web应用程序的安全组件。
Remember-me:记住我功能组件,负责实现自动登录功能。
OpenID:OpenID功能组件,负责与Open ID提供商的集成。

Spring Security的主要特点包含:

可扩展性:Spring Security框架提供了很多可扩展的类和接口,可以通过自定义实现这些接口和类来满足自己的需求。
配置简单:Spring Security框架的配置非常简单,只需配置几个关键的类即可实现基本的安全性控制。
弹性设计:Spring Security框架非常灵活,可以根据实际情况在不同的场景下灵活应对。
多种安全认证方式:Spring Security框架提供了很多种安全认证方式,如表单认证、基本认证、OAuth2等,可以根据实际需求选择合适的认证方式。

一、大体认证授权流程

官方说明:

Spring Security的认证机制包括AuthenticationManager和AuthenticationProvider两个核心组件。AuthenticationManager是一个接口,定义了身份验证的入口方法authenticate(),该方法接受一个Authentication对象作为参数,并返回一个封装了认证信息的Authentication对象。AuthenticationProvider是一个接口,定义了身份验证的具体实现,该接口的实现类可以根据不同的身份验证方式(如用户名密码、数字证书等)来实现身份验证的功能。

在Spring Security中,用户请求经过过滤器链,经过身份认证和授权决策来保护资源的安全。身份认证包括认证请求的处理和身份验证的实现。认证请求的处理包括UsernamePasswordAuthenticationFilter、RememberMeAuthenticationFilter等过滤器的处理。身份验证的实现则是通过AuthenticationManager来实现的。

授权决策由AccessDecisionManager和AccessDecisionVoter两个核心组件来实现。AccessDecisionManager是一个接口,定义了授权决策的入口方法decide(),该方法接受三个参数:Authentication对象(当前用户的认证信息)、Object对象(正在访问的资源)、List对象(访问资源所需的权限列表)。AccessDecisionVoter则是一个接口,定义了对当前用户的认证信息、当前请求所需的权限、资源的访问控制列表进行比较的方法,以决定当前用户是否有访问该资源的权限。

总的来说,Spring Security的认证授权原理是通过AuthenticationManager和AuthenticationProvider实现身份认证,通过AccessDecisionManager和AccessDecisionVoter实现授权决策,以保护资源的安全。

具体的执行流程其实是一个过滤链:

1、用户向应用程序发起请求,请求需要经过Spring Security的过滤器链。
2、过滤器链首先会经过UsernamePasswordAuthenticationFilter过滤器,该过滤器判断请求是否是一个认证请求。如果是认证请求,过滤器将获取请求中的用户名和密码,然后使用AuthenticationManager进行身份认证。
3、AuthenticationManager会根据用户名和密码创建一个Authentication对象,并将该对象传递给AuthenticationProvider进行认证。
4、AuthenticationProvider会根据传递过来的Authentication对象进行身份认证,并返回一个认证成功或失败的结果。
5、如果认证成功,UsernamePasswordAuthenticationFilter会将认证信息封装成一个Authentication对象,并将其放入SecurityContextHolder上下文中。
6、用户请求获取资源时,会经过FilterSecurityInterceptor过滤器,该过滤器会根据请求的URL和HTTP方法获取访问控制列表(Access Control List)。
7、Access Control List会包含访问资源所需要的权限信息,FilterSecurityInterceptor会将Authentication对象和Access Control List传递给AccessDecisionManager进行授权决策。
8、AccessDecisionManager会调用多个AccessDecisionVoter进行投票,并根据投票结果来决定当前用户是否有访问该资源的权限。如果用户被授权访问资源,应用程序将返回资源的响应结果。

总结就是首先经过认证过滤器实现认证,认证成功的话就会将用户信息存到authentication对象里面放到security上下文去(后续的权限校验需要获取到),这里面是包括权限的,之后再由AccessDecisionManager去根据相关策略进行权限鉴定

二、集成完整案例代码

思路:从上面的总结来看,认证的话,因为使用的是jwt,因此直接自定义一个过滤器来实现认证,同时之后还需要在AccessDecisionManager权限鉴定,所以我们在这个认证的过滤器里面去做认证并且获取到该用户的权限创建出Authentication对象存放到security上下文以支持后续的鉴权

目录结构:
只针对主要的展示,例如security和controller相关的
在这里插入图片描述

0、数据准备
/*
 Navicat Premium Data Transfer

 Source Server         : 本地
 Source Server Type    : MySQL
 Source Server Version : 80031 (8.0.31)
 Source Host           : localhost:3306
 Source Schema         : demo

 Target Server Type    : MySQL
 Target Server Version : 80031 (8.0.31)
 File Encoding         : 65001

 Date: 23/06/2023 23:21:38
*/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role`  (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '角色ID',
  `name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名称',
  `description` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '角色描述',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `name`(`name` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色表' ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of role
-- ----------------------------
INSERT INTO `role` VALUES (1, 'ROLE_ADMIN', '系统管理员');
INSERT INTO `role` VALUES (2, 'ROLE_USER', '普通用户');
INSERT INTO `role` VALUES (3, 'ROLE_GUEST', '游客');

-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名',
  `password` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码',
  `email` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱',
  `phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '手机号码',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `username`(`username` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES (1, 'alice', '1234', 'alice@example.com', '12345678901');
INSERT INTO `user` VALUES (2, 'bob', '5678', 'bob@example.com', '23456789012');

-- ----------------------------
-- Table structure for user_role
-- ----------------------------
DROP TABLE IF EXISTS `user_role`;
CREATE TABLE `user_role`  (
  `user_id` int NOT NULL COMMENT '用户ID',
  `role_id` int NOT NULL COMMENT '角色ID',
  PRIMARY KEY (`user_id`, `role_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户角色中间表' ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of user_role
-- ----------------------------
INSERT INTO `user_role` VALUES (1, 1);
INSERT INTO `user_role` VALUES (1, 2);
INSERT INTO `user_role` VALUES (2, 2);
INSERT INTO `user_role` VALUES (2, 3);

SET FOREIGN_KEY_CHECKS = 1;

1、pom依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.1</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>security</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>security</name>
    <description>security</description>
    <properties>
        <java.version>8</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.2</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-generator</artifactId>
            <version>3.5.2</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>org.freemarker</groupId>
            <artifactId>freemarker</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-boot-starter</artifactId>
            <version>3.0.0</version>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.11</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
2、编写过滤器
实现接口OncePerRequestFilter ,主要做了jwt的token认证和获取用户信息
包括权限列表存储到上下文
package com.example.config.security;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.example.entity.Role;
import com.example.entity.User;
import com.example.entity.UserRole;
import com.example.mapper.RoleMapper;
import com.example.mapper.UserMapper;
import com.example.mapper.UserRoleMapper;
import io.jsonwebtoken.Claims;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.annotation.Resource;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;

/**
 * @Classname AuthFilter
 * @Description TODO
 * @Version 1.0.0
 * @Date 2023/6/22 16:42
 * @Created by wlh12
 */
@Component
@Order(value = 1)
public class AuthFilter extends OncePerRequestFilter {

    @Resource
    private UserMapper userMapper;
    @Resource
    private UserRoleMapper userRoleMapper;

    @Resource
    private RoleMapper roleMapper;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader("Authorization");
        if (StringUtils.isBlank(token)) {
            token = request.getParameter("token");
        }
        // 没有token
        if (StringUtils.isBlank(token)) {
            //放行,因为后面的会抛出相应的异常
            filterChain.doFilter(request, response);
            return;
        }
        // 非法token
        String account = null;
        try {
            Claims claims = JwtUtil.parseJWT(token);
            account = claims.getSubject();
        } catch (Exception e) {
            throw new RuntimeException("非法token!");
        }
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(org.springframework.util.StringUtils.hasLength(account),User::getUsername,account);
        User user = userMapper.selectOne(queryWrapper);
        if (Objects.isNull(user)) {
            throw new RuntimeException("非法token!");
        }
        // 获取权限
        Authentication authenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), grantedAuthorities(user.getId()));
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request,response);
    }

    /**
     * 获取权限列表
     * @param userId
     * @return
     */
    private List<GrantedAuthority> grantedAuthorities(Integer userId){
        List<GrantedAuthority> roles = new ArrayList<>();
        List<UserRole> userRoles = userRoleMapper.selectList(new LambdaQueryWrapper<UserRole>().eq(UserRole::getUserId, userId));
        Set<Integer> set = userRoles.stream().map(i -> i.getRoleId()).collect(Collectors.toSet());
        if (set.isEmpty()){
            return roles;
        }
        List<Role> roleList = roleMapper.selectBatchIds(set);
        for (Role role : roleList) {
            roles.add(new SimpleGrantedAuthority(role.getName()));
        }
        return roles;
    }
}

3、认证失败和鉴权失败的处理
因为是前后端分离的项目,我们采用了返回一个统一的结果集的形式,形如
{code:xxx,message:xx}格式。所以需要在认证失败和鉴权失败的时候采用
封装处理

实现AuthenticationEntryPoint实现认证失败的处理

AuthenticationEntryPoint可以处理任何类型的异常,并提供一个入口点来处理认证错误。包括自定义的认证过滤器的异常和security自带的认证过滤器的异常都可以捕获处理。它可以根据具体的需求来实现不同的操作,如跳转到登录页面、返回错误信息等。

package com.example.config.security;

import cn.hutool.json.JSONUtil;
import com.example.entity.Result;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

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

/**
 * @Classname AuthHandle
 * @Description TODO
 * @Version 1.0.0
 * @Date 2023/6/22 16:30
 * @Created by wlh12
 */
@Component
public class AuthHandle implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        Result result = new Result();
        result.setCode("777");
        result.setMessage("认证失败,请重新登录!");
        String json = JSONUtil.toJsonStr(result);
        response.setStatus(200);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().print(json);
    }
}

实现AccessDeniedHandler 创建出鉴权错误(这个只能处理security的鉴权的异常,如果自定义了鉴权逻辑相关的异常需要其他手段)处理

package com.example.config.security;

import cn.hutool.json.JSONUtil;
import com.example.entity.Result;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

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

/**
 * @Classname AccessHandle
 * @Description TODO 授权失败的返回
 * @Version 1.0.0
 * @Date 2023/6/22 16:29
 * @Created by wlh12
 */
@Component
public class AccessHandle implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        Result result = new Result();
        result.setCode("776");
        result.setMessage("您的权限不足!");
        String json = JSONUtil.toJsonStr(result);
        response.setStatus(200);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().print(json);
    }
}

总结:上面的主要是就是处理认证和鉴权失败时异常的捕获之后如何处理响应。可以根据自己需求进行扩展,一般来说鉴权逻辑不需要去覆盖自带的。

4、Security配置类

继承WebSecurityConfigurerAdapter

package com.example.config.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.vote.AffirmativeBased;
import org.springframework.security.access.vote.RoleVoter;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.util.ArrayList;
import java.util.List;

/**
 * @Classname SecurityConfig
 * @Description TODO
 * @Version 1.0.0
 * @Date 2023/5/7 17:41
 * @Created by wlh12
 */
@Configuration
@EnableWebSecurity // 开启Security
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启在方法级别上的安全控制注解的支持
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthFilter authFilter;

    @Autowired
    private AccessHandle accessHandle;
    @Autowired
    private AuthHandle authHandle;
    public static final String[] ALLOW_ASK = {
            "/user/login"
    };
    //允许访问的静态资源
    public static final String[] STATIC_ALLOW_ASK = {
            "/swagger**/**",
            "/doc.html",
            "/webjars/**",
            "/swagger-ui.html",
            "/swagger-resources/**",
            "/v2/api-docs**"
    };

    @Override
    public void configure(WebSecurity web) throws Exception {
        // 禁用Spring Security默认的登录和注销页面
        web.ignoring()
                .antMatchers("/login")
                .antMatchers("/logout")
                .antMatchers(HttpMethod.OPTIONS, "/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 禁用 csrf, 由于使用的是JWT,我们这里不需要csrf
        http.cors().and().csrf().disable()
                // 基于token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 跨域预检请求
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // 需要admin权限
                .antMatchers("/user/**").hasRole("ADMIN")
                // .antMatchers("/login").permitAll()// 登录或未登录都能访问
                // 其他所有请求需要身份认证
                .anyRequest().authenticated()
                ;
        http.addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class);

        http.exceptionHandling()
                .accessDeniedHandler(accessHandle)
                .authenticationEntryPoint(authHandle);
    }
}

5、使用代码生成器生成controller、service和mapper

在usercontroller里面编写测试接口

package com.example.controller;

import com.example.entity.User;
import com.example.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

/**
 * <p>
 * 用户表 前端控制器
 * </p>
 *
 * @author baomidou
 * @since 2023-06-22
 */
@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;
    @GetMapping("/get/{id}")
    public User get(@PathVariable("id") Integer id){
        return userService.getById(id);
    }

}
6、测试结果

不携带token
在这里插入图片描述
携带非法token
在这里插入图片描述
认证成功且有权限
在这里插入图片描述
权限不足的:这里使用的是没有admin权限的用户的token
在这里插入图片描述

在这里插入图片描述
总结:Spring Security就是通过一系列的过滤链实现认证(视情况用,如果使用security的认证过滤器的话,可能需要实现其他相关的扩展,例如userdetailservice),然后将其认证对象Authentication存到上下文,后续鉴权使用。围绕这个思想就OK了