qrcode

马上订阅,开启修仙之旅

Spring Security 架构简介

一、技术概述

1.1 Spring vs Spring Boot vs Spring Security

1.1.1 Spring Framework

Spring Framework 为开发 Java 应用程序提供了全面的基础架构支持。它包含了一些不错的功能,如 “依赖注入”,以及一些现成的模块:

  • Spring JDBC
  • Spring MVC
  • Spring Security
  • Spring AOP
  • Spring ORM

这些模块可以大大减少应用程序的开发时间。例如,在 Java Web 开发的早期,我们需要编写大量样板代码以将记录插入数据源。但是,通过使用 Spring JDBC 模块的 JDBCTemplate,我们可以仅通过少量配置将其简化为几行代码。

1.1.2 Spring Boot

Spring Boot 是基于 Spring Framework,它为你的 Spring 应用程序提供了自动装配特性,它的设计目标是让你尽可能快的上手应用程序的开发。以下是 Spring Boot 所拥有的一些特性:

  • 可以创建独立的 Spring 应用程序,并且基于 Maven 或 Gradle 插件,可以创建可执行的 JARs 和 WARs;
  • 内嵌 Tomcat 或 Jetty 等 Servlet 容器;
  • 提供自动配置的 “starter” 项目对象模型(POMS)以简化 Maven 配置;
  • 尽可能自动配置 Spring 容器;
  • 提供一些常见的功能、如监控、WEB容器,健康,安全等功能;
  • 绝对没有代码生成,也不需要 XML 配置。
1.1.3 Spring Security

Spring Security 是一个能够为基于 Spring 的企业应用系统提供声明式的安全访问控制解决方案的安全框架。它提供了一组可以在 Spring 应用上下文中配置的 Bean,充分利用了 Spring IoC(Inversion of Control 控制反转),DI(Dependency Injection 依赖注入)和 AOP(面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。

Spring Security 拥有以下特性:

  • 对身份验证和授权的全面且可扩展的支持
  • 防御会话固定、点击劫持,跨站请求伪造等攻击
  • 支持 Servlet API 集成
  • 支持与 Spring Web MVC 集成

Spring、Spring Boot 和 Spring Security 三者的关系如下图所示:

spring-boot-security

1.2 Spring Security 集成

目前 Spring Security 5 支持与以下技术进行集成:

  • HTTP basic access authentication
  • LDAP system
  • OpenID identity providers
  • JAAS API
  • CAS Server
  • ESB Platform
  • ……
  • Your own authentication system

在进入 Spring Security 正题之前,我们先来了解一下它的整体架构:

spring-security-arch

二、核心组件

2.1 SecurityContextHolder,SecurityContext 和 Authentication

最基本的对象是 SecurityContextHolder,它是我们存储当前应用程序安全上下文的详细信息,其中包括当前使用应用程序的主体的详细信息。如当前操作的用户是谁,该用户是否已经被认证,他拥有哪些角色权限等。

默认情况下,SecurityContextHolder 使用 ThreadLocal 来存储这些详细信息,这意味着 Security Context 始终可用于同一执行线程中的方法,即使 Security Context 未作为这些方法的参数显式传递。

获取当前用户的信息

因为身份信息与当前执行线程已绑定,所以可以使用以下代码块在应用程序中获取当前已验证用户的用户名:

1
2
3
4
5
6
7
8
Object principal = SecurityContextHolder.getContext()
.getAuthentication().getPrincipal();

if (principal instanceof UserDetails) {
String username = ((UserDetails)principal).getUsername();
} else {
String username = principal.toString();
}

调用 getContext() 返回的对象是 SecurityContext 接口的一个实例,对应 SecurityContext 接口定义如下:

1
2
3
4
5
// org/springframework/security/core/context/SecurityContext.java
public interface SecurityContext extends Serializable {
Authentication getAuthentication();
void setAuthentication(Authentication authentication);
}
Authentication

在 SecurityContext 接口中定义了 getAuthentication 和 setAuthentication 两个抽象方法,当调用 getAuthentication 方法后会返回一个 Authentication 类型的对象,这里的 Authentication 也是一个接口,它的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
// org/springframework/security/core/Authentication.java
public interface Authentication extends Principal, Serializable {
// 权限信息列表,默认是GrantedAuthority接口的一些实现类,通常是代表权限信息的一系列字符串。
Collection<? extends GrantedAuthority> getAuthorities();
// 密码信息,用户输入的密码字符串,在认证过后通常会被移除,用于保障安全。
Object getCredentials();
Object getDetails();
// 最重要的身份信息,大部分情况下返回的是UserDetails接口的实现类,也是框架中的常用接口之一。
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

以上的 Authentication 接口是 spring-security-core jar 包中的接口,直接继承自 Principal 类,而 Principal 是位于 java.security 包中,由此可知 Authentication 是 spring security 中核心的接口。通过这个 Authentication 接口的实现类,我们可以得到用户拥有的权限信息列表,密码,用户细节信息,用户身份信息,认证信息等。

2.2 小结

下面我们来简单总结一下 SecurityContextHolder,SecurityContext 和 Authentication 这个三个对象之间的关系,SecurityContextHolder 用来保存 SecurityContext (安全上下文对象),通过调用 SecurityContext 对象中的方法,如 getAuthentication 方法,我们可以方便地获取 Authentication 对象,利用该对象我们可以进一步获取已认证用户的详细信息。

SecurityContextHolder,SecurityContext 和 Authentication 的详细定义如下所示:

security-context-holder

三、身份验证

3.1 Spring Security 中的身份验证是什么?

让我们考虑一个每个人都熟悉的标准身份验证方案:

  • 系统会提示用户使用用户名和密码登录。
  • 系统验证用户名和密码是否正确。
  • 若验证通过则获取该用户的上下文信息(如权限列表)。
  • 为用户建立安全上下文。
  • 用户继续进行,可能执行某些操作,该操作可能受访问控制机制的保护,该访问控制机制根据当前安全上下文信息检查操作所需的权限。

前三项构成了身份验证进程,因此我们将在 Spring Security 中查看这些内容。

  • 获取用户名和密码并将其组合到 UsernamePasswordAuthenticationToken 的实例中(我们之前看到的Authentication 接口的实例)。
  • 令牌将传递给 AuthenticationManager 的实例以进行验证。
  • AuthenticationManager 在成功验证时返回完全填充的 Authentication 实例。
  • SecurityContext 对象是通过调用 SecurityContextHolder.getContext().setAuthentication(…) 创建的,传入返回的身份验证 Authentication 对象。

3.2 Spring Security 身份验证流程示例

了解完上述的身份验证流程,我们来看一个简单的示例:

AuthenticationManager 接口:

1
2
3
4
5
public interface AuthenticationManager {
// 对传入的authentication对象进行认证
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}

SampleAuthenticationManager 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SampleAuthenticationManager implements AuthenticationManager {
static final List<GrantedAuthority> AUTHORITIES = new ArrayList<GrantedAuthority>();

static {
AUTHORITIES.add(new SimpleGrantedAuthority("ROLE_USER"));
}

public Authentication authenticate(Authentication auth) throws AuthenticationException {
// 判断用户名和密码是否相等,仅当相等时才认证通过
if (auth.getName().equals(auth.getCredentials())) {
return new UsernamePasswordAuthenticationToken(auth.getName(),
auth.getCredentials(), AUTHORITIES);
}
throw new BadCredentialsException("Bad Credentials");
}
}

AuthenticationExample 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class AuthenticationExample {
private static AuthenticationManager am = new SampleAuthenticationManager();

public static void main(String[] args) throws Exception {
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));

while(true) {
System.out.println("Please enter your username:");
String name = in.readLine();
System.out.println("Please enter your password:");
String password = in.readLine();
try {
// 使用用户输入的name和password创建request对象,这里的UsernamePasswordAuthenticationToken
// 是前面提到的Authentication接口的实现类
Authentication request = new UsernamePasswordAuthenticationToken(name, password);
// 使用SampleAuthenticationManager实例,对request进行认证操作
Authentication result = am.authenticate(request);
// 若认证成功,则保存返回的认证信息,包括已认证用户的授权信息
SecurityContextHolder.getContext().setAuthentication(result);
break;
} catch(AuthenticationException e) {
System.out.println("Authentication failed: " + e.getMessage());
}
}
System.out.println("Successfully authenticated. Security context contains: " +
SecurityContextHolder.getContext().getAuthentication());
}
}

在以上代码中,我们实现的 AuthenticationManager 将验证用户名和密码相同的任何用户。它为每个用户分配一个角色。上面代码的验证过程是这样的:

1
2
3
4
5
6
7
8
9
10
Please enter your username:
semlinker
Please enter your password:
12345
Authentication failed: Bad Credentials
Please enter your username:
semlinker
Please enter your password:
semlinker
Successfully authenticated. Security context contains: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@441d0230: Principal: semlinker; Credentials: [PROTECTED]; Authenticated: true; Details: null; Granted Authorities: ROLE_USER

四、 核心服务

4.1 AuthenticationManager,ProviderManager 和 AuthenticationProvider

AuthenticationManager(接口)是认证相关的核心接口,也是发起认证的出发点,因为在实际需求中,我们可能会允许用户使用用户名 + 密码登录,同时允许用户使用邮箱 + 密码,手机号码 + 密码登录,甚至,可能允许用户使用指纹登录,所以要求认证系统要支持多种认证方式。

Spring Security 中 AuthenticationManager 接口的默认实现是 ProviderManager,但它本身并不直接处理身份验证请求,它会委托给已配置的 AuthenticationProvider 列表,每个列表依次被查询以查看它是否可以执行身份验证。每个 Provider 验证程序将抛出异常或返回一个完全填充的 Authentication 对象。

也就是说,Spring Security 中核心的认证入口始终只有一个:AuthenticationManager,不同的认证方式:用户名 + 密码(UsernamePasswordAuthenticationToken),邮箱 + 密码,手机号码 + 密码登录则对应了三个 AuthenticationProvider。

下面我们来看一下 ProviderManager 的核心源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// spring-security-core-5.2.0.RELEASE-sources.jar
// org/springframework/security/authentication/ProviderManager.java
public class ProviderManager implements AuthenticationManager, MessageSourceAware,
InitializingBean {
// 维护一个AuthenticationProvider列表
private List<AuthenticationProvider> providers = Collections.emptyList();

public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
boolean debug = logger.isDebugEnabled();

// 遍历providers列表,判断是否支持当前authentication对象的认证方式
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}

try {
// 执行provider的认证方式并获取返回结果
result = provider.authenticate(authentication);

if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException e) {
prepareException(e, authentication);
throw e;
} catch (AuthenticationException e) {
lastException = e;
}
}

// 若当前ProviderManager无法完成认证操作,且其包含父级认证器,则允许转交给父级认证器尝试进行认证
if (result == null && parent != null) {
try {
result = parentResult = parent.authenticate(authentication);
}
catch (ProviderNotFoundException e) {
}
catch (AuthenticationException e) {
lastException = parentException = e;
}
}


if (result != null) {
if (eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
// 完成认证,从authentication对象中移除私密数据
((CredentialsContainer) result).eraseCredentials();
}

// 若父级AuthenticationManager认证成功,则派发AuthenticationSuccessEvent事件
if (parentResult == null) {
eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}

// 未认证成功,抛出ProviderNotFoundException异常
if (lastException == null) {
lastException = new ProviderNotFoundException(messages.getMessage(
"ProviderManager.providerNotFound",
new Object[] { toTest.getName() },
"No AuthenticationProvider found for {0}"));
}

if (parentException == null) {
prepareException(lastException, authentication);
}

throw lastException;
}
}

在 ProviderManager 进行认证的过程中,会遍历 providers 列表,判断是否支持当前 authentication 对象的认证方式,若支持该认证方式时,就会调用所匹配 provider(AuthenticationProvider)对象的 authenticate 方法进行认证操作。若认证失败则返回 null,下一个 AuthenticationProvider 会继续尝试认证,如果所有认证器都无法认证成功,则 ProviderManager 会抛出一个 ProviderNotFoundException 异常。

4.2 DaoAuthenticationProvider

在 Spring Security 中较常用的 AuthenticationProvider 是 DaoAuthenticationProvider,这也是 Spring Security 最早支持的 AuthenticationProvider 之一。顾名思义,Dao 正是数据访问层的缩写,也暗示了这个身份认证器的实现思路。DaoAuthenticationProvider 类的内部结构如下:

dao-authentication-provider

在实际项目中,最常见的认证方式是使用用户名和密码。用户在登录表单中提交了用户名和密码,而对于已注册的用户,在数据库中已保存了正确的用户名和密码,认证便是负责比对同一个用户名,提交的密码和数据库中所保存的密码是否相同便是了。

在 Spring Security 中,对于使用用户名和密码进行认证的场景,用户在登录表单中提交的用户名和密码,被封装成了 UsernamePasswordAuthenticationToken,而根据用户名加载用户的任务则是交给了 UserDetailsService,在 DaoAuthenticationProvider 中,对应的方法就是 retrieveUser,虽然有两个参数,但是 retrieveUser 只有第一个参数起主要作用,返回一个 UserDetails。retrieveUser 方法的具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// spring-security-core-5.2.0.RELEASE-sources.jar
// org/springframework/security/authentication/dao/DaoAuthenticationProvider.java
protected final UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}

在 DaoAuthenticationProvider 类的 retrieveUser 方法中,会以传入的 username 作为参数,调用 UserDetailsService 对象的 loadUserByUsername 方法加载用户。

4.3 UserDetails 与 UserDetailsService

4.3.1 UserDetails 接口

在 DaoAuthenticationProvider 类中 retrieveUser 方法签名是这样的:

1
2
3
4
protected final UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
}

该方法返回 UserDetails 对象,这里的 UserDetails 也是一个接口,它的定义如下:

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

顾名思义,UserDetails 表示详细的用户信息,这个接口涵盖了一些必要的用户信息字段,具体的实现类对它进行了扩展。前面我们也介绍了一个 Authentication 接口,它与 UserDetails 接口的定义如下:

user-details-vs-authentication

虽然 Authentication 与 UserDetails 很类似,但它们之间是有区别的。Authentication 的 getCredentials() 与 UserDetails 中的 getPassword() 需要被区分对待,前者是用户提交的密码凭证,后者是用户正确的密码,认证器其实就是对这两者进行比对。

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

4.3.2 UserDetailsService 接口

大多数身份验证提供程序都利用了 UserDetailsUserDetailsService 接口。UserDetailsService 接口的定义如下:

1
2
3
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

在 UserDetailsService 接口中,只有一个 loadUserByUsername 方法,用于通过 username 来加载匹配的用户。当找不到 username 对应用户时,会抛出 UsernameNotFoundException 异常。UserDetailsService 和 AuthenticationProvider 两者的职责常常被人们搞混,记住一点即可,UserDetailsService 只负责从特定的地方(通常是数据库)加载用户信息,仅此而已。

UserDetailsService 常见的实现类有 JdbcDaoImpl,InMemoryUserDetailsManager,前者从数据库加载用户,后者从内存中加载用户,当然你也可以自己实现 UserDetailsService。

4.4 Spring Security Architecture

前面我们已经介绍了 Spring Security 的核心组件(SecurityContextHolder,SecurityContext 和 Authentication)和核心服务(AuthenticationManager,ProviderManager 和 AuthenticationProvider),最后我们再来回顾一下 Spring Security 整体架构:

spring-security-arch

五、参考资源


欢迎小伙伴们订阅前端全栈修仙之路,及时阅读 Angular、TypeScript、Node.js/Java和Spring技术栈最新文章。

qrcode