Spring Security: Username/Password 认证

438 阅读5分钟

用户名/密码认证是最常用的认证方式。

下面是一个用户名/密码认证的示例:

@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 如何重定向到登录页面:

image.png

之前的配置构建了我们的 SecurityFilterChain。

  1. 用户请求一个没有经过认证的资源。
  2. Spring Security 的 AuthorizationFilter 拒绝当前请求,并抛出 AccessDeniedException 异常。
  3. 因为用户没有认证,所以 ExceptionTranslationFilter 将当前请求重定向到登录页面。登录面是在 AuthenticationEntryPoint 中指定的,在大多数情况下, AuthenticationEntryPoint 是一个 LoginUrlAuthenticationEntryPoint 实例。
  4. 浏览器重定向到登录页。
  5. 渲染登录页。

当用户输入用户名/密码后,UsernamePasswordAuthenticationFilter 对用户进行认证。UsernamePasswordAuthenticationFilter 继承自 AbstractAuthenticationProcessingFilter:

image.png

  1. 当用户提交了用户名/密码,UsernamePasswordAuthenticationFilter 会基于从 HttpServletRequest 获取的用户名/密码创建一个 Authentication 类型的 UsernamePasswordAuthenticationToken
  2. 接着,UsernamePasswordAuthenticationToken 会被传递给 AuthenticationManagerAuthenticationManager 的详情依赖于用户如何存储用户信息。
  3. 如果认证失败:
    1. SecurityContextHolder 被清空。
    2. RememberMeServices.loginFail 被调用。
    3. AuthenticationFailureHandler 被调用。
  4. 如果认证成功:
    1. SessionAuthenticationStrategy 被通知有一个新的登录。
    2. Authentication 被设置到 SecurityContextHolder 中。
    3. RememberMeServices.loginSuccess 被调用。
    4. ApplicationEventPublisher 发布一个 InteractiveAuthenticationSuccessEvent 事件。
    5. 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>
  1. 这个表单应该是执行一个 post 请求。
  2. 这个表单应该包含一个 CSRF Token, 这个 CSRF Token 会被 Thymeleaf 自动包含进去。
  3. 表单应该包含 username。
  4. 表单应该包含 password。
  5. 如果 HTTP 参数中包含名字为 error 的参数,表示用户名/密码错误。
  6. 如果 HTTP 参数中包含名字为 logout 的参数,表示登出成功。

如果是 Spring MVC, 那么还需要一个一个方法,能重定向到我们的登录页。示例如下:

@Controller
class LoginController {
	@GetMapping("/login")
	String login() {
		return "login";
	}
}

基本认证(Basic Authentication)

本节提供了 Spring Security 如何支持基于 servlet 的应用的基本的 HTTP 认证。 下图展示的是 Spring Security 的基本的 HTTP 认证的工作流程:

image.png

  1. 客户端向服务端的资源发起一个未认证的请求。
  2. AuthorizationFilter 拒绝未被认证的请求,并抛出 AccessDeniedException 异常。
  3. 因为用户没有认证,ExceptionTranslationFilter 开始进行认证。配置的 AuthenticationEntryPoint 是一个 BasicAuthenticationEntryPoint 实例。它会发送一个 WWW-Authenticate header。RequestCache 通常是不保存请求的 NullRequestCache ,因为客户端能够重放其最初请求的请求。

当客户端接收到 WWW-Authenticate header 后,它知道应该用用户名/密码重试。下图展示了用户名/密码处理流程:

image.png

  1. 当用户名/密码提交后,由 BasicAuthenticationFilter 从 HttpServletRequest 抽取出的用户名/密码,创建一个 BasicAuthenticationFilter 类型的 Authentication。
  2. 接着,UsernamePasswordAuthenticationToken 会被传递给 AuthenticationManagerAuthenticationManager 的详情依赖于用户如何存储用户信息。
  3. 如果认证失败:
    1. SecurityContextHolder 被清除。
    2. RememberMeServices.loginFail 被调用。
    3. AuthenticationEntryPoint 被调用,并触发 WWW-Authenticate 的再次发送。
  4. 如果成功:
    1. Authentication 被设置到 SecurityContextHolder 中。
    2. RememberMeServices.loginSuccess 被调用。
    3. 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;
}