Spring Security:深入身份验证和授权
本指南将帮助您了解 Spring Security 是什么,以及它的核心功能(例如身份验证、授权和常见的漏洞防护)是如何运作的。此外,还提供了一份详尽的常见问题解答。
(编者注:本文约 6500 字,您可能不想在移动设备上阅读。请将其加入书签并稍后再回来阅读。)
介绍
每个人迟早都需要为自己的项目添加安全性,而在 Spring 生态系统中,您可以借助Spring Security库来实现这一点。
因此,您将 Spring Security 添加到您的 Spring Boot(或普通Spring)项目中,然后突然……
-
...您有自动生成的登录页面。
-
…您无法再执行 POST 请求。
-
...您的整个应用程序处于锁定状态并提示您输入用户名和密码。
在经历了随后的精神崩溃之后,您可能会对这一切是如何运作的感兴趣。
什么是 Spring Security 以及它如何工作?
简短的回答:
从本质上讲,Spring Security 实际上只是一堆 servlet 过滤器,可帮助您向 Web 应用程序添加身份验证和授权。
它还能与 Spring Web MVC(或Spring Boot)等框架以及 OAuth2 或 SAML 等标准良好集成。它还能自动生成登录/注销页面,并防御 CSRF 等常见漏洞。
那么,这真的没有什么帮助,不是吗?
幸运的是,还有一个很长的答案:
本文的其余部分。
Web 应用程序安全:101
在成为 Spring Security 专家之前,您需要了解三个重要概念:
-
验证
-
授权
-
Servlet 过滤器
家长建议:请不要跳过本节,因为它是 Spring Security所有功能的基础。此外,我会尽可能地让它变得有趣。
1. 身份验证
首先,如果您正在运行一个典型的(Web)应用程序,则需要对用户进行身份验证。这意味着您的应用程序需要验证用户的身份是否与其声称的身份相符,通常通过用户名和密码检查来完成。
用户:“我是美国总统。我的username
头衔是:potus!”
您的网络应用程序:“当然可以,password
那么,总统先生,您的要求是什么?”
用户:“我的密码是:th3don4ld”。
您的网络应用程序:“正确。欢迎光临,先生!”
2.授权
在更简单的应用程序中,身份验证可能就足够了:用户通过身份验证后,就可以访问应用程序的每个部分。
但大多数应用程序都有权限(或角色)的概念。想象一下:客户可以访问你网店面向公众的前端,而管理员可以访问单独的管理区域。
两种类型的用户都需要登录,但仅仅进行身份验证并不能说明他们在系统中可以执行哪些操作。因此,您还需要检查已通过身份验证的用户的权限,即您需要授权该用户。
用户:“让我玩一下那个核足球……”
您的网络应用程序:“请稍等,我需要permissions
先检查一下……是的,总统先生,您的权限级别正确。尽情享受吧。”
用户:“那个红色按钮是什么……??”
3. Servlet 过滤器
最后,同样重要的是,我们来看看 Servlet 过滤器。它们与身份验证和授权有什么关系?(如果你对 Java Servlet 或过滤器完全陌生,我建议你读一读《Head First Servlets》这本虽然老书了,但仍然非常值得一读。)
为什么要使用 Servlet 过滤器?
回想一下我的另一篇文章,我们发现基本上任何 Spring Web 应用程序都只是一个 servlet:Spring 的旧DispatcherServlet,它将传入的 HTTP 请求(例如来自浏览器的请求)重定向到您的 @Controllers 或 @RestControllers。
问题是:DispatcherServlet 中没有硬编码的安全机制,而且你很可能也不想在 @Controllers 中费劲地处理原始的 HTTP Basic Auth 头。理想情况下,身份验证和授权应该在请求到达 @Controllers之前完成。
幸运的是,在 Java Web 世界中,有一种方法可以做到这一点:您可以将过滤器 放在 servlet 前面,这意味着您可以考虑编写一个 SecurityFilter 并在 Tomcat(servlet 容器/应用程序服务器)中对其进行配置,以便在每个传入的 HTTP 请求到达您的 servlet 之前对其进行过滤。
+-------------------------------+ +-----------------------------------+
| Browser | | SecurityFilter (Tomcat) |
|-------------------------------| |-----------------------------------|
| | | |
| https://my.bank/account | -------> | Check if user is authenticated/ |
| | | 1. authenticated |
| | | 2. authorized |
| | | |
| | | -- if false: HTTP 401/403 | ---------> +-----------------------------------+
| | | -- if true: continue to servlet | | DispatcherServlet (Tomcat) |
| | | | | @RestController/@Controller |
| | +-----------------------------------+ +-----------------------------------+
+-------------------------------+
一个简单的 SecurityFilter
SecurityFilter 大约有 4 个任务,一个简单的、过于简单的实现可能如下所示:
import javax.servlet.*;
import javax.servlet.http.HttpFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class SecurityServletFilter extends HttpFilter {
@Override
protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
UsernamePasswordToken token = extractUsernameAndPasswordFrom(request);
if (notAuthenticated(token)) {
// either no or wrong username/password
// unfortunately the HTTP status code is called "unauthorized", instead of "unauthenticated"
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // HTTP 401.
return;
}
if (notAuthorized(token, request)) {
// you are logged in, but don't have the proper rights
response.setStatus(HttpServletResponse.SC_FORBIDDEN); // HTTP 403
return;
}
// allow the HttpRequest to go to Spring's DispatcherServlet
// and @RestControllers/@Controllers.
chain.doFilter(request, response);
}
private UsernamePasswordToken extractUsernameAndPasswordFrom(HttpServletRequest request) {
// Either try and read in a Basic Auth HTTP Header, which comes in the form of user:password
// Or try and find form login request parameters or POST bodies, i.e. "username=me" & "password="myPass"
return checkVariousLoginOptions(request);
}
private boolean notAuthenticated(UsernamePasswordToken token) {
// compare the token with what you have in your database...or in-memory...or in LDAP...
return false;
}
private boolean notAuthorized(UsernamePasswordToken token, HttpServletRequest request) {
// check if currently authenticated user has the permission/role to access this request's /URI
// e.g. /admin needs a ROLE_ADMIN , /callcenter needs ROLE_CALLCENTER, etc.
return false;
}
}
-
首先,过滤器需要从请求中提取用户名/密码。提取方式可以是Basic Auth HTTP Header、表单字段、Cookie 等。
-
然后,过滤器需要根据某些东西(例如数据库)来验证用户名/密码组合。
-
成功验证后,过滤器需要检查用户是否有权访问所请求的 URI。
-
如果请求通过了所有这些检查,那么过滤器可以让请求通过到您的 DispatcherServlet,即您的 @Controllers。
过滤链
现实检查:虽然上述代码可以编译,但它迟早会导致一个庞大的过滤器,其中包含大量用于各种身份验证和授权机制的代码。
然而,在现实世界中,您会将这个过滤器拆分成多个过滤器,然后将它们链接在一起。
例如,传入的 HTTP 请求将……
-
首先,通过 LoginMethodFilter...
-
然后,通过 AuthenticationFilter...
-
然后,通过 AuthorizationFilter...
-
最后,点击您的 servlet。
这个概念称为FilterChain,上面过滤器中的最后一个方法调用实际上就是委托给该链:
chain.doFilter(request, response);
通过这样的过滤器(链),您基本上可以处理应用程序中的每个身份验证或授权问题,而无需更改实际的应用程序实现(想想:您的@RestControllers / @Controllers)。
有了这些知识,让我们来看看 Spring Security 如何利用这个过滤器魔法。
FilterChain 和安全配置 DSL
我们将以与上一章相反的方向开始介绍 Spring Security,从 Spring Security 的 FilterChain 开始。
Spring 的 DefaultSecurityFilterChain
假设您正确设置了 Spring Security,然后启动了 Web 应用程序。您将看到以下日志消息:
2020-02-25 10:24:27.875 INFO 11116 --- [ main] o.s.s.web.DefaultSecurityFilterChain : Creating filter chain: any request, [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@46320c9a, org.springframework.security.web.context.SecurityContextPersistenceFilter@4d98e41b, org.springframework.security.web.header.HeaderWriterFilter@52bd9a27, org.springframework.security.web.csrf.CsrfFilter@51c65a43, org.springframework.security.web.authentication.logout.LogoutFilter@124d26ba, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@61e86192, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@10980560, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@32256e68, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@52d0f583, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@5696c927, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@5f025000, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@5e7abaf7, org.springframework.security.web.session.SessionManagementFilter@681c0ae6, org.springframework.security.web.access.ExceptionTranslationFilter@15639d09, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@4f7be6c8]|
如果将该行扩展为列表,看起来 Spring Security 不仅仅安装了一个过滤器,而是安装了由 15 个(!)不同的过滤器组成的整个过滤器链。
因此,当一个 HTTP 请求到达时,它会经过这15 个过滤器,最终到达 @RestControllers。过滤器的顺序也很重要,从列表顶部开始,依次向下。
+----------------------------------+ +----------------------------------------+ +---------------------------------------+
| Browser HTTP Request |---------> | SecurityContextPersistenceFilter | -------> | HeaderWriterFilter | ----->
+----------------------------------+ +----------------------------------------+ +---------------------------------------+
+----------------------------------+ +----------------------------------------+ +---------------------------------------+
| CsrfFilter |---------> | LogoutFilter | -------> | UsernamePasswordAuthenticationFilter | ----->
+----------------------------------+ +----------------------------------------+ +---------------------------------------+
+----------------------------------+ +----------------------------------------+ +--------------------------------------+
| DefaultLoginPageGeneratingFilter |---------> | DefaultLogoutPageGeneratingFilter | -------> | BasicAuthenticationFilter | ----->
+----------------------------------+ +----------------------------------------+ +--------------------------------------+
+----------------------------------+ +----------------------------------------+ +--------------------------------------+
| RequestCacheAwareFilter |---------> | SecurityContextHolderAwareRequestFilter| -------> | AnonymousAuthenticationFilter | ----->
+----------------------------------+ +----------------------------------------+ +--------------------------------------+
+----------------------------------+ +----------------------------------------+ +--------------------------------------+
| SessionManagementFilter |---------> | ExceptionTranslationFilter | -------> | FilterSecurityInterceptor | ----->
+----------------------------------+ +----------------------------------------+ +--------------------------------------+
+----------------------------------+
| your @RestController/@Controller |
+----------------------------------+
分析 Spring 的 FilterChain
详细了解此链中的每个过滤器可能有些费解,但这里只对其中一些过滤器进行了解释。您可以自行查看Spring Security 的源代码来了解其他过滤器。
-
BasicAuthenticationFilter:尝试在请求中查找基本身份验证 HTTP 标头,如果找到,则尝试使用标头的用户名和密码对用户进行身份验证。
-
UsernamePasswordAuthenticationFilter:尝试查找用户名/密码请求参数/POST 正文,如果找到,则尝试使用这些值对用户进行身份验证。
-
DefaultLoginPageGeneratingFilter:如果您未明确禁用该功能,则会为您生成登录页面。此过滤器就是您在启用 Spring Security 时获得默认登录页面的原因。
-
DefaultLogoutPageGeneratingFilter:如果您未明确禁用该功能,则为您生成注销页面。
-
FilterSecurityInterceptor:是否授权。
因此,通过这几个过滤器,Spring Security 为您提供了一个登录/注销页面,以及使用基本身份验证或表单登录的功能,以及一些额外的好东西,如 CsrfFilter,我们稍后会看一下。
中场休息:这些过滤器很大程度上就是Spring Security。不多不少,它们完成了所有工作。剩下的就是配置它们的工作方式,例如,哪些 URL 需要保护,哪些 URL 需要忽略,以及使用哪些数据库表进行身份验证。
因此,接下来我们需要了解如何配置 Spring Security。
如何配置 Spring Security:WebSecurityConfigurerAdapter
使用最新的 Spring Security 和/或 Spring Boot 版本,配置 Spring Security 的方法是通过一个类:
-
用@EnableWebSecurity注释。
-
扩展了 WebSecurityConfigurer,它主要提供配置 DSL/方法。通过这些方法,您可以指定应用程序中需要保护的 URI,或启用/禁用哪些漏洞利用保护。
典型的 WebSecurityConfigurerAdapter 如下所示:
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/home").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll()
.and()
.httpBasic();
}
}
-
带有 @EnableWebSecurity 注释的普通 Spring @Configuration,从 WebSecurityConfigurerAdapter 扩展而来。
-
通过覆盖适配器的 configure(HttpSecurity) 方法,您可以获得一个很好的小型 DSL,您可以使用它来配置您的 FilterChain。
-
所有发往
/
和的请求/home
均被允许(准许)——用户无需进行身份验证。您正在使用antMatcher,这意味着您也可以在字符串中使用通配符(*、\*\*、?)。 -
任何其他请求都需要首先对用户进行身份验证,即用户需要登录。
-
您允许使用自定义的 loginPage ( ,即非 Spring Security 自动生成的 loginPage ) 进行表单登录 (表单中输入用户名/密码)
/login
。任何人都应该能够访问登录页面,而无需先登录 (permitAll;否则我们将陷入困境!)。 -
注销页面也是如此
-
除此之外,您还允许基本身份验证,即发送 HTTP 基本身份验证标头进行身份验证。
如何使用 Spring Security 的配置 DSL
需要一些时间来适应该 DSL,但您会在常见问题解答部分找到更多示例:AntMatchers:常见示例。
现在重要的是,此 configure
方法是您指定的地方:
-
需要保护哪些 URL(authenticated())以及哪些 URL 是允许的(permitAll())。
-
允许哪些身份验证方法(formLogin()、httpBasic())以及如何配置它们。
-
简而言之:您的应用程序的完整安全配置。
注意:你不需要立即重写适配器的 configure 方法,因为它默认自带了一个相当合理的实现。如下所示:
public abstract class WebSecurityConfigurerAdapter implements
WebSecurityConfigurer<WebSecurity> {
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin().and()
.httpBasic();
}
}
-
要访问应用程序上的任何URI( ),您需要进行身份验证(authenticated())。
anyRequest()
-
formLogin()
启用默认设置的表单登录( )。 -
与 HTTP 基本身份验证一样(
httpBasic()
)。
正是由于这个默认配置,你的应用程序在添加 Spring Security 后就被锁定了。是不是很简单?
摘要:WebSecurityConfigurerAdapter 的 DSL 配置
我们了解到 Spring Security 由几个过滤器组成,您可以使用 WebSecurityConfigurerAdapter @Configuration 类来配置它们。
但还缺少一个关键部分。以 Spring 的 BasicAuthFilter 为例。它可以从 HTTP Basic Auth 标头中提取用户名/密码,但它用什么来验证这些凭据呢?
这自然会引出一个问题:身份验证如何与 Spring Security 协同工作。
使用 Spring Security 进行身份验证
当谈到身份验证和 Spring Security 时,大致有三种情况:
-
默认值:您可以访问用户的(加密)密码,因为您已将其详细信息(用户名、密码)保存在数据库表中。
-
不太常见:您无法访问用户的(加密)密码。如果您的用户和密码存储在其他地方,例如提供 REST 身份验证服务的第三方身份管理产品中,就会出现这种情况。例如:Atlassian Crowd。
-
热门推荐:如果您想使用 OAuth2 或“使用 Google/Twitter 等登录”(OpenID),并可能与 JWT 结合使用。那么以下情况均不适用,您应该直接跳到OAuth2 章节。
注意:根据具体情况,你需要指定不同的@beans才能使 Spring Security 正常工作,否则最终会遇到一些令人困惑的异常(例如,如果忘记指定 PasswordEncoder,就会出现 NullPointerException)。请务必牢记这一点。
让我们看一下最主要的两种情况。
1. UserDetailsService:获取用户密码
假设你有一个数据库表,用于存储用户信息。它包含几列,但最重要的是用户名和密码列,用于存储用户的加密密码。
create table users (id int auto_increment primary key, username varchar(255), password varchar(255));
在这种情况下,Spring Security 需要您定义两个 bean 来启动和运行身份验证。
-
UserDetailsService。
-
密码编码器。
指定 UserDetailsService 非常简单:
@Bean
public UserDetailsService userDetailsService() {
return new MyDatabaseUserDetailsService();
}
- MyDatabaseUserDetailsService 实现了 UserDetailsService,这是一个非常简单的接口,它由一个返回 UserDetails 对象的方法组成:
public class MyDatabaseUserDetailsService implements UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 1. Load the user from the users table by username. If not found, throw UsernameNotFoundException.
// 2. Convert/wrap the user to a UserDetails object and return it.
return someUserDetails;
}
}
public interface UserDetails extends Serializable {
String getUsername();
String getPassword();
// <3> more methods:
// isAccountNonExpired,isAccountNonLocked,
// isCredentialsNonExpired,isEnabled
}
-
UserDetailsService 通过用户的用户名加载 UserDetails。注意,该方法只接受一个参数:用户名(不是密码)。
-
UserDetails 接口具有获取(加密!)密码的方法和获取用户名的方法。
-
UserDetails 甚至有更多的方法,比如帐户是否处于活动状态或被阻止、凭据是否已过期或用户拥有哪些权限 - 但我们不会在这里介绍它们。
因此,您可以像我们上面所做的那样自己实现这些接口,或者使用 Spring Security 提供的现有接口。
现成的实施方案
只需简单说明一下:您始终可以自己实现 UserDetailsService 和 UserDetails 接口。
但是,您还会发现 Spring Security 提供的现成的实现,您可以使用/配置/扩展/覆盖它们。
-
JdbcUserDetailsManager是一个基于 JDBC(数据库)的 UserDetailsService。您可以根据用户表/列的结构进行配置。
-
InMemoryUserDetailsManager,它将所有用户详细信息保存在内存中,非常适合测试。
-
org.springframework.security.core.userdetail.User,这是一个合理的、默认的 UserDetails 实现,你可以使用。这意味着你的实体/数据库表和这个用户类之间可能会存在映射/复制。或者,你也可以简单地让你的实体实现 UserDetails 接口。
完整的用户详细信息工作流程:HTTP 基本身份验证
现在回想一下你的 HTTP 基本身份验证,这意味着你正在使用 Spring Security 和 Basic Auth 来保护你的应用程序。当你指定 UserDetailsService 并尝试登录时,会发生以下情况:
-
使用过滤器从 HTTP Basic Auth 标头中提取用户名/密码组合。您无需执行任何操作,它会在后台自动完成。
-
调用MyDatabaseUserDetailsS ervice从数据库加载相应的用户,包装为 UserDetails 对象,该对象公开用户的加密密码。
-
从 HTTP Basic Auth 标头中提取密码,自动加密,然后将其与 UserDetails 对象中的加密密码进行比较。如果两者匹配,则用户身份验证成功。
这就是全部内容了。但是等等, Spring Security如何加密来自客户端的密码(步骤 3)?使用什么算法?
密码编码器
Spring Security 无法神奇地猜出您首选的密码加密算法。因此,您需要指定另一个 @Bean,即PasswordEncoder。
例如,如果您想对所有密码使用 BCrypt 加密(Spring Security 的默认加密) ,则需要在 SecurityConfig 中指定此 @Bean。
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
如果你有多种密码加密算法,比如一些老用户使用 MD5 算法存储密码(不要这样做),而新用户使用 Bcrypt 算法,甚至使用 SHA-256 算法,该怎么办?这时,你应该使用以下编码器:
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
这个委托编码器是如何工作的?它会检查 UserDetail 的加密密码(例如,来自你的数据库表),现在必须以 开头{prefix}
。这个前缀就是你的加密方法!你的数据库表将如下所示:
用户名 |
密码 |
|
{bcrypt}$2y$12$6t86Rpr3llMANhCUt26oUen2WhvXr/A89Xo9zJion8W7gWgZ/zA0C |
||
{sha256}5ffa39f5757a0dad5dfada519d02c6b71b61ab1df51b4ed1f3beed6abe0ff5f6 |
Spring Security 将:
-
读取这些密码并去掉前缀( {bcrypt} 或 {sha256} )。
-
根据前缀值,使用正确的PasswordEncoder(即BCryptEncoder或SHA256Encoder)
-
使用该 PasswordEncoder 加密传入的未加密密码,并将其与存储的密码进行比较。
这就是有关 PasswordEncoders 的全部内容。
摘要:获取用户密码
本节的要点是:如果您使用 Spring Security 并有权访问用户的密码,那么:
-
指定 UserDetailsService。可以是自定义实现,也可以使用并配置 Spring Security 提供的实现。
-
指定一个PasswordEncoder。
这就是 Spring Security 身份验证的概要。
2. AuthenticationProvider:无法访问用户密码
现在,假设您正在使用Atlassian Crowd进行集中身份管理。这意味着您所有应用程序的用户和密码都存储在 Atlassian Crowd 中,而不再存储在数据库表中。
这有两层含义:
-
您的应用程序中不再有用户密码,因为您不能要求 Crowd 直接向您提供这些密码。
-
但是,您有一个 REST API,可以使用您的用户名和密码登录。(向
/rest/usermanagement/1/authentication
REST 端点发出 POST 请求)。
如果是这种情况,您就不能再使用 UserDetailsService,而是需要实现并提供AuthenticationProvider @Bean。
@Bean
public AuthenticationProvider authenticationProvider() {
return new AtlassianCrowdAuthenticationProvider();
}
AuthenticationProvider 主要由一种方法组成,一个简单的实现可能如下所示:
public class AtlassianCrowdAuthenticationProvider implements AuthenticationProvider {
Authentication authenticate(Authentication authentication)
throws AuthenticationException {
String username = authentication.getPrincipal().toString();
String password = authentication.getCredentials().toString();
User user = callAtlassianCrowdRestService(username, password);
if (user == null) {
throw new AuthenticationException("could not login");
}
return new UserNamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), user.getAuthorities());
}
// other method ignored
}
-
与 UserDetails load() 方法相比,您只能访问用户名,而现在您可以访问完整的身份验证尝试,通常包含用户名和密码。
-
您可以采取任何您想的方式来验证用户身份,例如调用 REST 服务。
-
如果认证失败,则需要抛出异常。
-
如果身份验证成功,您需要返回一个完全初始化的 UsernamePasswordAuthenticationToken。它是 Authentication 接口的一个实现,并且需要将字段 authenticated 设置为 true(上面使用的构造函数会自动设置该值)。我们将在下一章介绍授权机制。
完整的 AuthenticationProvider 工作流程:HTTP 基本身份验证
现在回想一下你的 HTTP 基本身份验证,这意味着你正在使用 Spring Security 和 Basic Auth 来保护你的应用程序。当你指定 AuthenticationProvider 并尝试登录时,会发生以下情况:
-
使用过滤器从 HTTP Basic Auth 标头中提取用户名/密码组合。您无需执行任何操作,它会在后台自动完成。
-
使用该用户名和密码调用您的AuthenticationProvider(例如 AtlassianCrowdAuthenticationProvider),以便您自己进行身份验证(例如 REST 调用)。
无需进行密码加密或类似操作,因为你实际上是委托第三方进行实际的用户名/密码校验。简而言之,这就是 AuthenticationProvider 身份验证!
摘要:AuthenticationProvider
本节的要点是:如果您使用 Spring Security 并且无权访问用户密码,则请实现并提供 AuthenticationProvider @Bean。
使用 Spring Security 进行授权
到目前为止,我们只讨论了身份验证,例如用户名和密码检查。
现在让我们看一下权限,或者更确切地说是 Spring Security 中的角色和权限。
什么是授权?
以典型的电子商务网店为例,它可能包含以下部分:
-
网店本身。假设它的 URL 是
www.youramazinshop.com
。 -
也许是一个呼叫中心客服人员专用的区域,他们可以登录并查看客户最近购买的商品或包裹的所在位置。其网址可以是
www.youramazinshop.com/callcenter
。 -
一个独立的管理区域,管理员可以在此登录并管理呼叫中心代理或网店的其他技术方面(例如主题、性能等)。其 URL 可以是
www.youramazinshop.com/admin
。
这具有以下含义,因为仅仅验证用户身份已经不够了:
-
顾客显然不应该有权访问呼叫中心或管理区域。他只能在网站上购物。
-
呼叫中心代理不应该能够访问管理区域。
-
而管理员可以访问网上商店、呼叫中心区域和管理区域。
简单地说,您希望根据不同用户的权限或角色允许他们进行不同的访问。
什么是权限?什么是角色?
简单的:
-
权限(最简单的形式)只是一个字符串,它可以是任何内容,例如:用户、ADMIN、ROLE_ADMIN 或 53cr37_r0l3。
-
角色是带有
ROLE_
前缀的权限。因此,名为 的角色ADMIN
与名为 的权限相同ROLE_ADMIN
。
角色和权限之间的区别纯粹是概念性的,并且常常使刚接触 Spring Security 的人感到困惑。
为什么要区分角色和权限?
老实说,我已经阅读了 Spring Security 文档以及有关这个问题的几个相关 StackOverflow 线程,但我无法给你一个明确的、好的答案。
什么是 GrantedAuthorities?什么是 SimpleGrantedAuthorities?
当然,Spring Security 并不允许你仅仅使用字符串。有一个 Java 类可以表示你的权限字符串,其中一个常用的类是 SimpleGrantedAuthority。
public final class SimpleGrantedAuthority implements GrantedAuthority {
private final String role;
@Override
public String getAuthority() {
return role;
}
}
(注意:还有其他权限类,允许您将其他对象(例如主体)与字符串一起存储,我不会在这里介绍它们。目前,我们只使用 SimpleGrantedAuthority。)
1. UserDetailsService:在哪里存储和获取权限?
假设您将用户存储在自己的应用程序中(例如:UserDetailsService),那么您将拥有一个用户表。
现在,您只需在其中添加一个名为“authorities”的列即可。本文中,我选择了一个简单的字符串列,但它可以包含多个逗号分隔的值。或者,我也可以创建一个完全独立的表“AUTHORITIES”,但就本文的讨论范围而言,这样就足够了。
注意:回顾section_title:您将authorities(即字符串)保存到数据库中。这些 authority 恰好以 ROLE_ 前缀开头,因此,就 Spring Security 而言,这些 authority也是角色。
用户名 |
密码 |
当局 |
|
{bcrypt}… |
角色管理员 |
||
{sha256}… |
角色_呼叫中心 |
剩下要做的唯一一件事就是调整您的 UserDetailsService 以读取该权限列。
public class MyDatabaseUserDetailsService implements UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userDao.findByUsername(username);
List<SimpleGrantedAuthority> grantedAuthorities = user.getAuthorities().map(authority -> new SimpleGrantedAuthority(authority)).collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), grantedAuthorities);
}
}
-
您只需将数据库列中的任何内容映射到 SimpleGrantedAuthorities 列表即可。完成。
-
再次强调,我们在这里使用了 Spring Security 的 UserDetails 基类实现。您也可以使用自己的类来实现 UserDetails,这样甚至不需要进行映射。
2. AuthenticationManager:在哪里存储和获取权限?
当用户来自第三方应用程序(例如 Atlassian Cloud)时,您需要了解他们使用什么概念来支持权限。Atlassian Crowd 曾有“角色”的概念,但后来弃用了它,转而使用“组”。
因此,根据您使用的实际产品,您需要在 AuthenticationProvider 中将其映射到 Spring Security 权限。
public class AtlassianCrowdAuthenticationProvider implements AuthenticationProvider {
Authentication authenticate(Authentication authentication)
throws AuthenticationException {
String username = authentication.getPrincipal().toString();
String password = authentication.getCredentials().toString();
atlassian.crowd.User user = callAtlassianCrowdRestService(username, password);
if (user == null) {
throw new AuthenticationException("could not login");
}
return new UserNamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), mapToAuthorities(user.getGroups()));
}
// other method ignored
}
-
注意:这不是Atlassian Crowd 的实际代码,但可以达到其目的。您通过 REST 服务进行身份验证,并返回一个 JSON User 对象,该对象随后会被转换为 atlassian.crowd.User 对象。
-
该用户可以是一个或多个组的成员,这些组在这里假设只是字符串。然后,您可以简单地将这些组映射到 Spring 的“SimpleGrantedAuthority”。
重新审视 Authorities 的 WebSecurityConfigurerAdapter
到目前为止,我们讨论了很多关于在 Spring Security 中存储和检索已认证用户的权限。但是如何使用 Spring Security 的 DSL保护具有不同权限的 URL 呢?很简单:
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/admin").hasAuthority("ROLE_ADMIN")
.antMatchers("/callcenter").hasAnyAuthority("ROLE_ADMIN", "ROLE_CALLCENTER")
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.httpBasic();
}
}
-
要访问该
/admin
区域,您(即用户)需要经过身份验证并且拥有权限(简单字符串)ROLE_ADMIN。 -
要访问该
/callcenter
区域,您需要经过身份验证并且拥有 ROLE_ADMIN或ROLE_CALLCENTER权限。 -
对于任何其他请求,您不需要特定的角色,但仍需要进行身份验证。
请注意,上面的代码(1,2)等同于以下内容:
http
.authorizeRequests()
.antMatchers("/admin").hasRole("ADMIN")
.antMatchers("/callcenter").hasAnyRole("ADMIN", "CALLCENTER")
-
现在,不再调用“hasAuthority”,而是调用“hasRole”。注意
ROLE_ADMIN
:Spring Security 将在已认证用户身上查找权限。 -
现在,您无需调用“hasAnyAuthority”,而是调用“hasAnyRole”。注意:Spring Security 将在经过身份验证的用户上查找名为
ROLE_ADMIN
或 的权限。ROLE_CALLCENTER
hasAccess 和 SpEL
最后,但同样重要的是,配置授权最强大的方法是使用访问方法。它允许您指定几乎任何有效的 SpEL 表达式。
http
.authorizeRequests()
.antMatchers("/admin").access("hasRole('admin') and hasIpAddress('192.168.1.0/24') and @myCustomBean.checkAccess(authentication,request)")
- 您正在检查用户是否具有 ROLE_ADMIN、特定的 IP 地址以及自定义 bean 检查。
要全面了解 Spring 基于表达式的访问控制的功能,请查看官方文档。
常见的漏洞防护
Spring Security 可以帮助您防御各种常见的攻击。首先是定时攻击(例如,即使用户不存在,Spring Security 也会始终加密登录时提供的密码),最后是针对缓存控制攻击、内容嗅探、点击劫持、跨站点脚本等的防护。
本指南无法详细介绍每种攻击。因此,我们只讨论最让 Spring Security 新手头疼的一种防护措施:跨站请求伪造 (Cross-Site-Request-Forgery)。
跨站请求伪造:CSRF
如果您对 CSRF 完全陌生,不妨看看这个 YouTube 视频来快速了解一下。不过,简单来说, Spring Security默认会使用有效的 CSRF 令牌保护所有传入的 POST(或 PUT/DELETE/PATCH)请求。
这意味着什么?
CSRF 和服务端渲染 HTML
想象一下银行转账表格或任何表格(例如登录表格),它由您的@Controllers在Thymeleaf或Freemarker等模板技术的帮助下呈现。
<form action="/transfer" method="post"> <!-- 1 -->
<input type="text" name="amount"/>
<input type="text" name="routingNumber"/>
<input type="text" name="account"/>
<input type="submit" value="Transfer"/>
</form>
启用 Spring Security 后,您将无法再提交该表单。因为 Spring Security 的 CSRFFilter 会在任何 POST (PUT/DELETE) 请求中寻找一个额外的隐藏参数:即所谓的 CSRF 令牌。
默认情况下,它会为每个 HTTP 会话生成一个这样的令牌并将其存储在那里。你需要确保将其注入到你的任何 HTML 表单中。
CSRF 令牌和 Thymeleaf
由于 Thymeleaf 与 Spring Security 集成良好(与 Spring Boot 一起使用时),您只需将以下代码片段添加到任何表单,即可自动将令牌从会话注入到表单中。更棒的是,如果您在表单中使用了“th:action”,Thymeleaf 会自动为您注入该隐藏字段,无需手动操作。
<form action="/transfer" method="post"> <!-- 1 -->
<input type="text" name="amount"/>
<input type="text" name="routingNumber"/>
<input type="text" name="account"/>
<input type="submit" value="Transfer"/>
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
</form>
<!-- OR -->
<form th:action="/transfer" method="post"> <!-- 2 -->
<input type="text" name="amount"/>
<input type="text" name="routingNumber"/>
<input type="text" name="account"/>
<input type="submit" value="Transfer"/>
</form>
-
在这里,我们手动添加 CSRF 参数。
-
这里我们使用Thymeleaf的表单支持。
注意:有关 Thymeleaf 的 CSRF 支持的更多信息,请参阅官方文档。
CSRF 和其他模板库
我无法在本节中涵盖所有模板库,但作为最后的手段,您始终可以将 CSRFToken 注入到任何 @Controller 方法中,并将其添加到模型中以在视图中呈现它或直接将其作为 HttpServletRequest 请求属性访问。
@Controller
public class MyController {
@GetMaping("/login")
public String login(Model model, CsrfToken token) {
// the token will be injected automatically
return "/templates/login";
}
}
CSRF 与 React 或 Angular
对于 JavaScript 应用(例如 React 或 Angular 单页应用),情况会有所不同。您需要执行以下操作:
-
配置 Spring Security 以使用CookieCsrfTokenRepository,它会将 CSRFToken 放入 cookie“XSRF-TOKEN”中(并将其发送到浏览器)。
-
让您的 Javascript 应用程序获取该 cookie 值,并将其作为“X-XSRF-TOKEN”标头随每个 POST(/PUT/PATCH/DELETE) 请求发送。
要查看完整的复制粘贴 React 示例,请查看这篇精彩的博客文章:https://developer.okta.com/blog/2018/07/19/simple-crud-react-and-spring-boot。
禁用 CSRF
如果您只提供无状态 REST API,而 CSRF 保护毫无意义,则应该完全禁用 CSRF 保护。操作方法如下:
@EnableWebSecurity
@Configuration
public class WebSecurityConfig extends
WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable();
}
}
OAuth2
太糟糕了!本节只是下一篇文章的预告:Spring Security 与 OAuth2。为什么?
因为 Spring Security 的 OAuth2 集成实际上是一个复杂的主题,需要另外 7,000-10,000 个字来讨论,这不适合本文的范围。
敬请关注。
Spring 集成
Spring Security 和 Spring 框架
在本文的大部分内容中,您仅在应用程序的Web 层指定了安全配置。您使用 antMatcher 或 regexMatchers 以及 WebSecurityConfigurerAdapter 的 DSL 保护了某些 URL。这是一种非常良好且标准的安全方法。
除了保护你的 Web 层之外,还有“纵深防御”的概念。这意味着除了保护 URL 之外,你可能需要保护你的业务逻辑本身。想想你的 @Controllers、@Components、@Services 甚至 @Repositories。简而言之,就是你的 Spring Bean。
方法安全性
该方法method security
通过注解实现,基本上可以将其放在 Spring bean 的任何公共方法上。您还需要在 ApplicationContextConfiguration 上添加 @EnableGlobalMethodSecurity 注解来显式启用方法安全性。
@Configuration
@EnableGlobalMethodSecurity(
prePostEnabled = true,
securedEnabled = true,
jsr250Enabled = true)
public class YourSecurityConfig extends WebSecurityConfigurerAdapter{
}
-
prePostEnabled 属性启用了对 Spring
@PreAuthorize
和@PostAuthorize
注解的支持。支持意味着,除非您将该标志设置为 true,否则 Spring 将忽略此注解。 -
securedEnabled 属性启用了对
@Secured
注解的支持。支持意味着,除非您将该标志设置为 true,否则 Spring 将忽略此注解。 -
jsr250Enabled 属性启用了对
@RolesAllowed
注解的支持。支持意味着,除非您将该标志设置为 true,否则 Spring 将忽略此注解。
@PreAuthorize、@Secured 和 @RolesAllowed 之间有什么区别?
@Secured 和 @RolesAllowed 基本相同,不过 @Secured 是 Spring 特有的注解,包含在 spring-security-core 依赖项中;而 @RolesAllowed 是标准化注解,包含在 javax.annotation-api 依赖项中。这两个注解都接受一个权限/角色字符串作为值。
@PreAuthorize/@PostAuthorize 也是(较新的)Spring 特定注释,并且比上述注释更强大,因为它们不仅可以包含权限/角色,还可以包含任何有效的 SpEL 表达式。
AccessDeniedException
最后,如果您尝试以不够的权限/角色访问受保护的方法,所有这些注释都会引发一个问题。
那么,让我们最终看看这些注释的实际作用。
@Service
public class SomeService {
@Secured("ROLE_CALLCENTER")
// == @RolesAllowed("ADMIN")
public BankAccountInfo get(...) {
}
@PreAuthorize("isAnonymous()")
// @PreAuthorize("#contact.name == principal.name")
// @PreAuthorize("ROLE_ADMIN")
public void trackVisit(Long id);
}
}
-
如上所述,@Secured 接受权限/角色作为参数。@RolesAllowed 也是如此。注意:请记住,这
@RolesAllowed("ADMIN")
将检查是否已授予权限ROLE_ADMIN
。 -
如上所述,@PreAuthorize 不仅接受授权,还接受任何有效的 SpEL 表达式。如需查看上述常用内置安全表达式列表
isAnonymous()
(无需编写自己的 SpEL 表达式),请参阅官方文档。
我应该使用哪个注释?
这主要是一个同质性的问题,而不是将自己过多地束缚于 Spring 特定的 API(这是一个经常被提出的论点)。
如果使用@Secured,请坚持使用它,不要在 28% 的其他 bean 中使用 @RolesAllowed 注释,以努力实现标准化,但永远不要完全实现。
首先,您可以始终使用@Secured,并在需要时立即切换到@PreAuthorize。
Spring Security 和 Spring Web MVC
至于与 Spring WebMVC 的集成,Spring Security 允许您做以下几件事:
-
除了 antMatchers 和 regexMatchers 之外,您还可以使用 mvcMatchers。区别在于,antMatchers 和 regexMatchers 基本上是使用通配符匹配 URI 字符串,而 mvcMatchers 的行为与 @RequestMappings完全相同。
-
将当前已验证的主体注入@Controller/@RestController 方法。
-
将当前会话 CSRFToken 注入 @Controller/@RestController 方法。
-
正确处理异步请求处理的安全性。
@Controller
public class MyController {
@RequestMapping("/messages/inbox")
public ModelAndView findMessagesForUser(@AuthenticationPrincipal CustomUser customUser, CsrfToken token) {
// .. find messages for this user and return them ...
}
}
-
如果用户已通过身份验证,@AuthenticationPrincipal 将注入一个主体;如果用户未通过身份验证,则注入 null。此主体是来自 UserDetailsService/AuthenticationManager 的对象!
-
或者您可以将当前会话 CSRFToken 注入到每个方法中。
如果您不使用 @AuthenticationPrincipal 注解,则必须通过 SecurityContextHolder 自行获取主体。这是旧版 Spring Security 应用程序中常见的技术。
@Controller
public class MyController {
@RequestMapping("/messages/inbox")
public ModelAndView findMessagesForUser(CsrfToken token) {
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof UserDetails) {
CustomUser customUser = (CustomUser) authentication.getPrincipal();
// .. find messages for this user and return them ...
}
// todo
}
}
Spring Security 和 Spring Boot
无论何时将spring-boot-starter-security依赖项添加到 Spring Boot 项目,Spring Boot 实际上只会为您预先配置 Spring Security 。
除此之外,所有安全配置都是通过简单的 Spring Security 概念(想想:WebSecurityConfigurerAdapter、身份验证和授权规则)完成的,本质上与 Spring Boot 无关。
因此,本指南中您阅读的所有内容都与 Spring Security 和 Spring Boot 的结合使用一一对应。如果您不了解基本的 Security 知识,就不要指望能够正确理解这两种技术是如何协同工作的。
Spring Security 和 Thymeleaf
Spring Security 与 Thymeleaf 集成良好。它提供了一种特殊的 Spring Security Thymeleaf 方言,允许您将安全表达式直接放入 Thymeleaf HTML 模板中。
<div sec:authorize="isAuthenticated()">
This content is only shown to authenticated users.
</div>
<div sec:authorize="hasRole('ROLE_ADMIN')">
This content is only shown to administrators.
</div>
<div sec:authorize="hasRole('ROLE_USER')">
This content is only shown to users.
</div>
要了解这两种技术如何协同工作的完整和更详细的概述,请参阅官方文档。
常问问题
最新的 Spring Security 版本是什么?
截至 2020 年 4 月,该版本为 {springsecurityversion}。
请注意,如果您使用 Spring Boot 定义的 Spring Security 依赖项,则您可能使用的是稍旧的 Spring Security 版本,例如 5.2.1。
旧版本的 Spring Security 是否与最新版本兼容?
Spring Security 最近经历了一些重大变化。因此,您需要找到目标版本的迁移指南并进行操作:
-
Spring Security 3.x 到 4.x → https://docs.spring.io/spring-security/site/migrate/current/3-to-4/html5/migrate-3-to-4-jc.html
-
Spring Security 4.x 到 5.x(< 5.3) → https://docs.spring.io/spring-security/site/docs/5.0.15.RELEASE/reference/htmlsingle/#new (不是真正的指南,只是介绍新功能)
-
Spring Security 5.x 到 5.3 → https://docs.spring.io/spring-security/site/docs/5.3.1.RELEASE/reference/html5/#new(不是真正的指南,只是介绍了新功能)
我需要添加哪些依赖项才能使 Spring Security 正常工作?
平原泉项目
如果您使用的是普通的 Spring 项目(而不是Spring Boot),则需要向项目添加以下两个 Maven/Gradle 依赖项:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
<version>5.2.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
<version>5.2.2.RELEASE</version>
</dependency>
您还需要在 web.xml 或 Java 配置中配置 SecurityFilterChain。请参阅此处了解如何操作。
Spring Boot 项目
如果您正在使用 Spring Boot 项目,则需要向项目添加以下 Maven/Gradle 依赖项:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
其他所有内容都将自动为您配置,您可以立即开始编写 WebSecurityConfigurerAdapter。
如何以编程方式访问 Spring Security 中当前经过身份验证的用户?
如文章中所述,Spring Security 将当前已验证的用户(或者更确切地说是 SecurityContext)存储在 SecurityContextHolder 内部的线程局部变量中。您可以像这样访问它:
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
请注意,如果您未登录,Spring Security默认会在 SecurityContextHolder 上设置身份验证。这会导致一些混淆,因为人们自然会期望那里有一个空值。AnonymousAuthenticationToken
AntMatchers:常见示例
一个无意义的例子展示了最有用的 antMatchers(和 regexMatcher/mvcMatcher)可能性:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/api/user/**", "/api/ticket/**", "/index").hasAuthority("ROLE_USER")
.antMatchers(HttpMethod.POST, "/forms/**").hasAnyRole("ADMIN", "CALLCENTER")
.antMatchers("/user/**").access("@webSecurity.check(authentication,request)");
}
如何使用 Spring Security 自定义登录页面?
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll();
}
- 您的自定义登录页面的 URL。指定此 URL 后,自动生成的登录页面将消失。
如何使用 Spring Security 进行编程登录?
UserDetails principal = userDetailsService.loadUserByUsername(username);
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
如何针对某些路径禁用 CSRF?
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().ignoringAntMatchers("/api/**");
}
鳍
如果你已经读到这里,即使没有 OAuth2,你也应该对 Spring Security 生态系统的复杂性有了相当好的理解。总结一下:
-
如果您对 Spring Security 的 FilterChain 的工作原理及其默认漏洞保护(例如:CSRF)有基本的了解,这将有所帮助。
-
确保理解身份验证和授权之间的区别。此外,还要了解针对特定身份验证工作流程需要指定哪些@bean 。
-
确保您了解 Spring Security 的 WebSecurityConfigurerAdapter 的 DSL 以及基于注释的方法安全性。
-
最后但并非最不重要的一点是,它有助于仔细检查 Spring Security 与其他框架和库(如 Spring MVC 或 Thymeleaf)的集成。
今天就到这里吧,这趟旅程真是精彩,不是吗?谢谢阅读!
致谢
非常感谢Patricio "Pato" Moschcovich,他不仅校对了这篇文章,还提供了宝贵的反馈!
更多的
您可能还对我新发布的Spring Security 练习课程感兴趣,该课程将以一种相当独特的方式教授 Spring Security 和 OAuth2。