用户认证的最常见方式之一是用户名和密码认证。因此,Spring Security为使用用户名和密码进行验证提供了全面的支持。
Reading the Username & Password
Spring Security为从HttpServletRequest中读取用户名和密码提供了以下几种内置机制:
Form Login(推荐)
Spring Security提供了获取html表单中用户名和密码的支持。本节提供了基于表单的认证在Spring Security中如何工作的细节。
让我们来看看基于表单的登录在Spring Security中是如何工作的。首先,我们看到用户是如何被重定向到登录表单的:
该图建立在SecurityFilterChain图上。
- 首先,一个用户向其未被授权的资源/private提出了一个未经认证的请求。
- Spring Security的FilterSecurityInterceptor通过抛出一个AccessDeniedException异常来表明未经认证的请求被拒绝了。
- 由于用户没有被认证,ExceptionTranslationFilter启动了开始认证,并通过配置的AuthenticationEntryPoint发送一个重定向到登录页面。在大多数情况下,AuthenticationEntryPoint是LoginUrlAuthenticationEntryPoint的一个实例。
- 然后,浏览器将请求它被重定向到的登录页面。
- 呈现登录页面。
当用户名和密码被提交后,UsernamePasswordAuthenticationFilter会对用户名和密码进行认证。UsernamePasswordAuthenticationFilter扩展了AbstractAuthenticationProcessingFilter,所以这张图看起来应该很相似。
该图建立在SecurityFilterChain图上。
- 当用户提交他们的用户名和密码时,UsernamePasswordAuthenticationFilter通过从HttpServletRequest中提取用户名和密码来创建一个UsernamePasswordAuthenticationToken,这是一种认证类型。
- 接下来,UsernamePasswordAuthenticationToken被传递到AuthenticationManager中进行认证。AuthenticationManager的细节取决于用户信息的存储方式。
- 如果认证失败:
- 清空SecurityContextHolder
- 执行RememberMeServices.loginFail,如果配置了remember me的话
- 执行AuthenticationFailureHandler
- 如果认证成功:
- SessionAuthenticationStrategy被通知有新的登录
- 一个包含完整信息的Authentication将被设置到SecurityContextHolder,之后将SecurityContextPersistenceFilter设置到HttpSession中
- 执行RememberMeServices.loginSuccess,如果配置了 remeber me的话
- ApplicationEventPublisher发布了一个InteractiveAuthenticationSuccessEvent
- 执行AuthenticationSuccessHandler。通常这是一个SimpleUrlAuthenticationSuccessHandler,当我们重定向到登录页面时,它将重定向到一个由ExceptionTranslationFilter保存的请求。
Spring Security的表单登录是默认启用的。然而,一旦提供任何基于Servlet的配置,就必须明确提供基于表单的登录。一个最小的、明确的Java配置如下:
protected void configure(HttpSecurity http) {
http
// ...
.formLogin(withDefaults());
}
在这种配置下,Spring Security将呈现一个默认的登录页面。然而大多数生产应用将需要一个自定义的登录表单,下面的配置演示了如何提供一个自定义的登录表单:
protected void configure(HttpSecurity http) throws Exception {
http
// ...
.formLogin(form -> form
.loginPage("/login")
.permitAll()
);
}
当登录页面在Spring Security配置中被指定时,你要负责渲染该页面。下面是一个Thymeleaf模板,它生成了一个符合/login的登录页面的HTML登录表单。
<!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>
关于默认的HTML表格,有几个关键点:
- 该表单发送一个post请求到/login
- 该表单需要一个CSRF令牌,该令牌由Thymeleaf自动包含
- 该表单提供一个用户名username参数
- 该表单提供一个密码password参数
- 如果发现HTTP参数error,表明用户未能提供有效的用户名/密码
- 如果发现HTTP参数logout,表明用户已经成功退出登录
如果你使用的是Spring MVC,你将需要一个控制器,将GET /login映射到我们创建的登录模板。下面是一个最小的LoginController示例:
@Controller
class LoginController {
@GetMapping("/login")
String login() {
return "login";
}
}
Basic Authentication(不推荐)
本节详细介绍了Spring Security如何为基于servlet的应用程序提供对基本HTTP认证的支持。 让我们来看看HTTP基本认证是如何在Spring Security中工作的。首先,我们看到WWW-Authenticate头被送回给未认证的客户端。
该图建立在SecurityFilterChain图上。
- 首先,一个用户向其未被授权的资源/private提出了一个未经认证的请求。
- Spring Security的FilterSecurityInterceptor通过抛出一个AccessDeniedException异常来表明未经认证的请求被拒绝了。
- 由于用户没有经过认证,ExceptionTranslationFilter启动了开始认证。配置的AuthenticationEntryPoint是BasicAuthenticationEntryPoint的一个实例,它发送一个 WWW-Authentication:Basic realm=”Realm”的头的响应。RequestCache通常是一个NullRequestCache,它不保存请求,因为客户端有能力重新请求。
当客户端收到WWW-Authenticate头时,它知道它应该用一个用户名和密码重试。下面是正在处理的用户名和密码的流程:
该图建立在SecurityFilterChain图上。
- 当用户提交他们的用户名和密码时,BasicAuthenticationFilter通过从HttpServletRequest中提取用户名和密码,创建一个UsernamePasswordAuthenticationToken,这是一种认证类型。
- 接下来,UsernamePasswordAuthenticationToken被传递到AuthenticationManager中进行认证。AuthenticationManager的细节取决于用户信息的存储方式。
- 如果认证失败:
- 清空SecurityContextHolder
- 执行RememberMeServices.loginFail,如果配置了remember me的话
- 调用AuthenticationEntryPoint发送一个 WWW-Authentication:Basic realm=”Realm”的头的响应
- 如果认证成功:
- 一个包含完整信息的Authentication将被设置到SecurityContextHolder
- 执行RememberMeServices.loginSuccess,如果配置了 remeber me的话
- BasicAuthenticationFilter调用FilterChain.doFilter(request,response)来继续执行其余的应用逻辑
Spring Security的HTTP Basic认证支持在默认情况下是启用的。然而,只要提供任何基于Servlet的配置,就必须明确提供HTTP Basic。一个最小的、明确的Java配置如下:
protected void configure(HttpSecurity http) {
http
// ...
.httpBasic(withDefaults());
}
Password Storage
几种存储密码的方式:
- Simple Storage with In-Memory Authentication
- Relational Databases with JDBC Authentication
- Custom data stores with UserDetailsService
- LDAP storage with LDAP Authentication
In-Memory Authentication(基于内存的认证)
Spring Security使InMemoryUserDetailsManager实现UserDetailsManager(继承自UserDetailsService),为使用基于内存检索username/password的认证提供支持。InMemoryUserDetailsManager提供对UserDetails的管理。
在下面例子中,我们使用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);
}
你可以选择在密码前加上{noop}来表示密码不加密存储。
<user-service>
<user name="user"
password="{noop}password"
authorities="ROLE_USER" />
<user name="admin"
password="{noop}password"
authorities="ROLE_USER,ROLE_ADMIN" />
</user-service>
JDBC Authentication(基于数据库的认证)
Spring Security的JdbcDaoImpl实现了UserDetailsService,为使用基于JDBC检索用户名/密码的认证提供支持。JdbcUserDetailsManager扩展了JdbcDaoImpl,通过UserDetailsManager接口提供对UserDetails的管理。当Spring Security被配置为接受用户名/密码的认证时,它就会使用基于UserDetails的认证。
Default Schema
Spring Security为基于JDBC的认证提供了默认的sql查询语句。本节提供了默认sql查询语句所对应的默认用户数据库模型,你可以手动调整以匹配任何定制的查询和你使用的数据库方言。
User Schema
JdbcDaoImpl需要一些表来加载用户的密码、账户状态(启用或禁用)和权限(角色)列表。
:::info The default schema is also exposed as a classpath resource named org/springframework/security/core/userdetails/jdbc/users.ddl. :::
create table users
(
username varchar_ignorecase(50) not null primary key,
password varchar_ignorecase(500) not null,
enabled boolean not null
);
create table authorities
(
username varchar_ignorecase(50) not null,
authority varchar_ignorecase(50) not null,
constraint fk_authorities_users foreign key (username) references users (username)
);
create unique index ix_auth_username on authorities (username, authority);
Group Schema
Spring Security同样提供了默认的群组数据库模型。
create table groups (
id bigint generated by default as identity(start with 0) primary key,
group_name varchar_ignorecase(50) not null
);
create table group_authorities (
group_id bigint not null,
authority varchar(50) not null,
constraint fk_group_authorities_group foreign key(group_id) references groups(id)
);
create table group_members (
id bigint generated by default as identity(start with 0) primary key,
username varchar(50) not null,
group_id bigint not null,
constraint fk_group_members_group foreign key(group_id) references groups(id)
);
Setting up a DataSource
在我们配置使用JdbcUserDetailsManager之前,我们必须创建一个数据源。在我们的例子中,我们将设置一个嵌入式的数据源,用默认的用户数据库模型进行初始化。
@Bean
DataSource dataSource() {
return new EmbeddedDatabaseBuilder()
.setType(H2)
.addScript("classpath:org/springframework/security/core/userdetails/jdbc/users.ddl")
.build();
}
JdbcUserDetailsManager Bean
在下面的例子中,我们使用Spring Boot CLI对密码进行编码,得到的编码密码为{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW。
@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;
}
UserDetails
UserDetails由UserDetailsService返回。DaoAuthenticationProvider将验证后的UserDetails放入Authentication的principle字段中。
UserDetailsService
UserDetailsService被DaoAuthenticationProvider用来检索用户名、密码和其他属性,以便用用户名和密码进行认证。Spring Security提供了UserDetailsService的内存和JDBC实现。 你可以通过暴露一个自定义的UserDetailsService作为一个bean来定义自定义认证。例如,假设CustomUserDetailsService实现了UserDetailsService,下面将自定义认证:
:::info 只有在AuthenticationManagerBuilder没有被填充并且没有定义AuthenticationProviderBean的情况下才会使用。 :::
@Bean
CustomUserDetailsService customUserDetailsService() {
return new CustomUserDetailsService();
}
PasswordEncoder
Spring Security的Servlet支持通过与PasswordEncoder集成来安全地存储密码。定制Spring Security使用的PasswordEncoder实现可以通过暴露PasswordEncoder Bean来完成。
DaoAuthenticationProvider
DaoAuthenticationProvider是一个AuthenticationProvider的实现,它利用UserDetailsService和PasswordEncoder来验证一个用户名和密码。
让我们来看看DaoAuthenticationProvider在Spring Security中是如何工作的。
- 读取用户名和密码的认证过滤器将一个UsernamePasswordAuthenticationToken传递给AuthenticationManager的实现ProviderManager
- ProviderManager包含众多的AuthenticationProvider,不同的AuthenticationProvider应用于不同场景下的认证,比如此处的DaoAuthenticationProvider就是用于username、password认证
- DaoAuthenticationProvider通过UserDetailsService从系统中加载UserDetails
- 然后,DaoAuthenticationProvider使用PasswordEncoder来与上一步返回的UserDetails上的密码进行比对
- 当认证成功时,返回的UsernamePasswordAuthenticationToken(principal字段=userDetails(由userDetailService返回))将被认证过滤器设置在SecurityContextHolder上