spring security (史上最全)

悠悠 2024-03-23 15:27 210阅读 0赞

认证与授权(Authentication and Authorization)

一般意义来说的应用访问安全性,都是围绕认证(Authentication)和授权(Authorization)这两个核心概念来展开的。

即:

  • 首先需要确定用户身份,
  • 再确定这个用户是否有访问指定资源的权限。

认证这块的解决方案很多,主流的有CASSAML2OAUTH2等(不巧这几个都用过-_-),我们常说的单点登录方案(SSO)说的就是这块,

授权的话主流的就是spring security和shiro。

shiro比较轻量级,相比较而言spring security确实架构比较复杂。但是shiro与 ss,掌握一个即可。

这里尼恩给大家做一下系统化、体系化的梳理,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。

注:本文以 PDF 持续更新,最新尼恩 架构笔记、面试题 的PDF文件,请从下面的链接获取: 码云

什么是OAuth2 ?

OAuth2是一个关于授权的开放标准,核心思路是通过各类认证手段(具体什么手段OAuth2不关心)认证用户身份,

并颁发token(令牌),使得第三方应用可以使用该令牌在限定时间限定范围访问指定资源。

主要涉及的RFC规范有【RFC6749(整体授权框架)】、【RFC6750(令牌使用)】、【RFC6819(威胁模型)】这几个,一般我们需要了解的就是RFC6749

获取令牌的方式主要有四种,分别是授权码模式简单模式密码模式客户端模式

总之:OAuth2是一个授权(Authorization)协议。

认证(Authentication)证明的你是不是这个人,

而授权(Authorization)则是证明这个人有没有访问这个资源(Resource)的权限。

下面这张图来源于OAuth 2.0 authorization framework RFC Document,是OAuth2的一个抽象流程。

  1. +--------+ +---------------+
  2. | |--(A)- Authorization Request ->| Resource |
  3. | | | Owner |
  4. | |<-(B)-- Authorization Grant ---| |
  5. | | +---------------+
  6. | |
  7. | | +---------------+
  8. | |--(C)-- Authorization Grant -->| Authorization |
  9. | Client | | Server |
  10. | |<-(D)----- Access Token -------| |
  11. | | +---------------+
  12. | |
  13. | | +---------------+
  14. | |--(E)----- Access Token ------>| Resource |
  15. | | | Server |
  16. | |<-(F)--- Protected Resource ---| |
  17. +--------+ +---------------+

先来解释一下上图的名词:

Resource Owner:资源所有者,即用户

Client:客户端应用程序(Application)

Authorization Server:授权服务器

Resource Server:资源服务器

再来解释一下上图的大致流程:

(A) 用户连接客户端应用程序以后,客户端应用程序要求用户给予授权

(B) 用户同意给予客户端应用程序授权

(C) 客户端应用程序使用上一步获得的授权(Grant),向授权服务器申请令牌

(D) 授权服务器对客户端应用程序的授权(Grant)进行验证后,确认无误,发放令牌

(E) 客户端应用程序使用令牌,向资源服务器申请获取资源

(F) 资源服务器确认令牌无误,同意向客户端应用程序开放资源

从上面的流程可以看出,如何获取**授权(Grant)**才是关键。

在OAuth2中有4种授权类型:

  • Authorization Code(授权码模式)

功能最完整、流程最严密的授权模式。通过第三方应用程序服务器与认证服务器进行互动。广泛用于各种第三方认证。

  • Implicit(简化模式):

不通过第三方应用程序服务器,直接在浏览器中向认证服务器申请令牌,更加适用于移动端的App及没有服务器端的第三方单页面应用。

  • Resource Owner Password(密码模式)

用户向客户端服务器提供自己的用户名和密码,用户对客户端高度信任的情况下使用,比如公司、组织的内部系统,SSO。

  • Client Credentials(客户端模式):

客户端服务器以自己的名义,而不是以用户的名义,向认证服务器进行认证。

下面主要讲最常用的(1)和(3)。此外,还有一个模式叫Refresh Token,也会在下面介绍。

Resource Owner Password(密码模式)

  1. +----------+
  2. | Resource |
  3. | Owner |
  4. | |
  5. +----------+
  6. v
  7. | Resource Owner
  8. (A) Password Credentials
  9. |
  10. v
  11. +---------+ +---------------+
  12. | |>--(B)---- Resource Owner ------->| |
  13. | | Password Credentials | Authorization |
  14. | Client | | Server |
  15. | |<--(C)---- Access Token ---------<| |
  16. | | (w/ Optional Refresh Token) | |
  17. +---------+ +---------------+
  18. Figure 5: Resource Owner Password Credentials Flow

它的步骤如下:

(A) 用户(Resource Owner)向客户端(Client)提供用户名和密码。

(B) 客户端将用户名和密码发给认证服务器(Authorization Server),向后者请求令牌。

(C) 认证服务器确认无误后,向客户端提供访问令牌。

Authorization Code(授权码模式)

  1. +----------+
  2. | Resource |
  3. | Owner |
  4. | |
  5. +----------+
  6. ^
  7. |
  8. (B)
  9. +----|-----+ Client Identifier +---------------+
  10. | -+----(A)-- & Redirection URI ---->| |
  11. | User- | | Authorization |
  12. | Agent -+----(B)-- User authenticates --->| Server |
  13. | | | |
  14. | -+----(C)-- Authorization Code ---<| |
  15. +-|----|---+ +---------------+
  16. | | ^ v
  17. (A) (C) | |
  18. | | | |
  19. ^ v | |
  20. +---------+ | |
  21. | |>---(D)-- Authorization Code ---------' |
  22. | Client | & Redirection URI |
  23. | | |
  24. | |<---(E)----- Access Token -------------------'
  25. +---------+ (w/ Optional Refresh Token)
  26. Note: The lines illustrating steps (A), (B), and (C) are broken into
  27. two parts as they pass through the user-agent.

它的步骤如下:

(A) 用户(Resource Owner)通过用户代理(User-Agent)访问客户端(Client),客户端索要授权,并将用户导向认证服务器(Authorization Server)。

(B) 用户选择是否给予客户端授权。

(C) 假设用户给予授权,认证服务器将用户导向客户端事先指定的”重定向URI”(redirection URI),同时附上一个授权码。

(D) 客户端收到授权码,附上早先的”重定向URI”,向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。

(E) 认证服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)。这一步也对用户不可见。

令牌刷新(refresh token)

  1. +--------+ +---------------+
  2. | |--(A)------- Authorization Grant --------->| |
  3. | | | |
  4. | |<-(B)----------- Access Token -------------| |
  5. | | & Refresh Token | |
  6. | | | |
  7. | | +----------+ | |
  8. | |--(C)---- Access Token ---->| | | |
  9. | | | | | |
  10. | |<-(D)- Protected Resource --| Resource | | Authorization |
  11. | Client | | Server | | Server |
  12. | |--(E)---- Access Token ---->| | | |
  13. | | | | | |
  14. | |<-(F)- Invalid Token Error -| | | |
  15. | | +----------+ | |
  16. | | | |
  17. | |--(G)----------- Refresh Token ----------->| |
  18. | | | |
  19. | |<-(H)----------- Access Token -------------| |
  20. +--------+ & Optional Refresh Token +---------------+

当我们申请token后,Authorization Server不仅给了我们Access Token,还有Refresh Token。

当Access Token过期后,我们用Refresh Token访问/refresh端点就可以拿到新的Access Token了。

我们要和Spring Security的认证(Authentication)区别开来,

什么是Spring Security?

Spring Security是一套安全框架,可以基于RBAC(基于角色的权限控制)对用户的访问权限进行控制,

核心思想是通过一系列的filter chain来进行拦截过滤,对用户的访问权限进行控制,

spring security 的核心功能主要包括:

  • 认证 (你是谁)
  • 授权 (你能干什么)
  • 攻击防护 (防止伪造身份)

其核心就是一组过滤器链,项目启动后将会自动配置。最核心的就是 Basic Authentication Filter 用来认证用户的身份,一个在spring security中一种过滤器处理一种认证方式。

img

比如,对于username password认证过滤器来说,

会检查是否是一个登录请求;

是否包含username 和 password (也就是该过滤器需要的一些认证信息) ;

如果不满足则放行给下一个。

下一个按照自身职责判定是否是自身需要的信息,basic的特征就是在请求头中有 Authorization:Basic eHh4Onh4 的信息。中间可能还有更多的认证过滤器。最后一环是 FilterSecurityInterceptor,这里会判定该请求是否能进行访问rest服务,判断的依据是 BrowserSecurityConfig中的配置,如果被拒绝了就会抛出不同的异常(根据具体的原因)。Exception Translation Filter 会捕获抛出的错误,然后根据不同的认证方式进行信息的返回提示。

注意:绿色的过滤器可以配置是否生效,其他的都不能控制。

二、入门项目

首先创建spring boot项目HelloSecurity,其pom主要依赖如下:

  1. <dependencies>
  2. <dependency>
  3. <groupId>org.springframework.boot</groupId>
  4. <artifactId>spring-boot-starter-thymeleaf</artifactId>
  5. </dependency>
  6. <dependency>
  7. <groupId>org.springframework.boot</groupId>
  8. <artifactId>spring-boot-starter-test</artifactId>
  9. <scope>test</scope>
  10. </dependency>
  11. <dependency>
  12. <groupId>org.springframework.security</groupId>
  13. <artifactId>spring-security-test</artifactId>
  14. <scope>test</scope>
  15. </dependency>
  16. </dependencies>

然后在src/main/resources/templates/目录下创建页面:

  1. home.html
  2. <!DOCTYPE html>
  3. <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
  4. <head>
  5. <title>Spring Security Example</title>
  6. </head>
  7. <body>
  8. <h1>Welcome!</h1>
  9. <p>Click <a th:href="@{/hello}">here</a> to see a greeting.</p>
  10. </body>
  11. </html>

我们可以看到, 在这个简单的视图中包含了一个链接: “/hello”. 链接到了如下的页面,Thymeleaf模板如下:

  1. hello.html
  2. <!DOCTYPE html>
  3. <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
  4. xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
  5. <head>
  6. <title>Hello World!</title>
  7. </head>
  8. <body>
  9. <h1>Hello world!</h1>
  10. </body>
  11. </html>

Web应用程序基于Spring MVC。 因此,你需要配置Spring MVC并设置视图控制器来暴露这些模板。 如下是一个典型的Spring MVC配置类。在src/main/java/hello目录下(所以java都在这里):

  1. @Configuration
  2. public class MvcConfig extends WebMvcConfigurerAdapter {
  3. @Override
  4. public void addViewControllers(ViewControllerRegistry registry) {
  5. registry.addViewController("/home").setViewName("home");
  6. registry.addViewController("/").setViewName("home");
  7. registry.addViewController("/hello").setViewName("hello");
  8. registry.addViewController("/login").setViewName("login");
  9. }
  10. }

addViewControllers()方法(覆盖WebMvcConfigurerAdapter中同名的方法)添加了四个视图控制器。 两个视图控制器引用名称为“home”的视图(在home.html中定义),另一个引用名为“hello”的视图(在hello.html中定义)。 第四个视图控制器引用另一个名为“login”的视图。 将在下一部分中创建该视图。此时,可以跳过来使应用程序可执行并运行应用程序,而无需登录任何内容。然后启动程序如下:

  1. @SpringBootApplication
  2. public class Application {
  3. public static void main(String[] args) throws Throwable {
  4. SpringApplication.run(Application.class, args);
  5. }
  6. }

2、加入Spring Security

假设你希望防止未经授权的用户访问“/ hello”。 此时,如果用户点击主页上的链接,他们会看到问候语,请求被没有被拦截。 你需要添加一个障碍,使得用户在看到该页面之前登录。您可以通过在应用程序中配置Spring Security来实现。 如果Spring Security在类路径上,则Spring Boot会使用“Basic认证”来自动保护所有HTTP端点。 同时,你可以进一步自定义安全设置。首先在pom文件中引入:

  1. <dependencies>
  2. ...
  3. <dependency>
  4. <groupId>org.springframework.boot</groupId>
  5. <artifactId>spring-boot-starter-security</artifactId>
  6. </dependency>
  7. ...
  8. </dependencies>

如下是安全配置,使得只有认证过的用户才可以访问到问候页面:

  1. @Configuration
  2. @EnableWebSecurity
  3. public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
  4. @Override
  5. protected void configure(HttpSecurity http) throws Exception {
  6. http
  7. .authorizeRequests()
  8. .antMatchers("/", "/home").permitAll()
  9. .anyRequest().authenticated()
  10. .and()
  11. .formLogin()
  12. .loginPage("/login")
  13. .permitAll()
  14. .and()
  15. .logout()
  16. .permitAll();
  17. }
  18. @Autowired
  19. public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
  20. auth
  21. .inMemoryAuthentication()
  22. .withUser("user").password("password").roles("USER");
  23. }
  24. }

WebSecurityConfig类使用了@EnableWebSecurity注解 ,以启用Spring Security的Web安全支持,并提供Spring MVC集成。它还扩展了WebSecurityConfigurerAdapter,并覆盖了一些方法来设置Web安全配置的一些细节。

configure(HttpSecurity)方法定义了哪些URL路径应该被保护,哪些不应该。具体来说,“/”和“/ home”路径被配置为不需要任何身份验证。所有其他路径必须经过身份验证。

当用户成功登录时,它们将被重定向到先前请求的需要身份认证的页面。有一个由 loginPage()指定的自定义“/登录”页面,每个人都可以查看它。

对于configureGlobal(AuthenticationManagerBuilder) 方法,它将单个用户设置在内存中。该用户的用户名为“user”,密码为“password”,角色为“USER”。

现在我们需要创建登录页面。前面我们已经配置了“login”的视图控制器,因此现在只需要创建登录页面即可:

  1. login.html
  2. <!DOCTYPE html>
  3. <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
  4. xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
  5. <head>
  6. <title>Spring Security Example </title>
  7. </head>
  8. <body>
  9. <div th:if="${param.error}">
  10. Invalid username and password.
  11. </div>
  12. <div th:if="${param.logout}">
  13. You have been logged out.
  14. </div>
  15. <form th:action="@{/login}" method="post">
  16. <div><label> User Name : <input type="text" name="username"/> </label></div>
  17. <div><label> Password: <input type="password" name="password"/> </label></div>
  18. <div><input type="submit" value="Sign In"/></div>
  19. </form>
  20. </body>
  21. </html>

你可以看到,这个Thymeleaf模板只是提供一个表单来获取用户名和密码,并将它们提交到“/ login”。 根据配置,Spring Security提供了一个拦截该请求并验证用户的过滤器。 如果用户未通过认证,该页面将重定向到“/ login?error”,并在页面显示相应的错误消息。 注销成功后,我们的应用程序将发送到“/ login?logout”,我们的页面显示相应的登出成功消息。最后,我们需要向用户提供一个显示当前用户名和登出的方法。 更新hello.html 向当前用户打印一句hello,并包含一个“注销”表单,如下所示:

  1. <!DOCTYPE html>
  2. <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
  3. xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
  4. <head>
  5. <title>Hello World!</title>
  6. </head>
  7. <body>
  8. <h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
  9. <form th:action="@{/logout}" method="post">
  10. <input type="submit" value="Sign Out"/>
  11. </form>
  12. </body>
  13. </html>

三、参数详解

1、注解 @EnableWebSecurity

在 Spring boot 应用中使用 Spring Security,用到了 @EnableWebSecurity注解,官方说明为,该注解和 @Configuration 注解一起使用, 注解 WebSecurityConfigurer 类型的类,或者利用@EnableWebSecurity 注解继承 WebSecurityConfigurerAdapter的类,这样就构成了 Spring Security 的配置。

2、抽象类 WebSecurityConfigurerAdapter

一般情况,会选择继承 WebSecurityConfigurerAdapter 类,其官方说明为:WebSecurityConfigurerAdapter 提供了一种便利的方式去创建 WebSecurityConfigurer的实例,只需要重写 WebSecurityConfigurerAdapter 的方法,即可配置拦截什么URL、设置什么权限等安全控制。

3、方法 configure(AuthenticationManagerBuilder auth) 和 configure(HttpSecurity http)

Demo 中重写了 WebSecurityConfigurerAdapter 的两个方法:

  1. /**
  2. * 通过 {@link #authenticationManager()} 方法的默认实现尝试获取一个 {@link AuthenticationManager}.
  3. * 如果被复写, 应该使用{@link AuthenticationManagerBuilder} 来指定 {@link AuthenticationManager}.
  4. *
  5. * 例如, 可以使用以下配置在内存中进行注册公开内存的身份验证{@link UserDetailsService}:
  6. *
  7. * // 在内存中添加 user 和 admin 用户
  8. * @Override
  9. * protected void configure(AuthenticationManagerBuilder auth) {
  10. * auth
  11. * .inMemoryAuthentication().withUser("user").password("password").roles("USER").and()
  12. * .withUser("admin").password("password").roles("USER", "ADMIN");
  13. * }
  14. *
  15. * // 将 UserDetailsService 显示为 Bean
  16. * @Bean
  17. * @Override
  18. * public UserDetailsService userDetailsServiceBean() throws Exception {
  19. * return super.userDetailsServiceBean();
  20. * }
  21. *
  22. */
  23. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  24. this.disableLocalConfigureAuthenticationBldr = true;
  25. }
  26. /**
  27. * 复写这个方法来配置 {@link HttpSecurity}.
  28. * 通常,子类不能通过调用 super 来调用此方法,因为它可能会覆盖其配置。 默认配置为:
  29. *
  30. * http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().httpBasic();
  31. *
  32. */
  33. protected void configure(HttpSecurity http) throws Exception {
  34. logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity).");
  35. http
  36. .authorizeRequests()
  37. .anyRequest().authenticated()
  38. .and()
  39. .formLogin().and()
  40. .httpBasic();
  41. }

4、final 类 HttpSecurity

HttpSecurity 常用方法及说明:






























































































方法 说明
openidLogin() 用于基于 OpenId 的验证
headers() 将安全标头添加到响应
cors() 配置跨域资源共享( CORS )
sessionManagement() 允许配置会话管理
portMapper() 允许配置一个PortMapper(HttpSecurity#(getSharedObject(class))),其他提供SecurityConfigurer的对象使用 PortMapper 从 HTTP 重定向到 HTTPS 或者从 HTTPS 重定向到 HTTP。默认情况下,Spring Security使用一个PortMapperImpl映射 HTTP 端口8080到 HTTPS 端口8443,HTTP 端口80到 HTTPS 端口443
jee() 配置基于容器的预认证。 在这种情况下,认证由Servlet容器管理
x509() 配置基于x509的认证
rememberMe 允许配置“记住我”的验证
authorizeRequests() 允许基于使用HttpServletRequest限制访问
requestCache() 允许配置请求缓存
exceptionHandling() 允许配置错误处理
securityContext() HttpServletRequests之间的SecurityContextHolder上设置SecurityContext的管理。 当使用WebSecurityConfigurerAdapter时,这将自动应用
servletApi() HttpServletRequest方法与在其上找到的值集成到SecurityContext中。 当使用WebSecurityConfigurerAdapter时,这将自动应用
csrf() 添加 CSRF 支持,使用WebSecurityConfigurerAdapter时,默认启用
logout() 添加退出登录支持。当使用WebSecurityConfigurerAdapter时,这将自动应用。默认情况是,访问URL”/ logout”,使HTTP Session无效来清除用户,清除已配置的任何#rememberMe()身份验证,清除SecurityContextHolder,然后重定向到”/login?success”
anonymous() 允许配置匿名用户的表示方法。 当与WebSecurityConfigurerAdapter结合使用时,这将自动应用。 默认情况下,匿名用户将使用org.springframework.security.authentication.AnonymousAuthenticationToken表示,并包含角色 “ROLE_ANONYMOUS”
formLogin() 指定支持基于表单的身份验证。如果未指定FormLoginConfigurer#loginPage(String),则将生成默认登录页面
oauth2Login() 根据外部OAuth 2.0或OpenID Connect 1.0提供程序配置身份验证
requiresChannel() 配置通道安全。为了使该配置有用,必须提供至少一个到所需信道的映射
httpBasic() 配置 Http Basic 验证
addFilterAt() 在指定的Filter类的位置添加过滤器

5、类 AuthenticationManagerBuilder

  1. /**
  2. * {@link SecurityBuilder} used to create an {@link AuthenticationManager}. Allows for
  3. * easily building in memory authentication, LDAP authentication, JDBC based
  4. * authentication, adding {@link UserDetailsService}, and adding
  5. * {@link AuthenticationProvider}'s.
  6. */

意思是,AuthenticationManagerBuilder 用于创建一个 AuthenticationManager,让我能够轻松的实现内存验证、LADP验证、基于JDBC的验证、添加UserDetailsService、添加AuthenticationProvider。

使用yaml文件定义的用户名、密码登录

在application.yaml中定义用户名密码:

  1. spring:
  2. security:
  3. user:
  4. name: root
  5. password: root

使用root/root登录,可以正常访问/hello

使用代码中指定的用户名、密码登录
  • 使用configure(AuthenticationManagerBuilder) 添加认证。
  • 使用configure(httpSecurity) 添加权限

    @Configuration
    public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {

    1. @Override
    2. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    3. auth
    4. .inMemoryAuthentication()
    5. .withUser("admin") // 添加用户admin
    6. .password("{noop}admin") // 不设置密码加密
    7. .roles("ADMIN", "USER")// 添加角色为admin,user
    8. .and()
    9. .withUser("user") // 添加用户user
    10. .password("{noop}user")
    11. .roles("USER")
    12. .and()
    13. .withUser("tmp") // 添加用户tmp
    14. .password("{noop}tmp")
    15. .roles(); // 没有角色
    16. }
    17. @Override
    18. protected void configure(HttpSecurity http) throws Exception {
    19. http
    20. .authorizeRequests()
    21. .antMatchers("/product/**").hasRole("USER") //添加/product/** 下的所有请求只能由user角色才能访问
    22. .antMatchers("/admin/**").hasRole("ADMIN") //添加/admin/** 下的所有请求只能由admin角色才能访问
    23. .anyRequest().authenticated() // 没有定义的请求,所有的角色都可以访问(tmp也可以)。
    24. .and()
    25. .formLogin().and()
    26. .httpBasic();
    27. }

    }

添加AdminController、ProductController

  1. @RestController
  2. @RequestMapping("/admin")
  3. public class AdminController {
  4. @RequestMapping("/hello")
  5. public String hello(){
  6. return "admin hello";
  7. }
  8. }
  9. @RestController
  10. @RequestMapping("/product")
  11. public class ProductController {
  12. @RequestMapping("/hello")
  13. public String hello(){
  14. return "product hello";
  15. }
  16. }

通过上面的设置,访问http://localhost:8080/admin/hello只能由admin访问,http://localhost:8080/product/hello admin和user都可以访问,http://localhost:8080/hello 所有用户(包括tmp)都可以访问。

使用数据库的用户名、密码登录
添加依赖
  1. <dependency>
  2. <groupId>org.springframework.boot</groupId>
  3. <artifactId>spring-boot-starter-data-jpa</artifactId>
  4. </dependency>
  5. <dependency>
  6. <groupId>mysql</groupId>
  7. <artifactId>mysql-connector-java</artifactId>
  8. </dependency>
添加数据库配置
  1. spring:
  2. datasource:
  3. url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
  4. username: root
  5. password: root
  6. driver-class-name: com.mysql.cj.jdbc.Driver
配置spring-security认证和授权
  1. @Configuration
  2. public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
  3. @Autowired
  4. private UserDetailsService userDetailsService;
  5. @Override
  6. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  7. auth.userDetailsService(userDetailsService)// 设置自定义的userDetailsService
  8. .passwordEncoder(passwordEncoder());
  9. }
  10. @Override
  11. protected void configure(HttpSecurity http) throws Exception {
  12. http
  13. .authorizeRequests()
  14. .antMatchers("/product/**").hasRole("USER")
  15. .antMatchers("/admin/**").hasRole("ADMIN")
  16. .anyRequest().authenticated() //
  17. .and()
  18. .formLogin()
  19. .and()
  20. .httpBasic()
  21. .and().logout().logoutUrl("/logout");
  22. }
  23. @Bean
  24. public PasswordEncoder passwordEncoder() {
  25. return NoOpPasswordEncoder.getInstance();// 使用不使用加密算法保持密码
  26. // return new BCryptPasswordEncoder();
  27. }
  28. }

如果需要使用BCryptPasswordEncoder,可以先在测试环境中加密后放到数据库中:

  1. @Test
  2. void encode() {
  3. BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
  4. String password = bCryptPasswordEncoder.encode("user");
  5. String password2 = bCryptPasswordEncoder.encode("admin");
  6. System.out.println(password);
  7. System.out.println(password2);
  8. }
配置自定义UserDetailsService来进行验证
  1. @Component("userDetailsService")
  2. public class CustomUserDetailsService implements UserDetailsService {
  3. @Autowired
  4. UserRepository userRepository;
  5. @Override
  6. public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {
  7. // 1. 查询用户
  8. User userFromDatabase = userRepository.findOneByLogin(login);
  9. if (userFromDatabase == null) {
  10. //log.warn("User: {} not found", login);
  11. throw new UsernameNotFoundException("User " + login + " was not found in db");
  12. //这里找不到必须抛异常
  13. }
  14. // 2. 设置角色
  15. Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
  16. GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(userFromDatabase.getRole());
  17. grantedAuthorities.add(grantedAuthority);
  18. return new org.springframework.security.core.userdetails.User(login,
  19. userFromDatabase.getPassword(), grantedAuthorities);
  20. }
  21. }
配置JPA中的UserRepository
  1. @Repository
  2. public interface UserRepository extends JpaRepository<User, Long> {
  3. User findOneByLogin(String login);
  4. }
添加数据库数据

image-20201130200749622

  1. CREATE TABLE `user` (
  2. `id` int(28) NOT NULL,
  3. `login` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
  4. `password` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
  5. `role` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
  6. PRIMARY KEY (`id`)
  7. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
  8. INSERT INTO `demo`.`user`(`id`, `login`, `password`, `role`) VALUES (1, 'user', 'user', 'ROLE_USER');
  9. INSERT INTO `demo`.`user`(`id`, `login`, `password`, `role`) VALUES (2, 'admin', 'admin', 'ROLE_ADMIN');

默认角色前缀必须是ROLE_,因为spring-security会在授权的时候自动使用match中的角色加上ROLE_后进行比较。

四:获取登录信息

  1. @RequestMapping("/info")
  2. public String info(){
  3. String userDetails = null;
  4. Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
  5. if(principal instanceof UserDetails) {
  6. userDetails = ((UserDetails)principal).getUsername();
  7. }else {
  8. userDetails = principal.toString();
  9. }
  10. return userDetails;
  11. }

使用SecurityContextHolder.getContext().getAuthentication().getPrincipal();获取当前的登录信息。

五: Spring Security 核心组件

SecurityContext

SecurityContext是安全的上下文,所有的数据都是保存到SecurityContext中。

可以通过SecurityContext获取的对象有:

  • Authentication

SecurityContextHolder

SecurityContextHolder用来获取SecurityContext中保存的数据的工具。通过使用静态方法获取SecurityContext的相对应的数据。

  1. SecurityContext context = SecurityContextHolder.getContext();

Authentication

Authentication表示当前的认证情况,可以获取的对象有:

UserDetails:获取用户信息,是否锁定等额外信息。

Credentials:获取密码。

isAuthenticated:获取是否已经认证过。

Principal:获取用户,如果没有认证,那么就是用户名,如果认证了,返回UserDetails。

UserDetails

  1. public interface UserDetails extends Serializable {
  2. Collection<? extends GrantedAuthority> getAuthorities();
  3. String getPassword();
  4. String getUsername();
  5. boolean isAccountNonExpired();
  6. boolean isAccountNonLocked();
  7. boolean isCredentialsNonExpired();
  8. boolean isEnabled();
  9. }

UserDetailsService

UserDetailsService可以通过loadUserByUsername获取UserDetails对象。该接口供spring security进行用户验证。

通常使用自定义一个CustomUserDetailsService来实现UserDetailsService接口,通过自定义查询UserDetails。

AuthenticationManager

AuthenticationManager用来进行验证,如果验证失败会抛出相对应的异常。

PasswordEncoder

密码加密器。通常是自定义指定。

BCryptPasswordEncoder:哈希算法加密

NoOpPasswordEncoder:不使用加密

六:spring security session 无状态支持权限控制(前后分离)

spring security会在默认的情况下将认证信息放到HttpSession中。

但是对于我们的前后端分离的情况,如app,小程序,web前后分离等,httpSession就没有用武之地了。这时我们可以通过configure(httpSecurity)设置spring security是否使用httpSession。

  1. @Configuration
  2. public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
  3. // code...
  4. @Override
  5. protected void configure(HttpSecurity http) throws Exception {
  6. http
  7. .sessionManagement()
  8. //设置无状态,所有的值如下所示。
  9. .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
  10. // code...
  11. }
  12. // code...
  13. }

共有四种值,其中默认的是ifRequired。

  • always – a session will always be created if one doesn’t already exist,没有session就创建。
  • ifRequired – a session will be created only if required (default),如果需要就创建(默认)。
  • never – the framework will never create a session itself but it will use one if it already exists
  • stateless – no session will be created or used by Spring Security 不创建不使用session

由于前后端不通过保存session和cookie来进行判断,所以为了保证spring security能够记录登录状态,所以需要传递一个值,让这个值能够自我验证来源,同时能够得到数据信息。选型我们选择JWT。对于java客户端我们选择使用jjwt。

添加依赖
  1. <dependency>
  2. <groupId>io.jsonwebtoken</groupId>
  3. <artifactId>jjwt-api</artifactId>
  4. <version>0.11.2</version>
  5. </dependency>
  6. <dependency>
  7. <groupId>io.jsonwebtoken</groupId>
  8. <artifactId>jjwt-impl</artifactId>
  9. <version>0.11.2</version>
  10. <scope>runtime</scope>
  11. </dependency>
  12. <dependency>
  13. <groupId>io.jsonwebtoken</groupId>
  14. <artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
  15. <version>0.11.2</version>
  16. <scope>runtime</scope>
  17. </dependency>
创建工具类JWTProvider

JWTProvider需要至少提供两个方法,一个用来创建我们的token,另一个根据token获取Authentication。

provider需要保证Key密钥是唯一的,使用init()构建,否则会抛出异常。

  1. @Component
  2. @Slf4j
  3. public class JWTProvider {
  4. private Key key; // 私钥
  5. private long tokenValidityInMilliseconds; // 有效时间
  6. private long tokenValidityInMillisecondsForRememberMe; // 记住我有效时间
  7. @Autowired
  8. private JJWTProperties jjwtProperties; // jwt配置参数
  9. @Autowired
  10. private UserRepository userRepository;
  11. @PostConstruct
  12. public void init() {
  13. byte[] keyBytes;
  14. String secret = jjwtProperties.getSecret();
  15. if (StringUtils.hasText(secret)) {
  16. log.warn("Warning: the JWT key used is not Base64-encoded. " +
  17. "We recommend using the `jhipster.security.authentication.jwt.base64-secret` key for optimum security.");
  18. keyBytes = secret.getBytes(StandardCharsets.UTF_8);
  19. } else {
  20. log.debug("Using a Base64-encoded JWT secret key");
  21. keyBytes = Decoders.BASE64.decode(jjwtProperties.getBase64Secret());
  22. }
  23. this.key = Keys.hmacShaKeyFor(keyBytes); // 使用mac-sha算法的密钥
  24. this.tokenValidityInMilliseconds =
  25. 1000 * jjwtProperties.getTokenValidityInSeconds();
  26. this.tokenValidityInMillisecondsForRememberMe =
  27. 1000 * jjwtProperties.getTokenValidityInSecondsForRememberMe();
  28. }
  29. public String createToken(Authentication authentication, boolean rememberMe) {
  30. long now = (new Date()).getTime();
  31. Date validity;
  32. if (rememberMe) {
  33. validity = new Date(now + this.tokenValidityInMillisecondsForRememberMe);
  34. } else {
  35. validity = new Date(now + this.tokenValidityInMilliseconds);
  36. }
  37. User user = userRepository.findOneByLogin(authentication.getName());
  38. Map<String ,Object> map = new HashMap<>();
  39. map.put("sub",authentication.getName());
  40. map.put("user",user);
  41. return Jwts.builder()
  42. .setClaims(map) // 添加body
  43. .signWith(key, SignatureAlgorithm.HS512) // 指定摘要算法
  44. .setExpiration(validity) // 设置有效时间
  45. .compact();
  46. }
  47. public Authentication getAuthentication(String token) {
  48. Claims claims = Jwts.parserBuilder()
  49. .setSigningKey(key)
  50. .build()
  51. .parseClaimsJws(token).getBody(); // 根据token获取body
  52. User principal;
  53. Collection<? extends GrantedAuthority> authorities;
  54. principal = userRepository.findOneByLogin(claims.getSubject());
  55. authorities = principal.getAuthorities();
  56. return new UsernamePasswordAuthenticationToken(principal, token, authorities);
  57. }
  58. }

注意这里我们创建的User需要实现UserDetails对象,这样我们可以根据principal.getAuthorities()获取到权限,如果不实现UserDetails,那么需要自定义authorities并添加到UsernamePasswordAuthenticationToken中。

  1. @Data
  2. @Entity
  3. @Table(name="user")
  4. public class User implements UserDetails {
  5. @Id
  6. @Column
  7. private Long id;
  8. @Column
  9. private String login;
  10. @Column
  11. private String password;
  12. @Column
  13. private String role;
  14. @Override
  15. // 获取权限,这里就用简单的方法
  16. // 在spring security中,Authorities既可以是ROLE也可以是Authorities
  17. public Collection<? extends GrantedAuthority> getAuthorities() {
  18. return Collections.singleton(new SimpleGrantedAuthority(role));
  19. }
  20. @Override
  21. public String getUsername() {
  22. return login;
  23. }
  24. @Override
  25. public boolean isAccountNonExpired() {
  26. return true;
  27. }
  28. @Override
  29. public boolean isAccountNonLocked() {
  30. return false;
  31. }
  32. @Override
  33. public boolean isCredentialsNonExpired() {
  34. return true;
  35. }
  36. @Override
  37. public boolean isEnabled() {
  38. return true;
  39. }
  40. }
创建登录成功,登出成功处理器

登录成功后向前台发送jwt。

认证成功,返回jwt:

  1. public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler{
  2. void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException{
  3. PrintWriter writer = response.getWriter();
  4. writer.println(jwtProvider.createToken(authentication, true));
  5. }
  6. }

登出成功:

  1. public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
  2. void onLogoutSuccess(HttpServletRequest var1, HttpServletResponse var2, Authentication var3) throws IOException, ServletException{
  3. PrintWriter writer = response.getWriter();
  4. writer.println("logout success");
  5. writer.flush();
  6. }
  7. }
设置登录、登出、取消csrf防护

登出无法对token进行失效操作,可以使用数据库保存token,然后在登出时删除该token。

  1. @Configuration
  2. public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
  3. // code...
  4. @Override
  5. protected void configure(HttpSecurity http) throws Exception {
  6. http
  7. // code...
  8. // 添加登录处理器
  9. .formLogin().loginProcessingUrl("/login").successHandler((request, response, authentication) -> {
  10. PrintWriter writer = response.getWriter();
  11. writer.println(jwtProvider.createToken(authentication, true));
  12. })
  13. // 取消csrf防护
  14. .and().csrf().disable()
  15. // code...
  16. // 添加登出处理器
  17. .and().logout().logoutUrl("/logout").logoutSuccessHandler((HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> {
  18. PrintWriter writer = response.getWriter();
  19. writer.println("logout success");
  20. writer.flush();
  21. })
  22. // code...
  23. }
  24. // code...
  25. }
使用JWT集成spring-security

添加Filter供spring-security解析token,并向securityContext中添加我们的用户信息。

在UsernamePasswordAuthenticationFilter.class之前我们需要执行根据token添加authentication。关键方法是从jwt中获取authentication,然后添加到securityContext中。

在SecurityConfiguration中需要设置Filter添加的位置。

创建自定义Filter,用于jwt获取authentication:

  1. @Slf4j
  2. public class JWTFilter extends GenericFilterBean {
  3. private final static String HEADER_AUTH_NAME = "auth";
  4. private JWTProvider jwtProvider;
  5. public JWTFilter(JWTProvider jwtProvider) {
  6. this.jwtProvider = jwtProvider;
  7. }
  8. @Override
  9. public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
  10. try {
  11. HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
  12. String authToken = httpServletRequest.getHeader(HEADER_AUTH_NAME);
  13. if (StringUtils.hasText(authToken)) {
  14. // 从自定义tokenProvider中解析用户
  15. Authentication authentication = this.jwtProvider.getAuthentication(authToken);
  16. SecurityContextHolder.getContext().setAuthentication(authentication);
  17. }
  18. // 调用后续的Filter,如果上面的代码逻辑未能复原“session”,SecurityContext中没有想过信息,后面的流程会检测出"需要登录"
  19. filterChain.doFilter(servletRequest, servletResponse);
  20. } catch (Exception ex) {
  21. throw new RuntimeException(ex);
  22. }
  23. }
  24. }

向HttpSecurity添加Filter和设置Filter位置:

  1. public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
  2. // code...
  3. @Override
  4. protected void configure(HttpSecurity http) throws Exception {
  5. http
  6. .sessionManagement()
  7. //设置添加Filter和位置
  8. .and().addFilterBefore(new JWTFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
  9. // code...
  10. }
  11. // code...
  12. }
MySecurityConfiguration代码
  1. @Configuration
  2. @EnableGlobalMethodSecurity(prePostEnabled = true)
  3. public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {
  4. @Autowired
  5. private UserDetailsService userDetailsService;
  6. @Autowired
  7. private JWTProvider jwtProvider;
  8. @Override
  9. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  10. auth.userDetailsService(userDetailsService)// 设置自定义的userDetailsService
  11. .passwordEncoder(passwordEncoder());
  12. }
  13. @Override
  14. protected void configure(HttpSecurity http) throws Exception {
  15. http
  16. .sessionManagement()
  17. .sessionCreationPolicy(SessionCreationPolicy.STATELESS)//设置无状态
  18. .and()
  19. .authorizeRequests() // 配置请求权限
  20. .antMatchers("/product/**").hasRole("USER") // 需要角色
  21. .antMatchers("/admin/**").hasRole("ADMIN")
  22. .anyRequest().authenticated() // 所有的请求都需要登录
  23. .and()
  24. // 配置登录url,和登录成功处理器
  25. .formLogin().loginProcessingUrl("/login").successHandler((request, response, authentication) -> {
  26. PrintWriter writer = response.getWriter();
  27. writer.println(jwtProvider.createToken(authentication, true));
  28. })
  29. // 取消csrf防护
  30. .and().csrf().disable()
  31. .httpBasic()
  32. // 配置登出url,和登出成功处理器
  33. .and().logout().logoutUrl("/logout")
  34. .logoutSuccessHandler((HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> {
  35. PrintWriter writer = response.getWriter();
  36. writer.println("logout success");
  37. writer.flush();
  38. })
  39. // 在UsernamePasswordAuthenticationFilter之前执行我们添加的JWTFilter
  40. .and().addFilterBefore(new JWTFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
  41. }
  42. @Bean
  43. public PasswordEncoder passwordEncoder() {
  44. return NoOpPasswordEncoder.getInstance();
  45. }
  46. @Override
  47. public void configure(WebSecurity web) {
  48. // 添加不做权限的URL
  49. web.ignoring()
  50. .antMatchers("/swagger-resources/**")
  51. .antMatchers("/swagger-ui.html")
  52. .antMatchers("/webjars/**")
  53. .antMatchers("/v2/**")
  54. .antMatchers("/h2-console/**");
  55. }
  56. }
使用注解对方法进行权限管理

需要在MySecurityConfiguration上添加@EnableGlobalMethodSecurity(prePostEnabled = true)注解,prePostEnabled默认为false,需要设置为true后才能全局的注解权限控制。

prePostEnabled设置为true后,可以使用四个注解:

添加实体类School:

  1. @Data
  2. public class School implements Serializable {
  3. private Long id;
  4. private String name;
  5. private String address;
  6. }
  • @PreAuthorize

在访问之前就进行权限判断

  1. @RestController
  2. public class AnnoController {
  3. @Autowired
  4. private JWTProvider jwtProvider;
  5. @RequestMapping("/annotation")
  6. // @PreAuthorize("hasRole('ADMIN')")
  7. @PreAuthorize("hasAuthority('ROLE_ADMIN')")
  8. public String info(){
  9. return "拥有admin权限";
  10. }
  11. }

hasRole和hasAuthority都会对UserDetails中的getAuthorities进行判断区别是hasRole会对字段加上ROLE_后再进行判断,上例中使用了hasRole('ADMIN'),那么就会使用ROLE_ADMIN进行判断,如果是hasAuthority('ADMIN'),那么就使用ADMIN进行判断。

  • @PostAuthorize

在请求之后进行判断,如果返回值不满足条件,会抛出异常,但是方法本身是已经执行过了的。

  1. @RequestMapping("/postAuthorize")
  2. @PreAuthorize("hasRole('ADMIN')")
  3. @PostAuthorize("returnObject.id%2==0")
  4. public School postAuthorize(Long id) {
  5. School school = new School();
  6. school.setId(id);
  7. return school;
  8. }

returnObject是内置对象,引用的是方法的返回值。

如果returnObject.id%2==0为 true,那么返回方法值。如果为false,会返回403 Forbidden。

  • @PreFilter

在方法执行之前,用于过滤集合中的值。

  1. @RequestMapping("/preFilter")
  2. @PreAuthorize("hasRole('ADMIN')")
  3. @PreFilter("filterObject%2==0")
  4. public List<Long> preFilter(@RequestParam("ids") List<Long> ids) {
  5. return ids;
  6. }

filterObject是内置对象,引用的是集合中的泛型类,如果有多个集合,需要指定filterTarget

  1. @PreFilter(filterTarget="ids", value="filterObject%2==0")
  2. public List<Long> preFilter(@RequestParam("ids") List<Long> ids,@RequestParam("ids") List<User> users,) {
  3. return ids;
  4. }

filterObject%2==0会对集合中的值会进行过滤,为true的值会保留。

第一个例子返回的值在执行前过滤返回2,4。

image-20201202115120854

  • @PostFilter

会对返回的集合进行过滤。

  1. @RequestMapping("/postFilter")
  2. @PreAuthorize("hasRole('ADMIN')")
  3. @PostFilter("filterObject.id%2==0")
  4. public List<School> postFilter() {
  5. List<School> schools = new ArrayList<School>();
  6. School school;
  7. for (int i = 0; i < 10; i++) {
  8. school = new School();
  9. school.setId((long)i);
  10. schools.add(school);
  11. }
  12. return schools;
  13. }

上面的方法返回结果为:id为0,2,4,6,8的School对象。

七、原理讲解

1、校验流程图

img

2、源码分析

  • AbstractAuthenticationProcessingFilter 抽象类

    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)

    1. throws IOException, ServletException {
  1. HttpServletRequest request = (HttpServletRequest) req;
  2. HttpServletResponse response = (HttpServletResponse) res;
  3. if (!requiresAuthentication(request, response)) {
  4. chain.doFilter(request, response);
  5. return;
  6. }
  7. if (logger.isDebugEnabled()) {
  8. logger.debug("Request is to process authentication");
  9. }
  10. Authentication authResult;
  11. try {
  12. authResult = attemptAuthentication(request, response);
  13. if (authResult == null) {
  14. // return immediately as subclass has indicated that it hasn't completed
  15. // authentication
  16. return;
  17. }
  18. sessionStrategy.onAuthentication(authResult, request, response);
  19. }
  20. catch (InternalAuthenticationServiceException failed) {
  21. logger.error(
  22. "An internal error occurred while trying to authenticate the user.",
  23. failed);
  24. unsuccessfulAuthentication(request, response, failed);
  25. return;
  26. }
  27. catch (AuthenticationException failed) {
  28. // Authentication failed
  29. unsuccessfulAuthentication(request, response, failed);
  30. return;
  31. }
  32. // Authentication success
  33. if (continueChainBeforeSuccessfulAuthentication) {
  34. chain.doFilter(request, response);
  35. }
  36. successfulAuthentication(request, response, chain, authResult);
  37. }

调用 requiresAuthentication(HttpServletRequest, HttpServletResponse) 决定是否需要进行验证操作。如果需要验证,则会调用 attemptAuthentication(HttpServletRequest, HttpServletResponse) 方法,有三种结果:

  1. 返回一个 Authentication 对象。配置的 SessionAuthenticationStrategy` 将被调用,然后 然后调用 successfulAuthentication(HttpServletRequest,HttpServletResponse,FilterChain,Authentication) 方法。
  2. 验证时发生 AuthenticationException。unsuccessfulAuthentication(HttpServletRequest, HttpServletResponse, AuthenticationException) 方法将被调用。
  3. 返回Null,表示身份验证不完整。假设子类做了一些必要的工作(如重定向)来继续处理验证,方法将立即返回。假设后一个请求将被这种方法接收,其中返回的Authentication对象不为空。

    • UsernamePasswordAuthenticationFilter(AbstractAuthenticationProcessingFilter的子类)

    public Authentication attemptAuthentication(HttpServletRequest request,

    1. HttpServletResponse response) throws AuthenticationException {
    2. if (postOnly && !request.getMethod().equals("POST")) {
    3. throw new AuthenticationServiceException(
    4. "Authentication method not supported: " + request.getMethod());
    5. }
    6. String username = obtainUsername(request);
    7. String password = obtainPassword(request);
    8. if (username == null) {
    9. username = "";
    10. }
    11. if (password == null) {
    12. password = "";
    13. }
    14. username = username.trim();
    15. UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
    16. username, password);
    17. // Allow subclasses to set the "details" property
    18. setDetails(request, authRequest);
    19. return this.getAuthenticationManager().authenticate(authRequest);
    20. }

attemptAuthentication () 方法将 request 中的 username 和 password 生成 UsernamePasswordAuthenticationToken 对象,用于 AuthenticationManager 的验证(即 this.getAuthenticationManager().authenticate(authRequest) )。默认情况下注入 Spring 容器的 AuthenticationManager 是 ProviderManager。

  • ProviderManager(AuthenticationManager的实现类)

    public Authentication authenticate(Authentication authentication)

    1. throws AuthenticationException {
    2. Class<? extends Authentication> toTest = authentication.getClass();
    3. AuthenticationException lastException = null;
    4. Authentication result = null;
    5. boolean debug = logger.isDebugEnabled();
    6. for (AuthenticationProvider provider : getProviders()) {
    7. if (!provider.supports(toTest)) {
    8. continue;
    9. }
    10. if (debug) {
    11. logger.debug("Authentication attempt using "
    12. + provider.getClass().getName());
    13. }
    14. try {
    15. result = provider.authenticate(authentication);
    16. if (result != null) {
    17. copyDetails(authentication, result);
    18. break;
    19. }
    20. }
    21. catch (AccountStatusException e) {
    22. prepareException(e, authentication);
    23. // SEC-546: Avoid polling additional providers if auth failure is due to
    24. // invalid account status
    25. throw e;
    26. }
    27. catch (InternalAuthenticationServiceException e) {
    28. prepareException(e, authentication);
    29. throw e;
    30. }
    31. catch (AuthenticationException e) {
    32. lastException = e;
    33. }
    34. }
    35. if (result == null && parent != null) {
    36. // Allow the parent to try.
    37. try {
    38. result = parent.authenticate(authentication);
    39. }
    40. catch (ProviderNotFoundException e) {
    41. // ignore as we will throw below if no other exception occurred prior to
    42. // calling parent and the parent
    43. // may throw ProviderNotFound even though a provider in the child already
    44. // handled the request
    45. }
    46. catch (AuthenticationException e) {
    47. lastException = e;
    48. }
    49. }
    50. if (result != null) {
    51. if (eraseCredentialsAfterAuthentication
    52. && (result instanceof CredentialsContainer)) {
    53. // Authentication is complete. Remove credentials and other secret data
    54. // from authentication
    55. ((CredentialsContainer) result).eraseCredentials();
    56. }
    57. eventPublisher.publishAuthenticationSuccess(result);
    58. return result;
    59. }
    60. // Parent was null, or didn't authenticate (or throw an exception).
    61. if (lastException == null) {
    62. lastException = new ProviderNotFoundException(messages.getMessage(
    63. "ProviderManager.providerNotFound",
    64. new Object[] {
    65. toTest.getName() },
    66. "No AuthenticationProvider found for {0}"));
    67. }
    68. prepareException(lastException, authentication);
    69. throw lastException;

    }

尝试验证 Authentication 对象。AuthenticationProvider 列表将被连续尝试,直到 AuthenticationProvider 表示它能够认证传递的过来的Authentication 对象。然后将使用该 AuthenticationProvider 尝试身份验证。如果有多个 AuthenticationProvider 支持验证传递过来的Authentication 对象,那么由第一个来确定结果,覆盖早期支持AuthenticationProviders 所引发的任何可能的AuthenticationException。 成功验证后,将不会尝试后续的AuthenticationProvider。如果最后所有的 AuthenticationProviders 都没有成功验证 Authentication 对象,将抛出 AuthenticationException。从代码中不难看出,由 provider 来验证 authentication, 核心点方法是:

  1. Authentication result = provider.authenticate(authentication);

此处的 provider 是 AbstractUserDetailsAuthenticationProvider,AbstractUserDetailsAuthenticationProvider 是AuthenticationProvider的实现,看看它的 authenticate(authentication) 方法:

  1. // 验证 authentication
  2. public Authentication authenticate(Authentication authentication)
  3. throws AuthenticationException {
  4. Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
  5. messages.getMessage(
  6. "AbstractUserDetailsAuthenticationProvider.onlySupports",
  7. "Only UsernamePasswordAuthenticationToken is supported"));
  8. // Determine username
  9. String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
  10. : authentication.getName();
  11. boolean cacheWasUsed = true;
  12. UserDetails user = this.userCache.getUserFromCache(username);
  13. if (user == null) {
  14. cacheWasUsed = false;
  15. try {
  16. user = retrieveUser(username,
  17. (UsernamePasswordAuthenticationToken) authentication);
  18. }
  19. catch (UsernameNotFoundException notFound) {
  20. logger.debug("User '" + username + "' not found");
  21. if (hideUserNotFoundExceptions) {
  22. throw new BadCredentialsException(messages.getMessage(
  23. "AbstractUserDetailsAuthenticationProvider.badCredentials",
  24. "Bad credentials"));
  25. }
  26. else {
  27. throw notFound;
  28. }
  29. }
  30. Assert.notNull(user,
  31. "retrieveUser returned null - a violation of the interface contract");
  32. }
  33. try {
  34. preAuthenticationChecks.check(user);
  35. additionalAuthenticationChecks(user,
  36. (UsernamePasswordAuthenticationToken) authentication);
  37. }
  38. catch (AuthenticationException exception) {
  39. if (cacheWasUsed) {
  40. // There was a problem, so try again after checking
  41. // we're using latest data (i.e. not from the cache)
  42. cacheWasUsed = false;
  43. user = retrieveUser(username,
  44. (UsernamePasswordAuthenticationToken) authentication);
  45. preAuthenticationChecks.check(user);
  46. additionalAuthenticationChecks(user,
  47. (UsernamePasswordAuthenticationToken) authentication);
  48. }
  49. else {
  50. throw exception;
  51. }
  52. }
  53. postAuthenticationChecks.check(user);
  54. if (!cacheWasUsed) {
  55. this.userCache.putUserInCache(user);
  56. }
  57. Object principalToReturn = user;
  58. if (forcePrincipalAsString) {
  59. principalToReturn = user.getUsername();
  60. }
  61. return createSuccessAuthentication(principalToReturn, authentication, user);
  62. }

AbstractUserDetailsAuthenticationProvider 内置了缓存机制,从缓存中获取不到的 UserDetails 信息的话,就调用如下方法获取用户信息,然后和 用户传来的信息进行对比来判断是否验证成功。

  1. // 获取用户信息
  2. UserDetails user = retrieveUser(username,
  3. (UsernamePasswordAuthenticationToken) authentication);

retrieveUser() 方法在 DaoAuthenticationProvider 中实现,DaoAuthenticationProvider 是 AbstractUserDetailsAuthenticationProvider的子类。具体实现如下:

  1. protected final UserDetails retrieveUser(String username,
  2. UsernamePasswordAuthenticationToken authentication)
  3. throws AuthenticationException {
  4. UserDetails loadedUser;
  5. try {
  6. loadedUser = this.getUserDetailsService().loadUserByUsername(username);
  7. }
  8. catch (UsernameNotFoundException notFound) {
  9. if (authentication.getCredentials() != null) {
  10. String presentedPassword = authentication.getCredentials().toString();
  11. passwordEncoder.isPasswordValid(userNotFoundEncodedPassword,
  12. presentedPassword, null);
  13. }
  14. throw notFound;
  15. }
  16. catch (Exception repositoryProblem) {
  17. throw new InternalAuthenticationServiceException(
  18. repositoryProblem.getMessage(), repositoryProblem);
  19. }
  20. if (loadedUser == null) {
  21. throw new InternalAuthenticationServiceException(
  22. "UserDetailsService returned null, which is an interface contract violation");
  23. }
  24. return loadedUser;
  25. }

可以看到此处的返回对象 userDetails 是由 UserDetailsService 的 #loadUserByUsername(username) 来获取的。

八、玩转自定义登录

1. form 登录的流程

下面是 form 登录的基本流程:

d76e7961ba01cb50ed2a23e82cf8bbb8.png

只要是 form 登录基本都能转化为上面的流程。接下来我们看看 Spring Security 是如何处理的。

3. Spring Security 中的登录

默认它提供了三种登录方式:

  • formLogin() 普通表单登录
  • oauth2Login() 基于 OAuth2.0 认证/授权协议
  • openidLogin() 基于 OpenID 身份认证规范

以上三种方式统统是 AbstractAuthenticationFilterConfigurer 实现的,

4. HttpSecurity 中的 form 表单登录

启用表单登录通过两种方式一种是通过 HttpSecurityapply(C configurer) 方法自己构造一个 AbstractAuthenticationFilterConfigurer 的实现,这种是比较高级的玩法。 另一种是我们常见的使用 HttpSecurityformLogin() 方法来自定义 FormLoginConfigurer 。我们先搞一下比较常规的第二种。

4.1 FormLoginConfigurer

该类是 form 表单登录的配置类。它提供了一些我们常用的配置方法:

  • loginPage(String loginPage) : 登录 页面而并不是接口,对于前后分离模式需要我们进行改造 默认为 /login
  • loginProcessingUrl(String loginProcessingUrl) 实际表单向后台提交用户信息的 Action,再由过滤器UsernamePasswordAuthenticationFilter 拦截处理,该 Action 其实不会处理任何逻辑。
  • usernameParameter(String usernameParameter) 用来自定义用户参数名,默认 username
  • passwordParameter(String passwordParameter) 用来自定义用户密码名,默认 password
  • failureUrl(String authenticationFailureUrl) 登录失败后会重定向到此路径, 一般前后分离不会使用它。
  • failureForwardUrl(String forwardUrl) 登录失败会转发到此, 一般前后分离用到它。 可定义一个 Controller (控制器)来处理返回值,但是要注意 RequestMethod
  • defaultSuccessUrl(String defaultSuccessUrl, boolean alwaysUse) 默认登陆成功后跳转到此 ,如果 alwaysUsetrue 只要进行认证流程而且成功,会一直跳转到此。一般推荐默认值 false
  • successForwardUrl(String forwardUrl) 效果等同于上面 defaultSuccessUrlalwaysUsetrue 但是要注意 RequestMethod
  • successHandler(AuthenticationSuccessHandler successHandler) 自定义认证成功处理器,可替代上面所有的 success 方式
  • failureHandler(AuthenticationFailureHandler authenticationFailureHandler) 自定义失败成功处理器,可替代上面所有的 failure 方式
  • permitAll(boolean permitAll) form 表单登录是否放开

知道了这些我们就能来搞个定制化的登录了。

5. Spring Security 聚合登录 实战

接下来是我们最激动人心的实战登录操作。 有疑问的可认真阅读 Spring 实战 的一系列预热文章。

5.1 简单需求

我们的接口访问都要通过认证,登陆错误后返回错误信息(json),成功后前台可以获取到对应数据库用户信息(json)(实战中记得脱敏)。

我们定义处理成功失败的控制器:

  1. @RestController
  2. @RequestMapping("/login")
  3. public class LoginController {
  4. @Resource
  5. private SysUserService sysUserService;
  6. /**
  7. * 登录失败返回 401 以及提示信息.
  8. *
  9. * @return the rest
  10. */
  11. @PostMapping("/failure")
  12. public Rest loginFailure() {
  13. return RestBody.failure(HttpStatus.UNAUTHORIZED.value(), "登录失败了,老哥");
  14. }
  15. /**
  16. * 登录成功后拿到个人信息.
  17. *
  18. * @return the rest
  19. */
  20. @PostMapping("/success")
  21. public Rest loginSuccess() {
  22. // 登录成功后用户的认证信息 UserDetails会存在 安全上下文寄存器 SecurityContextHolder 中
  23. User principal = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
  24. String username = principal.getUsername();
  25. SysUser sysUser = sysUserService.queryByUsername(username);
  26. // 脱敏
  27. sysUser.setEncodePassword("[PROTECT]");
  28. return RestBody.okData(sysUser,"登录成功");
  29. }
  30. }

然后 我们自定义配置覆写 void configure(HttpSecurity http) 方法进行如下配置(这里需要禁用crsf):

  1. @Configuration
  2. @ConditionalOnClass(WebSecurityConfigurerAdapter.class)
  3. @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
  4. public class CustomSpringBootWebSecurityConfiguration {
  5. @Configuration
  6. @Order(SecurityProperties.BASIC_AUTH_ORDER)
  7. static class DefaultConfigurerAdapter extends WebSecurityConfigurerAdapter {
  8. @Override
  9. protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  10. super.configure(auth);
  11. }
  12. @Override
  13. public void configure(WebSecurity web) throws Exception {
  14. super.configure(web);
  15. }
  16. @Override
  17. protected void configure(HttpSecurity http) throws Exception {
  18. http.csrf().disable()
  19. .cors()
  20. .and()
  21. .authorizeRequests().anyRequest().authenticated()
  22. .and()
  23. .formLogin()
  24. .loginProcessingUrl("/process")
  25. .successForwardUrl("/login/success").
  26. failureForwardUrl("/login/failure");
  27. }
  28. }
  29. }

使用 Postman 或者其它工具进行 Post 方式的表单提交 http://localhost:8080/process?username=Felordcn&password=12345 会返回用户信息:

  1. {
  2. "httpStatus": 200,
  3. "data": {
  4. "userId": 1,
  5. "username": "Felordcn",
  6. "encodePassword": "[PROTECT]",
  7. "age": 18
  8. },
  9. "msg": "登录成功",
  10. "identifier": ""
  11. }

把密码修改为其它值再次请求认证失败后 :

  1. {
  2. "httpStatus": 401,
  3. "data": null,
  4. "msg": "登录失败了,老哥",
  5. "identifier": "-9999"
  6. }

6. 多种登录方式的简单实现

就这么完了了么?现在登录的花样繁多。常规的就有短信、邮箱、扫码 ,第三方是以后我要讲的不在今天范围之内。 如何应对想法多的产品经理? 我们来搞一个可扩展各种姿势的登录方式。我们在上面 2. form 登录的流程 中的 用户判定 之间增加一个适配器来适配即可。 我们知道这个所谓的 判定就是 UsernamePasswordAuthenticationFilter

我们只需要保证 uri 为上面配置的/process 并且能够通过 getParameter(String name) 获取用户名和密码即可

我突然觉得可以模仿 DelegatingPasswordEncoder 的搞法, 维护一个注册表执行不同的处理策略。当然我们要实现一个 GenericFilterBeanUsernamePasswordAuthenticationFilter 之前执行。同时制定登录的策略。

6.1 登录方式定义

定义登录方式枚举 ``。

  1. public enum LoginTypeEnum {
  2. /**
  3. * 原始登录方式.
  4. */
  5. FORM,
  6. /**
  7. * Json 提交.
  8. */
  9. JSON,
  10. /**
  11. * 验证码.
  12. */
  13. CAPTCHA
  14. }
6.2 定义前置处理器接口

定义前置处理器接口用来处理接收的各种特色的登录参数 并处理具体的逻辑。这个借口其实有点随意 ,重要的是你要学会思路。我实现了一个 默认的 form' 表单登录 和 通过RequestBody放入json` 的两种方式,篇幅限制这里就不展示了。具体的 DEMO 参见底部。

  1. public interface LoginPostProcessor {
  2. /**
  3. * 获取 登录类型
  4. *
  5. * @return the type
  6. */
  7. LoginTypeEnum getLoginTypeEnum();
  8. /**
  9. * 获取用户名
  10. *
  11. * @param request the request
  12. * @return the string
  13. */
  14. String obtainUsername(ServletRequest request);
  15. /**
  16. * 获取密码
  17. *
  18. * @param request the request
  19. * @return the string
  20. */
  21. String obtainPassword(ServletRequest request);
  22. }
6.3 实现登录前置处理过滤器

该过滤器维护了 LoginPostProcessor 映射表。 通过前端来判定登录方式进行策略上的预处理,最终还是会交给 UsernamePasswordAuthenticationFilter 。通过 HttpSecurityaddFilterBefore(preLoginFilter, UsernamePasswordAuthenticationFilter.class)方法进行前置。

  1. package cn.felord.spring.security.filter;
  2. import cn.felord.spring.security.enumation.LoginTypeEnum;
  3. import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
  4. import org.springframework.security.web.util.matcher.RequestMatcher;
  5. import org.springframework.util.Assert;
  6. import org.springframework.util.CollectionUtils;
  7. import org.springframework.web.filter.GenericFilterBean;
  8. import javax.servlet.FilterChain;
  9. import javax.servlet.ServletException;
  10. import javax.servlet.ServletRequest;
  11. import javax.servlet.ServletResponse;
  12. import javax.servlet.http.HttpServletRequest;
  13. import java.io.IOException;
  14. import java.util.Collection;
  15. import java.util.HashMap;
  16. import java.util.Map;
  17. import static org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_PASSWORD_KEY;
  18. import static org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.SPRING_SECURITY_FORM_USERNAME_KEY;
  19. /**
  20. * 预登录控制器
  21. *
  22. * @author Felordcn
  23. * @since 16 :21 2019/10/17
  24. */
  25. public class PreLoginFilter extends GenericFilterBean {
  26. private static final String LOGIN_TYPE_KEY = "login_type";
  27. private RequestMatcher requiresAuthenticationRequestMatcher;
  28. private Map<LoginTypeEnum, LoginPostProcessor> processors = new HashMap<>();
  29. public PreLoginFilter(String loginProcessingUrl, Collection<LoginPostProcessor> loginPostProcessors) {
  30. Assert.notNull(loginProcessingUrl, "loginProcessingUrl must not be null");
  31. requiresAuthenticationRequestMatcher = new AntPathRequestMatcher(loginProcessingUrl, "POST");
  32. LoginPostProcessor loginPostProcessor = defaultLoginPostProcessor();
  33. processors.put(loginPostProcessor.getLoginTypeEnum(), loginPostProcessor);
  34. if (!CollectionUtils.isEmpty(loginPostProcessors)) {
  35. loginPostProcessors.forEach(element -> processors.put(element.getLoginTypeEnum(), element));
  36. }
  37. }
  38. private LoginTypeEnum getTypeFromReq(ServletRequest request) {
  39. String parameter = request.getParameter(LOGIN_TYPE_KEY);
  40. int i = Integer.parseInt(parameter);
  41. LoginTypeEnum[] values = LoginTypeEnum.values();
  42. return values[i];
  43. }
  44. /**
  45. * 默认还是Form .
  46. *
  47. * @return the login post processor
  48. */
  49. private LoginPostProcessor defaultLoginPostProcessor() {
  50. return new LoginPostProcessor() {
  51. @Override
  52. public LoginTypeEnum getLoginTypeEnum() {
  53. return LoginTypeEnum.FORM;
  54. }
  55. @Override
  56. public String obtainUsername(ServletRequest request) {
  57. return request.getParameter(SPRING_SECURITY_FORM_USERNAME_KEY);
  58. }
  59. @Override
  60. public String obtainPassword(ServletRequest request) {
  61. return request.getParameter(SPRING_SECURITY_FORM_PASSWORD_KEY);
  62. }
  63. };
  64. }
  65. @Override
  66. public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
  67. ParameterRequestWrapper parameterRequestWrapper = new ParameterRequestWrapper((HttpServletRequest) request);
  68. if (requiresAuthenticationRequestMatcher.matches((HttpServletRequest) request)) {
  69. LoginTypeEnum typeFromReq = getTypeFromReq(request);
  70. LoginPostProcessor loginPostProcessor = processors.get(typeFromReq);
  71. String username = loginPostProcessor.obtainUsername(request);
  72. String password = loginPostProcessor.obtainPassword(request);
  73. parameterRequestWrapper.setAttribute(SPRING_SECURITY_FORM_USERNAME_KEY, username);
  74. parameterRequestWrapper.setAttribute(SPRING_SECURITY_FORM_PASSWORD_KEY, password);
  75. }
  76. chain.doFilter(parameterRequestWrapper, response);
  77. }
  78. }
6.4 验证

通过 POST 表单提交方式 http://localhost:8080/process?username=Felordcn&password=12345&login_type=0 可以请求成功。或者以下列方式也可以提交成功:

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM1MDY3MzIy_size_16_color_FFFFFF_t_70

更多的方式 只需要实现接口 LoginPostProcessor 注入 PreLoginFilter

九 整合JWT做登录认证

JWT是JSON Web Token的缩写,是目前最流行的跨域认证解决方法。

互联网服务认证的一般流程是:

  1. 用户向服务器发送账号、密码
  2. 服务器验证通过后,将用户的角色、登录时间等信息保存到当前会话中
  3. 同时,服务器向用户返回一个session_id(一般保存在cookie里)
  4. 用户再次发送请求时,把含有session_id的cookie发送给服务器
  5. 服务器收到session_id,查找session,提取用户信息

上面的认证模式,存在以下缺点:

  • cookie不允许跨域
  • 因为每台服务器都必须保存session对象,所以扩展性不好

JWT认证原理是:

  1. 用户向服务器发送账号、密码
  2. 服务器验证通过后,生成token令牌返回给客户端(token可以包含用户信息)
  3. 用户再次请求时,把token放到请求头Authorization
  4. 服务器收到请求,验证token合法后放行请求

JWT token令牌可以包含用户身份、登录时间等信息,这样登录状态保持者由服务器端变为客户端,服务器变成无状态了;token放到请求头,实现了跨域

JWT的组成

  1. eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

JWT由三部分组成:

  • Header(头部)
  • Payload(负载)
  • Signature(签名)

表现形式为:Header.Payload.Signature

Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子:

  1. {
  2. "alg": "HS256",
  3. "typ": "JWT"
  4. }

上面代码中,alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT

上面的 JSON 对象使用 Base64URL 算法转成字符串

Payload

Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段:

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

当然,用户也可以定义私有字段。

这个 JSON 对象也要使用 Base64URL 算法转成字符串

Signature

Signature 部分是对前两部分的签名,防止数据篡改

签名算法如下:

  1. HMACSHA256(
  2. base64UrlEncode(header) + "." +
  3. base64UrlEncode(payload),
  4. your-256-bit-secret
  5. )

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用”.”分隔

JWT认证和授权

Security是基于AOP和Servlet过滤器的安全框架,为了实现JWT要重写那些方法、自定义那些过滤器需要首先了解security自带的过滤器。security默认过滤器链如下:

  1. org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
  2. org.springframework.security.web.context.SecurityContextPersistenceFilter
  3. org.springframework.security.web.header.HeaderWriterFilter
  4. org.springframework.security.web.authentication.logout.LogoutFilter
  5. org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
  6. org.springframework.security.web.savedrequest.RequestCacheAwareFilter
  7. org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
  8. org.springframework.security.web.authentication.AnonymousAuthenticationFilter
  9. org.springframework.security.web.session.SessionManagementFilter
  10. org.springframework.security.web.access.ExceptionTranslationFilter
  11. org.springframework.security.web.access.intercept.FilterSecurityInterceptor

SecurityContextPersistenceFilter

这个过滤器有两个作用:

  • 用户发送请求时,从session对象提取用户信息,保存到SecurityContextHolder的securitycontext中
  • 当前请求响应结束时,把SecurityContextHolder的securitycontext保存的用户信息放到session,便于下次请求时共享数据;同时将SecurityContextHolder的securitycontext清空

由于禁用session功能,所以该过滤器只剩一个作用即把SecurityContextHolder的securitycontext清空。举例来说明为何要清空securitycontext:用户1发送一个请求,由线程M处理,当响应完成线程M放回线程池;用户2发送一个请求,本次请求同样由线程M处理,由于securitycontext没有清空,理应储存用户2的信息但此时储存的是用户1的信息,造成用户信息不符

UsernamePasswordAuthenticationFilter

UsernamePasswordAuthenticationFilter继承自AbstractAuthenticationProcessingFilter,处理逻辑在doFilter方法中:

  1. 当请求被UsernamePasswordAuthenticationFilter拦截时,判断请求路径是否匹配登录URL,若不匹配继续执行下个过滤器;否则,执行步骤2
  2. 调用attemptAuthentication方法进行认证。UsernamePasswordAuthenticationFilter重写了attemptAuthentication方法,负责读取表单登录参数,委托AuthenticationManager进行认证,返回一个认证过的token(null表示认证失败)
  3. 判断token是否为null,非null表示认证成功,null表示认证失败
  4. 若认证成功,调用successfulAuthentication。该方法把认证过的token放入securitycontext供后续请求授权,同时该方法预留一个扩展点(AuthenticationSuccessHandler.onAuthenticationSuccess方法),进行认证成功后的处理
  5. 若认证失败,同样可以扩展uthenticationFailureHandler.onAuthenticationFailure进行认证失败后的处理
  6. 只要当前请求路径匹配登录URL,那么无论认证成功还是失败,当前请求都会响应完成,不再执行过滤器链

UsernamePasswordAuthenticationFilterattemptAuthentication方法,执行逻辑如下:

  1. 从请求中获取表单参数。因为使用HttpServletRequest.getParameter方法获取参数,它只能处理Content-Type为application/x-www-form-urlencoded或multipart/form-data的请求,若是application/json则无法获取值
  2. 把步骤1获取的账号、密码封装成UsernamePasswordAuthenticationToken对象,创建未认证的token。UsernamePasswordAuthenticationToken有两个重载的构造方法,其中public UsernamePasswordAuthenticationToken(Object principal, Object credentials)创建未经认证的token,public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities)创建已认证的token
  3. 获取认证管理器AuthenticationManager,其缺省实现为ProviderManager,调用其authenticate进行认证
  4. ProviderManagerauthenticate是个模板方法,它遍历所有AuthenticationProvider,直至找到支持认证某类型token的AuthenticationProvider,调用AuthenticationProvider.authenticate方法认证,AuthenticationProvider.authenticate加载正确的账号、密码进行比较验证
  5. AuthenticationManager.authenticate方法返回一个已认证的token

AnonymousAuthenticationFilter

AnonymousAuthenticationFilter负责创建匿名token:

  1. public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
  2. if (SecurityContextHolder.getContext().getAuthentication() == null) {
  3. SecurityContextHolder.getContext().setAuthentication(this.createAuthentication((HttpServletRequest)req));
  4. if (this.logger.isTraceEnabled()) {
  5. this.logger.trace(LogMessage.of(() -> {
  6. return "Set SecurityContextHolder to " + SecurityContextHolder.getContext().getAuthentication();
  7. }));
  8. } else {
  9. this.logger.debug("Set SecurityContextHolder to anonymous SecurityContext");
  10. }
  11. } else if (this.logger.isTraceEnabled()) {
  12. this.logger.trace(LogMessage.of(() -> {
  13. return "Did not set SecurityContextHolder since already authenticated " + SecurityContextHolder.getContext().getAuthentication();
  14. }));
  15. }
  16. chain.doFilter(req, res);
  17. }

如果当前用户没有认证,会创建一个匿名token,用户是否能读取资源交由FilterSecurityInterceptor过滤器委托给决策管理器判断是否有权限读取

实现思路

JWT认证思路:

  1. 利用Security原生的表单认证过滤器验证用户名、密码
  2. 验证通过后自定义AuthenticationSuccessHandler认证成功处理器,由该处理器生成token令牌

JWT授权思路:

  1. 使用JWT目的是让服务器变成无状态,不用session共享数据,所以要禁用security的session功能(http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS))
  2. token令牌数据结构设计时,payload部分要储存用户名、角色信息
  3. token令牌有两个作用:

    1. 认证, 用户发送的token合法即代表认证成功
    2. 授权,令牌验证成功后提取角色信息,构造认证过的token,将其放到securitycontext,具体权限判断交给security框架处理
  4. 自己实现一个过滤器,拦截用户请求,实现(3)中所说的功能

代码实现 创建JWT工具类

  1. <dependency>
  2. <groupId>com.auth0</groupId>
  3. <artifactId>java-jwt</artifactId>
  4. <version>3.12.0</version>
  5. </dependency>

我们对java-jwt提供的API进行封装,便于创建、验证、提取claim

  1. @Slf4j
  2. public class JWTUtil {
  3. // 携带token的请求头名字
  4. public final static String TOKEN_HEADER = "Authorization";
  5. //token的前缀
  6. public final static String TOKEN_PREFIX = "Bearer ";
  7. // 默认密钥
  8. public final static String DEFAULT_SECRET = "mySecret";
  9. // 用户身份
  10. private final static String ROLES_CLAIM = "roles";
  11. // token有效期,单位分钟;
  12. private final static long EXPIRE_TIME = 5 * 60 * 1000;
  13. // 设置Remember-me功能后的token有效期
  14. private final static long EXPIRE_TIME_REMEMBER = 7 * 24 * 60 * 60 * 1000;
  15. // 创建token
  16. public static String createToken(String username, List role, String secret, boolean rememberMe) {
  17. Date expireDate = rememberMe ? new Date(System.currentTimeMillis() + EXPIRE_TIME_REMEMBER) : new Date(System.currentTimeMillis() + EXPIRE_TIME);
  18. try {
  19. // 创建签名的算法实例
  20. Algorithm algorithm = Algorithm.HMAC256(secret);
  21. String token = JWT.create()
  22. .withExpiresAt(expireDate)
  23. .withClaim("username", username)
  24. .withClaim(ROLES_CLAIM, role)
  25. .sign(algorithm);
  26. return token;
  27. } catch (JWTCreationException jwtCreationException) {
  28. log.warn("Token create failed");
  29. return null;
  30. }
  31. }
  32. // 验证token
  33. public static boolean verifyToken(String token, String secret) {
  34. try{
  35. Algorithm algorithm = Algorithm.HMAC256(secret);
  36. // 构建JWT验证器,token合法同时pyload必须含有私有字段username且值一致
  37. // token过期也会验证失败
  38. JWTVerifier verifier = JWT.require(algorithm)
  39. .build();
  40. // 验证token
  41. DecodedJWT decodedJWT = verifier.verify(token);
  42. return true;
  43. } catch (JWTVerificationException jwtVerificationException) {
  44. log.warn("token验证失败");
  45. return false;
  46. }
  47. }
  48. // 获取username
  49. public static String getUsername(String token) {
  50. try {
  51. // 因此获取载荷信息不需要密钥
  52. DecodedJWT jwt = JWT.decode(token);
  53. return jwt.getClaim("username").asString();
  54. } catch (JWTDecodeException jwtDecodeException) {
  55. log.warn("提取用户姓名时,token解码失败");
  56. return null;
  57. }
  58. }
  59. public static List<String> getRole(String token) {
  60. try {
  61. // 因此获取载荷信息不需要密钥
  62. DecodedJWT jwt = JWT.decode(token);
  63. // asList方法需要指定容器元素的类型
  64. return jwt.getClaim(ROLES_CLAIM).asList(String.class);
  65. } catch (JWTDecodeException jwtDecodeException) {
  66. log.warn("提取身份时,token解码失败");
  67. return null;
  68. }
  69. }
  70. }

代码实现认证

验证账号、密码交给UsernamePasswordAuthenticationFilter,不用修改代码

认证成功后,需要生成token返回给客户端,我们通过扩展AuthenticationSuccessHandler.onAuthenticationSuccess方法实现

  1. @Component
  2. public class JWTAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
  3. @Override
  4. public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
  5. ResponseData responseData = new ResponseData();
  6. responseData.setCode("200");
  7. responseData.setMessage("登录成功!");
  8. // 提取用户名,准备写入token
  9. String username = authentication.getName();
  10. // 提取角色,转为List<String>对象,写入token
  11. List<String> roles = new ArrayList<>();
  12. Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
  13. for (GrantedAuthority authority : authorities){
  14. roles.add(authority.getAuthority());
  15. }
  16. // 创建token
  17. String token = JWTUtil.createToken(username, roles, JWTUtil.DEFAULT_SECRET, true);
  18. httpServletResponse.setCharacterEncoding("utf-8");
  19. // 为了跨域,把token放到响应头WWW-Authenticate里
  20. httpServletResponse.setHeader("WWW-Authenticate", JWTUtil.TOKEN_PREFIX + token);
  21. // 写入响应里
  22. ObjectMapper mapper = new ObjectMapper();
  23. mapper.writeValue(httpServletResponse.getWriter(), responseData);
  24. }
  25. }

为了统一返回值,我们封装了一个ResponseData对象

代码实现 授权

自定义一个过滤器JWTAuthorizationFilter,验证token,token验证成功后认为认证成功

  1. @Slf4j
  2. public class JWTAuthorizationFilter extends OncePerRequestFilter {
  3. @Override
  4. protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
  5. String token = getTokenFromRequestHeader(request);
  6. Authentication verifyResult = verefyToken(token, JWTUtil.DEFAULT_SECRET);
  7. if (verifyResult == null) {
  8. // 即便验证失败,也继续调用过滤链,匿名过滤器生成匿名令牌
  9. chain.doFilter(request, response);
  10. return;
  11. } else {
  12. log.info("token令牌验证成功");
  13. SecurityContextHolder.getContext().setAuthentication(verifyResult);
  14. chain.doFilter(request, response);
  15. }
  16. }
  17. // 从请求头获取token
  18. private String getTokenFromRequestHeader(HttpServletRequest request) {
  19. String header = request.getHeader(JWTUtil.TOKEN_HEADER);
  20. if (header == null || !header.startsWith(JWTUtil.TOKEN_PREFIX)) {
  21. log.info("请求头不含JWT token, 调用下个过滤器");
  22. return null;
  23. }
  24. String token = header.split(" ")[1].trim();
  25. return token;
  26. }
  27. // 验证token,并生成认证后的token
  28. private UsernamePasswordAuthenticationToken verefyToken(String token, String secret) {
  29. if (token == null) {
  30. return null;
  31. }
  32. // 认证失败,返回null
  33. if (!JWTUtil.verifyToken(token, secret)) {
  34. return null;
  35. }
  36. // 提取用户名
  37. String username = JWTUtil.getUsername(token);
  38. // 定义权限列表
  39. List<GrantedAuthority> authorities = new ArrayList<>();
  40. // 从token提取角色
  41. List<String> roles = JWTUtil.getRole(token);
  42. for (String role : roles) {
  43. log.info("用户身份是:" + role);
  44. authorities.add(new SimpleGrantedAuthority(role));
  45. }
  46. // 构建认证过的token
  47. return new UsernamePasswordAuthenticationToken(username, null, authorities);
  48. }
  49. }
  50. OncePerRequestFilter`保证当前请求中,此过滤器只被调用一次,执行逻辑在`doFilterInternal

代码实现 security配置

  1. @Configuration
  2. @EnableWebSecurity
  3. public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
  4. @Autowired
  5. private AjaxAuthenticationEntryPoint ajaxAuthenticationEntryPoint;
  6. @Autowired
  7. private JWTAuthenticationSuccessHandler jwtAuthenticationSuccessHandler;
  8. @Autowired
  9. private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler;
  10. @Bean
  11. public PasswordEncoder passwordEncoder() {
  12. return new BCryptPasswordEncoder();
  13. }
  14. protected void configure(HttpSecurity http) throws Exception {
  15. http.csrf().disable()
  16. .authorizeRequests().anyRequest().authenticated()
  17. .and()
  18. .formLogin()
  19. .successHandler(jwtAuthenticationSuccessHandler)
  20. .failureHandler(ajaxAuthenticationFailureHandler)
  21. .permitAll()
  22. .and()
  23. .addFilterAfter(new JWTAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
  24. .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
  25. .and()
  26. .exceptionHandling().authenticationEntryPoint(ajaxAuthenticationEntryPoint);
  27. }
  28. }

配置里取消了session功能,把我们定义的过滤器添加到过滤链中;同时,定义ajaxAuthenticationEntryPoint处理未认证用户访问未授权资源时抛出的异常

  1. @Component
  2. public class AjaxAuthenticationEntryPoint implements AuthenticationEntryPoint {
  3. @Override
  4. public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
  5. ResponseData responseData = new ResponseData();
  6. responseData.setCode("401");
  7. responseData.setMessage("匿名用户,请先登录再访问!");
  8. httpServletResponse.setCharacterEncoding("utf-8");
  9. ObjectMapper mapper = new ObjectMapper();
  10. mapper.writeValue(httpServletResponse.getWriter(), responseData);
  11. }
  12. }

过滤器链(filter chain)的介绍

上一节中,主要讲了Spring Security认证和授权的核心组件及核心方法。但是,什么时候调用这些方法呢?答案就是Filter和AOP。Spring Security在我们进行用户认证以及授予权限的时候,通过各种各样的拦截器来控制权限的访问。
对于基于HttpRequest的方式对端点进行保护,我们使用一个Filter Chain来保护;对于基于方法调用进行保护,我们使用AOP来保护。本篇重点讲Spring Security中过滤器链的种类及过滤器中如何实现的认证和授权。

Spring Security会默认为我们添加15个过滤器,我们可以从WebSecurity(WebSecurity是Spring Security加载的一个重要对象,将在下节具体讲述)的performBuild()方法中看到过滤器链SecurityFilterChain的构建过程,并交由FilterChainProxy对象代理。我们从SecurityFilterChain的默认实现类DefaultSecurityFilterChain中的log看出,Spring Security由以下过滤器组成了过滤器链:

  1. Creating filter chain: any request, [
  2. org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@7f353a0f,
  3. org.springframework.security.web.context.SecurityContextPersistenceFilter@4735d6e5,
  4. org.springframework.security.web.header.HeaderWriterFilter@314a31b0,
  5. org.springframework.security.web.csrf.CsrfFilter@4ef2ab73,
  6. org.springframework.security.web.authentication.logout.LogoutFilter@57efc6fd,
  7. org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@d88f893,
  8. org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@2cd388f5,
  9. org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@7ea2412c,
  10. org.springframework.security.web.authentication.www.BasicAuthenticationFilter@2091833,
  11. org.springframework.security.web.savedrequest.RequestCacheAwareFilter@4dad0eed,
  12. org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@16132f21,
  13. org.springframework.security.web.authentication.AnonymousAuthenticationFilter@1c93b51e,
  14. org.springframework.security.web.session.SessionManagementFilter@59edb4f5,
  15. org.springframework.security.web.access.ExceptionTranslationFilter@104dc1a2,
  16. org.springframework.security.web.access.intercept.FilterSecurityInterceptor@1de0641b
  17. ]

下面就是各个过滤器的功能,

其中SecurityContextPersistenceFilter,UsernamePasswordAuthenticationFilter及FilterSecurityInterceptor分别对应了SecurityContext,AuthenticationManager,AccessDecisionManager的处理。

[WebAsyncManagerIntegrationFilter] (异步方式)提供了对securityContext和WebAsyncManager的集成。

方式是通过SecurityContextCallableProcessingInterceptor的beforeConcurrentHandling(NativeWebRequest, Callable)方法来将SecurityContext设置到Callable上。

其实就是把SecurityContext设置到异步线程中,使其也能获取到用户上下文认证信息。

[SecurityContextPersistenceFilter] (同步方式)在请求之前从SecurityContextRepository(默认实现是HttpSessionSecurityContextRepository)获取信息并填充SecurityContextHolder(如果没有,则创建一个新的ThreadLocal的SecurityContext),并在请求完成并清空SecurityContextHolder并更新SecurityContextRepository。

在Spring Security中,虽然安全上下文信息被存储于Session中,但实际的Filter中不应直接操作Session(过滤器一般负责核心的处理流程,而具体的业务实现,通常交给其中聚合的其他实体类),而是用如HttpSessionSecurityContextRepository中loadContext(),saveContext()来存取session。

[HeaderWriterFilter] 用来给http响应添加一些Header,比如X-Frame-Options,X-XSS-Protection*,X-Content-Type-Options。

[CsrfFilter] 默认开启,用于防止csrf攻击的过滤器

[LogoutFilter] 处理注销的过滤器

[UsernamePasswordAuthenticationFilter] 表单提交了username和password,被封装成UsernamePasswordAuthenticationToken对象进行一系列的认证,便是主要通过这个过滤器完成的,即调用AuthenticationManager.authenticate()。在表单认证的方法中,这是最最关键的过滤器。具体过程是:

(1)调用AbstractAuthenticationProcessingFilter.doFilter()方法执行过滤器

(2)调用UsernamePasswordAuthenticationFilter.attemptAuthentication()方法

(3)调用AuthenticationManager.authenticate()方法(实际上委托给AuthenticationProvider的实现类来处理)

[DefaultLoginPageGeneratingFilter] & [DefaultLogoutPageGeneratingFilter] 如果没有配置/login及login page, 系统则会自动配置这两个Filter。

[BasicAuthenticationFilter] Processes a HTTP request’s BASIC authorization headers, putting the result into the SecurityContextHolder.

[RequestCacheAwareFilter] 内部维护了一个RequestCache,用于缓存request请求

[SecurityContextHolderAwareRequestFilter] 此过滤器对ServletRequest进行了一次包装,使得request具有更加丰富的API(populates the ServletRequest with a request wrapper which implements servlet API security methods)

[AnonymousAuthenticationFilter] 匿名身份过滤器,spring security为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。它位于身份认证过滤器(e.g. UsernamePasswordAuthenticationFilter)之后,意味着只有在上述身份过滤器执行完毕后,SecurityContext依旧没有用户信息,AnonymousAuthenticationFilter该过滤器才会有意义。

[SessionManagementFilter] 和session相关的过滤器,内部维护了一个SessionAuthenticationStrategy来执行任何与session相关的活动,比如session-fixation protection mechanisms or checking for multiple concurrent logins。

[ExceptionTranslationFilter] 异常转换过滤器,这个过滤器本身不处理异常,而是将认证过程中出现的异常(AccessDeniedException and AuthenticationException)交给内部维护的一些类去处理。它
位于整个springSecurityFilterChain的后方,用来转换整个链路中出现的异常,将其转化,顾名思义,转化以意味本身并不处理。一般其只处理两大类异常:AccessDeniedException访问异常和AuthenticationException认证异常。

它将Java中的异常和HTTP的响应连接在了一起,这样在处理异常时,我们不用考虑密码错误该跳到什么页面,账号锁定该如何,只需要关注自己的业务逻辑,抛出相应的异常便可。如果该过滤器检测到AuthenticationException,则将会交给内部的AuthenticationEntryPoint去处理,如果检测到AccessDeniedException,需要先判断当前用户是不是匿名用户,如果是匿名访问,则和前面一样运行AuthenticationEntryPoint,否则会委托给AccessDeniedHandler去处理,而AccessDeniedHandler的默认实现,是AccessDeniedHandlerImpl。

[FilterSecurityInterceptor] 这个过滤器决定了访问特定路径应该具备的权限,这些受限的资源访需要什么权限或角色,这些判断和处理都是由该类进行的。

(1)调用FilterSecurityInterceptor.invoke()方法执行过滤器

(2)调用AbstractSecurityInterceptor.beforeInvocation()方法

(3)调用AccessDecisionManager.decide()方法决策判断是否有该权限

参考

JSON Web Token 入门教程
Spring Security-5-认证流程梳理
Spring Security3源码分析(5)-SecurityContextPersistenceFilter分析
Spring Security addFilter() 顺序问题
前后端联调之Form Data与Request Payload,你真的了解吗?
Spring Boot 2 + Spring Security 5 + JWT 的单页应用 Restful 解决方案
SpringBoot实战派-第十章源码
https://www.cnblogs.com/cjsblog/p/9184173.html
https://www.cnblogs.com/storml/p/10937486.html

技术自由的实现路径 PDF 获取:

实现你的 架构自由:

《吃透8图1模板,人人可以做架构》

《10Wqps评论中台,如何架构?B站是这么做的!!!》

《阿里二面:千万级、亿级数据,如何性能优化? 教科书级 答案来了》

《峰值21WQps、亿级DAU,小游戏《羊了个羊》是怎么架构的?》

《100亿级订单怎么调度,来一个大厂的极品方案》

《2个大厂 100亿级 超大流量 红包 架构方案》

… 更多架构文章,正在添加中

实现你的 响应式 自由:

《响应式圣经:10W字,实现Spring响应式编程自由》

这是老版本 《Flux、Mono、Reactor 实战(史上最全)》

实现你的 spring cloud 自由:

《Spring cloud Alibaba 学习圣经》 PDF

《分库分表 Sharding-JDBC 底层原理、核心实战(史上最全)》

《一文搞定:SpringBoot、SLF4j、Log4j、Logback、Netty之间混乱关系(史上最全)》

实现你的 linux 自由:

《Linux命令大全:2W多字,一次实现Linux自由》

实现你的 网络 自由:

《TCP协议详解 (史上最全)》

《网络三张表:ARP表, MAC表, 路由表,实现你的网络自由!!》

实现你的 分布式锁 自由:

《Redis分布式锁(图解 - 秒懂 - 史上最全)》

《Zookeeper 分布式锁 - 图解 - 秒懂》

实现你的 王者组件 自由:

《队列之王: Disruptor 原理、架构、源码 一文穿透》

《缓存之王:Caffeine 源码、架构、原理(史上最全,10W字 超级长文)》

《缓存之王:Caffeine 的使用(史上最全)》

《Java Agent 探针、字节码增强 ByteBuddy(史上最全)》

实现你的 面试题 自由:

4000页《尼恩Java面试宝典 》 40个专题

以上尼恩 架构笔记、面试题 的PDF文件更新,请到《技术自由圈》公号获取↓↓↓

发表评论

表情:
评论列表 (有 0 条评论,210人围观)

还没有评论,来说两句吧...

相关阅读

    相关 ByteBuddy(

    ByteBuddy(史上最全) 文章很长,建议收藏起来慢慢读! [总目录 博客园版][Link 1] 为大家准备了更多的好文章!!!! 推荐:尼恩Java面试宝典(持

    相关 正则

    [一个正则表达式测试(只可输入中文、字母和数字)][Link 1] 在项目中碰到了正则表达式的运用,正则还是非常强大的,不管什么编程语言,基本上都可以用到。之前在用jav