本文要点
Spring Security 是一个 Java/Jakarta EE 框架,为企业级应用程序提供了认证、授权和其他安全特性。
开发人员可以基于 Spring Security 的SecurityFilterChain
提供综合式的配置,以管理 CORS、CSRF 防护和身份认证过滤器,同时允许配置特定的端点,比如注册和登录。
可以策略性地使用访问令牌和刷新令牌,以平衡安全性问题和用户便利性,最大限度地减少令牌泄露的风险,同时增强用户体验。
Axios 可用于客户端应用程序,以便于高效处理基于令牌的请求,它的拦截器能够管理令牌插入和刷新场景,确保健壮和无缝的用户交互。
流程图可以更好地理解 Spring Security 在幕后是如何协调 API 调用的。
在本文中,我们将会使用Spring Security的基础设施、访问和刷新令牌,研究通过客户端 JavaScript 应用程序注册和认证用户的解决方案。
使用 Spring Security 的基础样例非常多,所以本文的目标是使用流程图更详细地描述可能的过程。
在 GitHub 的仓库中,你可以找到样例的源码。
注意:本文主要关注基础的成功场景,省略了错误和异常处理。
术语
认证是验证用户身份标识的过程。
授权是确定允许用户访问哪些资源和执行哪些操作的过程。
令牌(访问令牌)是一个数据实体,包含了识别用户身份或授予用户访问受限资源的必要信息。
刷新令牌是一种凭证,它能够让客户端应用获取新的访问令牌,而无需用户再次登录。刷新令牌的理念涉及到安全性和用户便利性之间的权衡。保留一个长时间有效的访问令牌会带来泄露的风险,而频繁提示用户进行登录又会降低用户的体验。刷新令牌通过如下方式解决了这个问题:
允许客户端应用在访问令牌过期后获取一对新的令牌,而无需用户再次登录。
减少访问令牌受攻击的时间窗口。
基本处理流程与 Spring Security 配置列表
系统支持如下的基本场景:
用户注册
通过登录表单进行用户的认证和授权,然后重定向到用户的页面
业务处理 - 请求已注册的用户数
令牌刷新
Spring Security 的整体配置可以通过SecurityConfiguration
类中定义的filterChain()
方法来实现:
@Bean
SecurityFilterChain filterChain(final HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.exceptionHandling(configurer -> configurer
.accessDeniedHandler(accessDeniedHandler))
.sessionManagement(configurer -> configurer
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(SIGNIN_ENTRY_POINT).permitAll()
.requestMatchers(SIGNUP_ENTRY_POINT).permitAll()
.requestMatchers(SWAGGER_ENTRY_POINT).permitAll()
.requestMatchers(API_DOCS_ENTRY_POINT).permitAll()
.requestMatchers(TOKEN_REFRESH_ENTRY_POINT).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(buildLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class);
http.oauth2Login(configurer -> configurer
.authorizationEndpoint(config -> config
.authorizationRequestRepository(authorizationRequestRepository()))
.failureHandler(failureHandler)
.successHandler(oauth2AuthenticationSuccessHandler));
return http.build();
}
复制代码
下面,我们逐一分析每种场景。
用户注册
当用户在注册表单中填完所有必填的字段并提交请求后,将会执行如图 1 所示的步骤:
图 1:用户注册
为了允许访问/signup
端点,并绕过 Spring Security 默认的认证需求,我们需要配置 Spring Security 允许未认证的用户访问这个特殊的端点。这可以通过修改安全配置来实现,即将/signup
端点排除在认证要求之外。
如下代码展示了如何使用SecurityConfiguration
类中定义的上述filterChain()
方法来配置 Spring Security,以允许访问/signup
端点:
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(SIGNIN_ENTRY_POINT).permitAll()
.requestMatchers(SIGNUP_ENTRY_POINT).permitAll()
.requestMatchers(SWAGGER_ENTRY_POINT).permitAll()
.requestMatchers(API_DOCS_ENTRY_POINT).permitAll()
.requestMatchers(TOKEN_REFRESH_ENTRY_POINT).permitAll()
.anyRequest().authenticated()
)
复制代码
下一个重要的地方的是配置中包含了一个令牌过滤器,它会拦截所有的请求并检查请求中的令牌,如filterChain()
方法中这一部分所示:
.addFilterBefore(buildLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class);
复制代码
为了在注册请求中排除这种验证,我们需要在构建令牌过滤器的时候指定如何识别该过滤器用于哪些路径的机制。我们看一下SecurityConfiguration
类中定义的buildTokenAuthenticationFilter()
方法:
protected TokenAuthenticationFilter buildTokenAuthenticationFilter() {
List<String> pathsToSkip = new ArrayList<>(Arrays.asList(SIGNIN_ENTRY_POINT, SIGNUP_ENTRY_POINT, SWAGGER_ENTRY_POINT, API_DOCS_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT));
SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip);
TokenAuthenticationFilter filter = new TokenAuthenticationFilter(jwtTokenProvider, matcher, failureHandler);
filter.setAuthenticationManager(this.authenticationManager);
return filter;
}
复制代码
在这里,我们使用了SkipPathRequestMatcher
类(如下所示),它将pathsToSkip
参数中声明的路径从过滤器的路径中排除掉了(在样例中,我们将SIGNUP_ENTRY_POINT
添加到了这个数组中)。
public class SkipPathRequestMatcher implements RequestMatcher {
private final OrRequestMatcher matchers;
public SkipPathRequestMatcher(final List<String> pathsToSkip) {
Assert.notNull(pathsToSkip, "List of paths to skip is required.");
List<RequestMatcher> m = pathsToSkip.stream()
.map(AntPathRequestMatcher::new)
.collect(Collectors.toList());
matchers = new OrRequestMatcher(m);
}
@Override
public boolean matches(final HttpServletRequest request) {
return !matchers.matches(request);
}
}
复制代码
用户通过表单进行认证和授权
如图 2 所示,当请求成功绕过令牌过滤器后,就会由业务控制器来进行处理:
图 2:用户通过表单进行认证和授权
客户端向服务器端点/login
发送用户名和密码。
为了让LoginAuthenticationFilter
拦截请求,我们需要相应地配置 Spring Security:
@Bean
protected LoginAuthenticationFilter buildLoginProcessingFilter() {
LoginAuthenticationFilter filter = new LoginAuthenticationFilter(SIGNIN_ENTRY_POINT, authenticationSuccessHandler, failureHandler);
filter.setAuthenticationManager(this.authenticationManager);
return filter;
}
复制代码
注意,在创建过滤器的时候,除了 URI 之外,我们还声明了认证成功和失败时的处理器,以及认证管理器。下面我们会对其进行详细讨论。
List<String> pathsToSkip = new ArrayList<>(Arrays.asList(SIGNIN_ENTRY_POINT, SIGNUP_ENTRY_POINT, SWAGGER_ENTRY_POINT, API_DOCS_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT));
复制代码
@Bean
SecurityFilterChain filterChain(final HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 构建器配置
.addFilterBefore(buildLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class);
// 构建器配置
return http.build();
}
复制代码
在LoginAuthenticationFilter
类中,我们重写了 Spring 在执行过滤器时要调用的两个方法。第一个方法是attemptAuthentication()
,在这个方法中,我们初始化了一个发往AuthenticationManager
的认证请求,而AuthenticationManager
是我们在创建过滤器的时候提供的。但是,管理器(manager)本身并不会执行认证操作,它只会作为处理该任务的提供器(provider)的容器。AuthenticationManager
接口会负责找到合适的提供器并将请求传递给它。如下是创建管理器和注册管理器的过程:
@Bean
public AuthenticationManager authenticationManager(final ObjectPostProcessor<Object> objectPostProcessor) throws Exception {
var auth = new AuthenticationManagerBuilder(objectPostProcessor);
auth.authenticationProvider(loginAuthenticationProvider);
auth.authenticationProvider(tokenAuthenticationProvider);
auth.authenticationProvider(refreshTokenAuthenticationProvider);
return auth.build();
}
复制代码
接下来,管理器会被声明为所有已创建过滤器的参数。
为了让AuthenticationManager
能够找到所需的提供器(在我们的样例中,也就是LoginAuthenticationProvider
),在提供器本身中,需要声明它所支持的类型,如下面的supports()
方法所示:
@Override
public boolean supports(final Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
复制代码
在样例中,我们声明提供器能够支持UsernamePasswordAuthenticationToken
类。当在过滤器中创建UsernamePasswordAuthenticationToken
类型的对象并将其传递给AuthenticationManager
时,它就能使用LoginAuthenticationFilter
类中定义的attemptAuthentication()
方法,根据对象类型正确地找到所需的提供器:
@Override
public Authentication attemptAuthentication(final HttpServletRequest request, final HttpServletResponse response) throws AuthenticationException {
// some code above
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
token.setDetails(authenticationDetailsSource.buildDetails(request));
return this.getAuthenticationManager().authenticate(token);
}
复制代码
在AuthenticationManager
找到了所需的提供器之后,它会调用authenticate()
方法,提供器会直接对用户的登录名和密码进行验证。然后,将结果返回给过滤器。
我们重写的第二个方法是successfulAuthentication()
,在 Spring 认证成功后会调用该方法。处理认证成功的任务落在了 Spring Security 的AuthenticationSuccessHandler
接口上,当我们创建过滤器时(如前文所述)声明了该接口。该处理器有一个重写方法onAuthenticationSuccess()
,我们一般会在该方法中记录生成的令牌并为请求设置请求成功的响应码。
// LoginAuthenticationSuccessHandler
@Override
public void onAuthenticationSuccess(final HttpServletRequest request, final HttpServletResponse response, final Authentication authentication) throws IOException {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
JwtPair jwtPair = tokenProvider.generateTokenPair(userDetails);
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
JsonUtils.writeValue(response.getWriter(), jwtPair);
}
复制代码
接下来,Spring 的基础设施会将成功的响应转发给客户端。
业务处理 - 请求已注册的用户数
在本样例中,我们将业务请求定义为检索数据库中用户的数量。预期行为是这样的,对于已登录用户的所有请求,我们就会检查令牌。令牌校验的流程是由TokenAuthenticationFilter
发起的,然后按照与前文类似的流程,请求会委托给TokenAuthenticationProvider
。在验证成功之后,过滤器将请求转发给 web 应用的标准过滤器链,最终,请求会抵达业务控制器AuthController
,如图 3 所示。
图 3:请求已注册的用户数
客户端发送一个请求到服务器的端点/users/count
,并在请求中附带令牌。
为了让TokenAuthenticationFilter
拦截该请求,我们需要在 Spring Security 中对其进行配置:
protected TokenAuthenticationFilter buildTokenAuthenticationFilter() {
List<String> pathsToSkip = new ArrayList<>(Arrays.asList(SIGNIN_ENTRY_POINT, SIGNUP_ENTRY_POINT, SWAGGER_ENTRY_POINT, API_DOCS_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT));
SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip);
TokenAuthenticationFilter filter = new TokenAuthenticationFilter(jwtTokenProvider, matcher, failureHandler);
filter.setAuthenticationManager(this.authenticationManager);
return filter;
}
复制代码
和前面的过滤器一样,我们声明了AuthenticationManager
,这个类会被调用以寻找提供器
@Bean
SecurityFilterChain filterChain(final HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 构建器配置
.addFilterBefore(buildLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class);
// 构建器配置
return http.build();
}
复制代码
为了让AuthenticationManager
找到所需的提供器,我们使用了authenticationManager()
方法:
@Bean
public AuthenticationManager authenticationManager(final ObjectPostProcessor<Object> objectPostProcessor) throws Exception {
var auth = new AuthenticationManagerBuilder(objectPostProcessor);
auth.authenticationProvider(loginAuthenticationProvider);
auth.authenticationProvider(tokenAuthenticationProvider);
auth.authenticationProvider(refreshTokenAuthenticationProvider);
return auth.build();
}
复制代码
@Override
public boolean supports(final Class<?> authentication) {
return (JwtAuthenticationToken.class.isAssignableFrom(authentication));
}
复制代码
所以,过滤器应该构建一个JwtAuthenticationToken
对象。AuthenticationManager 会基于其类型,查找适当的提供器并发送该对象,以便于TokenAuthenticationFilter
类中定义的attemptAuthentication()
方法进行认证。
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
return getAuthenticationManager().authenticate(new JwtAuthenticationToken(tokenProvider.getTokenFromRequest(request)));
}
复制代码
认证成功之后,successfulAuthentication()
会将原始的请求转发给标准过滤器链,在这里它最终会抵达业务控制器AuthController
。
令牌刷新
令牌刷新的过程如图 4 所示。
图 4:令牌刷新
令牌刷新的过程与登录的过程非常类似:
客户端发送令牌刷新请求到/refreshToken
端点。
请求被RefreshTokenAuthenticationFilter
拦截,因为端点的 URI 包含在了该过滤器允许的 URI 列表中。
过滤器使用 attemptAuthentication()方法尝试进行认证,访问AuthenticationManager
,后者又会调用RefreshTokenAuthenticationProvider
。如前文的两个样例所述,这个提供器之所以能够被选中,是因为它支持一个特定的类型,也就是我们在过滤器中所构造的RefreshJwtAuthenticationToken
:
@Override
public boolean supports(final Class<?> authentication) {
return (RefreshJwtAuthenticationToken.class.isAssignableFrom(authentication));
}
复制代码
认证成功之后,与登录过程一样,successAuthentication()
方法会调用相同的处理器LoginAuthenticationSuccessHandler
,它会在响应中记录生成的令牌对。
客户端的流程描述
要使用流程图来说明 JavaScript 应用端的流程似乎比较麻烦,因为流程的分支取决于服务器的响应。因此,我们直接关注代码,并逐步描述代码中发生的情况,我们看一下apiClient.js
文件:
// 导入语句
const userStore = useUserStore();
// axios客户端初始化
const apiClient = axios.create({
baseURL: process.env.API_URL
});
// 从userStore中添加令牌
function authHeader() {
let token = userStore.getToken;
if (token) {
return {Authorization: 'Bearer ' + token};
} else {
return {};
}
}
// 添加拦截器以便于在每个请求中包含令牌
apiClient.interceptors.request.use(function (config) {
config.headers = authHeader();
return config;
});
// 添加处理每个响应的拦截器
apiClient.interceptors.response.use(function (response) {
return response; // 成功响应
}, function (error) { // 失败响应
const req = error.config;
if (isTokenExpired(error)) {
if (isRefreshTokenRequest(req)) {
//refreshToken已过期,清除令牌信息并转发至登录页面
clearAuthCache();
window.location.href = '/login?expired=true';
}
// 令牌已过期,需要刷新令牌
return authService.refreshToken(userStore.getRefreshToken).then(response => {
// 将新令牌对保存到存储中
userStore.login(response);
// 重复原始的业务请求
return apiClient.request(req);
});
}
// 在失败的认证中,我们在服务器端设置了401代码
// 包含不正确或空令牌
if (error.response?.status === 401) {
clearAuthCache();
}
return Promise.reject(error);
});
export default apiClient;
复制代码
我们使用Axios库向服务器发送请求。
在 Axios 中注册一个拦截器,它会拦截所有的请求并向请求中添加令牌(借助authHeader()
方法)。
我们在 Axios 中注册了一个响应拦截器,它会拦截所有的响应并执行如下的逻辑:
如果响应失败的话,我们会检查状态码:
如果响应中包含401
状态码(比如,令牌无效或缺失令牌),我们会删除现有令牌的所有信息,并重定向到登录页面。
如果响应中包含令牌过期代码(该代码是由服务器在TokenAuthenticationProvider
和RefreshTokenAuthenticationProvider
进行令牌检验时生成的),我们会额外检查原始请求是否为令牌刷新请求:
如果原始请求是普通的业务请求,则令牌过期信息表明访问令牌已经过期。为了刷新访问令牌,我们发送一个带有refreshToken
的刷新请求。然后,我们会保存响应中的新令牌对,并使用更新后的令牌重复原始的业务请求。
如果原始请求是令牌刷新请求,令牌过期信息则表明refreshToken
也已经过期了。此时,需要用户再次进行登录。因此,我们会删除现有令牌的所有信息,并重定向到登录页面。
如果响应成功的话,我们将其转发到客户端。
结论
在本例中,我们使用流程图详细研究了使用 Spring Security 和令牌的几个关键流程。超出本文范围的是异常处理和 OAuth2,我们将在其他文章中单独介绍。
作者简介:
Alexandr Manunin 拥有超过 15 年的 IT 行业工作经验,具有系统和业务分析背景。目前,他在一个金融科技项目中担任全栈工程师(JavaScript/Java)。Alexandr 感兴趣的领域包括金融科技、安全、分析可视化和图数据结构。
原文链接:
Spring Security Configuration with Flow Diagrams
评论