上一篇:Spring Security / Servlet Application / 大图景
样例代码:Spring Security Sample
参考:Spring Security Reference
Servlet 鉴权的架构组件
SecurityContextHolder
SecurityContextHolder
来存储鉴权对象的详细信息SecurityContext
SecurityContextHolder
获得 SecurityContext
Authentication
Authentication
AuthenticationManager
的输入,为其提供用户输入的鉴权凭证(通常为密码)SecurityContext
中获取GrantedAuthority
Authentication
中授予主体的权限(即:roles、scopes 等)AuthenticationManager
ProviderManager
AuthenticationManager
的实现AuthenticationProvider
ProviderManager
用 AuthenticationProvider
来执行特定类型的鉴权AuthenticationEntryPoint
AbstractAuthenticationProcessingFilter
鉴权机制
SecurityContextHolder
是 Spring Security 鉴权模型的核心
通过 SecurityContextHolder.getContext() 来获取 SecurityContext
Spring Security 用 SecurityContextHolder
来存储鉴权主体的细节
Spring Security 不关心数据是如何存入 SecurityContextHolder
的
如果其中有值,那么就会被当做当前被鉴权的用户
指定一个被鉴权的用户的最简单的方式,就是直接设置 SecurityContextHolder
例 54. 设置 SecurityContextHolder
SecurityContext context = SecurityContextHolder.createEmptyContext(); (1)
Authentication authentication = new TestingAuthenticationToken("username", "password", "ROLE_USER"); (2)
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context); (3)
(1)创建一个空的 SecurityContext
这里创建了一个新的 SecurityContext
实例而不是 SecurityContextHolder.getContext().setAuthentication(authentication)
这样可以避免多线程之间竞争
(2)创建一个新的 Authentication
对象
Spring Security 不在乎 SecurityContext
中的 Authentication
是什么类型的实现
(3)将 SecurityContext
存入 SecurityContextHolder
Spring Security 会使用这个信息来授权
如果你想获取授权主体的信息,同样可以访问 SecurityContextHolder
例 55. 获取当前授权用户的相关信息
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
默认情况下,SecurityContextHolder
使用 ThreadLocal
来存储这些细节信息
使用 ThreadLocal
,可以在处理完当前主体请求后,安全地清理线程
FilterChainProxy
保证每次会清理 SecurityContext
有一些应用不适合使用 ThreadLocal
例如 Swing 客户端可能会希望所有的 JVM 线程都使用同一个安全上下文
SecurityContextHolder
可以在启动时配置一个 strategy 策略对象
由这个策略对象来决定使用何种方式对上下文进行存储
对于独立应用,可以使用 SecurityContextHolder.MODE_GLOBAL
策略
另一些应用,希望由安全线程创建的所有线程共享同一个安全身份
此时可以使用 SecurityContextHolder.MODE_INHERITABLETHREADLOCAL
策略
可以通过两种方式来改变默认的 SecurityContextHolder.MODE_THREADLOCAL
策略
SecurityContextHolder
的静态方法package org.springframework.security.core.context;
public class SecurityContextHolder {
...
private static SecurityContextHolderStrategy strategy; // 62
...
public static SecurityContext getContext() {
// 103
return strategy.getContext();
}
...
}
从 SecurityContextHolder
获取 SecurityContext
一个 SecurityContext
对象包含一个 Authentication
对象
Authentication
有两个主要目的
AuthenticationManager
的输入,提供用户输入的鉴权凭证(credential)Authentication.isAuthenticated()
返回 false
SecurityContext
获取当前的 Authentication
对象可以从 Authentication
获取:
UserDetails
实例GrantedAuthority
表示授予用户的高层权限,例如 roles、scopes可以通过 Authentication.getAuthorities()
获得 GrantedAuthority
该方法返回后一个 GrantedAuthority
的 Collection
GrantedAuthority
是高层的权限,通常为 roles 或 scopes
例如:ROLE_ADMINISTRATOR
或者 ROLE_HR_SUPERVISOR
这些角色在之后会被用于 web 授权
Spring Security 的其它部分可以解释这些权限
当使用基于用户名密码的鉴权时,GrantedAuthority
通常靠 UserDetailsService
载入
通常 GrantedAuthority
对象是应用范围的权限
它们不专属于某个特定的领域对象(domain object)
不应该用 GrantedAuthority
来代表专属于第 54 号员工的一个权限
因为如果有非常多这样的权限,很快就会内存溢出(或者在非常少的情况下,会导致应用花很长的时间给用户鉴权)
Spring Security 设计了其它方式来处理这种常见的需求
为此需要使用项目的领域对象安全(domain object security)能力
上边的意思是,当前讨论的权限是针对某一类人的设计,而不是针对每个具体的人
需要针对每个具体的人的情况,需要使用 domain object security 能力
一个 SecurityFilterChain 对应一个 AuthenticationManager
一个 AuthenticationManager 对应多个 AuthenticationProvider
AuthenticationManager
是定义了 Spring Security 的 Filter 如何进行鉴权的 API
Spring Security 的 Filter 调用 AuthenticationManager
进行鉴权
并将其返回的 Authentication
设置到 SecurityContextHolder
中
如果你没有集成 Spring Security 的 Filter
你可以直接设置 SecurityContextHolder
并且不用调用 AuthenticationManager
ProviderManager
是最常用的 AuthenticationManager
实现
ProviderManager
持有一个 AuthenticationProvider
的 List
ProviderManager
会遍历这个 List
将任务委托给 List
中 AuthenticationProvider.supports(Class>) 为 true 的 AuthenticationProvider
即:调用匹配上的 AuthenticationProvider 的 authenticate(Authentication) 方法
如果 AuthenticationProvider.authenticate(Authentication) 抛出 AuthenticationException
则继续循环调用下一个 supports(Class>) 匹配上的 AuthenticationProvider 的 authenticate(Authentication)
直到某个 AuthenticationProvider.authenticate(Authentication) 鉴权成功
如果所有的 AuthenticationProvider
都没能鉴权成功
则调用 this.parent.authenticate(Authentication),即:委托给父 AuthenticationManager
进行鉴权
如果父 AuthenticationManager
也没鉴权成功,则抛出上边过程中最后一次鉴权抛出的 AuthenticationException
如果上述整个过程既没鉴权成功,也没抛出过 AuthenticationException
例如:所有的 AuthenticationProvider.supports(Class>) 都为 false 且没有父 AuthenticationManager
则抛出 ProviderNotFoundException
(一个特殊的 AuthenticationException
,表示
ProviderManager
没有被配置支持这个特殊类型的 Authentication
)
package org.springframework.security.authentication;
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
// ...
private List<AuthenticationProvider> providers = Collections.emptyList();
// ...
private AuthenticationManager parent; // 100
// ...
public List<AuthenticationProvider> getProviders() {
// 265
return this.providers;
}
// ...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 165
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
provider.getClass().getSimpleName(), ++currentPosition, size));
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException ex) {
prepareException(ex, authentication);
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw ex;
}
catch (AuthenticationException ex) {
lastException = ex;
}
}
if (result == null && this.parent != null) {
// Allow the parent to try.
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
}
catch (ProviderNotFoundException ex) {
// ignore as we will throw below if no other exception occurred prior to
// calling parent and the parent
// may throw ProviderNotFound even though a provider in the child already
// handled the request
}
catch (AuthenticationException ex) {
parentException = ex;
lastException = ex;
}
}
if (result != null) {
if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
// Authentication is complete. Remove credentials and other secret data
// from authentication
((CredentialsContainer) result).eraseCredentials();
}
// If the parent AuthenticationManager was attempted and successful then it
// will publish an AuthenticationSuccessEvent
// This check prevents a duplicate AuthenticationSuccessEvent if the parent
// AuthenticationManager already published it
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
// Parent was null, or didn't authenticate (or throw an exception).
if (lastException == null) {
lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound",
new Object[] {
toTest.getName() }, "No AuthenticationProvider found for {0}"));
}
// If the parent AuthenticationManager was attempted and failed then it will
// publish an AbstractAuthenticationFailureEvent
// This check prevents a duplicate AbstractAuthenticationFailureEvent if the
// parent AuthenticationManager already published it
if (parentException == null) {
prepareException(lastException, authentication);
}
throw lastException;
}
// ...
}
每个 AuthenticationProvider
负责处理某个特定类型的鉴权
例如,某个 AuthenticationProvider
专门用于验证用户名密码的有效性
另一个 AuthenticationProvider
则专门负责 SAML assertion 的认证
这样的设计,可以在只提供一个 AuthenticationManager
Bean 的情况下,处理多种不同类型的鉴权
并且各种不同类型鉴权的关注点相互分离,放在各个不同的 AuthenticationProvider
中
可以为 ProviderManager
配置一个父 AuthenticationManager
没有 AuthenticationProvider 成功鉴权时,会调用父 AuthenticationManager
的鉴权
多个 ProviderManager
实例可以共享一个相同的父 AuthenticationManager
在使用多个有共同兜底鉴权逻辑的 SecurityFilterChain
时,可以使用这种结构
各个 SecurityFilterChain
持有一个自己的 ProviderManager
为其提供特定的鉴权机制
同时,这些 ProviderManager
又持有同一个父 AuthenticationManager
共享同一套兜底的鉴权策略
不同类型的 Authentication
由不同类型的 SecurityFilter
创建并放入 AuthenticationManager.authenticate(Authentication)
例如:OAuth2AuthorizationCodeGrantFilter 创建的 OAuth2AuthorizationCodeAuthenticationToken
默认情况下 ProviderManager
会将鉴权成功后返回的 Authentication
对象中的敏感的凭证信息(credentials)清理掉
类似密码这样的信息只是用来鉴权的,鉴权成功后就没有必要再保留,应该从 HttpSession
中清除掉
在一个无状态的应用中,为了提高性能,可能会用到缓存
如果使用了缓存技术,这个默认的清理工作可能会导致问题
如果 Authentication
持有的引用指向的对象被缓存(例如 UserDetails
)
鉴权成功后 ProviderManager
将它的凭证清理掉了
则无法再次使用这个缓存的对象进行鉴权,因为其中的凭证被删除了
如果用户关闭浏览器后再次登录,或者在别的设备再次登录
在鉴权时,通过用户名获取 UserDetails
时如果使用的是上次的没有凭证的 UserDetails
对象,则会导致鉴权 Bug
当使用缓存时,需要考虑这个问题,解决方案:
AuthenticationProvider
返回时,对对象做一个复制(即不要让 ProviderManager
清理到缓存对象)ProviderManager
的 eraseCredentialsAfterAuthentication 属性设置为 false第一种方案,在缓存失效前,用户凭证(敏感信息)被保留,增加了安全风险
第二种方案,则是不再清理凭证(敏感信息),更不安全
可以考虑自定义 UserDetails
,重写获取凭证 getter 的逻辑,在凭证为 null 时去数据库通过 id 或用户名再查一次
即:只缓存凭证以外的信息,允许 ProviderManager
的清理
既然一个特定类型的 SecurityFilter
对应着一个特定类型的 Authentication
而特定类型的鉴权逻辑 AuthenticationProvider
又由特定类型的 Authentication
决定
为何不直接把不同类型的鉴权逻辑放到对应类型的 SecurityFilter
中呢?
这是为了关注点分离
Filter
负责判断请求是否是鉴权请求(POST 且是鉴权 URI)、从请求中获取凭证、鉴权异常处理AuthenticationManager
负责验证凭证的有效性关注点分离后,除了方便测试外,还有下面这些好处:
SecurityFilter
和 AuthenticationProvider
可以脱离一一映射的关系,增加灵活性、重用性Filter
可以共享同一个验证凭证的 AuthenticationProvider
AuthenticationProvider
统一放到 AuthenticationManager
AuthenticationManager
AuthenticationProvider
之间共享父 AuthenticationManager
ProviderManager
可以抽象出一些统一的工作ProviderManager
会在鉴权成功后清理凭证(credentials)AuthenticationProvider
的切面,可以让 AuthenticationProvider
更专注入于验证凭证的工作默认的
ProviderManager
中包含一个 AnonymousAuthenticationProvider
ProviderManager
的父 AuthenticationManager
也是一个 ProviderManager
这个父 ProviderManager
包含一个 DaoAuthenticationProvider
可以向 ProviderManager
注入多个 AuthenticationProvider
每个 AuthenticationProvider
提供特定类型的鉴权,例如:
DaoAuthenticationProvider
提供基于用户名密码的鉴权JwtAuthenticationProvider
提供基于 JWT token 的鉴权AuthenticationEntryPoint
请求凭证AuthenticationEntryPoint
用于发送向客户端请求凭证的 HTTP 响应
有的时候,客户端在访问资源的请求中就已经直接包含了凭证(例如用户名密码)
这种情况下,Spring Security 不需要发送一个向客户端请求凭证的 HTTP 响应
如果客户端发起一个访问资源的请求,而客户端还没有进行过鉴权
那么就会使用 AuthenticationEntryPoint
的实现,来发送向客户端请求凭证的 HTTP 响应
AuthenticationEntryPoint
的实现可以重定向到登录页,或者返回一个带 WWW-Authenticate 响应头的响应等等
AbstractAuthenticationProcessingFilter
用来作为对用户的凭证进行鉴权的基础的 Filter
Spring Security 先通过 AuthenticationEntryPoint
请求凭证
然后 AbstractAuthenticationProcessingFilter
对凭证进行鉴权
OAuth2LoginAuthenticationFilter
、UsernamePasswordAuthenticationFilter
继承了该抽象类
AbstractAuthenticationProcessingFilter
会创建一个 Authentication
Authentication
的具体类型取决于 AbstractAuthenticationProcessingFilter
的实现类UsernamePasswordAuthenticationFilter
会用用户名密码创建一个 UsernamePasswordAuthenticationToken
Authentication
作为参数,调用 AuthenticationManager.authenticate(Authentication) 进行鉴权SecurityContextHolder
AuthenticationFailureHandler
SessionAuthenticationStrategy
有一个新的登录Authentication
放入 SecurityContextHolder
SecurityContextPersistenceFilter
会将 SecurityContext
存入 HttpSession
ApplicationEventPublisher
发布一个 InteractiveAuthenticationSuccessEvent
AuthenticationSuccessHandler
最常见的鉴权方式,就是验证用户名密码的有效性
Spring Security 对基于用户名密码的鉴权提供了全面的支持
Spring Security 提供了下面这些内置的机制来从 HttpServletRequest
中读取用户名密码:
每一种读取用户名密码的机制,都能使用下边这些存储机制中的任意一种:
Spring Security 支持通过 HTML 表单提交用户名密码的形式
下边的图展示了 Spring Security 是如何重定向到登录页的
FilterSecurityInterceptor
抛出 AccessDeniedException
异常ExceptionTranslationFilter
捕获异常,开始鉴权AuthenticationEntryPoint
重定向到登录页AuthenticationEntryPoint
常见的实现是 LoginUrlAuthenticationEntryPoint
用户提交用户名密码之后,UsernamePasswordAuthenticationFilter
对用户名和密码进行鉴权
UsernamePasswordAuthenticationFilter
继承自 AbstractAuthenticationProcessingFilter
UsernamePasswordAuthenticationFilter
会从 HttpServletRequest
中获取用户名和密码UsernamePasswordAuthenticationToken
对象Authentication
的实现UsernamePasswordAuthenticationToken
会被传入 AuthenticationManager
进行鉴权AuthenticationManager
的工作细节取决于如何存储用户信息SecurityContextHolder
AuthenticationFailureHandler
SessionAuthenticationStrategy
有一个新的登录Authentication
放入 SecurityContextHolder
ApplicationEventPublisher
发布一个 InteractiveAuthenticationSuccessEvent
AuthenticationSuccessHandler
SimpleUrlAuthenticationSuccessHandler
ExceptionTranslationFilter
在重定向到登录页时缓存的原请求地址默认情况下,表单登录是开启的
但只要客户端程序员重写了 configure(HttpSecurity http) 方法,默认的表单登录配置就会被覆盖
此时要开启表单登录,需要显式地配置
@Component
public class FormLoginConfig extends WebSecurityConfigurerAdapter {
// ...
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin();
}
// ...
}
上边的配置会使用 Spring 提供的登录页,具体的,由 DefaultLoginPageGeneratingFilter
提供
要使用自定义的登录页,需要如下配置
@Component
public class FormLoginConfig extends WebSecurityConfigurerAdapter {
// ...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// ...
.formLogin(formLoginConfigurer -> formLoginConfigurer
.loginPage("/login")
.permitAll()
);
}
// ...
}
自定义的登录页,可以使用 Thymeleaf 模板来渲染
DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>Please Log Intitle>
head>
<body>
<h1>Please Log Inh1>
<div th:if="${param.error}">Invalid username and password.div>
<div th:if="${param.logout}">You have been logged out.div>
<form th:action="@{/login}" method="post">
<div>
<input type="text" name="username" placeholder="Username"/>
div>
<div>
<input type="password" name="password" placeholder="Password"/>
div>
<input type="submit" value="Log in" />
form>
body>
html>
默认的 HTML 表单需要满足:
如果使用 Spring MVC,可以用 Controller 来作为登录页的入口
@Controller
class LoginController {
@GetMapping("/login")
String login() {
return "login";
}
}
RFC 7617:Basic HTTP Authentication
Spring Security 为基于 Servlet 的应用提供了对 Basic HTTP Authentication 的支持
向客户请求凭证时
返回 WWW-Authenticate 响应头
实现了 Basic HTTP Authentication 协议的浏览器会弹出用户名密码表单
FilterSecurityInterceptor
抛出 AccessDeniedException
ExceptionTranslationFilter
捕获异常,发现是未鉴权用户,开始请求用户凭证BasicAuthenticationEntryPoint
的鉴权入口方法RequestCache
一般会缓存一个 NullRequestCache
,即鉴权成功后不会重放请求用户代理(如浏览器)收到带有 WWW-Authenticate 响应头的响应后弹出弹框让用户登录
用户提交用户名密码后,Spring Security 处理逻辑如下图
BasicAuthenticationFilter
会从 HttpServletRequest
中获取用户名和密码UsernamePasswordAuthenticationToken
对象Authentication
的实现UsernamePasswordAuthenticationToken
会被传入 AuthenticationManager
进行鉴权AuthenticationManager
的工作细节取决于如何存储用户信息SecurityContextHolder
AuthenticationEntryPoint
再次返回带有 WWW-Authenticate 响应头的响应Authentication
放入 SecurityContextHolder
BasicAuthenticationFilter
调用 FilterChain.doFilter(request,response)
Basic HTTP Authentication
第一次未鉴权访返回 WWW-Authenticate 响应头时,就已经设置了 Cookie:JSESSIONID=…
也就是说鉴权以前就已经创建了 Session
提交用户名密码会直接重放之前的请求并携带用户名密码(用户名密码加密后放在 Authorization 请求头中)
后端鉴权成功后把用户信息放入之前创建的 Session 即可
默认情况下,Basic HTTP Authentication 是开启的
但只要客户端程序员重写了 configure(HttpSecurity http) 方法,默认配置就会被覆盖
此时要开启 Basic HTTP Authentication,需要显式地配置
@Component
public class FormLoginConfig extends WebSecurityConfigurerAdapter {
// ...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// ...
.httpBasic(withDefaults());
}
// ...
}
RFC 2617:Digest Access Authentication
由 DigestAuthenticationFilter
支持 Digest Access Authentication
Digest Access Authentication 不安全,不推荐使用
通过用户名密码鉴权的逻辑:
通过用户名查询数据库中的用户记录,然后比较用户提交的密码与数据库保存的密码是否相同
通过用户名密码进行鉴权的 AuthenticationProvider
默认是 DaoAuthenticationProvider
DaoAuthenticationProvider
通过用户名查询用户记录是通过调用 UserDetailsService.loadUserByUsername(String username)
要想使用注册在内存中的用户信息,只需要创建一个 InMemoryUserDetailsManager
类型的 Bean
InMemoryUserDetailsManager
实现了 UserDetailsService
InMemoryUserDetailsManager
会从内存中查询用户记录
InMemoryUserDetailsManager
还实现了 UserDetailsManager
接口,用于管理 UserDetails
当配置为用户名密码鉴权时,Spring Security 会基于 UserDetails
进行鉴权
如果没有配置自己的 UserDetailsService
Bean,默认使用 InMemoryUserDetailsManager
下边的例子使用 User.builder()
来构建 UserDetails
使用 Spring Boot CLI 加密密码
具体的:
默认使用 DelegatingPasswordEncoder 来解密密码
{bcrypt} 大括号中的 bcrypt 用于指定真正用于解码的解码器类型,除去 {bcrypt} 剩下的部分就是密文
bcrypt 对应的解码器是:BCryptPasswordEncoder
下边密码的密文用 BCryptPasswordEncoder
解码后的明文就是:“password”
@Configuration
public class FormLoginConfig extends WebSecurityConfigurerAdapter {
// ...
@Bean
public UserDetailsService users() {
UserDetails user = User.builder()
.username("user")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER")
.build();
UserDetails admin = User.builder()
.username("admin")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
// ...
}
下边的例子使用 User.withDefaultPasswordEncoder()
来构建 UserDetails
,使用默认的方式加密密码
因为密码的明文就在代码的字面量中,因此可以很容易地通过反编译看到密码,不安全,不应该在生产中使用
@Configuration
public class FormLoginConfig extends WebSecurityConfigurerAdapter {
// ...
@Bean
public UserDetailsService users() {
// The builder will ensure the passwords are encoded before saving in memory
UserBuilder users = User.withDefaultPasswordEncoder();
UserDetails user = users
.username("user")
.password("password")
.roles("USER")
.build();
UserDetails admin = users
.username("admin")
.password("password")
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
// ...
}
Spring Security 的 JdbcDaoImpl
实现了 UserDetailsService
接口
支持基于用户名密码的鉴权,通过 JDBC 注册和查询用户记录
JdbcUserDetailsManager
继承了 JdbcDaoImpl
JdbcUserDetailsManager
还实现了 UserDetailsManager
接口,用于管理 UserDetails
当配置为用户名密码鉴权时,Spring Security 会基于 UserDetails
进行鉴权
Spring Security 为基于 JDBC 的鉴权提供了默认的请求模式
只要数据库按照要求的方式建表,就能正确 JdbcUserDetailsManager
就能正确的工作
示例为 H2 数据库的 SQL,可以根据不同的数据库类型调整为对应的方言
Spring Security 的类路径下提供了建表语句:org/springframework/security/core/userdetails/jdbc/users.ddl
create table users(
username varchar_ignorecase(50) not null primary key,
password varchar_ignorecase(500) not null,
enabled boolean not null
);
create table authorities (
username varchar_ignorecase(50) not null,
authority varchar_ignorecase(50) not null,
constraint fk_authorities_users foreign key(username) references users(username)
);
create unique index ix_auth_username on authorities (username,authority);
如果应用中有分组
create table groups (
id bigint generated by default as identity(start with 0) primary key,
group_name varchar_ignorecase(50) not null
);
create table group_authorities (
group_id bigint not null,
authority varchar(50) not null,
constraint fk_group_authorities_group foreign key(group_id) references groups(id)
);
create table group_members (
id bigint generated by default as identity(start with 0) primary key,
username varchar(50) not null,
group_id bigint not null,
constraint fk_group_members_group foreign key(group_id) references groups(id)
);
配置数据源,JDBC 会使用数据源来访问具体的数据库
@Configuration
public class DataSourceConfig {
/**
* 配置数据源 DataSource Bean
* EmbeddedDatabaseBuilder 是 Spring 内置的数据库,所以不需要配置连接信息
* addScript() 中的 .ddl 文件是 Spring Security 提供的,用来创建鉴权用到的最基本的表
*
* @return DataSource
*/
@Bean
DataSource dataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.addScript("classpath:org/springframework/security/core/userdetails/jdbc/users.ddl")
.build();
}
}
配置 JdbcUserDetailsManager
/**
* 通过 JDBC 注册用户
*/
@Configuration
public class A05_JDBCConfig extends WebSecurityConfigurerAdapter {
// ...
/**
* JdbcUserDetailsManager 继承了 JdbcDaoImpl
* JdbcDaoImpl 实现了 UserDetailsService
* JdbcDaoImpl 会通过 JDBC 查询用户记录,JDBC 使用 DataSource Bean 来连接数据源
*
* JdbcUserDetailsManager 还实现了 UserDetailsManager 接口,用于管理 UserDetails
* 当配置为用户名密码鉴权时,Spring Security 会基于 UserDetails 进行鉴权
*
* @return UserDetailsService
*/
@Bean
UserDetailsManager users(DataSource dataSource) {
UserDetails user = User.builder()
.username("user")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER")
.build();
UserDetails admin = User.builder()
.username("admin")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER", "ADMIN")
.build();
JdbcUserDetailsManager users = new JdbcUserDetailsManager(dataSource);
users.createUser(user);
users.createUser(admin);
return users;
}
// ...
}
UserDetailsService
通过用户名查询用户记录,返回 UserDetails
DaoAuthenticationProvider
会验证 UserDetails
的有效性,然后返回一个 Authentication
Authentication
的 principal(主体)就是之前的 UserDetails
DaoAuthenticationProvider
使用 UserDetailsService
来检索用户名密码以及其它鉴权需要的属性
Spring Security 提供了 UserDetailsService
的 in-memory 和 JDBC 实现
可以自定义 UserDetailsService
Bean
由于上述鉴权逻辑是
DaoAuthenticationProvider
提供的
如果配置了AuthenticationManagerBuilder
或者AuthenticationProviderBean
自定义的AuthenticationManager
或者AuthenticationProvider
就会替代原来的组件
自定义的UserDetailsService
是否被使用,就取决于自定义的AuthenticationManager
是否用到它了
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
// ...
@Bean
UserDetailsService userDetailsService() {
return new CustomUserDetailsService();
}
// ...
}
Spring Security 的 Servlet 支持通过集成 PasswordEncoder
安全地存储密码
可以自定义 PasswordEncoder
Bean
DaoAuthenticationProvider
在验证用户名密码有效性时,会使用
PasswordEncoder.matches(CharSequence rawPassword, String encodedPassword)
package org.springframework.security.authentication.dao;
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
// ...
private PasswordEncoder passwordEncoder; // 48
// ...
@Override
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
// 69
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
// ...
}
DaoAuthenticationProvider
是一个 AuthenticationProvider
的实现
它利用 UserDetailsService
和 PasswordEncoder
来进行用户名密码鉴权
UsernamePasswordAuthenticationToken
中传递给 AuthenticationManager
AuthenticationManager
的实现是 ProviderManager
ProviderManager
被配置为使用 DaoAuthenticationProvider
来鉴权DaoAuthenticationProvider
找到 UserDetailsService
BeanUserDetailsService.loadUserByUsername()
获取 UserDetails
DaoAuthenticationProvider
使用 PasswordEncoder
Bean 来验证上一步返回的 UserDetails
中密码的有效性UsernamePasswordAuthenticationToken
类型的 Authentication
UserDetails
UsernamePasswordAuthenticationToken
将被鉴权 Filter 放入 SecurityContextHolder
中LDAP(Lightweight Directory Access Protocol)
轻型目录访问协议
是一项基于目录树结构的数据存储技术,类似 Zookeeper 的树结构存储
只是 Zookeeper 的强项是保证分布式环境下的有序性,存储的只是配置信息,并不建议被当做数据库使用
而 LDAP 是可以当数据库存储大量数据的
只是 LDAP 并不支持事务等数据库特性,适合用于查询频率远远大于增删改频率的场景
如果要存储的数据结构适合用树结构来表示,那么使用 LDAP 的查询效率是很高的
LDAP 服务自己来管理数据的存取,要使用 LDAP 的应用只需要和 LDAP 服务通信就行
一个企业的组织架构、人员职位特别适合树形结构
而这些又是用户体系的一部分,因此必然又是和权限控制绑定在一起的
因此 LDAP 常常被组织用作用户信息、角色信息的中心仓库和鉴权服务(即:用户权限数据库 + 鉴权授权服务)
Spring Security 在被配置为通过用户名密码鉴权时,支持基于 LDAP 的鉴权
尽管 LDAP 的鉴权是基于用户名密码的,但并没有使用 UserDetailsService
因为在使用 LDAP Bind Operation 时,并不会返回密码,因此应用无法自己校验密码的有效性
由于 LDAP 服务可以根据许多不同的场景做许多不同的配置
Spring Security 的 LDAP provider 被设计为完全可配置的
Spring Security 为鉴权和角色获取设计了各自独立的策略接口,并且提供了默认实现
通过配置默认实现可以满足大多数的应用场景
由 SessionManagementFilter
和 SessionAuthenticationStrategy
来完成和 HTTP 会话相关的功能
SessionManagementFilter
会将会话操作委托给自己持有的 SessionAuthenticationStrategy
常见的功能包括:会话超时检测、会话固定 攻击防护、限制一个已鉴权用户可以同时拥有多少个并发会话
SecurityContextRepository
负责在 Web 请求之间持久化 SecurityContext
(通常对应会话域)
SecurityContextRepository
的默认实现是 HttpSessionSecurityContextRepository
Servlet 容器把 HttpServletRequest
对象通过 DelegatingFilterProxy
传递给 Spring Security Bean
HttpSessionSecurityContextRepository
用 HttpServletRequest.getSession()
返回的 HttpSession
来持久化 SecurityContext
Spring Security 流程中,一般用 SecurityContextHolder
存取 SecurityContext
SecurityContextHolder
会根据配置使用 SecurityContextHolderStrategy
来存取 SecurityContext
多数时候 SecurityContextHolderStrategy
的实现是 ThreadLocalSecurityContextHolderStrategy
而 ThreadLocalSecurityContextHolderStrategy
是用 ThreadLocal
来存取 SecurityContext
的
注意:这里存取的 SecurityContext
和 SecurityContextRepository
持久化的是同一个对象
综上所述,SecurityContextHolder
在 ThreadLocal
线程范围内存取 SecurityContext
而 Servlet 容器会为每个请求分配一个线程,因此 SecurityContextHolder
本质上是在请求域维护 SecurityContext
SecurityContextRepository
则是在会话域维护 SecurityContext
请求首先会经过 SecurityContextPersistenceFilter
在其 doFilter() 中,就会通过 SecurityContextRepository
从 HttpSession
中载入会话域的 SecurityContext
如果会话域中没有,会新建 SecurityContext
然后把得到的 SecurityContext
通过 SecurityContextHolder.setContext()
放到请求域中
之后就通过 SecurityContextHolder
来获取 SecurityContext
在 SecurityContextPersistenceFilter
的 finally 语句块中,最终会清理 SecurityContextHolder
然后将本次请求域的 HttpSession
通过 SecurityContextRepository
存入会话域
请求会按顺序经过:
SecurityContextPersistenceFilter
LogoutFilter
UsernamePasswordAuthenticationFilter
SessionManagementFilter
ExceptionTranslationFilter
FilterSecurityInterceptor
SessionManagementFilter
的工作:
SecurityContextRepository
是否能通过 HttpServletRequest
找到 SecurityContext
SecurityContext
通过 SecurityContextRepository
立即持久化SecurityContextPersistenceFilter
也会做这个持久化工作SessionManagementFilter
中确认鉴权成功后第一时间持久化SecurityContext
HttpServletRequest
的 API 检查会话是否已超时InvalidSessionStrategy.onInvalidSessionDetected()
处理SessionManagementFilter
时 Session 已超时HttpSession
还未被删除,里边的内容也还在,但是已过期SessionAuthenticationStrategy.onAuthentication()
SessionAuthenticationStrategy
来插入一些与 HTTP Session 相关的额外的逻辑准确的说,应该把
SecurityContext
的存储分为【当前域】和【持久化域】
【当前域】用SecurityContextHolder
存取
【持久化域】用SecurityContextRepository
存取
只是,一般的,【当前域】的SecurityContextHolder
的策略一般为存入ThreadLocal
对应 Servlet 的请求域
【持久化域】的SecurityContextRepository
一般存入HttpSession
对应 Servlet 的会话域
注意:
SessionManagementFilter
是一个保险,但不一定会用上
例如 AbstractAuthenticationProcessingFilter
中,如果是鉴权请求,无论是否鉴权成功,都是不会调用 FilterChain.doFilter()
的
因此位于过滤链后边的 SessionManagementFilter
不会被调用,会直接回到 SecurityContextPersistenceFilter
的 finally 语句块中
SecurityContextPersistenceFilter
的 finally 语句块中就会把 SecurityContext
放入 SecurityContextRepository
之后用户再次请求时,由于 SecurityContextRepository
中能取到 SecurityContext
,SessionManagementFilter
会直接放过
OAuth2LoginAuthenticationFilter
、UsernamePasswordAuthenticationFilter
都继承了 AbstractAuthenticationProcessingFilter
在使用这些 Filter 后,大多数时候 SessionManagementFilter
什么都没做
由于 AbstractAuthenticationProcessingFilter
中也调用了 SessionAuthenticationStrategy.onAuthentication()
因此此时配置的 SessionAuthenticationStrategy
仍然会起作用
默认的 SessionAuthenticationStrategy
为 CompositeSessionAuthenticationStrategy
它会遍历一个 List
并挨个调用这些元素的 onAuthentication() 方法
默认的,列表中有一个 AbstractSessionFixationProtectionStrategy
的匿名对象
它的作用是防止 “ 会话固定 ” 攻击,如果在鉴权之前就存在 Session,会刷新 Session ID,让之前的 Session ID 失效
参考:Security Namespace Configuration
默认情况下
SessionManagementFilter
持有的 SessionAuthenticationStrategy
为 CompositeSessionAuthenticationStrategy
SessionManagementFilter
持有的 InvalidSessionStrategy
为 null会话固定
Spring Boot 自动配置
上一篇:Spring Security / Servlet Application / 大图景
样例代码:Spring Security Sample