用户名/密码认证是最常用的认证方式。
下面是一个用户名/密码认证的示例:
@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 自动注册一个基于内存的 UserDetailsService。用默认的 AuthenticationManager 注册 DaoAuthenticationProvider,并且开启 Form Login 和 HTTP Basic 认证。
发布一个 AuthenticationManager Bean
自定义 AuthenticationManager 是一个常见的需求。比如:通过 REST API 认证用户,而不是使用 Form Login。
咱们可以使用一下代码自定义 AuthenticationManager 并注册成 Spring 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();
}
}
通过上面的配置后,我们可以直接把 AuthenticationManager 作为一个 Spring Bean 使用。
下面是一个使用示例:
@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
通常 AuthenticationManager 使用用户名/密码认证时, 内部是包含一个 DaoAuthenticationProvider 的。在某些情景下,我们想要定制 AuthenticationManager 实例。比如:我们希望缓存用户,从而需要禁用掉凭证擦除(credential erasure) 功能,我们可以按照以下方法定制:
@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);
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();
}
}
Form Login
Spring Security 提供了 HTML form 表单登录方式。 下图展示的是,Spring Security 如何重定向到登录页面:
之前的配置构建了我们的 SecurityFilterChain。
- 用户请求一个没有经过认证的资源。
- Spring Security 的 AuthorizationFilter 拒绝当前请求,并抛出 AccessDeniedException 异常。
- 因为用户没有认证,所以 ExceptionTranslationFilter 将当前请求重定向到登录页面。登录面是在 AuthenticationEntryPoint 中指定的,在大多数情况下, AuthenticationEntryPoint 是一个 LoginUrlAuthenticationEntryPoint 实例。
- 浏览器重定向到登录页。
- 渲染登录页。
当用户输入用户名/密码后,UsernamePasswordAuthenticationFilter 对用户进行认证。UsernamePasswordAuthenticationFilter 继承自 AbstractAuthenticationProcessingFilter:
- 当用户提交了用户名/密码,
UsernamePasswordAuthenticationFilter会基于从HttpServletRequest获取的用户名/密码创建一个Authentication类型的UsernamePasswordAuthenticationToken。 - 接着,
UsernamePasswordAuthenticationToken会被传递给AuthenticationManager。AuthenticationManager的详情依赖于用户如何存储用户信息。 - 如果认证失败:
SecurityContextHolder被清空。RememberMeServices.loginFail被调用。AuthenticationFailureHandler被调用。
- 如果认证成功:
SessionAuthenticationStrategy被通知有一个新的登录。Authentication被设置到SecurityContextHolder中。RememberMeServices.loginSuccess被调用。ApplicationEventPublisher发布一个InteractiveAuthenticationSuccessEvent事件。AuthenticationSuccessHandler被调用,它通常是一个SimpleUrlAuthenticationSuccessHandler,它会重定向请求到在重定向到登录页时被ExceptionTranslationFilter保存的请求。
默认情况下,Spring Security 的表单登录方式是可用的。但是基于 Servlet 的 Spring Security 必须要明确配置,才会启用,配置示例如下:
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.formLogin(withDefaults());
// ...
}
在前面的配置,应用会渲染一个默认的登录页,但是用户可以自定义登录页面,下面的配置演示了如何自定义登录页:
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.formLogin(form -> form
.loginPage("/login")
.permitAll()
);
// ...
}
下面是一个登录页面的示例:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>Please Log In</title>
</head>
<body>
<h1>Please Log In</h1>
<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>
- 这个表单应该是执行一个 post 请求。
- 这个表单应该包含一个 CSRF Token, 这个 CSRF Token 会被 Thymeleaf 自动包含进去。
- 表单应该包含 username。
- 表单应该包含 password。
- 如果 HTTP 参数中包含名字为 error 的参数,表示用户名/密码错误。
- 如果 HTTP 参数中包含名字为 logout 的参数,表示登出成功。
如果是 Spring MVC, 那么还需要一个一个方法,能重定向到我们的登录页。示例如下:
@Controller
class LoginController {
@GetMapping("/login")
String login() {
return "login";
}
}
基本认证(Basic Authentication)
本节提供了 Spring Security 如何支持基于 servlet 的应用的基本的 HTTP 认证。 下图展示的是 Spring Security 的基本的 HTTP 认证的工作流程:
- 客户端向服务端的资源发起一个未认证的请求。
- AuthorizationFilter 拒绝未被认证的请求,并抛出 AccessDeniedException 异常。
- 因为用户没有认证,ExceptionTranslationFilter 开始进行认证。配置的 AuthenticationEntryPoint 是一个 BasicAuthenticationEntryPoint 实例。它会发送一个 WWW-Authenticate header。RequestCache 通常是不保存请求的 NullRequestCache ,因为客户端能够重放其最初请求的请求。
当客户端接收到 WWW-Authenticate header 后,它知道应该用用户名/密码重试。下图展示了用户名/密码处理流程:
- 当用户名/密码提交后,由 BasicAuthenticationFilter 从 HttpServletRequest 抽取出的用户名/密码,创建一个 BasicAuthenticationFilter 类型的 Authentication。
- 接着,
UsernamePasswordAuthenticationToken会被传递给AuthenticationManager。AuthenticationManager的详情依赖于用户如何存储用户信息。 - 如果认证失败:
- SecurityContextHolder 被清除。
- RememberMeServices.loginFail 被调用。
- AuthenticationEntryPoint 被调用,并触发 WWW-Authenticate 的再次发送。
- 如果成功:
- Authentication 被设置到 SecurityContextHolder 中。
- RememberMeServices.loginSuccess 被调用。
- BasicAuthenticationFilter 调用 FilterChain.doFilter(request,response) 继续应用逻辑。
默认情况下,Spring Security 是支持 HTTP 基本认证的。但是基于 servlet 的应用,必须要明确配置。示例如下:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
// ...
.httpBasic(withDefaults());
return http.build();
}
密码存储
存储机制:
- 简单的内存存储
- JDBC 关系型数据库存储
- 自定义数据存储
- LDAP 存储
内存存储
Spring Security 的 InMemoryUserDetailsManager 实现了 UserDetailsService 来提供对存储在内存中的且基于用户名/密码的认证方式的支持。 InMemoryUserDetailsManager 提供了对实现了 UserDetailsManager 接口的 UserDetails 的管理。UserDetails 是被用在当 Spring Security 被配置为接收用户名/密码进行认证的时候。
下面的例子是用 Spring Boot CLI 命令行接口工具编码后的密码值,最后得到了 {bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW:
@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 来确保存储在内存中的密码被保护。但是它并不保护通过解码源码来获取密码。因此 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
Spring Security 的 JdbcDaoImpl 实现了 UserDetailsService 用来支持从数据库获取用户名密码进行认证的方式。JdbcUserDetailsManager 继承了 JdbcDaoImpl 通过 UserDetailsManager 接口,来提供对 UserDetails 的管理。
Default Schema(默认架构,即默认表结构): Spring Security 提供了默认的基于 JDBC 的查询。其中包括用户架构(User Schema)和组架构(Group Schema)。
默认的表结构所在位置:org/springframework/security/core/userdetails/jdbc/users.ddl.
在配置 JdbcUserDetailsManager 之前,我们需要先创建 DataSource, 在下面的例子,我们使用默认的架构进行初始化:
@Bean
DataSource dataSource() {
return new EmbeddedDatabaseBuilder()
.setType(H2)
.addScript(JdbcDaoImpl.DEFAULT_USER_SCHEMA_DDL_LOCATION)
.build();
}
配置 JdbcUserDetailsManager ,在下面的例子中,还是用 Spring Boot CLI 工具编码一个密码,进行演示:
@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;
}