第 4 章 Spring 安全

第 4 章 Spring 安全
本章内容:

自动配置 Spring Security
自定义用户存储
自定义登录页面
防御 CSRF 攻击
了解你的用户
你有没有注意到电视情景喜剧里的大多数人都不锁门?在《Leave it to Beaver》的时代,人们不锁门并不是什么稀罕事。但是,在我们关心隐私和安全的今天,我们却看到电视上的人物能够畅通无阻地进入他们的公寓和家中,这似乎很疯狂。

信息可能是我们现在拥有的最有价值的东西;骗子们正在寻找通过潜入不安全的应用程序来窃取我们的数据和身份的方法。作为软件开发人员,我们必须采取措施保护应用程序中的信息。无论是用用户名与密码保护的电子邮件帐户,还是用交易密码保护的经济帐户,安全性都是大多数应用程序的一个重要方面。
4.1 启用 Spring Security
保护 Spring 应用程序的第一步是将 Spring Boot security starter 依赖项添加到构建中。在项目的 pom.xml 文件中,添加以下 内容:

org.springframework.boot spring-boot-starter-security 如果正在使用 Spring Tool Suite,这甚至更简单。右键单击 pom.xml 文件并从 Spring 上下文菜单中选择 编辑 Starters。将出现 “启动依赖项” 对话框。检查核心类别下的安全条目,如图 4.1 所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Wtbjo5Kt-1673505108514)(E:\Document\spring-in-action-v5-translate\第一部分 Spring 基础\第四章 Spring 安全\图 4.1 使用 Spring Tool Suite 添加 security starter.jpg)]

图 4.1 使用 Spring Tool Suite 添加 security starter

上面的依赖项是保护应用程序所需的唯一的东西。当应用程序启动时,自动配置将检测类路径中的 Spring Security,并设置一些基本的安全性配置。

如果想尝试一下,启动应用程序并访问主页(或任何页面)。将提示使用 HTTP 基本身份验证对话框进行身份验证。要想通过认证,需要提供用户名和密码。用户名是 user。至于密码,它是随机生成并写入了应用程序日志文件。日志条目应该是这样的:

Using default security password: 087cfc6a-027d-44bc-95d7-cbb3a798a1ea
假设正确地输入了用户名和密码,将被授予对应用程序的访问权。

保护 Spring 应用程序似乎非常简单。Taco Cloud 应用程序的已经被保护了,我想我现在可以结束这一章,进入下一个主题了。但是在我们开始之前,让我们考虑一下自动配置提供了什么样的安全性。

只需要在项目构建中添加 security starter,就可以获得以下安全特性:

所有的 HTTP 请求路径都需要认证。
不需要特定的角色或权限。
没有登录页面。
身份验证由 HTTP 基本身份验证提供。
只有一个用户;用户名是 user。
这是一个良好的开端,但我认为大多数应用程序(包括 Taco Cloud)的安全需求将与这些基本的安全特性有很大的不同。

如果要正确地保护 Taco Cloud 应用程序,还有更多的工作要做。至少需要配置 Spring Security 来完成以下工作:

提示使用登录页面进行身份验证,而不是使用 HTTP 基本对话框。
为多个用户提供注册页面,让新的 Taco Cloud 用户可以注册。
为不同的请求路径应用不同的安全规则。例如,主页和注册页面根本不需要身份验证。
为了满足对 Taco Cloud 的安全需求,必须编写一些显式的配置,覆盖自动配置提供的内容。首先需要配置一个合适的用户存储,这样就可以有多个用户。
4.2 配置 Spring Security
多年来,有几种配置 Spring Security 的方法,包括冗长的基于 xml 的配置。幸运的是,Spring Security 的几个最新版本都支持基于 Java 的配置,这种配置更容易读写。

在本章结束之前,已经在基于 Java 的 Spring Security 配置中配置了所有 Taco Cloud 安全需求。但是在开始之前,可以通过编写下面清单中所示的基本配置类来简化它。程序清单 4.1 一个基本的 Spring Security 配置类

package tacos.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}
这个基本的安全配置做了什么?嗯,不是很多,但是它确实离需要的安全功能更近了一步。如果再次尝试访问 Taco Cloud 主页,仍然会提示需要登录。但是,将看到一个类似于图 4.2 的登录表单,而不是一个 HTTP 基本身份验证对话框提示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X0oNLLxd-1673508567926)(E:\Document\spring-in-action-v5-translate\第一部分 Spring 基础\第四章 Spring 安全\图 4.2 Spring Security 提供了一个免费的普通登录页面.jpg)]

图 4.2 Spring Security 提供了一个免费的普通登录页面

提示:你可能会发现,在手动测试安全性时,将浏览器设置为 private 或 incognito 模式是很有用的。这将确保每次打开私人/隐身窗口时都有一个新的会话。必须每次都登录到应用程序,但是可以放心,你在安全性方面所做的任何更改都将被应用,并且旧 session 的任何残余都不会阻止你查看你的更改。

这是一个小小的改进 —— 使用 web 页面进行登录的提示(即使它在外观上相当简单)总是比 HTTP 基本对话框更友好。将在 4.3.2 节中自定义登录页面。然而,当前的任务是配置一个能够处理多个用户的用户存储。

事实证明,Spring Security 为配置用户存储提供了几个选项,包括:

一个内存用户存储

基于 JDBC 的用户存储

由 LDAP 支持的用户存储

定制用户详细信息服务

无论选择哪个用户存储,都可以通过重写 WebSecurityConfigurerAdapter 配置基类中定义的 configure() 方法来配置它。首先,你需要在 SecurityConfig 类中添加以下方法:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {

}
现在,只需要使用使用给定 AuthenticationManagerBuilder 的代码来替换这些省略号,以指定在身份验证期间如何查找用户。首先,将尝试内存用户存储。

4.2.1 内存用户存储
用户信息可以保存在内存中。假设只有少数几个用户,这些用户都不可能改变。在这种情况下,将这些用户定义为安全配置的一部分可能非常简单。

例如,下一个清单显示了如何在内存用户存储中配置两个用户 “buzz” 和 “woody”。程序清单 4.2 在内存用户存储中定义用户

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser(“buzz”)
.password(“infinity”)
.authorities(“ROLE_USER”)
.and()
.withUser(“woody”)
.password(“bullseye”)
.authorities(“ROLE_USER”);
}
正如你所看到的,AuthenticationManagerBuilder 使用构造器风格的 API 来配置身份验证细节。在这种情况下,对 inMemoryAuthentication() 方法的调用,可以直接在安全配置本身中指定用户信息。

对 withUser() 的每个调用都会启动用户的配置。给 withUser() 的值是用户名,而密码和授予的权限是用 password() 和 authority() 方法指定的。如程序清单 4.2 所示,两个用户都被授予 ROLE_USER 权限。用户 “buzz” 的密码被配置为 “infinity”。同样,“woody” 的密码是 “bullseye”。

内存中的用户存储应用于测试或非常简单的应用程序时非常方便,但是它不允许对用户进行简单的编辑。如果需要添加、删除或更改用户,则必须进行必要的更改,然后重新构建、部署应用程序。

对于 Taco Cloud 应用程序,由于内存中用户存储的闲置,因此希望客户能够注册应用程序并管理自己的用户帐户,这不能够实现。因此让我们看看另一个允许使用数据库支持的用户存储的选项。

4.2.2 基于 JDBC 的用户存储
用户信息通常在关系数据库中维护,基于 JDBC 的用户存储似乎比较合适。下面的程序清单显示了如何配置 Spring Security,并将用户信息通过 JDBC 保存在关系型数据库中,来进行身份认证。

@Autowired
DataSource dataSource;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.jdbcAuthentication()
.dataSource(dataSource);
}
configure() 的这个实现在给定的 AuthenticationManagerBuilder 上调用 jdbcAuthentication()。然后,必须设置 DataSource,以便它知道如何访问数据库。这里使用的数据源是由自动装配提供的。

重写默认用户查询

虽然这个最小配置可以工作,但它对数据库模式做了一些假设。它期望已经存在某些表,用户数据将保存在这些表中。更具体地说,以下来自 Spring Security 内部的代码片段显示了在查找用户详细信息时将执行的 SQL 查询:

public static final String DEF_USERS_BY_USERNAME_QUERY =
"select username,password,enabled " +
"from users " +
“where username = ?”;

public static final String DEF_AUTHORITIES_BY_USERNAME_QUERY =
"select username,authority " +
"from authorities " +
“where username = ?”;

public static final String DEF_GROUP_AUTHORITIES_BY_USERNAME_QUERY =
"select g.id, g.group_name, ga.authority " +
"from groups g, group_members gm, group_authorities ga " +
"where gm.username = ? " +
"and g.id = ga.group_id " +
“and g.id = gm.group_id”;
第一个查询检索用户的用户名、密码以及是否启用它们,此信息用于对用户进行身份验证;下一个查询查询用户授予的权限,以进行授权;最后一个查询查询作为组的成员授予用户的权限。

如果可以在数据库中定义和填充满足这些查询的表,那么就没有什么其他要做的了。但是,数据库很可能不是这样的,需要对查询进行更多的控制。在这种情况下,可以配置自己的查询。程序清单 4.4 自定义用户详情查询

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.jdbcAuthentication()
.dataSource(dataSource)
.usersByUsernameQuery(
"select username, password, enabled from Users " +
“where username=?”)
.authoritiesByUsernameQuery(
"select username, authority from UserAuthorities " +
“where username=?”);
}
在本例中,仅重写了身份验证和基本授权查询,也可以通过使用自定义查询调用 groupAuthoritiesByUsername() 来重写组权限查询。

在将默认 SQL 查询替换为自己设计的查询时,一定要遵守查询的基本约定。它们都以用户名作为唯一参数。身份验证查询选择用户名、密码和启用状态;授权查询选择包含用户名和授予的权限的零个或多个行的数据;组权限查询选择零个或多个行数据,每个行有一个 group id、一个组名和一个权限。

使用编码密码

以身份验证查询为重点,可以看到用户密码应该存储在数据库中。唯一的问题是,如果密码以纯文本形式存储,就会受到黑客的窥探。但是如果在数据库中对密码进行编码,身份验证将失败,因为它与用户提交的明文密码不匹配。

为了解决这个问题,你需要通过调用 passwordEncoder() 方法指定一个密码编码器:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.jdbcAuthentication()
.dataSource(dataSource)
.usersByUsernameQuery(
"select username, password, enabled from Users " +
“where username=?”)
.authoritiesByUsernameQuery(
"select username, authority from UserAuthorities " +
“where username=?”)
.passwordEncoder(new StandardPasswordEncoder(“53cr3t”);
}
passwordEncoder() 方法接受 Spring Security 的 passwordEncoder 接口的任何实现。Spring Security 的加密模块包括几个这样的实现:

BCryptPasswordEncoder —— 采用 bcrypt 强哈希加密
NoOpPasswordEncoder —— 不应用任何编码
Pbkdf2PasswordEncoder —— 应用 PBKDF2 加密
SCryptPasswordEncoder —— 应用了 scrypt 散列加密
StandardPasswordEncoder —— 应用 SHA-256 散列加密
上述代码使用了 StandardPasswordEncoder。但是,如果没有现成的实现满足你的需求,你可以选择任何其他实现,甚至可以提供你自己的自定义实现。PasswordEncoder 接口相当简单:

public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
}
无论使用哪种密码编码器,重要的是要理解数据库中的密码永远不会被解码。相反,用户在登录时输入的密码使用相同的算法进行编码,然后将其与数据库中编码的密码进行比较。比较是在 PasswordEncoder 的 matches() 方法中执行的。

最后,将在数据库中维护 Taco Cloud 用户数据。但是,我没有使用 jdbcAuthentication(),而是想到了另一个身份验证选项。但在此之前,让我们先看看如何配置 Spring Security 以依赖于另一个常见的用户数据源:使用 LDAP(轻量级目录访问协议)接入的用户存储。

4.2.3 LDAP 支持的用户存储
要为基于 LDAP 的身份验证配置 Spring Security,可以使用 ldapAuthentication() 方法。这个方法与 jdbcAuthentication() 类似。下面的 configure() 方法显示了用于 LDAP 身份验证的简单配置:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchFilter(“(uid={0})”)
.groupSearchFilter(“member={0}”);
}
userSearchFilter() 和 groupSearchFilter() 方法用于为基本 LDAP 查询提供过滤器,这些查询用于搜索用户和组。默认情况下,用户和组的基本查询都是空的,这表示将从 LDAP 层次结构的根目录进行搜索。但你可以通过指定一个查询基数来改变这种情况:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchBase(“ou=people”)
.userSearchFilter(“(uid={0})”)
.groupSearchBase(“ou=groups”)
.groupSearchFilter(“member={0}”);
}
userSearchBase() 方法提供了查找用户的基本查询。同样,groupSearchBase() 方法指定查找组的基本查询。这个示例不是从根目录进行搜索,而是指定要搜索用户所在的组织单元是 people,组应该搜索组织单元所在的 group。

配置密码比较

针对 LDAP 进行身份验证的默认策略是执行绑定操作,将用户通过 LDAP 服务器直接进行验证。另一种选择是执行比较操作,这包括将输入的密码发送到 LDAP 目录,并要求服务器将密码与用户的密码属性进行比较。因为比较是在 LDAP 服务器中进行的,所以实际的密码是保密的。

如果希望通过密码比较进行身份验证,可以使用 passwordCompare() 方法进行声明:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchBase(“ou=people”)
.userSearchFilter(“(uid={0})”)
.groupSearchBase(“ou=groups”)
.groupSearchFilter(“member={0}”)
.passwordCompare();
}
默认情况下,登录表单中给出的密码将与用户 LDAP 条目中的 userPassword 属性值进行比较。如果密码保存在不同的属性中,可以使用 passwordAttribute() 指定密码属性的名称:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchBase(“ou=people”)
.userSearchFilter(“(uid={0})”)
.groupSearchBase(“ou=groups”)
.groupSearchFilter(“member={0}”)
.passwordCompare()
.passwordEncoder(new BCryptPasswordEncoder())
.passwordAttribute(“passcode”);
}
在本例中,指定密码属性应该与给定的密码进行比较。此外,还可以指定密码编码器,在进行服务器端密码比较时,最好在服务器端对实际密码加密。但是尝试的密码仍然会通过网络传递到 LDAP 服务器,并且可能被黑客截获。为了防止这种情况,可以通过调用 passwordEncoder() 方法来指定加密策略。

在前面的示例中,使用 bcrypt 密码散列函数对密码进行加密,这里的前提是密码在 LDAP 服务器中也是使用 bcrypt 加密的。

引用远程 LDAP 服务器

到目前为止,我们忽略了 LDAP 服务器和数据实际驻留的位置,虽然已经将 Spring 配置为根据 LDAP 服务器进行身份验证,但是该服务器在哪里呢?

默认情况下,Spring Security 的 LDAP 身份验证假设 LDAP 服务器正在本地主机上监听端口 33389。但是,如果 LDAP 服务器位于另一台机器上,则可以使用 contextSource() 方法来配置位置:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchBase(“ou=people”)
.userSearchFilter(“(uid={0})”)
.groupSearchBase(“ou=groups”)
.groupSearchFilter(“member={0}”)
.passwordCompare()
.passwordEncoder(new BCryptPasswordEncoder())
.passwordAttribute(“passcode”)
.contextSource()
.url(“ldap://tacocloud.com:389/dc=tacocloud,dc=com”);
}
contextSource() 方法返回 ContextSourceBuilder,其中提供了 url() 方法,它允许指定 LDAP 服务器的位置。

配置嵌入式 LDAP 服务器

如果没有 LDAP 服务器去做身份验证,Spring Security 可提供一个嵌入式 LDAP 服务器。可以通过 root() 方法为嵌入式服务器指定根后缀,而不是将 URL 设置为远程 LDAP 服务器:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchBase(“ou=people”)
.userSearchFilter(“(uid={0})”)
.groupSearchBase(“ou=groups”)
.groupSearchFilter(“member={0}”)
.passwordCompare()
.passwordEncoder(new BCryptPasswordEncoder())
.passwordAttribute(“passcode”)
.contextSource()
.root(“dc=tacocloud,dc=com”);
}
当 LDAP 服务器启动时,它将尝试从类路径中找到的任何 LDIF 文件进行数据加载。LDIF(LDAP 数据交换格式)是在纯文本文件中表示 LDAP 数据的标准方法,每个记录由一个或多个行组成,每个行包含一个 name:value 对,记录之间用空行分隔。

如果不希望 Spring 在类路径中寻找它能找到的 LDIF 文件,可以通过调用 ldif() 方法来更明确地知道加载的是哪个 LDIF 文件:

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchBase(“ou=people”)
.userSearchFilter(“(uid={0})”)
.groupSearchBase(“ou=groups”)
.groupSearchFilter(“member={0}”)
.passwordCompare()
.passwordEncoder(new BCryptPasswordEncoder())
.passwordAttribute(“passcode”)
.contextSource()
.root(“dc=tacocloud,dc=com”)
.ldif(“classpath:users.ldif”);
}
这里,特别要求 LDAP 服务器从位于根路径下的 users.ldif 文件中加载数据。如果你感兴趣,这里有一个LDIF 文件,你可以使用它来加载内嵌 LDAP 服务器的用户数据:

dn: ou=groups,dc=tacocloud,dc=com
objectclass: top
objectclass: organizationalUnit
ou: groups
dn: ou=people,dc=tacocloud,dc=com
objectclass: top
objectclass: organizationalUnit
ou: people
dn: uid=buzz,ou=people,dc=tacocloud,dc=com
objectclass: top
objectclass: person
objectclass: organizationalPerson
objectclass: inetOrgPerson
cn: Buzz Lightyear
sn: Lightyear
uid: buzz
userPassword: password
dn: cn=tacocloud,ou=groups,dc=tacocloud,dc=com
objectclass: top
objectclass: groupOfNames
cn: tacocloud
member: uid=buzz,ou=people,dc=tacocloud,dc=com
Spring Security 的内置用户存储非常方便,涵盖了一些常见的用例。但是 Taco Cloud 应用程序需要一些特殊的东西。当开箱即用的用户存储不能满足需求时,需要创建并配置一个定制的用户详细信息服务。

4.2.4 自定义用户身份验证
在上一章中,决定了使用 Spring Data JPA 作为所有 taco、配料和订单数据的持久化选项。因此,以同样的方式持久化用户数据是有意义的,这样做的话,数据最终将驻留在关系型数据库中,因此可以使用基于 JDBC 的身份验证。但是更好的方法是利用 Spring Data 存储库来存储用户。

不过,还是要先做重要的事情,让我们创建表示和持久存储用户信息的域对象和存储库接口。

当 Taco Cloud 用户注册应用程序时,他们需要提供的不仅仅是用户名和密码。他们还会告诉你,他们的全名、地址和电话号码,这些信息可以用于各种目的,不限于重新填充订单(更不用说潜在的营销机会)。

为了捕获所有这些信息,将创建一个 User 类,如下所示。程序清单 4.5 定义用户实体

package tacos;

import java.util.Arrays;
import java.util.Collection;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;

@Entity
@Data
@NoArgsConstructor(access=AccessLevel.PRIVATE, force=true)
@RequiredArgsConstructor
public class User implements UserDetails {
private static final long serialVersionUID = 1L;

@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long id;

private final String username;
private final String password;
private final String fullname;
private final String street;
private final String city;
private final String state;
private final String zip;
private final String phoneNumber;

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));
}

@Override
public boolean isAccountNonExpired() {
    return true;
}

@Override
public boolean isAccountNonLocked() {
    return true;
}

@Override
public boolean isCredentialsNonExpired() {
    return true;
}

@Override
public boolean isEnabled() {
    return true;
}

}
毫无疑问,你已经注意到 User 类比第 3 章中定义的任何其他实体都更加复杂。除了定义一些属性外,User 还实现了来自 Spring Security 的 UserDetails 接口。

UserDetails 的实现将向框架提供一些基本的用户信息,比如授予用户什么权限以及用户的帐户是否启用。

getAuthorities() 方法应该返回授予用户的权限集合。各种 isXXXexpired() 方法返回一个布尔值,指示用户的帐户是否已启用或过期。

对于 User 实体,getAuthorities() 方法仅返回一个集合,该集合指示所有用户将被授予 ROLE_USER 权限。而且,至少现在,Taco Cloud 还不需要禁用用户,所以所有的 isXXXexpired() 方法都返回 true 来表示用户处于活动状态。

定义了 User 实体后,现在可以定义存储库接口:

package tacos.data;
import org.springframework.data.repository.CrudRepository;
import tacos.User;

public interface UserRepository extends CrudRepository<User, Long> {
User findByUsername(String username);
}
除了通过扩展 CrudRepository 提供的 CRUD 操作之外,UserRepository 还定义了一个 findByUsername() 方法,将在用户详细信息服务中使用该方法根据用户名查找 User。

如第 3 章所述,Spring Data JPA 将在运行时自动生成该接口的实现。因此,现在可以编写使用此存储库的自定义用户详细信息服务了。

创建用户详细信息服务

Spring Security 的 UserDetailsService 是一个相当简单的接口:

public interface UserDetailsService {
UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException;
}
这个接口的实现是给定一个用户的用户名,期望返回一个 UserDetails 对象,如果给定的用户名没有显示任何结果,则抛出一个 UsernameNotFoundException。

由于 User 类实现了 UserDetails,同时 UserRepository 提供了一个 findByUsername() 方法,因此它们非常适合在自定义 UserDetailsService 实现中使用。下面的程序清单显示了将在 Taco Cloud 应用程序中使用的用户详细信息服务。程序清单 4.6 定义用户详细信息服务

package tacos.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import tacos.User;
import tacos.data.UserRepository;

@Service
public class UserRepositoryUserDetailsService implements UserDetailsService {

private UserRepository userRepo;

@Autowired
public UserRepositoryUserDetailsService(UserRepository userRepo) {
    this.userRepo = userRepo;
}

@Override
public UserDetails loadUserByUsername(String username)
    throws UsernameNotFoundException {
    User user = userRepo.findByUsername(username);
    if (user != null) {
        return user;
    }

    throw new UsernameNotFoundException("User '" + username + "' not found");
}

}
UserRepositoryUserDetailsService 通过 UserRepository 实例的构造器进行注入。然后,在它的 loadByUsername() 方法中,它调用 UserRepository 中的 findByUsername() 方法去查找 User;

loadByUsername() 方法只有一个简单的规则:不允许返回 null。因此如果调用 findByUsername() 返回 null,loadByUsername() 将会抛出一个 UsernameNotFoundExcepition。除此之外,被找到的 User 将会被返回。

你会注意到 UserRepositoryUserDetailsService 上有 @Service 注解。这是 Spring 的另一种构造型注释,它将该类标记为包含在 Spring 的组件扫描中,因此不需要显式地将该类声明为 bean。Spring 将自动发现它并将其实例化为 bean。

但是,仍然需要使用 Spring Security 配置自定义用户详细信息服务。因此,将再次返回到 configure() 方法:

@Autowired
private UserDetailsService userDetailsService;

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userDetailsService);
}
这次,只需调用 userDetailsService() 方法,将自动生成的 userDetailsService 实例传递给 SecurityConfig。

与基于 JDBC 的身份验证一样,也可以(而且应该)配置密码编码器,以便可以在数据库中对密码进行编码。为此,首先声明一个 PasswordEncoder 类型的bean,然后通过调用 PasswordEncoder() 将其注入到用户详细信息服务配置中:

@Bean
public PasswordEncoder encoder() {
return new StandardPasswordEncoder(“53cr3t”);
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userDetailsService)
.passwordEncoder(encoder());
}
我们必须讨论 configure() 方法中的最后一行,它出现了调用 encoder() 方法并将其返回值传递给 passwordEncoder()。但实际上,因为 encoder() 方法是用 @Bean 注释的,所以它将被用于在 Spring 应用程序上下文中声明一个 PasswordEncoder bean,然后拦截对 encoder() 的任何调用,以从应用程序上下文中返回 bean 实例。

既然已经有了一个通过 JPA 存储库读取用户信息的自定义用户详细信息服务,那么首先需要的就是一种让用户进入数据库的方法。需要为 Taco Cloud 用户创建一个注册页面,以便注册该应用程序。

用户注册

尽管 Spring Security 处理安全性的很多方面,但它实际上并不直接涉及用户注册过程,因此将依赖于 Spring MVC 来处理该任务。下面程序清单中的 RegistrationController 类展示并处理注册表单。程序清单 4.7 用户注册控制器

package tacos.security;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import tacos.data.UserRepository;

@Controller
@RequestMapping(“/register”)
public class RegistrationController {

private UserRepository userRepo;
private PasswordEncoder passwordEncoder;

public RegistrationController(
    UserRepository userRepo, PasswordEncoder passwordEncoder) {
    this.userRepo = userRepo;
    this.passwordEncoder = passwordEncoder;
}

@GetMapping
public String registerForm() {
    return "registration";
}

@PostMapping
public String processRegistration(RegistrationForm form) {
    userRepo.save(form.toUser(passwordEncoder));
    return "redirect:/login";
}

}
与任何典型的 Spring MVC 控制器一样,RegistrationController 使用 @Controller 进行注解,以将其指定为控制器,并将其标记为组件扫描。它还使用 @RequestMapping 进行注解,以便处理路径为 /register 的请求。

更具体地说,registerForm() 方法将处理 /register 的 GET 请求,它只返回注册的逻辑视图名。下面的程序清单显示了定义注册视图的 Thymeleaf 模板。程序清单 4.8 Thymeleaf 注册表单视图

Taco Cloud
<body>
    <h1>Register</h1>
    <img th:src="@{/images/TacoCloud.png}"/>
    
    <form method="POST" th:action="@{/register}" id="registerForm">
        
        <label for="username">Username: </label>
        <input type="text" name="username"/><br/>
        
        <label for="password">Password: </label>
        <input type="password" name="password"/><br/>
        
        <label for="confirm">Confirm password: </label>
        <input type="password" name="confirm"/><br/>
        
        <label for="fullname">Full name: </label>
        <input type="text" name="fullname"/><br/>
        
        <label for="street">Street: </label>
        <input type="text" name="street"/><br/>
        
        <label for="city">City: </label>
        <input type="text" name="city"/><br/>
        
        <label for="state">State: </label>
        <input type="text" name="state"/><br/>
        
        <label for="zip">Zip: </label>
        <input type="text" name="zip"/><br/>
        
        <label for="phone">Phone: </label>
        <input type="text" name="phone"/><br/>
        
        <input type="submit" value="Register"/>
    </form>
</body>
提交表单时,HTTP POST 请求将由 processRegistration() 方法处理。processRegistration() 的 RegistrationForm 对象绑定到请求数据,并使用以下类定义:

package tacos.security;

import org.springframework.security.crypto.password.PasswordEncoder;
import lombok.Data;
import tacos.User;

@Data
public class RegistrationForm {

private String username;
private String password;
private String fullname;
private String street;
private String city;
private String state;
private String zip;
private String phone;

public User toUser(PasswordEncoder passwordEncoder) {
    return new User(
        username, passwordEncoder.encode(password),
        fullname, street, city, state, zip, phone);
}

}
在大多数情况下,RegistrationForm 只是一个支持 Lombok 的基本类,只有少量属性。但是 toUser() 方法使用这些属性创建一个新的 User 对象,processRegistration() 将使用注入的 UserRepository 保存这个对象。

毫无疑问,RegistrationController 被注入了一个密码编码器。这与之前声明的 PasswordEncoder bean 完全相同。在处理表单提交时,RegistrationController 将其传递给 toUser() 方法,该方法使用它对密码进行编码,然后将其保存到数据库。通过这种方式,提交的密码以编码的形式写入,用户详细信息服务将能够根据编码的密码进行身份验证。

现在 Taco Cloud 应用程序拥有完整的用户注册和身份验证支持。但是如果在此时启动它,你会注意到,如果不是提示你登录,你甚至无法进入注册页面。这是因为,默认情况下,所有请求都需要身份验证。让我们看看 web 请求是如何被拦截和保护的,以便可以修复这种奇怪的先有鸡还是先有蛋的情况。
4.3 保护 web 请求
Taco Cloud 的安全需求应该要求用户在设计 tacos 或下订单之前进行身份验证。但是主页、登录页面和注册页面应该对未经身份验证的用户可用。

要配置这些安全规则,需要介绍一下 WebSecurityConfigurerAdapter 的另一个 configure() 方法:

@Override
protected void configure(HttpSecurity http) throws Exception {

}
这个 configure() 方法接受 HttpSecurity 对象,可以使用该对象来配置如何在 web 级别处理安全性。可以配置 HttpSecurity 的属性包括:

在允许服务请求之前,需要满足特定的安全条件
配置自定义登录页面
使用户能够退出应用程序
配置跨站请求伪造保护
拦截请求以确保用户拥有适当的权限是配置 HttpSecurity 要做的最常见的事情之一。让我们确保 Taco Cloud 的客户满足这些要求。

4.3.1 保护请求
需要确保 /design 和 /orders 的请求仅对经过身份验证的用户可用;应该允许所有用户发出所有其他请求。下面的 configure() 实现就是这样做的:

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers(“/design”, “/orders”)
.hasRole(“ROLE_USER”)
.antMatchers(“/”, “/**”).permitAll();
}
对 authorizeRequests() 的调用返回一个对象(ExpressionInterceptUrlRegistry),可以在该对象上指定 URL 路径和模式以及这些路径的安全需求。在这种情况下,指定两个安全规则:

对于 /design 和 /orders 的请求应该是授予 ROLE_USER 权限的用户的请求。
所有的请求都应该被允许给所有的用户。
这些规则的顺序很重要。首先声明的安全规则优先于较低级别声明的安全规则。如果交换这两个安全规则的顺序,所有请求都将应用 permitAll(),那么关于 /design 和 /orders 请求的规则将不起作用。

hasRole() 和 permitAll() 方法只是声明请求路径安全需求的两个方法。表 4.1 描述了所有可用的方法。

表 4.1 定义被保护路径的配置方法

方法 做了什么
access(String) 如果 SpEL 表达式的值为 true,则允许访问
anonymous() 默认用户允许访问
authenticated() 认证用户允许访问
denyAll() 无条件拒绝所有访问
fullyAuthenticated() 如果用户是完全授权的(不是记住用户),则允许访问
hasAnyAuthority(String…) 如果用户有任意给定的权限,则允许访问
hasAnyRole(String…) 如果用户有任意给定的角色,则允许访问
hasAuthority(String) 如果用户有给定的权限,则允许访问
hasIpAddress(String) 来自给定 IP 地址的请求允许访问
hasRole(String) 如果用户有给定的角色,则允许访问
not() 拒绝任何其他访问方法
permitAll() 无条件允许访问
rememberMe() 允许认证了的同时标记了记住我的用户访问
表 4.1 中的大多数方法为请求处理提供了基本的安全规则,但是它们是自我限制的,只支持那些方法定义的安全规则。或者,可以使用 access() 方法提供 SpEL 表达式来声明更丰富的安全规则。Spring Security 扩展了 SpEL,包括几个特定于安全性的值和函数,如表 4.2 所示。

表 4.2 Spring Security 对 SpEL 的扩展

Security 表达式 意指什么
authentication 用户认证对象
denyAll 通常值为 false
hasAnyRole(list of roles) 如果用户有任何给定的角色,则为 true
hasRole(role) 如果用户有给定的角色,则为 true
hasIpAddress(IP Address) 如果请求来自给定 IP 地址,则为 true
isAnonymous() 如果用户是默认用户,则为 true
isAuthenticated() 如果用户是认证了的,则为 true
isFullyAuthenticated() 如果用户被完全认证了的(不是使用记住我进行认证),则为 true
isRememberMe() 如果用户被标记为记住我后认证了,则为 true
permitAll() 通常值为 true
principal 用户 pricipal 对象
表 4.2 中的大多数安全表达式扩展对应于表 4.1 中的类似方法。实际上,使用 access() 方法以及 hasRole() 和 permitAll 表达式,可以按如下方式重写 configure()。程序清单 4.9 使用 Spring 表达式定义认证规则

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers(“/design”, “/orders”)
.access(“hasRole(‘ROLE_USER’)”)
.antMatchers(“/”, “/**”).access(“permitAll”);
}
乍一看,这似乎没什么大不了的。毕竟,这些表达式只反映了已经对方法调用所做的工作。但是表达式可以灵活得多。例如,假设(出于某种疯狂的原因)只想允许具有 ROLE_USER 权限的用户在周二(例如,在周二)创建新的 Taco;你可以重写表达式如下:

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers(“/design”, “/orders”)
.access("hasRole(‘ROLE_USER’) && " +
“T(java.util.Calendar).getInstance().get(”+
"T(java.util.Calendar).DAY_OF_WEEK) == " +
“T(java.util.Calendar).TUESDAY”)
.antMatchers(“/”, “/**”).access(“permitAll”);
}
使用基于 SpEL 的安全约束,这种可能性实际上是无限的。我敢打赌,你已经在构思基于 SpEL 的有趣的安全约束了。

只需使用 access() 和程序清单 4.9 中的 SpEL 表达式,就可以满足 Taco Cloud 应用程序的授权需求。现在,让我们来看看如何定制登录页面来适应 Taco Cloud 应用程序的外观。

4.3.2 创建用户登录页面
默认的登录页面比您开始时使用的笨拙的HTTP基本对话框要好得多,但它仍然相当简单,不太适合Taco云应用程序的其余部分。

要替换内置的登录页面,首先需要告诉 Spring Security 自定义登录页面的路径。这可以通过调用传递给 configure() 的 HttpSecurity 对象上的 formLogin() 来实现:

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers(“/design”, “/orders”)
.access(“hasRole(‘ROLE_USER’)”)
.antMatchers(“/”, “/**”).access(“permitAll”)

    .and()
    	.formLogin()
    	.loginPage("/login");

}
请注意,在调用 formLogin() 之前,需要使用对 and() 的调用来连接这一部分的配置和前面的部分。and() 方法表示已经完成了授权配置,并准备应用一些额外的 HTTP 配置。在开始新的配置部分时,将多次使用 and()。

连接之后,调用 formLogin() 开始配置自定义登录表单。之后对 loginPage() 的调用指定了将提供自定义登录页面的路径。当 Spring Security 确定用户未经身份验证并且需要登录时,它将把用户重定向到此路径。

现在需要提供一个控制器来处理该路径上的请求。因为你的登录页面非常简单 —— 除了一个视图什么都没有 —— 在 WebConfig 中声明它为一个视图控制器是很容易的。下面的 addViewControllers() 方法在将 “/” 映射到主控制器的视图控制器旁边设置登录页面视图控制器:

@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController(“/”).setViewName(“home”);
registry.addViewController(“/login”);
}
最后,需要定义 login 页面视图本身,因为使用 Thymeleaf 作为模板引擎,下面的 Thymeleaf 模板应该做得很好:

Taco Cloud
<body>
    <h1>Login</h1>
    <img th:src="@{/images/TacoCloud.png}"/>
    
    <div th:if="${error}">
        Unable to login. Check your username and password.
    </div>
    
    <p>New here? Click<a th:href="@{/register}">here</a> to register.</p>
    <!-- tag::thAction[] -->
    <form method="POST" th:action="@{/login}" id="loginForm">
    <!-- end::thAction[] -->
        <label for="username">Username: </label>
        <input type="text" name="username" id="username" /><br/>
        
        <label for="password">Password: </label>
        <input type="password" name="password" id="password" /><br/>
        
        <input type="submit" value="Login"/>
    </form>
</body>
关于这个登录页面需要注意的关键事情是,它发布到的路径以及用户名和密码字段的名称。默认情况下,Spring Security 在 /login 监听登录请求,并期望用户名和密码字段命名为 username 和 password。但是,这是可配置的。例如,以下配置自定义路径和字段名:

.and()
.formLogin()
.loginPage(“/login”)
.loginProcessingUrl(“/authenticate”)
.usernameParameter(“user”)
.passwordParameter(“pwd”)
这里,指定 Spring Security 应该监听请求 /authenticate 请求以处理登录提交。此外,用户名和密码字段现在应该命名为 user 和 pwd。

默认情况下,当 Spring Security 确定用户需要登录时,成功的登录将直接将用户带到他们所导航到的页面。如果用户要直接导航到登录页面,成功的登录将把他们带到根路径(例如,主页)。但你可以通过指定一个默认的成功页面来改变:

.and()
.formLogin()
.loginPage(“/login”)
.defaultSuccessUrl(“/design”)
按照这里的配置,如果用户在直接进入登录页面后成功登录,那么他们将被引导到 /design 页面。

另外,可以强制用户在登录后进入设计页面,即使他们在登录之前已经在其他地方导航,方法是将 true 作为第二个参数传递给 defaultSuccessUrl:

.and()
.formLogin()
.loginPage(“/login”)
.defaultSuccessUrl(“/design”, true)
现在已经处理了自定义登录页面,让我们来看看身份验证的另一面 —— 如何让用户登出。

4.3.3 登出
与登录应用程序同样重要的是登出。要启用登出功能,只需调用 HttpSecurity 对象上的 logout:

.and()
.logout()
.logoutSuccessUrl(“/”)
这将设置一个安全筛选器来拦截发送到 /logout 的请求。因此,要提供登出功能,只需在应用程序的视图中添加登出表单和按钮:

当用户单击按钮时,他们的 session 将被清除,他们将退出应用程序。默认情况下,它们将被重定向到登录页面,在那里它们可以再次登录。但是,如果希望它们被发送到另一个页面,可以调用 logoutSucessFilter() 来指定一个不同的登出后的登录页面:

.and()
.logout()
.logoutSuccessUrl(“/”)
在这个例子中,用户在登出后将被跳转到主页。

4.3.4 阻止跨站请求伪造攻击
跨站请求伪造(CSRF)是一种常见的安全攻击。它涉及到让用户在一个恶意设计的 web 页面上编写代码,这个页面会自动(通常是秘密地)代表经常遭受攻击的用户向另一个应用程序提交一个表单。例如,在攻击者的网站上,可能会向用户显示一个表单,该表单会自动向用户银行网站上的一个 URL 发送消息(该网站的设计可能很糟糕,很容易受到这种攻击),以转移资金。用户甚至可能不知道攻击发生了,直到他们注意到他们的帐户中少了钱。

为了防止此类攻击,应用程序可以在显示表单时生成 CSRF token,将该 token 放在隐藏字段中,然后将其存储在服务器上供以后使用。提交表单时,token 将与其他表单数据一起发送回服务器。然后服务器拦截请求,并与最初生成的 token 进行比较。如果 token 匹配,则允许继续执行请求。否则,表单一定是由一个不知道服务器生成的 token的恶意网站呈现的。

幸运的是,Spring Security 有内置的 CSRF 保护。更幸运的是,它是默认启用的,不需要显式地配置它。只需确保应用程序提交的任何表单都包含一个名为 _csrf 的字段,该字段包含 CSRF token。

Spring Security 甚至可以通过将 CSRF token 放在名为 _csrf 的请求属性中来简化这一过程。因此,可以使用以下代码,在 Thymeleaf 模板的一个隐藏字段中呈现 CSRF token:

如果使用 Spring MVC 的 JSP 标签库或带有 Spring 安全方言的 Thymeleaf,那么甚至不需要显式地包含一个隐藏字段,隐藏字段将自动呈现。

在 Thymeleaf 中,只需确保

元素的一个属性被前缀为 Thymeleaf 属性。因为让 Thymeleaf 将路径呈现为上下文相关是很常见的,所以这通常不是问题。例如,Thymeleaf 渲染隐藏字段所需要的仅仅是 th:action 属性:

当然也可以禁用 CSRF 支持,但我不太愿意展示如何禁用。CSRF 保护很重要,而且在表单中很容易处理,所以没有理由禁用它,但如果你坚持禁用它,你可以这样调用 disable():

.and()
.csrf()
.disable()
我再次提醒你不要禁用 CSRF 保护,特别是对于生产环境中的应用程序。

所有 web 层安全性现在都配置到 Taco Cloud 了。除此之外,现在有了一个自定义登录页面,并且能够根据 JPA 支持的用户存储库对用户进行身份验证。现在让我们看看如何获取有关登录用户的信息。
4.4 了解你的用户
通常,仅仅知道用户已经登录是不够的。通常重要的是要知道他们是谁,这样才能调整他们的体验。

例如,在 OrderController 中,当最初创建绑定到订单表单的订单对象时,如果能够用用户名和地址预先填充订单就更好了,这样他们就不必为每个订单重新输入它。也许更重要的是,在保存订单时,应该将订单实体与创建订单的用户关联起来。

为了在 Order 实体和 User 实体之间实现所需的连接,需要向 Order 类添加一个新属性:

@Data
@Entity
@Table(name=“Taco_Order”)
public class Order implements Serializable {

@ManyToOne
private User user;

...

}
此属性上的 @ManyToOne 注解表明一个订单属于单个用户,相反,一个用户可能有多个订单。(因为使用的是 Lombok,所以不需要显式地定义属性的访问方法。)

在 OrderController 中,processOrder() 方法负责保存订单。需要对其进行修改,以确定经过身份验证的用户是谁,并调用 Order 对象上的 setUser() 以将 Order 与该用户连接起来。

有几种方法可以确定用户是谁。以下是一些最常见的方法:

将主体对象注入控制器方法
将身份验证对象注入控制器方法
使用 SecurityContext 获取安全上下文
使用 @AuthenticationPrincipal 注解的方法
例如,可以修改 processOrder() 来接受 java.security.Principal 作为参数。然后可以使用主体名从 UserRepository 查找用户:

@PostMapping
public String processOrder(@Valid Order order, Errors errors,
SessionStatus sessionStatus,
Principal principal) {

User user = userRepository.findByUsername(principal.getName());
order.setUser(user);

...

}
这可以很好地工作,但是它会将与安全性无关的代码与安全代码一起丢弃。可以通过修改 processOrder() 来减少一些特定于安全的代码,以接受 Authentication 对象作为参数而不是 Principal:

@PostMapping
public String processOrder(@Valid Order order, Errors errors,
SessionStatus sessionStatus,
Authentication authentication) {

User user = (User) authentication.getPrincipal();
order.setUser(user);

...

}
有了身份验证,可以调用 getPrincipal() 来获取主体对象,在本例中,该对象是一个用户。注意,getPrincipal() 返回一个 java.util.Object,因此需要将其转换为 User。

然而,也许最干净的解决方案是简单地接受 processOrder() 中的用户对象,但是使用 @AuthenticationPrincipal 对其进行注解,以便它成为身份验证的主体:

@PostMapping
public String processOrder(@Valid Order order, Errors errors,
SessionStatus sessionStatus,
@AuthenticationPrincipal User user) {
if (errors.hasErrors()) {
return “orderForm”;
}

order.setUser(user);

orderRepo.save(order);
sessionStatus.setComplete();

return "redirect:/";

}
@AuthenticationPrincipal 的优点在于它不需要强制转换(与身份验证一样),并且将特定于安全性的代码限制为注释本身。当在 processOrder() 中获得 User 对象时,它已经准备好被使用并分配给订单了。

还有一种方法可以识别通过身份验证的用户是谁,尽管这种方法有点麻烦,因为它包含了大量与安全相关的代码。你可以从安全上下文获取一个认证对象,然后像这样请求它的主体:

Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
User user = (User) authentication.getPrincipal();
尽管这个代码段充满了与安全相关的代码,但是它与所描述的其他方法相比有一个优点:它可以在应用程序的任何地方使用,而不仅仅是在控制器的处理程序方法中,这使得它适合在较低级别的代码中使用。
4.5 小结
Spring Security 自动配置是一种很好的开始学习安全的方式,但大多数应用程序需要明确地配置安全,以满足其独特的安全需求。
用户细节可以在关系数据库、LDAP 或完全自定义实现支持的用户存储中进行管理。
Spring Security 自动防御 CSRF 攻击。
通过 SecurityContext 对象(从 SecurityContextHolder. getcontext() 中返回)或使用 @AuthenticationPrincipal 注入控制器中,可以获得认证用户的信息。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值