Spring实战
第4章 保护Spring
启用 Spring Security
- 保护Spring应用的第一步就是将Spring Boot Security starter依赖添加到构建文件中
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- 可以尝试一下,启动应用并尝试访问任意页面。应用将会弹出一个HTTP basic认证对话框并提示进行认证。要想通过这个认证,需要一个用户名和密码。用户名为user,密码是随机生成的,会被写入应用的日志文件中。
Using generated security password: 830b144e-f507-4059-9761-a83a0c2abff9
- 输入正确的用户名和密码之后,就有权进行访问了。
- 通过将Security starter添加到项目的构建文件中,我们得到了如下的安全特性:
- 所有的HTTP请求路径都需要认证
- 不需要特定的角色和权限
- 没有登录页面
- 认证过程是通过一个HTTP basic认证对话框实现的
- 系统只有一个用户,用户名为user
- 下面,我们将为了实现需要的功能,配置Spring Security
配置 Spring Security
- 有很多配置方式,比如冗长的XML的配置。但是最近版本的Spring Security支持基于Java的配置,这种方式更易于阅读和编写。
- 下面是Spring Security的基础配置类
package tacos.security;
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 {
}
- 注意继承的是xxxAdapter类,懂的都懂。
- Spring Security为配置用户存储提供了多个可选方案:
- 基于内存的用户存储
- 基于JDBC的用户存储
- 以LDAP作为后端的用户存储
- 自定义用户详情服务
- 不管使用哪种用户存储,都可以通过覆盖WebSecurityConfigurerAdapter基础配置类中定义的configure()方法来进行配置。首先,可以将下面的方法添加到SecurityConfig类中。
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
...;
}
基于内存的用户存储
- 用户信息可以存储在内存中。假设只有数量有限的几个用户且几乎不会发生变化,在这种情况下,将这些用户定义成安全配置的是非常简单的。
- 下面的程序将在内存用户存储中配置两个用户“buzz”和“woody”
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("buzz")
.password("{noop}infinity")
.authorities("ROLE_USER")
.and()
.withUser("woody")
.password("{noop}bullseye")
.authorities("ROLE_USER");
}
- 需要注意的是,使用了inMemoryAuthentication()方法来指定用户信息,也就是配置在了内存中
基于JDBC的用户存储
- 用户信息通常会在关系型数据库中进行维护,基于JDBC的用户存储方案会更加合理一些。下面的程序展示了使用JDBC对存储在关系型数据库中的用户信息进行认证所需的Spring Security配置。
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.jdbcAuthentication()
.dataSource(dataSource);
}
@Autowired
DataSource dataSource;
- 在这里的configure实现中,调用了jdbcAuthentication方法。我们还必须要配置一个DataSource,这样它才能知道如何访问数据库。这里的DataSource是通过自动装配的技巧获取到的。
重写默认的用户查询功能
- 默认的用户查询中,获取用户的用户名、密码以及是否启用的信息,用来进行用户认证。但是,可能我们的数据库与默认的不一致,那么可能会希望在查询上有更多控制权,下面是配置自己的查询:
@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=?"
);
}
@Autowired
DataSource dataSource;
- 在本例中,只重写了认证和基本权限的查询语句,但是通过调用groupAuthoritiesByUsername()方法,我们也能够将群组权限重写为自定义查询语句。
- 将默认的SQL查询替换为自定义的设计时,很重要一点就是要遵循查询基本协议。所有查询都会讲用户名作为唯一参数。认证查询会选取用户名、密码以及启用状态信息。权限查询会选取零行或多行包含该用户名及其权限信息的数据。群组权限查询会选取零行或多行数据,每行数据中都会包含群组ID、群组名称以及权限。
使用转码后的密码
- 看上面的认证查询,预期用户密码存储到了数据库。这里唯一的问题是如果密码使用明文存储,很容易收到黑客攻击。但是,如果数据库中的密码进行了转码,那么认证就会失败,因为它与用户提交的明文密码并不匹配。
- 为解决这个问题,我们需要借助passwordEncoder()方法指定一个密码转码器:
package tacos.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;
import javax.sql.DataSource;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@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 Pbkdf2PasswordEncoder("123456"));
}
@Autowired
DataSource dataSource;
}
- 上面代码使用了Pbkdf2PasswordEncoder,也就是使用了PBKDF2进行加密。除此之外,还有很多种加密方式,甚至可以实现PasswordEncoder接口中的encode和match两个方法进行实现。
以LDAP作为后端的用户存储
- 为了配置Spring Security使用基于LDAP认证,可以使用公ldapAuthentication()方法。这个方法在功能上类似于jdbcAuthentication(),只不过是LDAP版本。如下的configure()方法展现了LDAP认证的简单配置
package tacos.security;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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 {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}");
}
}
- 方法userSearchFilter()和groupSearchFilter()用来为基础LDAP查询提供过滤条件,分别用于搜索用户和组。默认情况下,对于用户和组的基础查询都是空的,也就是表明搜索会在LADP层级结构的根开始。但是我们可以通过指定查询基础来改变这个默认行为。这样的话,用户应该在名为people的组织单元下搜索而不是从根开始,而组应该在名为groups的组织单元下搜索。
配置密码比对
- 基于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服务器
- 默认情况下,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")
.and()
.contextSource()
.url("ldap://tacocloud.com:389/dc=tacocloud,dc=com");
}
配置嵌入式的LDAP服务器
- 如果没有现成的LDAP服务器供认证使用,Spring Security还为我们提供了嵌入式的LDAP服务器。我们不再需要设置远程的LDAP服务器的url,只需要通过root()方法指定嵌入式服务器的根前缀即可。
@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")
.and()
.contextSource()
.root("dc=tacocloud,dc=com");
}
- 当LDAP服务器启动时,会尝试在类路径下寻找LDIF文件来加载数据。LDIF是以文本文件展现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")
.and()
.contextSource()
.root("dc=tacocloud,dc=com")
.ldif("classpath:users.ldif");
}
自定义用户认证
定义用户领域对象和持久化
- 当Taco Cloud的顾客注册应用的时候,需要提供除了用户名和密码以外的更多信息。它们会提供全名、地址和电话号码。这些信息可以用于各种目的,包括预先填充表单。
- 为了捕获这些信息,我们要创建下面的User类。
package tacos;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.util.Arrays;
import java.util.Collection;
@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类比前面定义的实体都更加复杂,除了定义了一些属性之外,User类还实现了Spring Security的UserDetails接口。
- 通过实现该接口,可以提供更多信息给框架,比如用户都被授予了哪些权限以及用户的账号是否可用。
- getAuthorities()方法应该返回用户被授予权限的一个集合。各种is…Expired()方法都返回一个布尔值,表明用户的账号是否可用或已经过期。
- 下面是定义repository接口
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方法
创建用户详情服务
- Spring Security的UserDetialsService是一个相当简单的接口:
package org.springframework.security.core.userdetails;
public interface UserDetailsService {
UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}
- 这个接口的实现将会得到一个用户的用户名,并且要么返回查找到的UserDetials对象,要么在根据用户名无法得到任何结果的情况下抛出UsernameNotFoundException。
- 因为User类实现了UserDetails,并且UserRepository提供了findByUsername()方法,所以它们非常适合用在UserDetailsService实现中。
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 s) throws UsernameNotFoundException {
User user = userRepo.findByUsername(s);
if(user != null)
{
return user;
}
throw new UsernameNotFoundException(
"User '" + s + "' not found"
);
}
}
- 该类通过构造器将UserRepository注入进来。并在loadUserByUsername方法中调用其findByUsername方法来查找User。
- 该类添加了注解@Service,这是Spring另一个构造型注解,表明这个类要包含到Spring的组件扫描中,Spring会自动发现他并将其初始化为一个bean。
- 但是,我们依然需要将这个自定义的用户详情服务与Spring Security配置在一起。因此,还需要回到configure()方法
package tacos.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userDetailsService);
}
@Autowired
private UserDetailsService userDetailsService;
}
- 在这里,只是简单地调用了userDetailsService()方法,并将自动装配到SecurityConfig中的UserDetailsService实例传递了进去。
- 像基于JDBC的认证一样,我们也应该配置一个密码转码器。
package tacos.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userDetailsService)
.passwordEncoder(encoder());
}
@Qualifier("userRepositoryUserDetailsService")
@Autowired
private UserDetailsService userDetailsService;
@Bean
public PasswordEncoder encoder(){
return new Pbkdf2PasswordEncoder("123456");
}
}
注册用户
- 尽管在安全性方面,Spring Security会为我们处理很多事,但是它没有直接涉及用户注册的流程,所以我们需要借助Spring MVC的一些技能来完成这个任务。下面的类会负责展现和处理注册表单。
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.passwordEncoder = passwordEncoder;
this.userRepo = userRepo;
}
@GetMapping
public String registerForm()
{
return "registration";
}
@PostMapping
public String processRegistration (RegistrationForm form)
{
userRepo.save(form.toUser(passwordEncoder));
return "redirect:/login";
}
}
- 下面是注册表单的Thymeleaf视图
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Taco Cloud</title>
</head>
<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>
</html>
- 当表单提交时,processRegistration()方法会处理HTTP POST请求。ProcessRegistration()方法得到RegistrationForm对象绑定了请求的数据,该类定义如下:
package tacos.security;
import lombok.Data;
import org.springframework.security.crypto.password.PasswordEncoder;
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
);
}
}
- toUser()方法使用这些属性创建了一个新的User对象,processRegistration()使用注入的UserRepository保存了该对象。
- 可以发现RegistrationController注入了一个PasswordEncoder,在密码保存到数据库前,对其进行转码。
- 现在Taco Cloud应用已经有了完整的用户注册和认证功能。但是如果现在启动应用会发现无法进入注册页面,也不会提示登录。这是因为默认情况下,所有的请求都需要认证。
保护 Web 请求
- Taco Cloud的安全性需求是在用户在设计taco和提交订单时必须要经过认证。但是,主页、登录页、以及注册页应该对未认证的用户开放。
- 为了配置这些安全性规则,需要了解configure()功能
- 在为某个请求提供服务前,需要预先满足特定条件
- 配置自定义登录页
- 支持用户退出应用
- 预防跨站请求伪造
- 配置HttpSecurity常见的需求就是拦截请求以确保用户具备适当的权限。
保护请求
- 我们需要确保只有认证过的用户才能发起对"/design"和"/orders"的请求,而其他请求对所有用户都可用。
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/design", "/orders")
.hasRole("ROLE_USER")
.antMatchers("/", "/**")
.permitAll()
}
- 对authorizeRequests()的调用会返回一个对象,基于它我们可以指定URL路径和这些路径的安全需求。在本例中,我们指定了两条安全规则。
- 具备ROLE_USER权限的用户才可以访问"/design"和"/orders"
- 其他的请求允许所有用户访问
- 但是注意不能交换两个安全规则的顺序。
- 安全规则的写法有很多种,例如我们可以将上面程序重写为
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/design", "/orders")
.access("hasRole('ROLE_USER')")
.antMatchers("/", "/**")
.access("permitAll()");
}
创建自定义的登录页
- 默认登录页已经比最初丑陋的HTTP basic认证对话框好了很多,但是依然十分简单。
- 为了替换内置登录页,我们首先需要告诉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")
}
- and()表明开始一个新的配置区域。
- 当Spring Security断定用户没有认证并且需要登录的时候,就会将用户重定向到该路径。
- 现在,我们需要一个控制器来处理对该路径的请求。因为我们的登录页非常简单,只有一个视图,没有其他内容,所以可以很简单的在WebConfig中将其声明为一个视图控制器。
package tacos.web;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("home");
registry.addViewController("/login");
}
}
- 最后我们需要自己定义登录页视图
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org">
<head>
<title>Taco Cloud</title>
</head>
<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>
<form method="POST" th:action="@{/login}" id="loginForm">
<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>
</html>
- 该登录页需关注的是表单提交到哪里以及用户名、密码输入域的名称。默认情况下,Spring Security会在"/login"路径监听登录请求并且预期的用户名和密码输入域的名称为username和password。这都是可配置的。
退出
- 为了启用退出功能,我们只需要在HttpSecurity对象上调用logout方法。
.and()
.logout()
.logoutSuccessUrl("/");
- 这样会搭载一个安全过滤器,该过滤器会拦截对"/logout"的请求。所以,为了提供退出功能,我们需要为应用的视图添加一个退出表单和按钮。
<form method="post" th:action="@{/logout}" id="logoutForm">
<input type="submit" value="Logout">
</form>
防止跨站请求伪造
- 在Thymeleaf模板中。我们可以按照如下的方式在隐藏域中渲染CSRF token:
<input type="hidden" name="_csrf" th:value="${_csrf.token}"/>
- 但是如果使用了Spring Security的Thymeleaf方言,那么该隐藏域会自动生成。
- 为了让Thymeleaf渲染隐藏域,我们只需要使用th:action属性就可以了。
<form method="POST" th:action="@{/login}" id="loginForm">
了解用户是谁
- 通常,仅仅知道用户已登录是不够的,我们一般还需要知道他的身份,以优化用户体验。
- 例如,在OrderController中,在最初创建Order的时候会绑定一个订单的表单,如果我们能够预先将用户的姓名和地址填充到Order中就好了,这样用户就不需要为每个订单都重新输入这些信息了。也许更重要的是,在保存订单的时候应该将Order实体与创建该订单的用户关联起来。
- 为了让Order实体与User实体之间实现所需的关联,我们需要为Order类添加一个新的属性:
@ManyToOne
private User user;
- 这个属性上的@ManyToOne注解表明了一个订单只能属于一个用户,但是,一个用户却可以有多个订单。因为使用了Lombok,所以不需要为该属性显式定义访问器方法。
- 在OrderController中,processOrder()方法负责保存订单。这个方法需要修改以便于确定当前的认证用户是谁,并要调用Order对象的setUser()方法来建立订单和用户之间的关联。
- 我们有多种方式可以确定用户是谁,常见方式如下:
- 注入Principal对象到控制器方法中;
- 注入Authentication对象到控制器方法中;
- 使用SecurityContextHolder来获取安全上下文;
- 使用@AuthenticationPrincipal注解来标注方法;
- 我们推荐使用在processOrder()中直接添加一个接受的User对象,不过需要为其添加@AuthenticationPrincipal注解,这样他才会变成认证的principal:
package tacos.web;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.bind.support.SessionStatus;
import tacos.Order;
import tacos.User;
import tacos.data.OrderRepository;
import tacos.data.UserRepository;
import javax.validation.Valid;
import java.security.Principal;
@Controller
@RequestMapping("/orders")
@SessionAttributes("order")
public class OrderController {
private OrderRepository orderRepo;
public OrderController(OrderRepository orderRepo)
{
this.orderRepo = orderRepo;
}
@GetMapping("/current")
public String orderForm()
{
return "orderForm";
}
@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:/";
}
}