一篇带你学完SpringSecurity!

1.前言

基于项目:https://github.com/Epiphany6666/Elite-Code

本项目参考的是鱼皮的 面试鸭 项目,从0开始搭建SpringBoot、Vue程序,每一个功能都对应一个提交,其中Doc文件夹下包含本人的学习笔记、任务规划......

后续慢慢整合Redis、MQ等中间件...... 授权功能过几天升级为RBAC,由于这个学期学校课程非常多,导致开发进度很慢,但我不会停止!

欢迎大佬们多多提建议~(可以求个star嘛)

开始正题,Hello,我是洛言,前两天研究SpringSecurity头都要炸了,满脑子都是SpringSecurity为什么要设置这么多类,英语不好的还总是把 authentication(认证)authorization(授权) 弄混!!!

但真正深入学习SpringSecurity后感觉:发明框架的人太伟大了,并且类、方法的命名真的很值得参考。

下面带着大家一起从头开始学SpringSecurity,包含梳理SpringSecurity整个认证流程(第11节),以及如何整合JWT进行登录验证(第37节)等.....

(超小声说):如果有帮助到你,可以给文章点个赞和收藏,让更多的人看到 ~ ("▔□▔)/


---------------------------------

第一章 Spring Security快速入门

2.Spring Security基本功能

一、Spring Security介绍

**官方文档:**https://docs.spring.io/spring-security/reference/index.html

官网解释:Spring Security 是一个提供身份验证授权针对常见攻击的保护的框架。 它为保护命令式和反应式应用程序提供了一流的支持,是保护基于 Spring 的应用程序的事实标准。

image-20240228215906984

功能:

  • 身份认证(authentication)
  • 授权(authorization)
  • 防御常见攻击(protection against common attacks)

二、功能介绍

身份认证:身份认证是验证谁正在访问系统资源,判断用户是否为合法用户。认证用户的常见方式是要求用户输入用户名和密码。

授权:用户进行身份认证后,系统会控制谁能访问哪些资源,这个过程叫做授权。用户无法访问没有权限的资源。

防御常见攻击:

  • CSRF
  • HTTP Headers
  • HTTP Requests

3.实现最简单的身份验证

一、找到官网的身份认证(authentication)示例代码

官方代码示例:GitHub - spring-projects/spring-security-samples

在我们的课程中使用的是传统的SpringMVC

image-20240228220706330

按如下图顺序找到官方Demo

image-20240228220904244

二、实现最简单的身份验证

1、创建Spring Boot项目

项目名:security-demo

JDK:17

SpringBoot:3.2.0(依赖了Spring Security 6.2.0)

Dependencies:Spring Web、Spring Security、Thymeleaf

image-20250313195718461

选择这三个依赖,点击创建

image-20240306100216226

2、创建IndexController

java
复制代码
package com.luoyan.securitydemo.controller; @Controller public class IndexController { @GetMapping("/") public String index() { return "index"; } }

3、创建index.html

在路径resources/templates中创建index.html

html
复制代码
<html xmlns:th="https://www.thymeleaf.org"> <head> <title>Hello Security!</title> </head> <body> <h1>Hello Security</h1> <!--通过使用@{/logout},Thymeleaf将自动处理生成正确的URL,以适应当前的上下文路径。 这样,无论应用程序部署在哪个上下文路径下,生成的URL都能正确地指向注销功能。--> <a th:href="@{/logout}">Log Out</a> <a href="/logout">Log Out2</a> </body> </html>

4、启动项目测试Controller

浏览器中访问:http://localhost:8080/

**浏览器自动跳转到登录页面:**http://localhost:8080/login

SpringSecurity在刚开始运行的时候,它校验了我们的用户是否进行了身份认证,如果没有进行用户身份认证的话,默认情况下,会自动生成一个登录页,并且把我们的浏览器URL地址重定向到登录页。

image-20230410140908841

输入用户名:user,输入密码:在控制台的启动日志中查找初始的默认密码

image-20240306102818710

点击"Sign in"进行登录,浏览器就跳转到了index页面。此时可以点击 Log Out 退出

image-20240306102936785 image-20240306102959892

此时如果点击logout2也会有同样的效果

image-20240306105124570

区别:如果我们的应用程序发布在不同的应用程序下,那么thymeleaf这个标签会做自动的相对路径的处理,而普通的a标签不会,下一章我们会具体讲解 @{/logout} 的作用。


4.@{/logout}的作用

通过使用@{/logout},Thymeleaf将自动处理生成正确的URL,以适应当前的上下文路径。这样,无论应用程序部署在哪个上下文路径下,生成的URL都能正确地指向注销功能。

例如:如果我们在配置文件中添加如下内容

properties
复制代码
# 将此应用程序部署在demo路径下 server.servlet.context-path=/demo

那么 @{/logout} 可以自动处理url为正确的相对路径

但是如果是普通的 /logout,路径就会不正确

image-20240306105553915


5.页面样式无法加载的问题

页面样式bootstrap.min.css是一个CDN地址,需要通过科学上网的方式访问

image-20231130152247055

否则你的登录页会加载很久,并且看到的页面是这样的(登录按钮没有样式文件渲染,但是不影响登录功能的执行)

image-20231130152345471

6.Spring Security默认做了什么

  • 保护应用程序URL,一旦访问我们程序中任何一个地址的时候,都要进行身份认证。要求对应用程序的任何交互进行身份验证。
  • 程序启动时生成一个默认用户“user”。
  • 生成一个默认的随机密码,并将此密码记录在控制台上。
  • 生成默认的登录表单和注销页面。
  • 提供基于表单的登录和注销流程。
  • 对于Web请求,没有进行身份认证的时候,SpringSecurity会重定向到登录页面;
  • 对于服务请求,返回401未经授权。
  • 处理跨站请求伪造(CSRF)攻击。
  • 处理会话劫持攻击。
  • 写入Strict-Transport-Security以确保HTTPS。
  • 写入X-Content-Type-Options以处理嗅探攻击。
  • 写入Cache Control头来保护经过身份验证的资源。
  • 写入X-Frame-Options以处理点击劫持攻击。

7.Spring Security 的底层原理

一、引入

官方文档:Spring Security的底层原理

Architecture:整个SpringSecurity的基础架构

官方解释:本节探讨了基于Servlet的应用程序中Spring Security的高层架构。我们将在参考文档的“认证”、“授权”和“防御漏洞攻击”部分基于这一高层理解进行深入阐述。

image-20240306110334111

Spring Security之所以默认帮助我们做了那么多事情,它的底层原理是传统的Servlet过滤器

请求由我们的客户端向后端服务器发起,经过层层过滤器的进行,最后它会到达Servlet。

我们刚刚写的index方法,底层就是servlet当中的具体的方法。因为我们的应用程序是SpringBoot程序,所以我们的应用程序是通过controller的方式来实现的servlet。

在正常情况下这些过滤器在应用程序启动的时候就要被注册在servlet容器里才能工作,但为了更灵活的在应用程序当中配置这些过滤器,其实我们的应用程序采用的是SpringBoot形式,而SpringBoot里的本质是spring、springMVC这些基础框架,也就意味着,其实在spring这种环境下,希望把这些Filter对象当做spring容器中的bean对象来管理,这些过滤器在添加、删除、启用和禁用的过程中就会更灵活。


二、Filter

下图展示了处理一个Http请求时,过滤器和Servlet的工作流程:

filterchain

因此我们可以在过滤器中对请求进行修改或增强。


1)DelegatingFilterProxy

DelegatingFilterProxy(委托过滤器代理) 是 Spring Security 提供的一个 Filter 实现,可以在 Servlet 容器和 Spring 容器之间建立桥梁。通过使用 DelegatingFilterProxy,这样就可以将Servlet容器中的 Filter 实例放在 Spring 容器中管理。

filter先以bean对象的形式注册在spring容器中,DelegatingFilterProxy帮助我们在spring的环境中加载filter bean对象。但因为DelegatingFilterProxy本身也是个filter,它会被注册在servlet过滤器链(FilterChain)中。在Spring容器中注册的bean filter就可以在DelegatingFilterProxy调用,从而工作在整个Servlet中的FilterChain,也就是过滤器链中。整个过程就叫做委托。

delegatingfilterproxy

2)FilterChainProxy

复杂的业务中不可能只有一个过滤器。因此DelegatingFilterProxy管理的并不单单只是一个过滤器,而是一个过滤器链。

FilterChainProxy是Spring Security提供的一个特殊的Filter,它允许通过SecurityFilterChain将过滤器的工作委托给多个Bean Filter实例。

应用程序为了更灵活,DelegatingFilterProxy管理FilterChainProxy过滤器链代理的这个类,然后通过过滤器链代理再去调用SecurityFilterChain。FilterChainProxy又被DelegatingFilterProxy调用。DelegatingFilterProxy是真正注册在servlet容器中的。

filterchainproxy

3)SecurityFilterChain

SecurityFilterChain 被 FilterChainProxy 使用,负责查找当前的请求需要执行的Security Filter列表。

securityfilterchain

4)Multiple SecurityFilterChain

为了让应用程序更灵活。可以有多个SecurityFilterChain的配置,FilterChainProxy决定使用哪个SecurityFilterChain,用于处理更复杂的应用程序。如果请求的URL是 /api/messages/,它首先匹配SecurityFilterChain0的模式/api/**,发现是匹配的,此时就会执行SecurityFilterChain0中的过滤器;但如果不匹配,它会继续找,一直从 SecurityFilterChain 0 找到 SecurityFilterChain n - 1 。假设没有其他SecurityFilterChain实例匹配,那么将调用 SecurityFilterChain n,它匹配的是 /**,也就是所有客户端的请求。

multi securityfilterchain


8.DefaultSecurityFilterChain

首先在源代码中找到这个类,shift + shift 可进行全局搜索

image-20240306113159465

可以发现这个类实现了SecurityFilterChain

image-20240306113239432

默认情况下,SpringSecurity程序有一个默认的过滤器链,这一个默认的过滤器链里,有一系列默认的过滤器的集合

应用程序启动后,日志中有一行是和DefaultSecurityFilterChain相关的

image-20240306113456861

然后把这一行完整的复制下来,放到一个记事本中

这就是spring应用程序默认加载的16个过滤器

java
复制代码
o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [ org.springframework.security.web.session.DisableEncodeUrlFilter@5d152bcd, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@43cb5f38, org.springframework.security.web.context.SecurityContextHolderFilter@4684240f, org.springframework.security.web.header.HeaderWriterFilter@115ca7de, org.springframework.web.filter.CorsFilter@6435fa1c, org.springframework.security.web.csrf.CsrfFilter@31f295b6, org.springframework.security.web.authentication.logout.LogoutFilter@478b0739, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@72bd2871, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@2e4389ed, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@7944b8b4, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@413bef78, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@6975fb1c, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@553da911, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@d7bbf12, org.springframework.security.web.access.ExceptionTranslationFilter@63f6bed1, org.springframework.security.web.access.intercept.AuthorizationFilter@25f61c2c ]

另一个方法:打开DefaultSecurityFilterChain这个类,然后在这个类中设置一个断点

这里有一个构造方法,构造方法里有一个filters(过滤器列表),这个过滤器列表是当前这个类的一个成员,这一行过滤器列表会被赋值。

image-20240306113926637

向下执行一步,可以看见filters被赋值了。SecurityFilterChain接口的实现,加载了默认的16个Filter。

其中这些过滤器就有实现用户认证功能的,有实现授权功能的,还有实现系统的攻击防御功能的。

image-20240306114053807

UsernamePasswordAuthenticationFilter 帮助我们校验用户名和密码是否正确的方式来决定是否给这个用户进行一个验证,从而让他登录到系统的主页面中

image-20240306114146340

DefaultLoginPageGeneratingFilter 给我们展示一个Login配置登录页的过滤器

image-20240306114309425

DefaultLogoutPageGeneratingFilter 帮助我们展示一个登出页面的过滤器

image-20240306114331421

LogoutFilter 帮助我们实现默认登出流程

image-20240306114355685


9.SecurityProperties

默认情况下Spring Security将初始的用户名和密码存在了SecurityProperties类中。这个类中有一个静态内部类User,配置了默认的用户名(name = "user")和密码(password = uuid)

image-20240306125151944

我们也可以将用户名、密码配置在SpringBoot的配置文件中:在application.properties中配置自定义用户名和密码

properties
复制代码
spring.security.user.name=user spring.security.user.password=123

-----------------------------------

第二章 Spring Security自定义配置

10.基于内存的用户认证

一、创建自定义配置

前面的课程中我们学习了使用最简单的方式来创建一个基本的SpringSecurity用户认证程序,这里面提到的其实都是一些默认的SpringSecurity配置,例如默认的登录和登出页、默认的用户名和密码,都是在应用程序内部已经配置好的,我们只需要通过简单的几个步骤把应用程序创建出来,就可以直接使用这些默认的功能了。

实际开发的过程中,我们想要应用程序更加灵活,可以在 SpringSecurity 中创建自定义配置文件

官方文档:Java自定义配置,这里面就提到了如何在 SpringSecurity 中创建默认的配置文件。

首先需要创建一个类,这个类的名字无所谓,但是两个注解一定要加上

image-20240306125520060

二、代码实现

下面的代码直接从官方文档上复制粘贴即可。

java
复制代码
@Configuration // 标识这个类为配置类,这样spring应用程序一启动的时候,这个类的bean结点就会被初始在spring应用程序的上下文当中 @EnableWebSecurity // 如果我们想要在Security中做一个自定义配置的话,就需要在这个配置类上标识一个@EnableWebSecurity,目的是开启SpringSecurity的自定义配置,如果当前项目是SpringBoot项目,实际上@EnableWebSecurity是可以省略的,这是因为它包含在spring-boot-autoconfigure这个jar包,基于SpringBoot第三方的所有的配置都会在autoconfig中被预先定义好(在第三点中有解释)。但如果单纯只是个spring项目的话,这个就必须添加上 public class WebSecurityConfig { @Bean public UserDetailsService userDetailsService() { // 创建基于内存的用户信息管理器 InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); // 使用manager对象管理UserDetails对象 manager.createUser( // 创建一个UserDetails对象,用于管理用户名、用户密码、用户角色、用户权限等内容 User.withDefaultPasswordEncoder() .username("huan") .password("password") .roles("USER") .build()); // 最后把manager对象返回 return manager; } }

在createUser方法中,它会把创建出来的对象放到内存当中,即当前类的users属性中

image-20240306142104533

此时应用程序中默认的UserDetailsService就被替换成了我们现在定义的UserDetailsService,在替换的同时,默认的用户名和密码也被替换成了我们现在定义的用户名和密码。

**测试:**使用用户名huan,密码password登录

此时properties中设置的用户名和密码已经不能生效了,此时用户还是被管理在内存当中的。


三、代码部分解释

UserDetailsService用来管理用户信息,InMemoryUserDetailsManager是UserDetailsService的一个实现,用来管理基于内存的用户信息。

创建一个 java.com.luoyan.securitydemo.WebSecurityConfig 类:


如果当前项目是SpringBoot项目,实际上@EnableWebSecurity是可以省略的,这是因为它包含在spring-boot-autoconfigure这个jar包,基于SpringBoot第三方的所有的配置都会在autoconfig中被预先定义好 的原因:

我们有一个非常重要的jar包为spring-boot-autoconfigure,基于springboot项目 的所有的第三方默认的配置,都在 spring-boot-autoconfigure 中预先的定义好了。

如下图找到的类中,SpringBootWebSecurityConfiguration 这个类就是在SpringBoot环境下,针对于 SpringSecurity 做的一个默认的配置了。

红框框起来的黄色的内容意思就是,整个应用程序当中,只要EnableWebSecurity这个类被加载到了应用程序的上下文当中,那么@EnableWebSecurity注解就会生效。

image-20250105204644927

这个类什么时候加载到应用程序的上下文当中呢?我们在创建项目之初,只要这个依赖 spring-boot-starter-security 被添加在了我们的项目中,那么这个类就在我们的应用程序中加载进来了。

image-20240306131710845

只要它加载进来,这个注解就有了。


定义一个@Bean,类型是UserDetailsService

UserDetailsService本身并不是一个类,它是一个接口,这个接口下面有很多具体的实现 chtl + h

先对InMemoryUserDetailsManager做一个测试,它的意思就是把我们的用户信息管理在SpringSecurity应用程序的内存中。

image-20240306132627378


11.基于内存的用户认证源码分析

一、核心组件

① SecurityContextHolder

SecurityContextHolder 用于存储安全上下文(security context)的信息。当前操作的用户是谁,该用户是否已经被认证,他拥有哪些角色权限… 这些都被保存在 SecurityContextHolder 中。SecurityContextHolder 默认使用 ThreadLocal 策略来存储认证信息。看到 ThreadLocal 也就意味着,这是一种与线程绑定的策略。Spring Security 在用户登录时自动绑定认证信息到当前线程,在用户退出时,自动清除当前线程的认证信息。

因为身份信息是与线程绑定的,所以可以在程序的任何地方使用静态方法获取用户信息。


② Authentication

  • Authentication 是 spring security 包中的接口,直接继承自 Principal 类,而 Principal 是位于 java.security 包中的。可以见得,Authentication 在 spring security 中是最高级别的身份 / 认证的抽象。
  • 由这个顶级接口,我们可以得到用户拥有的权限信息列表,密码,用户细节信息,用户身份信息,认证信息。
image-20250313172859047

认证成功后,会返回一个已认证的 UsernamePasswordAuthenticationToken,此时传入的Principal就是 loadUserByUsername() 返回的 UserDetails实现对象

通常我们会将 Principal 强转成 Spring Security 中最常用的 UserDetails,这在 Spring Security 中非常常见,接口返回 Object,使用 instanceof 判断类型,强转成对应的具体实现类。接口详细解读如下:

  • getAuthorities():权限信息列表,默认是 GrantedAuthority 接口的一些实现类,通常是代表权限信息的一系列字符串。
  • getCredentials():密码信息,用户输入的密码字符串,在认证过后通常会被移除,用于保障安全。
  • getDetails():细节信息,web 应用中的实现接口通常为 WebAuthenticationDetails,它记录了访问者的 ip 地址和 sessionId 的值。
  • getPrincipal():敲黑板!!!最重要的身份信息,大部分情况下返回的是 UserDetails 接口的实现类,也是框架中的常用接口之一。
java
复制代码
package org.springframework.security.core; import java.io.Serializable; import java.security.Principal; import java.util.Collection; public interface Authentication extends Principal, Serializable { Collection<? extends GrantedAuthority> getAuthorities(); Object getCredentials(); Object getDetails(); Object getPrincipal(); boolean isAuthenticated(); void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException; }

③ AuthenticationManager

初次接触 Spring Security 的朋友相信会被 AuthenticationManagerProviderManagerAuthenticationProvider …...

这么多相似的 Spring 认证类搞得晕头转向,但只要稍微梳理一下就可以理解清楚它们的联系和设计者的用意。AuthenticationManager(接口)是认证相关的核心接口,也是发起认证的出发点,因为在实际需求中,我们可能会允许用户使用用户名 + 密码登录,同时允许用户使用邮箱 + 密码,手机号码 + 密码登录,甚至,可能允许用户使用指纹登录(还有这样的操作?没想到吧),所以说 AuthenticationManager 一般不直接认证,AuthenticationManager 接口的常用实现类 ProviderManager 内部会维护一个 List<AuthenticationProvider> 列表,存放多种认证方式,实际上这是委托者模式的应用(Delegate)。也就是说,核心的认证入口始终只有一个:AuthenticationManager,不同的认证方式:用户名 + 密码(UsernamePasswordAuthenticationToken),邮箱 + 密码,手机号码 + 密码登录则对应了三个 AuthenticationProvider

ProviderManager 中的 List,会依照次序去认证,认证成功则立即返回,若认证失败则返回 null,下一个 AuthenticationProvider 会继续尝试认证,如果所有认证器都无法认证成功,则 ProviderManager 会抛出一个 ProviderNotFoundException 异常。


④ UserDetails 与 UserDetailsService

UserDetails:代表了最详细的用户信息,这个接口涵盖了一些必要的用户信息字段,具体的实现类对它进行了扩展。

java
复制代码
public interface UserDetails extends Serializable { Collection<? extends GrantedAuthority> getAuthorities(); String getPassword(); String getUsername(); boolean isAccountNonExpired(); boolean isAccountNonLocked(); boolean isCredentialsNonExpired(); boolean isEnabled(); }

它和 Authentication 接口很类似,比如它们都拥有 username,authorities,区分他们也是本文的重点内容之一。Authentication 的 getCredentials()与 UserDetails 中的 getPassword() 需要被区分对待,前者是用户提交的密码凭证,后者是用户正确的密码,认证器其实就是对这两者的比对。

Authentication 中的 getAuthorities() 实际是由 UserDetails 的 getAuthorities() 传递而形成的。还记得 Authentication 接口中的 getUserDetails() 方法吗?其中的 UserDetails 用户详细信息便是经过了 AuthenticationProvider 之后被填充的。

java
复制代码
public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }

认证的核心步骤:

  1. 用户提交凭证(如用户名/密码)时,会创建一个未认证的 Authentication 对象(如 UsernamePasswordAuthenticationToken)。
  2. AuthenticationProvider(如 DaoAuthenticationProvider)负责验证凭证。
  3. UserDetailsService (如InMemoryUserDetailsManager)被调用,通过 loadUserByUsername(username) 从数据库加载用户详细信息(返回 UserDetails 对象)。
  4. 验证用户密码,并提取 UserDetails 中的权限信息。
  5. 构造已认证的 Authentication 对象,将 UserDetails 的权限复制到该对象中。

UserDetailsService 和 AuthenticationProvider 两者的职责常常被人们搞混,关于他们的问题在文档的 FAQ 和 issues 中屡见不鲜。记住一点即可,敲黑板!!!UserDetailsService 只负责从特定的地方(通常是数据库)加载用户信息,仅此而已,记住这一点,可以避免走很多弯路。UserDetailsService 常见的实现类有 JdbcDaoImpl,InMemoryUserDetailsManager,前者从数据库加载用户,后者从内存中加载用户,也可以自己实现 UserDetailsService,通常这更加灵活。


架构图

image-20250313175606519

二、登录流程

时序图:

SpringSecurity验证流程

程序启动时:

  • 创建InMemoryUserDetailsManager对象
  • 创建User对象,封装用户名密码
  • 使用 InMemoryUserDetailsManager 将User存入内存

AbstractAuthenticationProcessingFilter官方文档:https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-abstractprocessingfilter

在第7小节中有讲解Spring Security 的底层原理:Spring Security之所以默认帮助我们做了那么多事情,它的底层原理是传统的Servlet过滤器

其中 AbstractAuthenticationProcessingFilter 被用作验证用户凭证的基础过滤器,它可以验证提交给它的任何认证请求。

话不多说,我们直接打断点来到AbstractAuthenticationProcessingFilter的doFilter方法中

PS:为了方便阅读,复制来的源码只会展示重要的代码,不重要的暂时用 ... 省略。

  • java
    复制代码
    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { try { Authentication authenticationResult = attemptAuthentication(request, response); if (authenticationResult == null) { // return immediately as subclass has indicated that it hasn't completed return; } this.sessionStrategy.onAuthentication(authenticationResult, request, response); // Authentication success if (this.continueChainBeforeSuccessfulAuthentication) { chain.doFilter(request, response); } successfulAuthentication(request, response, chain, authenticationResult); } ... }
  • UsernamePasswordAuthenticationFilter(子类) 实现了 AbstractAuthenticationProcessingFilter(父类) 的抽象方法 attemptAuthentication

  • UsernamePasswordAuthenticationFilter 过滤器中的 attemptAuthentication 方法中将用户输入的用户名密码和从内存中获取到的用户信息进行比较,进行用户认证

    java
    复制代码
    @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this.postOnly && !request.getMethod().equals("POST")) { // 如果是post请求才往下走 throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } String username = obtainUsername(request); // 从请求中拿出用户名 username = (username != null) ? username.trim() : ""; String password = obtainPassword(request); // 从请求中拿出密码 password = (password != null) ? password : ""; UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password); // 将用户名和密码封装成一个未认证的UsernamePasswordAuthenticationToken对象 // details 对象里边放的是 WebAuthenticationDetails 实例,该实例主要描述了两个信息:请求的 remoteAddress 以及请求的 sessionId setDetails(request, authRequest); // authenticate方法就是校验你输入的用户名和密码是否符合内存中存储的用户名和密码 return this.getAuthenticationManager().authenticate(authRequest); }
  • 获取到一个 AuthenticationManager,这里获取到的是 ProviderManager,进入 ProviderManagerauthenticate 方法。

    AbstractAuthenticationToken 类 和 Authentication 类的关系是接口和实现的关系,Authentication有一个具体的抽象实现类:AbstractAuthenticationToken。

    UsernamePasswordAuthenticationToken就是AbstractAuthenticationToken的一个实现类(子类)。

    因此 UsernamePasswordAuthenticationToken 就是 Authentication 的一个实现类。

    java
    复制代码
    @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { // 获取authentication对象具体的类型 Class<? extends Authentication> toTest = authentication.getClass(); Authentication result = null; ... // 遍历 providers,获取到第一个支持验证 toTest类型 的 provider,则调用该 provider 的 authenticate 方法开始做校验 for (AuthenticationProvider provider : getProviders()) { if (!provider.supports(toTest)) { continue; } ... try { result = provider.authenticate(authentication); if (result != null) { // 把原始Authentication对象 的 details 属性拷贝到新的 Authentication对象 中 copyDetails(authentication, result); break; } } ... } ... if (result != null) { if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) { // 擦除凭证,这里是将凭证直接置为了null ((CredentialsContainer) result).eraseCredentials(); } if (parentResult == null) { // 将登录成功的事件广播出去 this.eventPublisher.publishAuthenticationSuccess(result); } return result; } ... }
  • 根据源码跟踪,匹配到的provider是 AbstractUserDetailsAuthenticationProvider,进入 AbstractUserDetailsAuthenticationProvider类authenticate方法

    在AbstractUserDetailsAuthenticationProvider类中的authenticate方法,它就获取到了前面用户输入的用户名和密码。

    java
    复制代码
    @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported")); String username = determineUsername(authentication); // 从authentication获取到用户名 boolean cacheWasUsed = true; UserDetails user = this.userCache.getUserFromCache(username); if (user == null) { cacheWasUsed = false; try { user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); // 通过用户名来到retrieveUser 方法中 } catch (UsernameNotFoundException ex) { ... } } }
  • 通过用户名来到 DaoAuthenticationProvider#retrieveUser 方法中

    可以发现,SpringSecurity自动使用 InMemoryUserDetailsManagerloadUserByUsername 方法从内存中获取User对象

    java
    复制代码
    @Override protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { prepareTimingAttackProtection(); try { // 这里的UserDetailsService就是在用户文件中创建的UserDetailsService,所以获取到的实际的对象就是InMemoryUserDetailsManager对象 UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username); if (loadedUser == null) { throw new InternalAuthenticationServiceException( "UserDetailsService returned null, which is an interface contract violation"); } return loadedUser; } catch (UsernameNotFoundException ex) { mitigateAgainstTimingAttack(authentication); throw ex; } catch (InternalAuthenticationServiceException ex) { throw ex; } catch (Exception ex) { throw new InternalAuthenticationServiceException(ex.getMessage(), ex); } }
  • 进入 InMemoryUserDetailsManager#loadUserByUsername 方法(从内存中将用户取出来)

    java
    复制代码
    @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 这个地方就是我们一开始存入到内存当中的用户信息,这里根据我们内存中输入的用户名username.toLowerCase(),从内存中看一看能不能获取到这个用户信息,如果能够获取到,就把这个用户信息返回回去 UserDetails user = this.users.get(username.toLowerCase()); if (user == null) { throw new UsernameNotFoundException(username); } // 将它组装成UserDetails对象,最后返回到应用程序当中 return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities()); }
  • 此时回到了retrieveUser 方法

    image-20240306141204913
  • 往下滑,接下来要做的是就是把用户输入的密码和内存中获取到的用户信息的密码进行验证,回到authenticate方法

    这里它将内存中的用户和用户输入的authentication对象传递到了additionalAuthenticationChecks方法中

    image-20240306141514226
    java
    复制代码
    @Override @SuppressWarnings("deprecation") protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { // Credentials:用户凭证,由于我们是使用用户名密码的方式进行登录,因此这里即用户密码 if (authentication.getCredentials() == null) { // 如果没有用户凭证就报错 this.logger.debug("Failed to authenticate since no credentials provided"); throw new BadCredentialsException(this.messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } // 如果有用户凭证,就把这个用户凭证获取出来,并变成字符串,即变成用户输入的密码 // 这个密码是从用户输入的用户信息获取出来的 String presentedPassword = authentication.getCredentials().toString(); // 把用户输入的密码和内存中获取的密码进行比较,此时userDetails.getPassword()获取到的密码是加密后的密码 // matches方法:先将用户输入的密码用同样的方式进行加密,加密后再和userDetails.getPassword()进行比较 if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) { this.logger.debug("Failed to authenticate since password does not match stored value"); throw new BadCredentialsException(this.messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } ... // 创建一个全新的UsernamePasswordAuthenticationToken,并返回 return createSuccessAuthentication(principalToReturn, authentication, user); }

三、用户信息保存流程

我们走完了 UsernamePasswordAuthenticationFilter类attemptAuthentication方法,此时 AbstractAuthenticationProcessingFilter 中的 doFilter方法 就可以继续往下走了。

java
复制代码
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { try { // 校验 Authentication authenticationResult = attemptAuthentication(request, response); if (authenticationResult == null) { return; } // 在用户认证成功后,应用会话安全策略(如更换 Session ID 防御会话固定攻击),并确保会话状态与认证信息同步,这里不做讨论 this.sessionStrategy.onAuthentication(authenticationResult, request, response); // Authentication success if (this.continueChainBeforeSuccessfulAuthentication) { chain.doFilter(request, response); } successfulAuthentication(request, response, chain, authenticationResult); } catch (InternalAuthenticationServiceException failed) { this.logger.error("An internal error occurred while trying to authenticate the user.", failed); unsuccessfulAuthentication(request, response, failed); } catch (AuthenticationException ex) { // Authentication failed unsuccessfulAuthentication(request, response, ex); } }

由源码的注释可知,认证失败时将调用 unsuccessfulAuthentication(...),认证成功时将调用 successfulAuthentication(...),用户信息保存的时机肯定在 successfulAuthentication(...)方法 中,我们跟进!

java
复制代码
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { // 创建一个空SecurityContext SecurityContext context = SecurityContextHolder.createEmptyContext(); // 将已认证的Authentication存入到SecurityContext context.setAuthentication(authResult); // 绑定到当前线程 SecurityContextHolder.setContext(context); this.securityContextRepository.saveContext(context, request, response); if (this.logger.isDebugEnabled()) { this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult)); } this.rememberMeServices.loginSuccess(request, response, authResult); if (this.eventPublisher != null) { this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass())); } this.successHandler.onAuthenticationSuccess(request, response, authResult); }

也就是说,在任何地方我们都可以通过 SecurityContextHolder.getContext() 获取到当前登录用户的信息!


12.基于数据库的数据源(项目搭建)

一、SQL

创建三个数据库表并插入测试数据

密码使用的是bcrypt加密方式

sql
复制代码
-- 创建数据库 CREATE DATABASE `security-demo`; USE `security-demo`; -- 创建用户表 CREATE TABLE `user`( `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `username` VARCHAR(50) DEFAULT NULL , `password` VARCHAR(500) DEFAULT NULL, `enabled` BOOLEAN NOT NULL ); -- 唯一索引 CREATE UNIQUE INDEX `user_username_uindex` ON `user`(`username`); -- 插入用户数据(密码是 "abc" ) INSERT INTO `user` (`username`, `password`, `enabled`) VALUES ('admin', '{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', TRUE), ('Helen', '{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', TRUE), ('Tom', '{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', TRUE);

二、引入依赖

xml
复制代码
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.30</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.4.1</version> <!-- 为了防止mybatisplus和SpringBoot版本冲突,先排除mybatis-plus和spring整合的一个类 --> <exclusions> <exclusion> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> </exclusion> </exclusions> </dependency> <!-- 重新引入MyBatis最新版本 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>

三、配置数据源

properties
复制代码
#MySQL数据源 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.url=jdbc:mysql://localhost:3306/security-demo?characterEncoding=utf-8&useSSL=false spring.datasource.username=root spring.datasource.password=1234 #SQL日志 mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl

四、实体类

java
复制代码
package com.luoyan.securitydemo.entity; @Data public class User { @TableId(value = "id", type = IdType.AUTO) private Integer id; private String username; private String password; private Boolean enabled; }

五、Mapper

接口

java
复制代码
package com.luoyan.securitydemo.mapper; @Mapper public interface UserMapper extends BaseMapper<User> { }

resources/mapper/UserMapper.xml

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.luoyan.securitydemo.mapper.UserMapper"> </mapper>

六、Service

接口

java
复制代码
package com.luoyan.securitydemo.service; public interface UserService extends IService<User> { }

实现

java
复制代码
package com.luoyan.securitydemo.service.impl; @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { }

七、Controller

java
复制代码
package com.luoyan.securitydemo.controller; @RestController // 这样我们可以通过获取到json形式的数据来看看我们当前基于数据库的用户信息的管理的功能是否能够成功的对接到我们项目当中 @RequestMapping("/user") public class UserController { @Resource public UserService userService; @GetMapping("/list") public List<User> getList(){ return userService.list(); } }

测试:localhost:8080/demo/user/list


13.基于数据库的用户认证流程分析

程序启动时:

基于数据库的用户认证和基于内存的用户认证是相似的,基于内存是创建InMemoryUserDetailsManager对象,基于数据库就需要我们自己去实现这个类了,这里命名为:DBUserDetailsManager

  • 创建DBUserDetailsManager类,参考InMemoryUserDetailsManager实现接口 UserDetailsManager, UserDetailsPasswordService

    查看UserDetailsManager类其中一个实现叫JdbcUserDetailsManager,这个类名是可以直接去实现基于数据库的用户认证,但是它是基于SpringTemplate持久层的,并不是基于Mybatis或者MyBatis-Plus的,所以如果持久层是Mybatis或者MyBatis-Plus的话,是无法使用JdbcUserDetailsManager,我们必须自己去实现一个这样类似的功能,让它

    image-20240307093448107

    在应用程序中初始化这个类的对象

之前基于内存的用户信息管理的时候需要手动创建User对象,但是这里是基于数据库的用户信息管理,就无需再创建对象了。

校验用户时:

  • SpringSecurity自动使用DBUserDetailsManagerloadUserByUsername方法从数据库中获取User对象
  • UsernamePasswordAuthenticationFilter过滤器中的attemptAuthentication方法中将用户输入的用户名密码和从数据库中获取到的用户信息进行比较,进行用户认证

14.基于数据库的用户认证流程实现

一、定义DBUserDetailsManager

java
复制代码
package com.luoyan.securitydemo.config; public class DBUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService { @Resource private UserMapper userMapper; /** * 从数据库中获取用户信息 * @param username the username identifying the user whose data is required. * @return * @throws UsernameNotFoundException */ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("username", username); User user = userMapper.selectOne(queryWrapper); // 这里如果User为null,可以参考InMemoryUserDetailsManager重写loadUserByUsername的源码实现 if (user == null) { throw new UsernameNotFoundException(username); } else { Collection<GrantedAuthority> authorities = new ArrayList<>(); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.getEnabled(), true, //用户账号是否过期 true, //用户凭证是否过期 true, //用户是否未被锁定 authorities); //权限列表 } } @Override public UserDetails updatePassword(UserDetails user, String newPassword) { return null; } @Override public void createUser(UserDetails user) { } @Override public void updateUser(UserDetails user) { } @Override public void deleteUser(String username) { } @Override public void changePassword(String oldPassword, String newPassword) { } @Override public boolean userExists(String username) { return false; } }

二、初始化UserDetailsService

修改WebSecurityConfig中的userDetailsService方法如下

由于在应用程序中只需要一个用户信息管理器就行了,所以第一个用户信息管理器可以给它屏蔽掉。

java
复制代码
@Bean public UserDetailsService userDetailsService() { // 创建基于数据库的用户信息管理器 DBUserDetailsManager manager = new DBUserDetailsManager(); // 如果是基于数据库的用户信息管理,创建对象的过程就不需要了,因为我们的用户信息事先已经存在数据库当中了,我们到时候去校验用户的时候,根据信息名取出来就行了。 return manager; }

由于只有UserDetailsService这一个对象是被创建在spring容器上下文中的,所以可以不用在配置类中使用@Bean注册到容器,而是直接在DBUserDetailsManager类上添加@Component注解

**测试:**使用数据库中配置的用户名和密码进行登录


15.SpringSecurity的默认配置

SpringSecurity(WebSecurityConfig)配置文件中,如果没有添加任何配置的话,它是有一个默认的行为的

官方文档:https://docs.spring.io/spring-security/reference/servlet/configuration/java.html#jc-httpsecurity

image-20240307102554872

实际上我们添加了一个自定义配置,就是DBUserDetailsManager类上添加@Component注解,只不过我们没有添加到WebSecurityConfig配置文件当中

我们将默认行为在WebSecurityConfig中添加如下配置

java
复制代码
@Bean // 针对HttpSecurity对象做一个具体的配置,然后根据这个配置形成一个过滤器链SecurityFilterChain public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { //authorizeRequests():开启授权保护 //anyRequest():对所有请求开启授权保护 //authenticated():已认证请求会自动被授权 http // 所以我们在应用程序当中,看到的过滤器链,里面具体的过滤器就是由这段配置来实现的 // authorizeRequests组装的就是在进行用户认证一些具体的功能,开启授权保护 // 如果我们的用户没有进行认证的话,应用程序就会开启授权保护,开启授权保护的过程中,如果用户没有进行认证,就会自动跳转到登录页;如果用户已经进行认证的话,就会被授权,用户被授权后,就能正常的进入到我们想要访问的页面当中去 .authorizeRequests(authorize -> authorize // 对所有请求开启授权保护 .anyRequest() // 已认证的请求会被自动授权 .authenticated()) // 自动使用表单数据,withDefaults():生成默认的登录表单和默认的登出页面 .formLogin(withDefaults())// 表单授权方式 .httpBasic(withDefaults());// 基本授权方式 return http.build(); }

注释掉 .formLogin(withDefaults()) 之后,在没有提供基于html登录页的基础上,此时就不会生成默认的登录页和登出页;此时浏览器会自带一个用户名和密码的输入框,不同浏览器的用户名和密码的输入框的风格可能会略有不同。并且它也不能生成默认的登出页,没有登出页就登出不了。此时可以清空浏览器的历史记录。这是最原始登录方式,我们很少这样使用,所以也可以把它(.httpBasic(withDefaults()))屏蔽掉。

重新查看过滤器链,可以发现.httpBasic(withDefaults());的过滤器链已经没有了。

注释掉 .formLogin(withDefaults())后可以发现少了 生成默认的登录页,生成默认的登录页,默认登录登出的基本的流程 这几个过滤器

image-20240307104635188


16.添加用户功能的实现

DBUserDetailsManager中有createUser方法,之前在创建基于内存的用户信息管理中用过。

image-20250108150600472

一、Controller

UserController中添加方法

java
复制代码
@PostMapping("/add") public void add(@RequestBody User user){ userService.saveUserDetails(user); }

二、Service

UserService接口中添加方法

java
复制代码
void saveUserDetails(User user);

UserServiceImpl实现中添加方法

java
复制代码
@Resource private DBUserDetailsManager dbUserDetailsManager; @Override public void saveUserDetails(User user) { // 由于这里面已经有一个关于实体类User类的引入,如果这里不写具体的实现的话,它们两个就重名了,所以这里需要写具体的完整的类名,然后使用默认的密码加密方案withDefaultPasswordEncoder UserDetails userDetails = org.springframework.security.core.userdetails.User .withDefaultPasswordEncoder() .username(user.getUsername()) //自定义用户名 .password(user.getPassword()) //自定义密码 .build(); dbUserDetailsManager.createUser(userDetails); }

三、修改配置

DBUserDetailsManager中添加方法

java
复制代码
/** * 向数据库中插入新的用户信息 * @param userDetails */ @Override public void createUser(UserDetails userDetails) { User user = new User(); user.setUsername(userDetails.getUsername()); user.setPassword(userDetails.getPassword()); user.setEnabled(true); userMapper.insert(user); }

17.关闭csrf攻击防御

pom中添加配置用于测试

xml
复制代码
<!--swagger测试--> <dependency> <groupId>com.github.xiaoymin</groupId> <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId> <version>4.4.0</version> </dependency>

**Swagger测试地址:**http://localhost:8080/demo/doc.html

此时会报403错误(没有权限访问网站),这是因为SpringSecurity默认开启了对csrf攻击的防御。这要求请求参数中必须有一个隐藏的 _csrf 字段。

image-20240307125831855

如果开启了csrf攻击的防御,那么针对所有的post请求在它的登录页面当中,都会自动生成一个隐藏的字段

html
复制代码
<input name="_csrf" type="hidden" value="mkU7biBhGoyGvNATNdWvljPHFRxYgHr3oZrd8q23Qk6PkAkQrnEKDRhRKbir3eIlA_ibplX1OH1rsRjawvm4w53WJnq5ojAm" />
image-20240307130221982

它的值是由一个随机的字符串(这个随机数是通过前端程序自动生成的)生成提交给服务器,用来防止csrf攻击,默认情况下,SpringSecurity需要以Post请求的客户端页面,必须要提供_csrf这样的字段。如果这个字段不存在,后台的校验就会被判定为没有权限访问(503)。

WebSecurityConfig#filterChain 方法中添加如下代码,关闭csrf攻击防御,这样就不需要前端请求提供_csrf字段了

java
复制代码
//关闭csrf攻击防御 http.csrf((csrf) -> { csrf.disable(); });

重新启动程序,再次发送请求,由于后端也没给前端返回信息,所以swagger文档的结果应该为空。

验证是否成功:需要去数据库中查看,然后再在登录页面使用刚刚注册的用户登录。

当关闭掉csrf攻击防御后,它的登出功能点击后,就直接退出到了登录页中,没有提示了。


18.密码加密算法

参考文档:Password Storage :: Spring Security

一、密码加密方式

明文密码:

最初,密码以明文形式存储在数据库中。但是恶意用户可能会通过SQL注入等手段获取到明文密码,或者程序员将数据库数据泄露的情况也可能发生。

Hash算法:

Spring Security的PasswordEncoder接口用于对密码进行单向转换,从而将密码安全地存储。对密码单向转换需要用到哈希算法,例如MD5、SHA-256、SHA-512等,哈希算法是单向的,只能加密,不能解密

因此,数据库中存储的是单向转换后的密码,Spring Security在进行用户身份验证时需要将用户输入的密码进行单向转换,然后与数据库的密码进行比较。

因此,如果发生数据泄露,只有密码的单向哈希会被暴露。由于哈希是单向的,并且在给定哈希的情况下只能通过暴力破解的方式,例如猜测密码,但是很消耗计算机性能。

彩虹表:

恶意用户创建称为彩虹表的查找表。

这个无需有MD5运算的时间,就减少了很多时间。

text
复制代码
彩虹表就是一个庞大的、针对各种可能的字母组合预先生成的哈希值集合,有了它可以快速破解各类密码。越是复杂的密码,需要的彩虹表就越大,主流的彩虹表都是100G以上,目前主要的算法有LM, NTLM, MD5, SHA1, MYSQLSHA1, HALFLMCHALL, NTLMCHALL, ORACLE-SYSTEM, MD5-HALF。

加盐密码:

为了减轻彩虹表的效果,开发人员开始使用加盐密码。不再只使用密码作为哈希函数的输入,而是为每个用户的密码生成随机字节(称为盐)。盐和用户的密码将一起经过哈希函数运算,生成一个唯一的哈希。盐将以明文形式与用户的密码一起存储。然后,当用户尝试进行身份验证时,盐和用户输入的密码一起经过哈希函数运算,再与存储的密码进行比较。唯一的盐意味着彩虹表不再有效,因为对于每个盐和密码的组合,哈希都是不同的。

自适应单向函数:

随着硬件的不断发展,加盐哈希也不再安全。原因是,计算机可以每秒执行数十亿次哈希计算。这意味着我们可以轻松地破解每个密码。

现在,开发人员开始使用自适应单向函数来存储密码。使用自适应单向函数验证密码时,故意占用资源(故意使用大量的CPU、内存或其他资源)。自适应单向函数允许配置一个“工作因子”,随着硬件的改进而增加。Spring官方建议将“工作因子”调整到系统中验证密码需要约一秒钟的时间。这种权衡是为了让攻击者难以破解密码

自适应单向函数包括bcrypt、PBKDF2、scrypt和argon2。其中SpringSecurity默认使用的是bcrypt。


19.PasswordEncoder

自适应单项函数如下

BCryptPasswordEncoder

SpringSecurity默认使用的密码加密方式。使用广泛支持的bcrypt算法来对密码进行哈希。为了增加对密码破解的抵抗力,bcrypt故意设计得较慢。和其他自适应单向函数一样,应该调整其参数,使其在您的系统上验证一个密码大约需要1秒的时间。BCryptPasswordEncoder的默认实现使用强度10。建议您在自己的系统上调整和测试强度参数,以便验证密码时大约需要1秒的时间。

Argon2PasswordEncoder

使用Argon2算法对密码进行哈希处理。Argon2是密码哈希比赛的获胜者。为了防止在自定义硬件上进行密码破解,Argon2是一种故意缓慢的算法,需要大量内存。与其他自适应单向函数一样,它应该在您的系统上调整为大约1秒来验证一个密码。当前的Argon2PasswordEncoder实现需要使用BouncyCastle库。

Pbkdf2PasswordEncoder

使用PBKDF2算法对密码进行哈希处理。为了防止密码破解,PBKDF2是一种故意缓慢的算法。与其他自适应单向函数一样,它应该在您的系统上调整为大约1秒来验证一个密码。当需要FIPS认证时,这种算法是一个很好的选择。

image-20230421184645177

SCryptPasswordEncoder

使用scrypt算法对密码进行哈希处理。为了防止在自定义硬件上进行密码破解,scrypt是一种故意缓慢的算法,需要大量内存。与其他自适应单向函数一样,它应该在您的系统上调整为大约1秒来验证一个密码。


密码加密测试

在测试类中编写一个测试方法

java
复制代码
@Test void testPassword() { // 工作因子,默认值是10,最小值是4,最大值是31,值越大运算速度越慢 PasswordEncoder encoder = new BCryptPasswordEncoder(4); //明文:"password" //密文:result,即使明文密码相同,每次生成的密文也不一致 String result = encoder.encode("password"); System.out.println(result); //密码校验,将原始密码和加盐加密后的密码进行匹配,在匹配的过程中,工作因子也会起作用,工作因子越大,匹配的时间也就会越长,因为匹配的过程中实际上也是重新生成密码的过程 Assert.isTrue(encoder.matches("password", result), "密码不一致"); }

ctrl + alt + b 查看实现该方法的类,选中 BCryptPasswordEncoder

image-20240307141658992

生成密码的过程中,会先生成一个盐值

image-20240307142830752

这个随机的盐值由 BCryPasswordEncoder的版本号、工作因子、随机数组成

image-20240307142953047

BCryPasswordEncoder.java 最初的密码加上盐值,最后再通过hashpw方法生成最终的密码

java
复制代码
public String encode(CharSequence rawPassword) { if (rawPassword == null) { throw new IllegalArgumentException("rawPassword cannot be null"); } else { String salt = this.getSalt(); // 最初的密码加上盐值,最后再通过hashpw方法生成最终的密码 return BCrypt.hashpw(rawPassword.toString(), salt); } }

运行 SecurityDemoApplicationTests 测试方法,但由于它其中会生成一个随机数,所以重新执行,得到的密码也不会是一样的

image-20240307145453146

工作因子越大,匹配的时间也会越长,因为匹配的过程实际上就是重新生成密码的过程。

image-20250108153730376

20.DelegatingPasswordEncoder

上面 SecurityDemoApplicationTests 测试类运行后得到的密码形式为:$2a$04$l7phORPmM8o4c5euxVKe/O1e1qX5fuNsJS0NNYve0z7p3WyPAOUSm

而表中存储的密码形式:{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW,多了一个前缀

一、分析表中存储的密码形式的前缀是怎么生成的

在做用户插入的过程中,UserServiceImpl.java 中 使用的是withDefaultPasswordEncoder方法

java
复制代码
@Override public void saveUserDetails(User user) { UserDetails userDetails = org.springframework.security.core.userdetails.User .withDefaultPasswordEncoder() .username(user.getUsername()) .password(user.getPassword()) .build(); dbUserDetailsManager.createUser(userDetails); }

PasswordEncoderFactories.java,这个方法中创建了 PasswordEncoder 对象

java
复制代码
@Deprecated public static UserBuilder withDefaultPasswordEncoder() { logger.warn("User.withDefaultPasswordEncoder() is considered unsafe for production " + "and is only intended for sample applications."); PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); return builder().passwordEncoder(encoder::encode); }

PasswordEncoder 对象里对所有的可能的密码加密算法做了一个处理,将所有可能的密码加密算法的前缀都放在了一个map集合的key中,前面就是密码加密算法,后面就是由这个密码加密算法所构成的加密器了。

PasswordEncoderFactories.java

java
复制代码
public final class PasswordEncoderFactories { private PasswordEncoderFactories() { } public static PasswordEncoder createDelegatingPasswordEncoder() { // 默认的密码加密算法就是 encodingId,也就是bcrypt String encodingId = "bcrypt"; Map<String, PasswordEncoder> encoders = new HashMap(); encoders.put(encodingId, new BCryptPasswordEncoder()); encoders.put("ldap", new LdapShaPasswordEncoder()); encoders.put("MD4", new Md4PasswordEncoder()); encoders.put("MD5", new MessageDigestPasswordEncoder("MD5")); encoders.put("noop", NoOpPasswordEncoder.getInstance()); encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5()); encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8()); encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1()); encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8()); encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1")); encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256")); encoders.put("sha256", new StandardPasswordEncoder()); encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2()); encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()); // 然后把 encodingId, encoders 这两个参数传给DelegatingPasswordEncoder,这是一个代理密码加密器 return new DelegatingPasswordEncoder(encodingId, encoders); } }

然后把 encodingId, encoders 这两个参数传给DelegatingPasswordEncoder,这是一个代理密码加密器

传进去后,将 idForEncode 加上前缀和后缀

java
复制代码
// public DelegatingPasswordEncoder(String idForEncode, Map<String, PasswordEncoder> idToPasswordEncoder) { this(idForEncode, idToPasswordEncoder, "{", "}"); }

这个方法的大概意思是:如果没有指定密码加密算法,就会使用默认的密码加密算法(idForEncode),也就是bcrypt

java
复制代码
public DelegatingPasswordEncoder(String idForEncode, Map<String, PasswordEncoder> idToPasswordEncoder, String idPrefix, String idSuffix) { ... }

二、为什么要加上这个前缀?

这个时候就需要找到在应用程序非常重要的一个类:DelegatingPasswordEncoder。

在这个类中有一个方法:matches方法,这个方法是在进行校验的时候做密码比对的一个方法。

此时通过应用程序来对这个方法进行调试。

通过如下源码可以知道:可以通过 {bcrypt} 前缀动态获取和密码的形式类型一致的PasswordEncoder对象

目的:方便随时做密码策略的升级,兼容数据库中的老版本密码策略生成的密码

换个角度来说,不同的用户可以有不同的密码生成策略,可以使用不同的密码加密算法。这也就是SpringSecurity中密码加密非常灵活的一个原因。

image-20240307154205076
java
复制代码
// rawPassword:在客户端浏览器输入的密码,prefixEncodedPassword应用程序从数据库中获取来的密码,这两个密码将会进行比较 public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) { if (rawPassword == null && prefixEncodedPassword == null) { return true; } else { // 通过extractId方法,将带有密码的前缀获取出来,id就为这个密码的前缀 String id = this.extractId(prefixEncodedPassword); // 然后通过这个密码的前缀来生成 PasswordEncoder,所以这个前缀的作用就是生成不同的密码加密器 // delegate就是bcrypt密码加密器了,接下来就会使用这个密码加密器来对应用程序密码进行一个校验 PasswordEncoder delegate = (PasswordEncoder)this.idToPasswordEncoder.get(id); if (delegate == null) { return this.defaultPasswordEncoderForMatches.matches(rawPassword, prefixEncodedPassword); } else { // 首先将带前缀的密码把前缀去掉,变成 encodedPassword String encodedPassword = this.extractEncodedPassword(prefixEncodedPassword); // 然后再用delegate.matches把输入的密码和去掉前缀的密码进行一个比对。所以这个方法就是我们之前测试用例中写的encoder.matches方法 return delegate.matches(rawPassword, encodedPassword); } } }
image-20231209011827867

21.自定义登录页面

一、创建登录Controller

java
复制代码
package com.luoyan.securitydemo.controller; @Controller public class LoginController { @GetMapping("/login") public String login() { return "login"; } }

二、创建登录页面

resources/templates/login.html

html
复制代码
<!DOCTYPE html> <html xmlns:th="https://www.thymeleaf.org"> <head> <title>登录</title> </head> <body> <h1>登录</h1> <div th:if="${param.error}"> 错误的用户名和密码.</div> <!--method必须为"post"--> <!--th:action="@{/login}" , 使用动态参数,表单中会自动生成_csrf隐藏字段,用于防止csrf攻击 login: 和登录页面保持一致即可,SpringSecurity自动进行登录认证--> <form th:action="@{/login}" method="post"> <div> <!--name必须为"username"--> <input type="text" name="username" placeholder="用户名"/> </div> <div> <!--name必须为"password"--> <input type="password" name="password" placeholder="密码"/> </div> <input type="submit" value="登录" /> </form> </body> </html>

重启应用程序,可以发现访问的仍是默认的login页面,这是因为默认情况下,应用程序在走到controller之前,它首先会经过一系列的过滤器,在过滤器当中,有我们之前默认的配置,默认的配置就对我们的应用程序做了默认的处理。如果应用程序没有进行授权,应用程序就会自动跳转到 /login 的地址中。如果 /login 地址作为请求发送到服务器端,服务器端默认的过滤器其实就已经对 /login 做了处理了,它处理的方式就是找默认的登录页。


三、配置SecurityFilterChain

为了跳转到我们的登录页,方式就是不使用默认配置,而是使用自定义配置

SecurityConfiguration:

java
复制代码
.formLogin( // 然后通过lamda的形式创建一个默认的配置 form -> { form // 明确了通过配置文件的形式定义登录页的位置,这样它就会去找我们自定义登录页的地址了。 .loginPage("/login"); }); //使用表单授权方式

重新启动应用程序,此时会报:localhost重定向过多,这是因为应用程序自己定义了应用程序之后,前面的过滤器就不起作用了,通过层层过滤器之后走到了自定义login地址,自定义login地址会遵循着前面授权的保护机制,前面授权的保护机制就是:对所有请求开启授权保护,那针对我们的login页也不例外,所以login页也被授权保护了,这样的话,当我们访问了loginPage后,因为有授权保护,所以重定向到了login页,但login页又有授权保护,又重定向到了login,这样就形成了一个递归跳转,我们得到的这个错误信息就是重定向次数过多。

java
复制代码
http // 所以我们在应用程序当中,看到的过滤器链,里面具体的过滤器就是由这段配置来实现的 // authorizeRequests组装的就是在进行用户认证一些具体的功能,开启授权保护 // 如果我们的用户没有进行认证的话,应用程序就会开启授权保护,开启授权保护的过程中,如果用户没有进行认证,就会自动跳转到登录页,如果用户已经进行认证的话,就会被授权,用户被授权后,就能正常的进入到我们想要访问的页面当中去 .authorizeRequests(authorize -> authorize // 对所有请求开启授权保护 .anyRequest() // 已认证的请求会被自动授权 .authenticated())

解决办法:针对loginPage做一个特殊的设置:permitAll

java
复制代码
.formLogin( // 然后通过lamda的形式创建一个默认的配置 form -> { form // 明确了通过配置文件的形式定义登录页的位置,这样它就会去找我们自定义登录页的地址了。 .loginPage("/login").permitAll(); //登录页面无需授权即可访问当前页面 });

22.登录页的细节

登录页中用户名和密码的name属性值必须为username 和 password,这是因为在做用户校验的时候,过滤器里接受的必须是username和password。这个过滤器叫:UsernamePasswordAuthenticationFilter。

html
复制代码
<!--name必须为"username"--> <input type="text" name="username" placeholder="用户名"/> <!--name必须为"password"--> <input type="password" name="password" placeholder="密码"/>

UsernamePasswordAuthenticationFilter 中有个方法: attemptAuthentication

java
复制代码
@Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this.postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } // 获取用户请求中的用户名 String username = obtainUsername(request); username = (username != null) ? username.trim() : ""; // 获取用户请求中的密码 String password = obtainPassword(request); password = (password != null) ? password : ""; UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password); // Allow subclasses to set the "details" property setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); }

点进获取用户名的方法看看

java
复制代码
@Nullable protected String obtainUsername(HttpServletRequest request) { return request.getParameter(this.passwordParameter); // passwordParameter }

passwordParameter的值为:private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;

而SPRING_SECURITY_FORM_PASSWORD_KEY的值又为:

java
复制代码
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";

修改默认的名字

java
复制代码
.formLogin( // 然后通过lamda的形式创建一个默认的配置 form -> { form // 明确了通过配置文件的形式定义登录页的位置,这样它就会去找我们自定义登录页的地址了。 .loginPage("/login").permitAll() //登录页面无需授权即可访问当前页面 .usernameParameter("myUsername") //自定义表单用户名参数,默认是 username .passwordParameter("myPassword") //自定义表单密码参数,默认是 password ; }); //使用表单授权方式

登录

html
复制代码
<!-- 从浏览器当中的字符串参数中,获取到error参数,如果这个参数存在,就会显示 错误的用户名和密码. 这样的一段提示信息 --> <div th:if="${param.error}"> 错误的用户名和密码.</div>

配置文件中增加:.failureUrl("/login?error")

java
复制代码
.formLogin( // 然后通过lamda的形式创建一个默认的配置 form -> { form // 明确了通过配置文件的形式定义登录页的位置,这样它就会去找我们自定义登录页的地址了。 .loginPage("/login").permitAll() //登录页面无需授权即可访问当前页面 .usernameParameter("myUsername") //自定义表单用户名参数,默认是 username .passwordParameter("myPassword") //自定义表单密码参数,默认是 password .failureUrl("/login?error") //登录失败的返回地址 ; }); //使用表单授权方式

并且在登录页中,使用到的是动态的login

html
复制代码
<form th:action="@{/login}" method="post">

这个动态的login一方面可以生成一个动态的地址,前面带demo

image-20240307163602917

另一方面表单提交的地址也是/demo/login

image-20240307163722516

这样的好处就是,无论应用程序发布在哪个路径它,它都能保证带发布路径的一个相对路径。

当开启csfr攻击防御时,页面源代码多了一个隐藏的字段,这样我的应用程序在登录的过程中就可以对csrf攻击进行防御了。

image-20240307164002712

但如果将动态链接变为静态地址后,它也不会生成csrf隐藏的字段。

image-20240307164137461

-----------------------------------

第三章 前后端分离

23.用户认证流程

在前后端分离的项目中,前端向后端发起一个请求,后端需要给前端返回一个JSON数据,而不是直接在浏览器中跳转到登陆成功的页面,所以在用户认证流程中,就要知道怎么样在用户登陆成功或者失败后将json数据返回给前端程序,然后由前端程序根据返回的JSON数据做进一步的处理。

在SecurityFilterChain过滤器链中,其中有一个过滤器叫:UsernamePasswordAuthentication,这个过滤器是专门用来处理用户认证流程的一个类。

这个流程中,先接收用户输入的用户名和密码,然后用用这个用户名和密码生成一个UsernamePasswordAuthenticationToken对象,接下来把这个对象交给AuthenticationManager做用户认证工作。这个用户认证工作结束之后就会有两种结果:

  • 失败
  • 成功

用户认证成功之后就会做一系列的处理,最终达到AuthenticationSuccessHandler,这个类就是处理用户认证成功之后,如何返回信息的类。所以我们要返回JSON数据,替换成原来的客户端浏览器跳转的话,我们就需要重新去实现AuthenticationSuccessHandler里的抽象方法。

同样,如果用户认证失败的话,最终就会来到AuthenticationFailureHandler类中的一个失败处理方法。所以如果我们想要返回JSON数据的话,就需要重新AuthenticationFailureHandler类里的处理方法。

因此我们现在需要做的事情就是重写 AuthenticationSuccessHandler 和 AuthenticationFailureHandler类当中的关键的处理方法。

  • 登录成功后调用:AuthenticationSuccessHandler
  • 登录失败后调用:AuthenticationFailureHandler
usernamepasswordauthenticationfilter

24.用户认证成功的代码实现

一、引入fastjson

因为我们最终需要在这两个类中返回JSON数据给前端程序,所以我们需要有一个方便处理数据的工具。

xml
复制代码
<dependency> <groupId>com.alibaba.fastjson2</groupId> <artifactId>fastjson2</artifactId> <version>2.0.37</version> </dependency>

二、认证成功的响应

在配置包中写一个 AuthenticationSuccessHandler

java
复制代码
package com.luoyan.securitydemo.config; public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { // 7.authentication的getPrincipal会获取到用户身份信息 Object principal = authentication.getPrincipal(); // 一般下面这两个信息不会返回到前端,即使是密文的方式返回到前端,也是不安全的。 // // authentication的getCredentials会获取到用户的凭证信息,如果是通过密码的方式进行登录,则这里的凭证就是用户密码了 // Object credentials = authentication.getCredentials(); // // authentication的getAuthorities会获取到用户的权限信息 // Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities(); // 由于这里专门讲解SpringSecurity,所以在项目当中对响应码的处理,这里就使用HashMap来简单的进行处理 // 4.由于这里面我们什么样的类型的数据都可能会存储,所以泛型先不写了。 HashMap result = new HashMap<>(); // 5.首先按照传统的响应结果的解决方案给它设置一个统一的响应码,比如当它成功的时候设置响应码为0 result.put("code", 0); result.put("message", "登陆成功"); // 6.给前端返回登陆成功后的用户信息,authentication会存储我们的用户信息 result.put("data", principal); //3.创建json结果,将结果对象转换成json字符串 String json = JSON.toJSONString(result); // 返回json数据到前端 // 1.写响应头 response.setContentType("application/json;charset=UTF-8"); // 2.写响应体,响应体也是个JSON数据,我们需要写一个json结果给前端,所以我们现在需要去创建json结果 response.getWriter().println(json); } }

三、在配置类中配置

SecurityFilterChain

java
复制代码
form.successHandler(new MyAuthenticationSuccessHandler()) //认证成功时的处理
image-20240307173531828

重新启动应用程序,再登录,可以看见返回了一个json形式的字符串

image-20240307173725175

25.用户认证失败的代码实现

一、失败结果处理

用户认证失败需要重写AuthenticationFailureHandler类,这个方法的重写和 onAuthenticationSuccess 差不多,直接复制过来然后进行修改即可。

java
复制代码
package com.luoyan.securitydemo.config; public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { //获取错误信息,getLocalizedMessage:获取本地信息,即翻译成中文的登录失败的信息 String localizedMessage = exception.getLocalizedMessage(); //创建结果对象 HashMap result = new HashMap(); result.put("code", -1); result.put("message", localizedMessage); //转换成json字符串 String json = JSON.toJSONString(result); //返回响应 response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(json); } }

二、配置配置类

SecurityFilterChain

java
复制代码
form.failureHandler(new MyAuthenticationFailureHandler()) //认证失败时的处理

26.用户注销处理

希望注销的时候也能返回一个JSON的结果

首先关闭 csrf攻击

一、注销结果处理

注销所需要实现的接口是 LogoutSuccessHandler,这里面和 MyAuthenticationFailureHandler 差不多,直接复制即可

java
复制代码
package com.luoyan.securitydemo.config; public class MyLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { //创建结果对象 HashMap result = new HashMap(); // 注销成功,响应码为0 result.put("code", 0); result.put("message", "注销成功"); //转换成json字符串 String json = JSON.toJSONString(result); //返回响应 response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(json); } }

二、配置配置文件

SecurityFilterChain

java
复制代码
http.logout(logout -> { logout.logoutSuccessHandler(new MyLogoutSuccessHandler()); //注销成功时的处理 });

验证:访问 http://localhost:8080/demo/logout


三、优化

第一种配置方式:通过链式操作把所有的配置代码都写到一起

java
复制代码
public class WebSecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() ) .formLogin( form -> { form .loginPage("/login").permitAll() //登录页面无需授权即可访问 .usernameParameter("username") //自定义表单用户名参数,默认是username .passwordParameter("password") //自定义表单密码参数,默认是password .failureUrl("/login?error") //登录失败的返回地址 .successHandler(new MyAuthenticationSuccessHandler()) // 认证成功时的处理 .failureHandler(new MyAuthenticationFailureHandler()) ; }).logout(logout -> { logout.logoutSuccessHandler(new MyLogoutSuccessHandler()); }); //使用表单授权方式; //关闭csrf攻击防御 http.csrf((csrf) -> { csrf.disable(); }); return http.build(); } }

第二种方法:将每一部分的配置都隔离开

java
复制代码
public class WebSecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 专门的授权配置 http .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() ); // 专门的登录配置 http.formLogin(form -> { form .loginPage("/login").permitAll() //登录页面无需授权即可访问 .usernameParameter("username") //自定义表单用户名参数,默认是username .passwordParameter("password") //自定义表单密码参数,默认是password .failureUrl("/login?error") //登录失败的返回地址 .successHandler(new MyAuthenticationSuccessHandler()) // 认证成功时的处理 .failureHandler(new MyAuthenticationFailureHandler()) ; }); // 专门的登出配置 http.logout(logout -> { logout.logoutSuccessHandler(new MyLogoutSuccessHandler()); // 注销成功时的处理 }); //使用表单授权方式; //关闭csrf攻击防御 http.csrf((csrf) -> { csrf.disable(); }); return http.build(); } }

27.请求未认证处理

当我们访问需要认证之后才能访问的接口时,比如说我们的主页面必须验证才能访问,如果没有验证的话,应用程序就会自动跳转到登录页,这个流程我们也希望系统能给一个json的结果。

在SpringSecurity中,它把这些关键的结点都进行了封装,我们只需要用固定的方式实现固定的接口就行了。然后把这些接口的对象配置在配置文件当中。

一、实现AuthenticationEntryPoint接口

Servlet Authentication Architecture :: Spring Security

当访问一个需要认证之后才能访问的接口的时候,Spring Security会使用AuthenticationEntryPoint将用户请求跳转到登录页面,要求用户提供登录凭证。

这里我们也希望系统返回json结果,因此我们定义类实现AuthenticationEntryPoint接口,这里面和 MyLogoutSuccessHandler 差不多,直接复制即可

java
复制代码
package com.luoyan.securitydemo.config; public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override // commence:当应用程序视图访问一个页面,而这个页面是必须要经过用户登录才能够访问的页面的时候,应用程序就会自动被跳转到登录页面,自动被跳转到登录页面的这个行为会被我们当前所写的这个方法所覆盖,我们直接在这个方法中返回json的结果就可以了。 public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { // 获取错误信息,但由于 localizedMessage 并没有给我们中文的结果,所以我们就直接自己定义一下 // String localizedMessage = authException.getLocalizedMessage(); //创建结果对象 HashMap result = new HashMap(); result.put("code", -1); // 失败 result.put("message", "需要登录"); //转换成json字符串 String json = JSON.toJSONString(result); //返回响应 response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(json); } }

二、配置配置文件

SecurityFilterChain

java
复制代码
//错误处理 http.exceptionHandling(exception -> { exception.authenticationEntryPoint(new MyAuthenticationEntryPoint());// 请求未认证的处理 });

28.跨域

跨域全称是跨域资源共享(Cross-Origin Resources Sharing,CORS),它是浏览器的保护机制,只允许网页请求统一域名下的服务,同一域名指=>协议、域名、端口号都要保持一致,如果有一项不同,那么就是跨域请求。在前后端分离的项目中,需要解决跨域的问题。

未来部署应用程序的时候,当前的这两台机器肯定是要部署在同一个局域网内的,所以我们就希望后端对前端服务器开启跨域访问权限。

在SpringSecurity中解决跨域很简单,在配置文件中添加如下配置即可

java
复制代码
//跨域 http.cors(withDefaults());

-------------------------------------

第四章 身份认证

29.用户认证信息

一、基本概念

Authentication指的是用户认证信息,然后从Authentication中可以获取到principal,principal就是用户的身份信息,credentials就是用户的凭证(eg:用户的密码),authorities就是用户的权限。

securitycontextholder

在handler中可以通过形参的Authentication获取到authentication对象,然后从authentication中可以获取到principal,credentials,authorities,那如果在controller的上下文环境中,那我们怎么获取这一系列的信息呢?

获取用户信息需要借助SecurityContext,而SecurityContext的获取要借助于SecurityContextHolder

在Spring Security框架中,SecurityContextHolder、SecurityContext、Authentication、Principal和Credential是一些与身份验证和授权相关的重要概念。它们之间的关系如下:

  1. SecurityContextHolder:SecurityContextHolder 是 Spring Security 存储已认证用户详细信息的地方。
  2. SecurityContext:SecurityContext 是从 SecurityContextHolder 获取的内容,包含当前已认证用户的 Authentication 信息。
  3. Authentication:Authentication 表示用户的身份认证信息。它包含了用户的Principal、Credential和Authority信息。
  4. Principal:表示用户的身份标识。它通常是一个表示用户的实体对象,例如用户名。Principal可以通过Authentication对象的getPrincipal()方法获取。
  5. Credentials:表示用户的凭证信息,例如密码、证书或其他认证凭据。Credential可以通过Authentication对象的getCredentials()方法获取。
  6. GrantedAuthority:表示用户被授予的权限

总结起来,SecurityContextHolder用于管理当前线程的安全上下文,存储已认证用户的详细信息,其中包含了SecurityContext对象,该对象包含了Authentication对象,后者表示用户的身份验证信息,包括Principal(用户的身份标识)和Credential(用户的凭证信息)。

在Controller中获取用户信息

一般是不会把密码返回给前台的,从authentication获取密码,可以做密码比对,可以做密码存储,但是获取的话是需要做脱敏处理的,所谓的脱敏就是不会吧敏感的信息返回给你。

IndexController:

java
复制代码
package com.luoyan.securitydemo.controller; // @Controller @RestController public class IndexController { @GetMapping("/") public Map index(){ System.out.println("index controller"); // 这个context对象就是SecurityContext SecurityContext context = SecurityContextHolder.getContext();//存储认证对象的上下文 Authentication authentication = context.getAuthentication();//认证对象 String username = authentication.getName();// 获取用户名 Object principal =authentication.getPrincipal();//身份 Object credentials = authentication.getCredentials();//凭证(一般会做脱敏,此时这里接收到的是null) Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();//权限 System.out.println(username); System.out.println(principal); System.out.println(credentials); System.out.println(authorities); // 如果想要首页也支持前后端分离开发,将上面的@Controller改为@RestControll // 创建结果对象,将想要返给前端的内容组装到map集合中 HashMap result = new HashMap(); result.put("code", 0); result.put("data", username); return result; } }

登录页面实际上是要由前端提供的,前端直接通过ajax的形式向我们后端login地址发送post请求就行了,后端会由过滤器自动接收这种形式的post请求。

principal里实际上是一个User类型的对象,User是UserDetailsUser

由于credentials做了脱敏处理,所以返回的是null


30.会话并发处理

会话并发处理会处理多个并发登录的账号

后登录的账号会使先登录的账号失效

一、实现处理器接口

实现接口SessionInformationExpiredStrategy

java
复制代码
package com.luoyan.securitydemo.config; public class MySessionInformationExpiredStrategy implements SessionInformationExpiredStrategy { // onExpiredSessionDetected:当有些会话的session失效的时候我们怎么处理,这里的处理方法也和之前类似,最终也是返回JSON形式的数据,但有用户退出的话,会给前端返回这样的一个JSON结果 @Override public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException { //创建结果对象 HashMap result = new HashMap(); result.put("code", -1); result.put("message", "该账号已从其他设备登录"); //转换成json字符串 String json = JSON.toJSONString(result); HttpServletResponse response = event.getResponse(); //返回响应 response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(json); } }

二、将该类配置在配置文件中

SecurityFilterChain

java
复制代码
//会话管理 http.sessionManagement(session -> { session // maximumSessions:针对于同一个账号最多可以同时在几个客户端进行登录 .maximumSessions(1) // 后面登录的账号会将先前登录的账号给挤掉,就会导致先前的账号超时退出,超时退出后就会调用刚刚我们在超时退出的接口方法中所实现的功能。 .expiredSessionStrategy(new MySessionInformationExpiredStrategy()); });

重新启动应用程序,访问定一个登录的浏览器的主页面,进行测试:


-------------------------------------

31.第五章 授权

授权管理的实现在SpringSecurity中非常灵活,可以帮助应用程序实现以下两种常见的授权需求:

  • 用户-权限-资源:例如张三的权限是添加用户、查看用户列表,李四的权限是查看用户列表

    比如我们这个项目中,资源可以理解成UserController中的 /user/add,这两个controller的地址就是 添加用户 权限 和 查看用户列表 权限所要访问的资源。

    对应前端服务器来说,资源指的是对应的前端的页面,或者是前端的某个按钮,如果你有这个权限的话,就可以看见添加用户的按钮,如果你没有权限的话,你就看不到添加用户的按钮。

  • 用户-角色-权限-资源:例如 张三是角色是管理员、李四的角色是普通用户,管理员能做所有操作,普通用户只能查看信息


32.基于request的授权(用户-权限-资源)

一、需求

  • 具有USER_LIST权限的用户可以访问/user/list接口
  • 具有USER_ADD权限的用户可以访问/user/add接口

二、配置权限

基于request这样的一个授权策略是需要在SecurityFilterChain的配置文件中去配授权方案

java
复制代码
//开启授权保护 http.authorizeRequests( // 如果满足授权配置的话,就走授权的配置策略了,如果不满足 .anyRequest() 往上的授权策略的话,就对不满足的其他的请求开启授权保护 authorize -> authorize // 具有USER_LIST权限的用户可以访问/user/list // 将资源和权限的关系做了一个映射 // USER_LIST、USER_ADD 这两个权限字符串是我们自己定义的,一般情况下定义成一个语义比较强的,或者是比较好理解的。 .requestMatchers("/user/list").hasAuthority("USER_LIST") //具有USER_ADD权限的用户可以访问/user/add .requestMatchers("/user/add").hasAuthority("USER_ADD") //对所有请求开启授权保护 .anyRequest() //已认证的请求会被自动授权 .authenticated() );

三、授予权限

给用户分配权限,正常情况下需要创建权限表或者是给这个用户分配权限字段。

用户表和权限表可以定义为一对多 或者 多对多的关系。

在我们这个课程当中,就直接在SpringSecurity中对权限进行硬编码,就不花费大量的精力在数据库的身上了。

所以我们打开专门处理用户信息的类:DBUserDetailsManager

DBUserDetailsManager中的loadUserByUsername方法,直接在这个已经获取出来的用户信息的权限进行一个硬编码的操作:

authorities.add()方法里应该是一个GrantedAuthority对象,而这个类型是一个接口类型,所以我们要对这个接口进行实现,然后再装进这个对象。比较简单的方式就是我们可以直接在这里面写一个匿名内部类的方式。

java
复制代码
// 方式一:lambda Collection<GrantedAuthority> authorities = new ArrayList<>(); authorities.add(()->"USER_LIST"); authorities.add(()->"USER_ADD"); // 方式二:匿名内部类 authorities.add(new GrantedAuthority() { @Override public String getAuthority() { return "USER_LIST"; } }); authorities.add(new GrantedAuthority() { @Override public String getAuthority() { return "USER_ADD"; } }); // 给当前登录的用户直接赋予USER_LIST和USER_ADD权限 authorities.add(() -> "USER_LIST"); authorities.add(() -> "USER_ADD");

测试:注销掉USER_LISt权限,此时访问 /user/list 会报403(未授权)错

image-20240307211435047

33.请求未授权的接口

如果用户没有对应的权限,就会报403错误,如果我们想让该错误也以JSON的形式返回,该如何实现呢?

image-20240307211842098

使用的是 SecurityFilterChain 中的 accessDeniedHandler 配置,所以我们在配置文件当中对这个配置进行处理

java
复制代码
//错误处理 http.exceptionHandling(exception -> { exception.authenticationEntryPoint(new MyAuthenticationEntryPoint());//请求未认证的接口 // accessDeniedHandler:当请求被拒绝的时候,即用户没有权限访问资源的时候,这里面需要一个 AccessDeniedHandler 接口,所以我们可以new一个AccessDeniedHandler类,然后去实现这个接口 exception.accessDeniedHandler(new MyAccessDeniedHandler()); })

MyAccessDeniedHandler.java

java
复制代码
package com.luoyan.securitydemo.config; public class MyAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { //创建结果对象 HashMap result = new HashMap(); result.put("code", -1); result.put("message", "没有权限"); //转换成json字符串 String json = JSON.toJSONString(result); //返回响应 response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(json); } }

更多的例子:Authorize HttpServletRequests :: Spring Security


优化

我们前面所有的配置,都可以使用匿名内部类

java
复制代码
// 错误处理 http.exceptionHandling(exception -> { exception.authenticationEntryPoint(new MyAuthenticationEntryPoint()); exception.accessDeniedHandler(new AccessDeniedHandler() { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { //创建结果对象 HashMap result = new HashMap(); result.put("code", -1); result.put("message", "没有权限"); //转换成json字符串 String json = JSON.toJSONString(result); //返回响应 response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(json); } }); });

另外也可以在这个地方以lamda表达式的形式来写这个方法

java
复制代码
// 错误处理 http.exceptionHandling(exception -> { exception.authenticationEntryPoint(new MyAuthenticationEntryPoint()); exception.accessDeniedHandler((request, response, accessDeniedException) -> { //创建结果对象 HashMap result = new HashMap(); result.put("code", -1); result.put("message", "没有权限"); //转换成json字符串 String json = JSON.toJSONString(result); //返回响应 response.setContentType("application/json;charset=UTF-8"); response.getWriter().println(json); }); });

重新启动应用程序,然后重新访问/user/list,此时就会出现JSON格式的数据了

image-20240307213718901


34.用户-角色-资源

**需求:**角色为ADMIN的用户才可以访问 /user/** 路径下的资源

一、配置角色

SecurityFilterChain

java
复制代码
//开启授权保护 http.authorizeRequests( authorize -> authorize //具有管理员角色的用户可以访问/user/** // 后面的角色名字(ADMIN)是我们自己起的 .requestMatchers("/user/**").hasRole("ADMIN") //对所有请求开启授权保护 .anyRequest() //已认证的请求会被自动授权 .authenticated() );

二、授予角色

为用户分配角色可以在数据库中创建一个角色列,或者创建一个角色表,然后建立用户和角色之间的关系。

为了方便,这里也是在DBUserDetailsManager中对角色信息进行了硬编码的配置。

首先将之前创建的User屏蔽掉,因为通过 userdetails.User 构造函数没有办法添加角色

java
复制代码
@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { QueryWrapper<User> queryWrapper = new QueryWrapper<>(); queryWrapper.eq("username", username); User user = userMapper.selectOne(queryWrapper); if (user == null) { throw new UsernameNotFoundException(username); } else { // Collection<GrantedAuthority> authorities = new ArrayList<>(); //// authorities.add(() -> "USER_LIST"); // authorities.add(() -> "USER_ADD"); // return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), user.getEnabled(), // true, //用户账号是否过期 // true, //用户凭证是否过期 // true, //用户是否未被锁定 // authorities); } }

User类里有一个方法:withUsername,这个方法会获取到UserBuilder对象,这个UserBuilder对象是User里的一个静态内部类,这个UserBuilder对象里面有个方法叫roles,通过这个方法可以为用户添加角色。

DBUserDetailsManager中的loadUserByUsername方法:

java
复制代码
return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .disabled(!user.getEnabled()) // 注意,这里是用户 是否 禁用 // 是否过期,这里注意User类中的是 credentialsNonExpired(是否不过期) .credentialsExpired(false) .accountLocked(false) // 用户是否被锁定 .roles("ADMIN") .build();

重新启动应用程序进行测试:

json
复制代码
{ "code": 0, "data": { "accountNonExpired": true, "accountNonLocked": true, "authorities": [ { // 它的本质还是authority属性,所以它的本质其实也是授权 "authority": "ROLE_ADMIN" } ], "credentialsNonExpired": true, "enabled": true, "username": "admin" }, "message": "登陆成功" }

这个角色信息还是会被赋值给这个属性的原因:我们在做角色分配的时候

image-20240307215554500

实际上依然是把应用程序当中角色分配给了 authorities ,只不过是在这个角色前面加了一个ROLE前缀

java
复制代码
public UserBuilder roles(String... roles) { List<GrantedAuthority> authorities = new ArrayList<>(roles.length); for (String role : roles) { Assert.isTrue(!role.startsWith("ROLE_"), () -> role + " cannot start with ROLE_ (it is automatically added)"); authorities.add(new SimpleGrantedAuthority("ROLE_" + role)); } return authorities(authorities); }

将用户角色改为普通的USRE用户重新启动:

java
复制代码
.roles("USRE")

此时就返回没有权限了,add也是同样

image-20240307215921822

35.授权-RBAC(用户-角色-权限-资源)

上面说的 用户-权限-资源 还是 用户-角色-资源 ,这两个实现在SpringSecurity中的本质是一样的,它们实际上都是在用户的信息里给用户的属性添加上authorities属性(授权字符串),所以我们的用户实际上是和权限相关联的。如果是授权字符串的话,它的值就会是授权字符串本身,eg:USER_LISTUSER_ADD;如果是角色字符串的话,它就会在角色字符串的前面加一个ROLE_前缀。

用户-角色-权限-资源 实际上在我们应用程序开发的过程中最常见的一种处理应用系统用户授权的一种解决方案。这种解决方案我们通常管它叫RBAC。

RBAC(Role-Based Access Control),基于角色的访问控制,是一种广泛使用的访问控制模型,通过角色来分配和管理用户的菜单权限。

主要指的是在数据库中有一个角色表)是一种常用的数据库设计方案,用户和角色相关联,角色和权限相关联。而不是权限直接和用户相关联的。

以下是一个基本的RBAC数据库设计方案的示例:

  1. 用户表(User table):包含用户的基本信息,例如用户名、密码和其他身份验证信息。
列名数据类型描述
user_idint用户ID
usernamevarchar用户名
passwordvarchar密码
emailvarchar电子邮件地址
.........
  1. 角色表(Role table):存储所有可能的角色及其描述。
列名数据类型描述
role_idint角色ID
role_namevarchar角色名称
descriptionvarchar角色描述
.........
  1. 权限表(Permission table):定义系统中所有可能的权限。
列名数据类型描述
permission_idint权限ID
permission_namevarchar权限名称
descriptionvarchar权限描述
.........
  1. 用户角色关联表(User-Role table):将用户与角色关联起来。
列名数据类型描述
user_role_idint用户角色关联ID
user_idint用户ID
role_idint角色ID
.........
  1. 角色权限关联表(Role-Permission table):将角色与权限关联起来。
列名数据类型描述
role_permission_idint角色权限关联ID
role_idint角色ID
permission_idint权限ID
.........

在这个设计方案中,用户可以被分配一个或多个角色,而每个角色又可以具有一个或多个权限。通过对用户角色关联和角色权限关联表进行操作,可以实现灵活的权限管理和访问控制。

当用户尝试访问系统资源时,系统可以根据用户的角色和权限决定是否允许访问。这样的设计方案使得权限管理更加简单和可维护,因为只需调整角色和权限的分配即可,而不需要针对每个用户进行单独的设置。


36.基于方法的授权

基于方法的授权:在controller层的方法当中,或者在service方法当中,我们会添加授权注解的方式对应用程序的访问进行授权。我们现在也是要在 DBUserDetailsManager类 中对我们用户添加一些权限或者是角色。

一、开启方法授权

在配置文件中添加如下注解

java
复制代码
@EnableMethodSecurity // 开启基于方法的授权

既然是基于方法的授权,所以在请求接收的过滤器中就不需要授权的配置了

java
复制代码
// .requestMatchers("/user/**").hasRole("ADMIN")

二、给用户授予角色和权限

DBUserDetailsManager中的loadUserByUsername方法:

java
复制代码
return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .roles("ADMIN") .build();

默认情况下,我们开启了基于方法的授权,应用程序当中的用户其实是对所有的方法都具有访问权限的。

此时就需要在controller层方法中,添加相关的注解。


三、常用授权注解

java
复制代码
// PreAuthorize:在执行方法之前,要检查用户有没有相应的权限 //用户必须有 ADMIN 角色 并且 用户名是 admin 才能访问此方法 @PreAuthorize("hasRole('ADMIN') and authentication.name == 'admim'") @GetMapping("/list") public List<User> getList(){ return userService.list(); } // 方法1: //用户必须有 ADMIN 角色 // @PreAuthorize("hasRole('ADMIN')") // 方法2: //用户必须有 USER_ADD 权限 才能访问此方法 @PreAuthorize("hasAuthority('USER_ADD')") @PostMapping("/add") public void add(@RequestBody User user){ userService.saveUserDetails(user); }

方法二给用户添加权限字符串

java
复制代码
return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) // .roles("ADMIN") // 注意 authorities 必须要写成多参数的形式,而不能再写一行,否则后面的 authorities 会覆盖掉前面的 authorities,而且还会覆盖掉前面的roles,所以这两行.roles和.authorities不能同时使用 .authorities("USER_ADD", "USER_UPDATE") .build();

更多的例子:Method Security :: Spring Security


37.Spring Security整合JWT

项目:https://github.com/Epiphany6666/Elite-Code

PS:此项目JWT实现使用的是hutool包下的,框架使用的是 Springboot 2.7.2

一、依赖引入

xml
复制代码
<!-- Hutool工具类 --> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> </dependency> <!-- SpringSecurity --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>

二、SpringSecurity配置

java
复制代码
package cn.elitecode.web.config; import cn.elitecode.web.filter.JwtAuthenticationTokenFilter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig { @Autowired private UserDetailsService userDetailsService; @Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; /** * 身份验证实现 * @return */ @Bean public AuthenticationManager authenticationManager() { DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); daoAuthenticationProvider.setUserDetailsService(userDetailsService); daoAuthenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder()); // 强散列哈希加密实现 return new ProviderManager(daoAuthenticationProvider); } /** * SpringSecurity配置 * @param httpSecurity * @return * @throws Exception */ @Bean public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { return httpSecurity // CSRF禁用,因为不使用session .csrf(csrf -> csrf.disable()) // 基于token获取SecurityContext,所以不需要session .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 注解标记允许匿名访问的url .authorizeHttpRequests((requests) -> { // 对于登录login 注册register 允许匿名访问 requests.antMatchers("/user/login", "/user/register").permitAll() // 静态资源,可匿名访问 .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll().antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll() // 除上面外的所有请求全部需要鉴权认证 .anyRequest().authenticated(); }) // 将 JWTFilter 添加到 UsernamePasswordAuthenticationFilter 前面(校验账号密码之前) .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class).build(); } /** * 强散列哈希加密实现 */ @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } }

三、JwtAuthenticationTokenFilter

java
复制代码
package cn.elitecode.web.filter; import cn.elitecode.common.exception.BaseException; import cn.elitecode.common.properties.JWTProperties; import cn.elitecode.constant.HttpStatus; import cn.elitecode.constant.UserConstant; import cn.hutool.core.date.DateUtil; import cn.hutool.core.exceptions.ValidateException; import cn.hutool.jwt.JWTUtil; import cn.hutool.jwt.JWTValidator; import cn.hutool.jwt.signers.JWTSignerUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Autowired private UserDetailsService userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader(JWTProperties.getTokenHeader()); if (authHeader != null && authHeader.startsWith(JWTProperties.getTokenHead())) { String authToken = authHeader.substring(JWTProperties.getTokenHead().length()); try { // 校验签名是否合法 JWTValidator.of(JWTUtil.parseToken(authToken)) .validateAlgorithm(JWTSignerUtil.hs256(JWTProperties.getSecret().getBytes())) .validateDate(DateUtil.date()); } catch (ValidateException e) { throw new BaseException(HttpStatus.PARAMS_ERROR, "Token不合法"); } // 将用户信息存入SecurityContext String username = (String) JWTUtil.parseToken(authToken).getPayload(UserConstant.LOGIN_USER_KEY); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } }

四、UserDetails

java
复制代码
package cn.elitecode.model.vo; import cn.elitecode.model.entity.User; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.io.Serial; import java.util.Collection; /** * 已登录用户VO */ public class LoginUser implements UserDetails { @Serial private static final long serialVersionUID = 4453625113463743280L; /** * 用户唯一标识 */ private String token; /** * 用户信息 */ private User user; public LoginUser(User user) { this.user = user; } public LoginUser() { } public LoginUser(String token, User user) { this.token = token; this.user = user; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUsername(); } /** * 指示是否已过期的用户的凭据(密码),过期的凭据防止认证 * @return */ @Override public boolean isAccountNonExpired() { return true; } /** * 指定用户是否解锁,锁定的用户无法进行身份验证 * @return */ @Override public boolean isAccountNonLocked() { return true; } /** * 账户是否未过期,过期无法验证 * @return */ @Override public boolean isCredentialsNonExpired() { return true; } /** * 是否可用 ,禁用的用户不能身份验证 * @return */ @Override public boolean isEnabled() { return true; } /** * 获取 * @return token */ public String getToken() { return token; } /** * 设置 * @param token */ public void setToken(String token) { this.token = token; } /** * 获取 * @return user */ public User getUser() { return user; } /** * 设置 * @param user */ public void setUser(User user) { this.user = user; } public String toString() { return "LoginUser{token = " + token + ", user = " + user + "}"; } }

五、UserDetailsService

java
复制代码
package cn.elitecode.web.service; import cn.elitecode.common.exception.user.UsernameNotFoundException; import cn.elitecode.constant.HttpStatus; import cn.elitecode.mapper.UserMapper; import cn.elitecode.model.entity.User; import cn.elitecode.model.vo.LoginUser; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Service; @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserMapper userMapper; /** * 获取用户信息 * @param username * @return */ @Override public UserDetails loadUserByUsername(String username) { // 查询用户是否存在 User user = userMapper.selectUserByUsername(username); if (user == null) { throw new UsernameNotFoundException(HttpStatus.PARAMS_ERROR, "账号或密码错误"); } return new LoginUser(user); } }

六、登录请求接口

① controller

java
复制代码
package cn.elitecode.web.controller; /** * 用户控制器 */ @RestController @RequestMapping("/user") public class UserController { @Autowired private UserService userService; @Autowired private PasswordEncoder passwordEncoder; /** * 用户登录 * @param userLoginDTO * @param request * @return */ @PostMapping("/login") private CommonResult<LoginUser> login(@RequestBody UserLoginDTO userLoginDTO, HttpServletRequest request) { if (userLoginDTO == null) { return CommonResult.error(HttpStatus.PARAMS_ERROR, "登录参数错误"); } String username = userLoginDTO.getUsername(); String userPassword = userLoginDTO.getPassword(); // 参数校验 ... return CommonResult.success(userService.login(username, userPassword, request)); } }

② service

java
复制代码
package cn.elitecode.service; /** * 用户 业务层 * */ public interface UserService { /** * 用户登录 * @param username 用户账号 * @param userPassword 用户密码 * @param request * @return 用户信息 */ LoginUser login(String username, String userPassword, HttpServletRequest request); /** * 获取当前登录用户 * @return */ LoginUser getLoginUser(); }

③ ServiceImpl

java
复制代码
package cn.elitecode.service.impl; /** * 用户 处理层 */ @Service public class UserServiceImpl implements UserService { private static final Logger log = LoggerFactory.getLogger(UserServiceImpl.class); private static final String USER_LOGIN_STATE = "user_login"; @Autowired private UserMapper userMapper; @Autowired private AuthenticationManager authenticationManager; @Override public LoginUser login(String username, String userPassword, HttpServletRequest request) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, userPassword); // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername,并将返回的UserDetails设置到SecurityContext中 Authentication authenticate = authenticationManager.authenticate(authenticationToken); LoginUser loginUser = (LoginUser) authenticate.getPrincipal(); if (!loginUser.isEnabled()) { throw new BaseException(HttpStatus.PARAMS_ERROR, "账号已被禁用"); } // 生成jwt令牌 HashMap<String, Object> claims = new HashMap<>(); claims.put(UserConstant.LOGIN_USER_KEY, loginUser.getUsername()); String token = JWT.create() .addPayloads(claims) .setSigner(JWTSignerUtil.hs256(JWTProperties.getSecret().getBytes())) // (签发时间)---------(生效时间)---------(当前时间)---------(失效时间) // 签发时间 .setIssuedAt(DateUtil.date()) // 失效时间 .setExpiresAt(DateUtil.offsetSecond(new Date(), JWTProperties.getExpiration())) .sign(); loginUser.setToken(token); return loginUser; } public LoginUser getLoginUser() { LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return loginUser; } }

④ UserMapper.java

java
复制代码
package cn.elitecode.mapper; /** * 用户表 数据层 */ public interface UserMapper { /** * 根据用户账号查询用户 * @param username 用户账号 * @return */ User selectUserByUsername(String username ); }

⑤ UserMapper.xml

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="cn.elitecode.mapper.UserMapper"> <resultMap id="UserResult" type="User"> <id property="id" column="id"/> <result property="username" column="username"/> <result property="password" column="password"/> <result property="nickName" column="nick_name"/> <result property="avatar" column="avatar"/> <result property="profile" column="profile"/> <result property="roles" column="roles" typeHandler="cn.elitecode.JSONTypeHandler"/> <result property="delFlag" column="del_flag"/> <result property="createBy" column="create_by"/> <result property="createTime" column="create_time"/> <result property="updateBy" column="update_by"/> <result property="updateTime" column="update_time"/> </resultMap> <sql id="selectUserVo"> id,username,password, nick_name,avatar,profile, roles,del_flag,create_by,create_time, update_by,update_time </sql> <select id="selectUserByUsername" resultMap="UserResult"> select <include refid="selectUserVo"/> from user where username = #{username} and del_flag = '0' </select> </mapper>

学习心得

新接触一个框架不要怕,源码看一天看不懂就看两天,两天看不懂就看三天。(想起了大二刷力扣题,有时候一个题解看不懂总感觉自己很蠢,但有时候发现第一天看不懂的题解第二天突然就看懂了,真的很神奇......)

如果觉得官方文档很难读懂,那么建议先搜索相关文章,再去阅读官方文档,会好很多。

也可以跟我一样疯狂问ai(狗头

image-20250314161129232

建议大家自己打断点看源码

image-20250312215219850

反思

第一次画时序图还有点不熟练o(╥﹏╥)o

时序图是否画的不够好?

image-20250314161248486

参考文献

B站SpringSecurity相关视频

Spring Security 认证流程:https://zhuanlan.zhihu.com/p/365513384

AuthenticationManager、ProviderManager和AuthenticationProvider三者之间的关系:https://www.cnblogs.com/xuwenjin/p/9565356.html

概念讲解:https://www.cnkirito.moe/spring-security-1/

Spring Security:升级已弃用的 WebSecurityConfigurerAdapter:https://springdoc.cn/spring-deprecated-websecurityconfigureradapter/

配置:https://docs.spring.io/spring-security/reference/servlet/configuration/java.html#jc-httpsecurity

捋一遍 Spring Security 登录流程:https://mp.weixin.qq.com/s/z6GeR5O-vBzY3SHehmccVA

整合JWT、Redis:https://mp.weixin.qq.com/s/HQQ3Lt-ydMsB1KmC1uTKhg

时序图学习:https://www.cnblogs.com/54chensongxia/p/13236965.html

deepseek

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP