本篇博文目录:
一.SpringSecurity简介
1.SpringSecurity
官网地址:
Spring Security简介https://spring.io/projects/spring-security
Spring Security英文教程:https://docs.spring.io/spring-security/reference/index.html
Spring Security中文教程:https://docs.gitcode.net/spring/guide/spring-security/overview.html
2.SpringSecurity相关概念
- 什么是SpringSecurity
- 关于什么是认证和授权
- SpringSecurity 特点
- SpringSecurity和Shiro的比较
二.认证和授权
1.认证
(1) 使用SpringSecurity进行简单的认证(SpringBoot项目中)
创建一个SpringBoot的Web项目,功能非常简单就是通过访问sayHello接口,在游览器输出字符串 "Hello,SpringSecurity"
:
项目pom.xml依赖:
<?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.7.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.dudu</groupId>
<artifactId>springsecuritydemo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springsecuritydemo</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!--springBootWeb依赖-->
<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>
<!--test测试-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
controller下创建SpringSecurityController:
运行项目,访问sayHello接口,搞定:
接下来在项目的pom.xml配置文件中导入SpringSecurity的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
再次访问sayHello接口,就会跳转到一个登入界面,在项目中我们并没有编写该登入界面的代码,其实这就是SpringSecurity 内置的用户身份验证:
身份验证默认的账号为:user,密码在项目启动的时候在控制台会打印,注意每次启动的时候密码会发生变化!
输入账号和密码后点击登入:
登入后,就能够成功访问sayHello接口了:
(2) SpringSecurity的原理
上图中的三个过滤器分别是:
在项目启动类中,按下图所示进行debug,就可以观察到这个过滤链,过滤链上一共有16个过滤器( run.getBean(DefaultSecurityFilterChain.class)
)
这16个过滤器分别是:
(3) SpringSecurity核心类
- Authentication
- SecurityContextHolder
- AuthenticationManager
Authentication authenticate(Authentication authentication) throws AuthenticationException;
- AuthenticationProvider
- UserDetailsService
- UserDetails
常用方法:
方法名 | 解释 |
---|---|
Collection<?extendsGrantedAuthority> getAuthorities(); | 表示获取登录用户所有权限 |
String getPassword(); | 表示获取密码 |
String getUsername(); | 表示获取用户名 |
boolean isAccountNonExpired(); | 表示判断账户是否过期 |
boolean isAccountNonLocked(); | 表示判断账户是否被锁定 |
boolean isCredentialsNonExpired(); | 表示凭证{密码}是否过期 |
boolean isEnabled(); | 表示当前用户是否可用 |
- PasswordEncoder
常用方法:
方法名 | 解释 |
---|---|
String encode(CharSequence rawPassword); | 表示把参数按照特定的解析规则进行解析 |
boolean matches(CharSequence rawPassword, String encodedPassword); | 表示验证从存储中获取的编码密码与编码后提交的原始密码是否匹配。如果密码匹配,则返回true;如果不匹配,则返回false。第一个参数表示需要被解析的密码。第二个参数表示存储的密码。 |
default boolean upgradeEncoding(String encodedPassword) | 表示如果解析的密码能够再次进行解析且达到更安全的结果则返回true,否则返回false。默认返回false。 |
内置的PasswordEncoder实现列表:
实现类 | 说明 |
---|---|
NoOpPasswordEncoder(已废除) | 明文密码加密方式,该方式已被废除(不建议在生产环境使用),不过还是支持开发阶段测试Spring Security的时候使用。 |
BCryptPasswordEncoder | 使用广泛支持的bcrypt 算法来散列密码 |
Argon2Passwordencoder | 使用Argon2 算法来散列密码, 是一种故意缓慢的算法,需要大量内存 |
PBKDF2PASSWORDENCODER | 使用PBKDF2 算法来散列密码,是一种故意缓慢的算法 |
SCryptPasswordEncoder | 使用scrypt算法来散列密码,是一种故意缓慢的算法,需要大量内存 |
- 有许多应用程序使用旧的密码编码,无法轻松地进行迁移。
- 密码存储的最佳实践将再次改变。
- 作为一种框架 Spring,安全不能频繁地进行破坏更改
- 确保使用当前的密码存储建议对密码进行编码
- 允许验证现代和遗留格式的密码。
- 允许在将来升级编码
方式1:创建默认的代理 PasswordEncoder(SpringSecurity5.x)
我们来看看createDelegatingPasswordEncoder()的源码:
运行效果:
方式2:创建自定义代理 PasswordEncoder
运行效果:
实际项目中如果不采用默认方式,可以通过@Bean的方式来统一配置全局共用的PasswordEncoder,如下所示:
上图所示在配置中还重新配置了users
就一个用户,用户名为user,密码123456,使用该用户进行登入:
登入成功:
(4) 认证登入案例(JWT+SpringSecurity实现登入案例)
SpringSecurity的认证流程图如下,如果不按默认方式进行,将对应接口的实现类换成自己的实现类即可:
JWT的流程图如下:
JWT+SpringSecurity实现登入案例:
项目中有一个SecurityConfig类,表示SpringSecurity的配置
这个configure配置是整个Security Config的配置(过滤器链的配置),下图红框中的配置表示在UsernamePasswordAuthenticationFilter过滤器前添加一个过滤器。
这个过滤器是继承OncePerRequestFilter过滤器(之所以继承 OncePerRequestFilter
因为OncePerRequestFilter
一个请求只被过滤器拦截一次。请求转发不会第二次触发过滤器 ,而Filter会触发二次)
该过滤器存放在component文件下的JwtAuthenticationTokenFilter类
程序运行后,过滤器会一个一个的执行当执行到UsernamePasswordAuthenticationFilter过滤器之前会先执行自定义的JwtAuthenticationTokenFilter过滤器中的doFilterInternal()方法:
该方法业务逻辑非常清晰,就是判断是否在请求头中存在JWT的Token,如果存在( 用户已经登入过了
)就从token的载荷中获取用户名,然后再根据UserDetaulsService的loadUserByUsername(username)获取用户信息 (UserDetaulsService被替换成从数据库中获取用户信息,详细代码如下图所示 ) 然后进行认证,如果不存在( 该请求为登入请求
),用户的信息从登入界面获取( 用户名,密码 )然后进行认证。
这里的AdminUserDetails其实就是UserDetails接口的实现类(进行了扩展):
自定义的JwtAuthenticationTokenFilter过滤器放行后,就会执行UsernamePasswordAuthenticationFilter过滤器,执行过程如下图:
下面通过Postman进行模拟
用户首次登入,访问http://localhost:8089/admin/login接口,并携带账号和密码:
在自定义JwtAuthenticationTokenFilter过滤器的doFilterInternal()方法中打断点,进行观察,因为首次登入头部没有信息,所以authHeader=null,最后 chain.doFilter(request, response);进行放行
接下来进行认证,代码会执行业务层的login(String username, String password)进行登入操作:
通过userDetailsService.loadUserByUsername(username)获取UserDetails对象,执行该方法会进入到SecurityConfig配置文件下的userDetailsService()中,从数据库中获取用户UmsAdmin信息和权限:
此时控制台输出结果:
从数据库中获取到的用户数据:
通过PasswordEncoder进行密码匹配
匹配成功,封装Authentication对象:
然后将Authentication保存到 SecurityContextHolder中
前面提到过UserDetails 会赋给认证通过的 Authentication 的 principal,确实如此
然后通过Token工具类生成Token字符串,并返回:
虽然传递的UserDetails对象,实际上只将用户名作为JWT的载荷( JWT中不要传入敏感信息
):
最后收到返回的Token并将Token返回给前端:
前端收到服务端的Token信息:
前端收到token信息后将JWT的token信息放在请求头中,然后再去访问相应接口,这里访问http://localhost:8089/admin/permission/1接口
再次经过自定义过滤器的时候,就可以获取该请求头中的数据
从请求头中获取关键信息,用户名和JWT的token:
如果解析出的username不为null,就通过UserDetailsService.loadUserByUsername(username)获取UserDetails:
一样的,执行UserDetailsService.loadUserByUsername(username)还是执行我们编写的userDetailsService() ,从数据库中获取
获取到的UserDetails对象
控制台输出信息:
然后通过JWT的工具类进行验证:
验证通过后封装Authenticationm,并将authentication保存在SecurityContextHolder中
然后放行执行其他过滤器:
同样的在业务层中实现业务代码,并返回给controller层:
controller层收到后返回给前端:
2.授权
(1) 加入权限到Authentication中
将权限信息写入到UserDetails中
再将UserDetails封装到Authentication中
AdminUserDetails是UserDetails的接口实现类
(2) SecurityConfig配置文件中开启注解权限配置
使用 @EnableGlobalMethodSecurity(prePostEnabled=true)
注解
(3) 给接口中的方法添加访问权限
使用 @PreAuthorize("hasAuthority('pms:brand:read')")
注解
重新运行后,访问 http://localhost:8089/admin/permission/1 接口 ( 不需要再登入,因为默认JWT失效时间为1周
),由于该用户没有权限访问,所以访问失败:
接下来更换用户test,密码为123456进行登入:
在http://localhost:8089/admin/permission/1中更换请求头的token信息:
再次访问接口(访问成功):
(4) 用户权限表的建立
RBAC权限模型
在RBAC模型里面,有3个基础组成部分,分别是:用户、角色和权限,它们之间的关系如下图所示:
前面项目中的权限数据表的设计就是采用RBAC模型,如下图所示( 数据库的设计来源于github上的mall项目,这里不做解释
):
ums_role角色表,表中存在三种角色,商品管理员,订单管理员和超级管理员
ums_admin用户表:
ums_admin和ums_role关联表 ums_admin_role_relation:
ums_permission:许可表
ums_admin和ums_permission的关联表:ums_admin_permission_relation:
ums_role和ums_permission的关联表ums_role_permission_relation:
查询权限的sql语句如下:
SELECT
p.*
FROM
ums_admin_role_relation ar
LEFT JOIN ums_role r ON ar.role_id = r.id
LEFT JOIN ums_role_permission_relation rp ON r.id = rp.role_id
LEFT JOIN ums_permission p ON rp.permission_id = p.id
WHERE
ar.admin_id = #{adminId}
AND p.id IS NOT NULL
AND p.id NOT IN (
SELECT
p.id
FROM
ums_admin_permission_relation pr
LEFT JOIN ums_permission p ON pr.permission_id = p.id
WHERE
pr.type = - 1
AND pr.admin_id = #{adminId}
)
UNION
SELECT
p.*
FROM
ums_admin_permission_relation pr
LEFT JOIN ums_permission p ON pr.permission_id = p.id
WHERE
pr.type = 1
AND pr.admin_id = #{adminId}
前面的项目中,获取数据库的权限由于有多张表参与,所以无法直接使用mybatis生成的dao进行操作,所以需要自己创建dao,如下:
UmsAdminRoleRelationDao.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.dudu.dao.UmsAdminRoleRelationDao">
<select id="getPermissionList" resultMap="com.dudu.mbg.mapper.UmsPermissionMapper.BaseResultMap">
SELECT
p.*
FROM
ums_admin_role_relation ar
LEFT JOIN ums_role r ON ar.role_id = r.id
LEFT JOIN ums_role_permission_relation rp ON r.id = rp.role_id
LEFT JOIN ums_permission p ON rp.permission_id = p.id
WHERE
ar.admin_id = #{adminId}
AND p.id IS NOT NULL
AND p.id NOT IN (
SELECT
p.id
FROM
ums_admin_permission_relation pr
LEFT JOIN ums_permission p ON pr.permission_id = p.id
WHERE
pr.type = - 1
AND pr.admin_id = #{adminId}
)
UNION
SELECT
p.*
FROM
ums_admin_permission_relation pr
LEFT JOIN ums_permission p ON pr.permission_id = p.id
WHERE
pr.type = 1
AND pr.admin_id = #{adminId}
</select>
</mapper>
dao层中定义UmsAdminRoleRelationDao的接口
业务层中通过adminRoleRelationDao获取数据库中的权限信息
每一个mapper接口中都没有@mapper所以需要添加一个MyBatisConfig的配置类通过@MapperScan注解扫描mapper
3.自定义失败处理
- 如果是认证过程中出现的异常会被封装成AuthenticationException然后调用
AuthenticationEntryPoint
对象的方法去进行异常处理。 - 如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用
AccessDeniedHandler
对象的方法去进行异常处理。
(1) 创建异常处理类
RestAuthenticationEntryPoint类( 当未登录或者token失效访问接口时,自定义的返回结果【认证失败】
):
package com.dudu.component;
import cn.hutool.json.JSONUtil;
import com.dudu.common.api.CommonResult;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 当未登录或者token失效访问接口时,自定义的返回结果【认证失败】
*/
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.parse(CommonResult.unauthorized(authException.getMessage())));
response.getWriter().flush();
}
}
RestfulAccessDeniedHandler类( 当访问接口没有权限时,自定义的返回结果【授权失败】
) :
package com.dudu.component;
import cn.hutool.json.JSONUtil;
import com.dudu.common.api.CommonResult;
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;
/**
* 当访问接口没有权限时,自定义的返回结果【授权失败】
*/
@Component
public class RestfulAccessDeniedHandler implements AccessDeniedHandler{
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().println(JSONUtil.parse(CommonResult.forbidden(e.getMessage())));
response.getWriter().flush();
}
}
(2) 配置移除处理类
在SecurityConfig中configure()方法中配置异常处理类:
- 先注入对应的处理器
- 然后我们可以使用HttpSecurity对象的方法去配置。
4.跨域问题
- 添加跨域配置类
package com.dudu.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
/**
* 全局跨域配置
*/
@Configuration
public class GlobalCorsConfig{
/**
* 允许跨域调用的过滤器
*/
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
//允许所有域名进行跨域调用
config.addAllowedOriginPattern("*");
//允许跨越发送cookie
config.setAllowCredentials(true);
//放行全部原始头信息
config.addAllowedHeader("*");
//允许所有请求方法跨域调用
config.addAllowedMethod("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
- 开启SpringSecurity的跨域访问
三.源码下载
JWT+SpringSecurity实现登入案例的代码采用github上的mall项目教程的代码,你可以通过该教程的链接进行下载,该教程路径https://www.macrozheng.com/mall/architect/mall_arch_05.html:
当然你也可以通过我的微信公众号进行下载,里面包括了博文中涉及到的所有代码,微信公众号搜索程序员孤夜(或扫描下方二维码),后台回复 登入案例
,即可获取本篇文章所使用的源代码下载链接。