39 KiB
- Spring Security
Spring Security
Spring Security简介
Spring Security作为一个安全框架,向使用者提供了用户认证、授权、常规攻击保护等功能。
Spring Security自动配置
默认情况下,在引入Spring Security的启动器依赖之后,Spring Boot自动配置会做如下工作:
- 启用Spring Security的默认配置,创建一个的bean对象,bean对象名称为“springSecurityFilterChain”,bean对象类型为SecurityFilterChain,实现了Filter。该bean对象为负责应用中所有与安全相关的操作(例如验证提交的username和password,保护应用的url等)
- 创建一个UserDetailsService的bean对象,并且产生一个为”user“的username和一个随机产生的password,随机产生的password会输出在console日志中
- 将名为springSecurityFilterChain的bean对象注册到servlet容器中,用来对每个servlet请求进行处理
Spring Security会通过Spring Security会通过BCtypt(Hash解密算法)来对密码的存储进行保护
Spring Security结构
DelegatingFilterProxy
Spring提供了Filter的一个实现类DelegatingFilterProxy,其将Servlet容器的生命周期和Spring的ApplicationContext连接在一起。
DelegatingFilterProxy会通过标准的servlet容器机制被注册,但是将所有工作都委托给Spring容器中实现了Filter的bean对象。
DelegatingFilterProxy会在ApplicationContext中查找实现了Filter的bean对象,并且调用该bean对象的doFilter方法
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { // Lazily get Filter that was registered as a Spring Bean // For the example in DelegatingFilterProxy delegate is an instance of Bean Filter0 Filter delegate = getFilterBean(someBeanName); // delegate work to the Spring Bean delegate.doFilter(request, response); }
FilterChainProxy
Spring Security支持FilterChainProxy,FilterChainProxy是一个由Spring Security提供的特殊Filter,FilterChainProxy通过SecurityFilterChain允许向多个Filters委托工作。
FilterChainProxy是一个bean对象,通过被包含在DelegatingFilterProxy中。
SecurityFilterChain
SecurityFilterChain通常被FilterChainProxy使用,用来决定在该次请求中调用哪个Spring Security Filters。
SecurityFilters
Security Filters通过SecurityFilterChain API被插入到FilterChainProxy中。
添加自定义filter到SecurityFilterChain
大多数情况下,默认的security是足够的,但是,也允许向security filter chain中添加自定义过滤器。
如下是一个自定义过滤器的示例,示例中自定义filter会在认证后校验用户对于请求的租户是否拥有权限:
import java.io.IOException;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String tenantId = request.getHeader("X-Tenant-Id"); //(1)
boolean hasAccess = isUserAllowed(tenantId); //(2)
if (hasAccess) {
filterChain.doFilter(request, response); //(3)
return;
}
throw new AccessDeniedException("Access denied"); //(4)
}
}
如下是自定义过滤器链的流程:
- 步骤从请求header中获取了tenantId
- 步骤校验了用户对于该tenant id代表的租户资源是否拥有访问权限
- 只有当用户对租户资源拥有访问权限时,才会继续调用filterChain中剩余的部分
- 如果用户没有访问权限,将会抛出AccessDeniedException
在定义完自定义过滤器后,还需要将自定义filter添加到Security Filter Chain中,添加代码如下所示例:
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...
.addFilterBefore(new TenantFilter(), AuthorizationFilter.class);
return http.build();
}
由于权限校验操作在身份认证之后,故而将TenantFilter加到AuthorizationFilter之前可以保证在执行TenantFilter时,所有的身份认证操作都已经执行完成。
在向SecurityFilterChain注册filter时,不应该将自定义filter注册为bean,因为spring会将注册为bean的filter对象都添加到内置容器中,这会造成注册为bean的filter被调用两次:一次被Spring Seurity调用,一次被container调用。
处理Security异常
ExceptionTranslationFilter将AccessDeniedException和AuthenticationException翻译为http response。
ExceptionTranslationFilter被插入到FilterChainProxy中,作为SecurityFilterChain中的一个filter。
如果应用程序没有抛出AccessDeniedException或AuthenticationException,那么ExceptionTranslationFilter并不会做任何事情。
ExceptionTranslationFilter的处理流程:
- ExceptionTranslationFilter首先会调用
filterChain.doFilter来执行后续的逻辑 - 在执行后续请求时,如果抛出了AuthenticationException,那么则会开启认证流程
- 清空SecurityContextHolder
- 保存HttpServletRequest,当认证通过时,还能重新发送原始请求
AuthenticationEntryPoint用于重新请求用户的凭证,其可能会重定向到登录页面或是发送www-authenticateheader
- 在执行后续请求时,如果抛出了AccessDeniedException,那么会调用
AccessDeniedHandler用于处理访问拒绝。 - 如果执行后续逻辑时,抛出了非
AuthenticationException和AccessDeniedException的异常,那么异常将会被重新抛出,ExceptionTranslationFilter并不会针对其进行处理
// ExceptionTranslationFilter的伪代码
try {
filterChain.doFilter(request, response);
} catch (AccessDeniedException | AuthenticationException ex) {
if (!authenticated || ex instanceof AuthenticationException) {
startAuthentication();
} else {
accessDenied();
}
}
在多次认证之间保存request
如果用户在发送请求访问需要认证的资源时,尚未经过身份认证,那么需要将本次请求保存起来,并且对用户进行认证,待用户认证通过之后,再重新执行保存的原始请求。在Spring Security中,是通过RequestCache来实现保存请求的。
RequestCache
HttpServletRequest被保存在RequestCache中,当用户经过重新认证后,RequestCache将会用于重新发送原始请求。
在Spring Security中,在sendStartAuthentication时会像requestCache中存储request,并且重新执行认证流程,认证通过后,将会重新发送原始请求。
RequestCacheAwareFilter负责请求的重新发送。
默认情况下,会使用HttpSessionRequestCache,如下代码展示了HttpSessionRequestCache的自定义使用:
@Bean
DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
// 匹配本次request时,需要确保请求url参数中具有continue参数
requestCache.setMatchingRequestParameterName("continue");
http
// ...
.requestCache((cache) -> cache
.requestCache(requestCache)
);
return http.build();
}
由于在认证失败时存储原始请求会占用内存,如果想要禁止在抛出AuthenticationException后,保存原始请求到RequestCache的行为,可以为SecurityFilterChain指定NullRequestCache:
@Bean
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
RequestCache nullRequestCache = new NullRequestCache();
http
// ...
.requestCache((cache) -> cache
.requestCache(nullRequestCache)
);
return http.build();
}
Spring Security身份认证架构
SecurityContextHolder
SecurityContextHolder用于存储用户的认证信息,是Spring Security身份认证认证模型的核心,SecurityContextHolder中包含有SecurityContextHolder,SecurityContextHolder负责将当前线程和SecurityContext相关联。
如果想要将用户表示为已经通过身份认证,最简单的方式是向SecurityContextHolder中设置值,示例如下:
SecurityContext context = SecurityContextHolder.createEmptyContext(); // 1
Authentication authentication =
new TestingAuthenticationToken("username", "password", "ROLE_USER"); //2
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context); //3
上述流程如下所示:
- 创建了一个空的SecurityContext。在最开始,应该通过createEmptyContext方法创建一个空的SecurityContext,而不是使用
getContext().setAuthentication(authentication),从而避免在多线程下的竞争场景 - 第二步中,创建了一个Authentication对象,Spring Security不关注向SecurityContext中塞入了哪种类型的Authentication实现
- 在第三步中,将SecurityContext设置到了SecurityContextHolder中,spring使用设置的SecurityContext信息进行身份认证
如果后续操作想要访问当前已认证用户的信息,可以访问SecurityContextHolder。
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
在默认情况下,SecurityContextHolder使用ThreadLocal来存SecurityContext,故而SecurityContext在同一线程内可以共享。在SecurityContextHolder中使用ThreadLocal是安全的,不会发生当前请求完成而SecurityContext未被清理的情况,Spring的FilterChainProxy保证SecurityContext会被清理。
在一些场景下,可能不希望使用ThreadLocal来存储ContextHolder,例如在swing程序下,可能希望整个java虚拟机中的线程都共享同一个security context。可以在启动时为SecurityContextHolder指定一个strategy,该strategy决定context应该如何被存储。
对于应用程序内所有线程共享的security context,可以使用SecurityContextHolder.MODE_GLOBAL策略。
SecurityContext
Security Context用于存储用户的身份认证信息,从SecurityContextHolder中获取。
Authentication
Authentication在Spring Security中具有两种使用场景:
- 在用户尚未被认证的场景,向
AuthenticationManager提供用于身份认证的凭证 - 在用户已经被认证的场景,Authentication用于代表当前已经通过认证的用户,此时可以通过SecurityContext来获取用户认证信息
Authentication包含有如下信息:
- 主体(principal):用于标识用户,当通过username/password进行认证时,其通常是UserDetails类的实例
- 凭据(credentials):通常是password,在许多场景下为了保证凭证不会被泄露,在用户通过认证后,凭证将会被清空
- 权限(authorities):代表用户被授予的权限,为
GrantedAuthority的实例集合
GrantedAuthority
GrantedAuthority是用户被授予的权限。
可以通过Authentication.getAuthorities()方法来获取用户被授予的权限集合,该方法会返回GrantedAuthority的集合。
AuthenticationManager
AuthenticationManager定义了Security Filters如何执行认证。
AuthenticationManager会对传递给其的Authentication执行认证操作,如果认证成功,会返回一个被完全填充的Authetication对象。
在AuthenticationManager的authenticate方法返回Authentication对象之后,调用AuthenticationManager的controller会将返回的Authentication对象设置到SecurityContextHolder中。
AuthenticationManager的实现可以是任何类,最通用的实现类是ProviderManager.
ProviderManager
ProviderManager是最常用的AuthenticationManager实现类,ProviderMananger将认证工作委托给了一个AuthenticationProvider的实例集合。在集合中的每一个AuthenticationProvider,都可以决定当前认证工作是认证成功、认证失败还是将认证工作交由集合中的后续AuthenticationProvider决定。
如果AuthenticationProvider集合中没有任何AuthenticationProvider可以决定当前认证工作是成功或是失败,那么当前认证工作会失败,并且抛出ProviderNotFoundException异常。该异常代表当前的认证方式没有被支持。
在实践中,每个AuthenticationProvider都明确其应该负责的认证方式,例如一个AuthenticationProvider负责用户名/密码形式的登录,另一个AuthenticationProvider负责saml形式的登录。故而,可以在支持多种认证方式的情况下,只通过一个AuthenticationManager bean对象来进行整合多种认证方式。
对于ProviderManager,可以配置一个parent ProviderManager,当本级没有AuthenticationProvider能够完成认证工作时,将会将认证工作委托给父级manager。
默认情况下,ProviderManager将会在认证成功后,清空任任何成功登录后返回Authenticaiton对象的凭证信息。
AuthenticationProvider
AuthenticationProvider负责执行特定类型的认证工作,可以将多个AuthenticationProvider对象注入到同一个ProviderMananger中。
例如,DaoAuthenticationProvider支持基于用户名/密码的认证,JwtAuthenticationManager则是支持基于jwt形式的认证。
AuthenticationEntryPoint
AuthenticationEntryPoint用于服务端向用户请求凭证(例如,重定向到登录页面,向客户端发送WWW-Authenticate响应等。)
一些场景下,用户在请求资源时会主动发送在请求中包含凭据。在这些场景下,spring security不用向客户返回相应来要求客户提供凭证。
在其他场景下,客户端可能在未认证的情况下发送请求来访问资源,这时,可以使用AuthenticationEntryPoint的实现来向客户请求凭证。AuthenticationEntryPoint的实现可以重定向到登录页,或是向客户端发送WWW-Authenticate响应,或是采用其他方式来向客户端请求凭据。
AbstractAuthenticationProcessingFilter
AbstractAuthenticationProcessingFilter用于认证用户的凭据。在凭据可以被认证之前,spring security通过会通过AuthentciationEntryPoint来向客户请求凭据。
AbstractAuthenticationProcessingFilter通过如下流程来执行认证:
- 当用户提交凭据时,AbstractAuthenticationProcessingFilter会根据将要被认证的request来创建一个Authentication对象,创建Authentication的类型基于AbstractAuthenticationProcessingFilter的继承类。例如,UsernamePasswordAuthenticationFilter将会基于request中提交的username和password创建一个UsernamePasswordAuthenticationToken
- 接下来,Authentication将会被传递给AuthenticationMananger用于认证
- 如果认证失败,那么
- SecurityContextHolder将会被清空
- RememberMeServices.loginFail方法将会被调用,如果remeberMe没有被调用,那么将不会执行任何操作
- AuthenticationFailureHandler将会被调用
- 如果认证成功:
- 将会通知SessionAuthenticaitonStrategy将会被提示有一个新的登录
- Authentication将会被设置到SecurityContextHolder中。如果想要在多个请求之前保存context,需要手动调用
SecurityContextRepository#saveContext方法,调用后,SecurityContext可以在后续的请求中自动被设置 - 调用RememberMeServices.loginSuccess方法,如果没有设置rememberMe,那么不会执行任何操作
- ApplicationEventPublisher将会发不InteractiveAuthenticationSuccessEvent事件
- AuthenticationSuccessHandler将会被调用
Username/Password Authentication
认证用户时最广泛使用的认证方式为用户名/密码认证。Spring Security对基于用户名和密码的认证提供了全面的支持。
可以通过如下方式来配置用户名和密码认证:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails userDetails = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(userDetails);
}
}
上述示例向SecurityFilterChain中自动注册了InMemoryUserDetailsService,并且向默认的AuthenticationManager中注册了DaoUserAuthenticationProvider,并且启用了FormLogin和HttpBasic的认证。
发布AuthenticationManager bean
当想要自定义认证过程时,可以将AuthenticationManager发布为bean对象,示例如下:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/login").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(authenticationProvider);
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails userDetails = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(userDetails);
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
在上述示例中,没有指定formLogin或是httpBasic来执行身份认证,并且针对login请求,其权限校验为permitAll,故而login请求的实际身份认证工作由自定义的loginController来执行
当添加了上述Configuration类后,可以指定自定义的Controller类:
@RestController
public class LoginController {
private final AuthenticationManager authenticationManager;
public LoginController(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
@PostMapping("/login")
public ResponseEntity<Void> login(@RequestBody LoginRequest loginRequest) {
Authentication authenticationRequest =
UsernamePasswordAuthenticationToken.unauthenticated(loginRequest.username(), loginRequest.password());
Authentication authenticationResponse =
this.authenticationManager.authenticate(authenticationRequest);
// ...
}
public record LoginRequest(String username, String password) {
}
}
自定义AuthenticationManager
默认情况下,SpringSecurity会在内部构建一个AuthenticationManager,该AuthenticationManager由DaoAuthenticationProvider组成,用于对username/password进行认证。
为了实现AuthenticationMananger的自定义,可以发布一个AuthenticationManager bean对象,spring security会使用发布的bean对象。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/login").permitAll()
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
ProviderManager providerManager = new ProviderManager(authenticationProvider);
providerManager.setEraseCredentialsAfterAuthentication(false);
// provider为最常用的AuthenticationManager的实现类
return providerManager;
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails userDetails = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(userDetails);
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
除了发布自定义的AuthenticaitonMananger之外,还可以通过修改AuthenticationManagerBuilder bean对象来实现。
AuthenticationManagerBuilder被发布为bean对象,并且用于构建Spring Security的全局AuthenticationManager,可以按如下方式来自定义:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// ...
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
// Return a UserDetailsService that caches users
// ...
}
@Autowired
public void configure(AuthenticationManagerBuilder builder) {
builder.eraseCredentials(false);
}
}
如果想要自定义登录页,可以通过如下方式:
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.formLogin(form -> form
.loginPage("/login")
.permitAll()
);
// ...
}
并且在使用spring mvc时,需要将/login和页面模板映射:
@Controller
class LoginController {
@GetMapping("/login")
String login() {
return "login";
}
}
上述permitAll方法确保了所有用户对loginUrl,loginProcessingUrl,failureUrl都拥有访问权限。
从请求中读取Username/Password
Spring Security提供如下方式来从请求中读取Username和password:
- Form
- Basic
- Digest
Form
在http用户名和密码被作为http form的形式(x-www-form-urlencoded)被传递时,Spring Security支持获取form形式的username和password。
默认情况下,Spring Security form login被开启,但是如果在应用手动配置了SecurityFilterChain,那么必须显式调用formLogin来指定。
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.formLogin(withDefaults());
// ...
}
非/login请求时,UsernamePassword并不执行认证校验逻辑
当未认证用户访问受保护资源时,AuthorizationFilter会抛出AccessDenied异常,导致重定向到登录页面。
UsernamePasswordAuthenticationFilter当请求url并不为/login时,并不执行认证逻辑,而是交由后续AuthorizationFilter来校验当前用户是否由访问资源的权限。
UsernamePasswordAuthenticationFilter只是在用户发送/login请求执行登录时,才通过authenticationManager来执行认证逻辑。
HttpBasic
在使用httpBasic时,如果未经认证的用户请求受保护资源,会返回WWW-Authenticate的header,此时客户端应该在下次提交时在请求中添加用户名和密码。
在使用httpBasic时,应该在请求的Authorizationheader中添加username和password的内容,形式如下:
Authorization: Basic <credentials>
其中<credentials>应该通过base64进行加密
Digest
目前应用中不应使用Digest。
password storage
in-memory authentication
InMemoryUserDetailsManager实现了UserDetailsService,其将用户名和密码存储在内存中,并且提供了基于username和password的认证。
使用实例如下所示:
@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来自动对传入密码进行编码处理:
@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);
}
jdbc authentication
JdbcDaoImpl实现了UserDetailsService,支持基于用户名和密码的认证,并且将密码存储在数据库中。
UserDetails
UserDetails由UserDetailsService返回,DaoAuthenticationProvider针对UserDetails及逆行校验,并且返回Authentication。Authentication是基于UserDetails进行构建的。
UserDetailsService
DaoAuthenticationProvider通过UserDetailsService来获取UserDetails信息,UserDetails信息中包含用户名、密码和用户其他信息。
可以通过自定义UserDetailsService的bean对象来自定义UserDetails的获取逻辑:
@Bean
CustomUserDetailsService customUserDetailsService() {
return new CustomUserDetailsService();
}
RememberMe
rememberMe要求在多个session之间,应用能够记住用户的身份。其通常是由cookie实现的,如果指定cookie在后续请求中被检测到,那么会进行自动登录。
Spring Security提供了两种Remember Me实现,一种是基于hash来保存token,另一种则是通过数据库来保存token。
hash based token
该方案通过hash来实现有效的rememberMe策略,在认证成功后,如下cookie会被发送给客户端:
base64(username + ":" + expirationTime + ":" + algorithmName + ":"
+algorithmHex(username + ":" + expirationTime + ":" password + ":" + key))
username: As identifiable to the UserDetailsService
password: That matches the one in the retrieved UserDetails
expirationTime: The date and time when the remember-me token expires, expressed in milliseconds
key: A private key to prevent modification of the remember-me token
algorithmName: The algorithm used to generate and to verify the remember-me token signature
其中,第四部分通过(username,expirationTime,password,key),以algorithmName算法生成。
当该cookie重新被发送到服务端时,服务端会取出cookie的前三部分,并通过UserDetailService查询出password(该password为获取的UserDetails中的password,为编码后的密码),按照cookie中指定的算法,结合存储在服务端的private key重新计算出第四部分,并且将计算结果和第四部分进行比较。
该算法可以保证cookie并没有被篡改,因为password和private key都存储在服务端,如果手动修改username或是过期时间,那么都会导致服务端计算的第四部分结果不匹配。
但是,使用该hash-based方法时,存在安全隐患,因为所有捕获到该token的agent都可以使用该token访问用户资源,直到token过期。
在用户意识到token已经被泄露时,可以通过修改密码来使该token无效,因为修改密码后第四部分的计算结果会发生变化,导致旧token失效。
如果想要开启rememberMe,可以通过如下方式开启:
http
.authorizeHttpRequests(authorize -> {
authorize.requestMatchers("/login").permitAll()
.anyRequest().authenticated();
})
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults())
.rememberMe(Customizer.withDefaults());
return http.build();
将token持久化到数据库中
为了在多次session之间记住用户的身份信息,可以通过将token存储到数据库中,从而记住用户的身份。
RememberMe的接口及其实现
remember-me将会由UsernamePasswordAuthenticationFilter来使用,与rememberMe相关的hook位于RememberMeServices接口中,并且将会在适当的时机被调用。如下展示了rememberMe相关的接口定义:
Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);
void loginFail(HttpServletRequest request, HttpServletResponse response);
void loginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication);
AbstractAuthenticationProcessingFilter只会调用loginSuccess或loginFail方法;而autoLogin方法将会由RememberMeAuthenticationFilter进行调用,当SecurityContextHolder不包含Authentication对象时,就会调用autoLogin方法。
TokenBasedRememberMeService
TokenBasedRemeberMeService支持上述基于hash的rememberMe方法。TokenBasedRememberMeServices会生成RememberMeAuthenticationToken,该token将会由RememberMeAuthenticationProvider进行验证。
为了TokenBasedRememberMeServices生成的token必须能被RememberMeAuthenticationToken正确的校验,必须相同的key必须能够在两者之间进行共享。
默认情况下,TokenBasedRememberMeService使用SHA-256来对token进行编码。如果想要针对TokenBasedRememberMeServices进行自定义,可以发布自己的bean:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, RememberMeServices rememberMeServices) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().authenticated()
)
.rememberMe((remember) -> remember
.rememberMeServices(rememberMeServices)
);
return http.build();
}
@Bean
RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
RememberMeTokenAlgorithm encodingAlgorithm = RememberMeTokenAlgorithm.SHA256;
TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices(myKey, userDetailsService, encodingAlgorithm);
// 修改编码算法
rememberMe.setMatchingAlgorithm(RememberMeTokenAlgorithm.MD5);
return rememberMe;
}
为了启用rememberMe,需要在应用上下文中包含如下bean:
@Bean
RememberMeAuthenticationFilter rememberMeFilter() {
RememberMeAuthenticationFilter rememberMeFilter = new RememberMeAuthenticationFilter();
rememberMeFilter.setRememberMeServices(rememberMeServices());
rememberMeFilter.setAuthenticationManager(theAuthenticationManager);
return rememberMeFilter;
}
@Bean
TokenBasedRememberMeServices rememberMeServices() {
TokenBasedRememberMeServices rememberMeServices = new TokenBasedRememberMeServices();
rememberMeServices.setUserDetailsService(myUserDetailsService);
rememberMeServices.setKey("springRocks");
return rememberMeServices;
}
@Bean
RememberMeAuthenticationProvider rememberMeAuthenticationProvider() {
RememberMeAuthenticationProvider rememberMeAuthenticationProvider = new RememberMeAuthenticationProvider();
rememberMeAuthenticationProvider.setKey("springRocks");
return rememberMeAuthenticationProvider;
}
匿名身份认证
Spring Security中,采用了deny-by-default策略,用以适配如下场景:除少数资源外(如homepage、login页面等),访问其他所有的资源都需要进行身份认证。
Spring Security提供了匿名身份认证,对于“匿名身份认证”的用户,其实际和未经认证的用户没有任何区别。匿名认证只是为配置访问控制属性提供了更加便捷的方式,在匿名认证时,即使当前SecurityContextHolder中存在anonymous authentication对象,在调用一些servlet api例如getCallerPrincipal方法时,方法仍然会返回null。
在使用anonymous authentication时,Spring Security的SecurityContextHolder一定含有Authentication对象,即使用户未经认证,Authentication对象也不可能为空。
匿名认证配置
匿名认证在Spring Security中是默认被提供的,可以对其进行自定义或者关闭。
为了提供匿名认证特性,需要存在如下三个类:AnonymousAuthenticationToken、AnonymousAuthenticationProvider、AnonymousAuthenticationFilter。
AnonymousAuthenticationToken:该类是Authentication的实现类,并且存储了该匿名实体所拥有的权限GrantedAuthorityAnonymousAuthenticationProvider:该类被整合到ProviderManager的provider链中,故而AnonymousAuthenticationToken可以被支持进行处理AnonymousAuthenticationFilter:该filter被嵌入到常规的认证机制之后,如果在执行到该filter时,当前SecurityContextHolder中不存在Authentication,那么其会自动将AnonymousAuthenticationToken加入到SecurityContextHolder中。
定义filter和provider的方式如下所示,filter和provider共享相同的key,故而filter创建的token可以被provider接收:
<bean id="anonymousAuthFilter"
class="org.springframework.security.web.authentication.AnonymousAuthenticationFilter">
<property name="key" value="foobar"/>
<property name="userAttribute" value="anonymousUser,ROLE_ANONYMOUS"/>
</bean>
<bean id="anonymousAuthenticationProvider"
class="org.springframework.security.authentication.AnonymousAuthenticationProvider">
<property name="key" value="foobar"/>
</bean>
userAttribute属性值,其格式为usernameInTheAuthenticationToken,grantedAuthority[,grantedAuthority]。
处理logout
如果当前项目的类路径中包含spring-boot-starter-security依赖,那么spring security会自动加入logout支持,针对GET和POST的/logout请求都会响应。
当通过get方式请求/logout时,spring security将展示一个登出页面。但如果csrf保护被关闭,那么不会展示登出确认页面,而是直接关闭。
当请求/logout时,将通过一系列logoutHandler来实现如下逻辑:
- 将http session标记为失效(SecurityContextLogoutHandler)
- 清空SecurityContextHolderStrategy (SecurityContextLogoutHandler)
- 清空SecurityContextRepository(SecurityContextLogoutHandler)
- 清空所有rememberMe authentication(TokenRememberMeServices / PersistentTokenRememberMeServices)
- 清空任何csrf token (LogoutSuccessEventPublishingLogoutHandler)
- 发送LogoutSuccessEvent(LogoutSuccessEventPublishingLogoutHandler)
自定义登出url
在filter chain中,logoutFilter位于AuthorizationFilter之前,故而没有必要显式的对/logout url执行permit操作(因为尚未执行到AuthorizationFilter之前,就会通过logoutFilter执行登出操作)。
但是,对于自定义的登出端口(默认情况下,logooutFilter只对/logout的请求进行处理),若url不为/logout,那么需要调用permitAll来让自定义登出端口可访问。
如果只是要修改spring security登出的url,可以通过如下方式进行修改,且不需要其他任何修改,其单单只是改变了logoutFilter匹配的url:
http
.logout((logout) -> logout.logoutUrl("/my/logout/uri"))
但是,如果自定义了logout success endpoint(通过spring mvc建立的controller endpoint),那么需要为自定义的logout endpoint执行permit操作来允许用户访问。因为只有当spring security中的操作都完成后,才会指定自定义的endpoint操作。
可以通过如下方式为自定义的登出endpoint进行授权:
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/my/success/endpoint").permitAll()
// ...
)
.logout((logout) -> logout.logoutSuccessUrl("/my/success/endpoint"))
logoutSuccessUrl为logout操作执行成功后,重定向到的url,该值默认为
/login?logout。logoutUrl则是触发logout操作的url。