Spring Security 添加进项目

现在可以为我们这个简单的应用来添加Spring Security了,同样只需要添加start依赖即可。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

添加以后无需像原来一样设置Spring Security的启动类,直接重启项目就可以发现所有的路径都被保护,需要输入用户名和密码。

用户名是user,而用户密码是在控制台里生成的一段随机密码。

在之前我们知道,必须来设置Spring Security才行,这里也少不了各种设置,我们为Spring Security在config下边创建一个设置类:

package cc.conyli.sia5.config;

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 {

}

现在使用的2.1.4.RELEASE版本,和SIA5成书的时候不同,即使不写配置类,也是一个页面进行登录,还是使用了Bootstrap4的样例的登录。

Spring Security支持从不同的来源获取登录信息,包括:

  1. 内存中存储认证信息
  2. JDBC存取数据
  3. LDAP身份认证
  4. 自定义userdetailservice–JPA实现

内存中存储和JDBC存取数据在之前已经学习过。这里不是重点,LDAP方式现在也用不到。重点是自定义的UserDetailService的JPA实现以及关于权限和路径更加详细的配置,这个是学习的重点。

配置Spring Security

首先是重写的configure方法,其中的参数是AuthenticationManagerBuilder的是方法是配置用户数据和如何验证。参数是HTTPSecurity的则是控制传递数据的过程和URL访问。

所以很显然配置用户都要使用Auth…参数的方法。

配置是链式调用方法,关键在于auth.的第一个方法,内存里存储就是.inMemoryAuthentication()

内存中存储用户数据

@Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .inMemoryAuthentication()
                .withUser("jenny")
                    .password("***")
                    .authorities("ROLE_USER")

                .and()

                .withUser("lee0709")
                    .password("***")
                    .authorities("ROLE_USER");
}

这么配置之后,控制台里就没有随机生成的密码了,user用户也失效,变成了自定义的用户名和密码。当然光这么配置还不行,因为没有配置密码验证器,之前学Udemy课程也是如此,需要加一行:

User.UserBuilder users = User.withDefaultPasswordEncoder();

这个方法实际上已经过期,日志里会提示不安全,除了开发时候不要使用,我们现在就用这个简单的,直接明文验证。

结果发现即使配置了这一行,会报错显示:

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

Mkyong.com上找到了答案

Spring Security 5.0之前,默认的PasswordEncoder是NoOpPasswordEncoder,也就是明文验证。从Spring Security 5开始,默认的变成了DelegatingPasswordEncoder,需要特殊的密码存储格式。

要让上边的密码变成明文验证,有两个方法(现在只有第一个方法有效了):

  1. 一是写成password("{noop}password")
  2. 二是使用User.withDefaultPasswordEncoder()和相关的UserDetailService一起使用。

如果是第一种,就无须User.UserBuilder这句话,直接在密码前边加{noop}即可,这也是推荐的做法。

这个答案的第二种方法已经被提示过期,实际使用了一下发现也无效,因此就用第一种方法即可。

JDBC存储和获取用户信息

JDBC的方法在之前学习过,需要符合Spring规定的Schema去创建数据表。

其核心就是一句,auth.jdbcAuthentication().dataSource(securityDataSource),然后还可以覆盖默认的查询。这些知道就好,关键是后边通过JPA去查询。

实现自己的验证-JPA实现

由于用户验证的本质是两个工作,一个是去哪里获得用户名和密码的信息,一个是提供验证服务,自定义的话,需要实现Spring里提供的若干个接口或者继承角色类:

  1. 自定义的User Entity类 实现–> UserDetails接口
  2. 自定义的UserDetailsService类 实现–> UserDetailService接口
  3. 自定义Authority类 继承–> GrantedAuthority

先来做一个User的表,简单一些:

SET FOREIGN_KEY_CHECKS=0;

-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(255) NOT NULL,
  `password` varchar(255) NOT NULL,
  `fullname` varchar(255) NOT NULL,
  `street` varchar(255) NOT NULL,
  `city` varchar(255) NOT NULL,
  `state` varchar(255) NOT NULL,
  `zip` varchar(255) NOT NULL,
  `phonenumber` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

然后编写对应的Entity类:

package cc.conyli.sia5.entity;

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.*;
import javax.validation.constraints.NotNull;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Collection;

@Entity
@Data
@NoArgsConstructor(access = AccessLevel.PRIVATE, force = true)
@RequiredArgsConstructor
@Table(name = "user")
public class User implements UserDetails, Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    @NotNull
    @Column(name = "username")
    private String username;

    @NotNull
    @Column(name = "password")
    private String password;

    @NotNull
    private String fullname;

    @NotNull
    private String street;

    @NotNull
    private String city;

    @NotNull
    private String state;

    @NotNull
    private String zip;

    @NotNull
    private 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;
    }
}

这其中的返回权限的方法,需要返回一个继承了GrantedAuthority类的权限实例的集合,这里就简单实例化了SimpleGrantedAuthority("ROLE_USER"),剩下是特殊的判断是不是激活,过期等。实际上可以根据User表中的数据进行判断得出,这里就不想先做太复杂。

然后需要实现UserDetailService类来提供验证的方法,这个Service类里还需要读取数据库,所以再创建一个神奇接口就可以了。

先是神奇接口:

package cc.conyli.sia5.dao;

import cc.conyli.sia5.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepo extends JpaRepository<User, Integer> {

    User getUserByUsername(String username);
    
}

方法会由Spring Data自动解析。

之后基于这个UserRepo类创建自定义的Service,只需要重写一个方法:

package cc.conyli.sia5.service;

import cc.conyli.sia5.dao.UserRepo;
import cc.conyli.sia5.entity.User;
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;

@Service
public class MyUserDetailService implements UserDetailsService {


    private UserRepo userRepo;

    @Autowired
    public MyUserDetailService(UserRepo userRepo) {
        this.userRepo = userRepo;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepo.getUserByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("|***| USERNAME: " + username + " IS NOT FOUND |***|");
        }
        return user;
    }
}

这个接口要求返回一个UserDetails类型的对象,所以返回我们自己继承了UserDetails接口的User类即可,这个类是通过JPA从数据库里查询到的。如果查不到,就抛出一个异常即可。

最后一步是回到Spring Security的配置类里,修改如下:

package cc.conyli.sia5.config;

import cc.conyli.sia5.service.MyUserDetailService;
import org.springframework.beans.factory.annotation.Autowired;
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.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private MyUserDetailService userDetailService;


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

        //使用自定义的用户数据服务进行验证
        auth
            .userDetailsService(userDetailService);

    }

}

此时在数据库里写上一些用户和密码,密码的部分依然采用{noop}的形式,就可以进行明文密码验证了。

如果需要自定义的密码验证器,有两种方式:

  1. 如果确定了密码方式,可以直接在数据库中以类似{bcrypt}方式开头来写入密文
  2. 在控制类里配置一个密码Encoder对象,然后在配置方法里设置加密方式。

第一种方法很简单,但是需要知道加密后的密文,如果后边需要用户注册,就不能使用这种方法,因为不知道如何写进去。而配置了密码Encoder对象之后,就可以利用其加密解密了。新的配置类如下:

package cc.conyli.sia5.config;

import cc.conyli.sia5.service.MyUserDetailService;
import org.springframework.beans.factory.annotation.Autowired;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private MyUserDetailService userDetailService;

    @Bean
    public PasswordEncoder encoder() {
        return new BCryptPasswordEncoder(4);
    }

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

        //使用自定义的用户数据服务进行验证
        auth
                .userDetailsService(userDetailService)
                .passwordEncoder(encoder());
    }
}

这里可以定义不同的密码Encoder对象,不同的Encoder对象的构造参数不同,像Bcrypt就是强度,其他的可能是盐字符串等。有了这个Encoder的Bean之后,就可以给userDetailsServiceu设置上这个Encoder对象,这样就会采用指定的加密解密方式。

配置了Encoder对象之后,注意,数据库里就无需再写{bcrypt}字样了,直接保存密文即可。

PasswordEncoder对象有两个方法,一个是.encode(String),参数是字符串,用于将字符串转换成明文。还一个是.matches(明文String,密文String),用于判断是否匹配。在编写用户注册功能的时候这个很常用。

用户注册功能

在之前实际上已经写过用户注册的功能,使用的是JDBC,采用JPA之后,其实更加简化了一些。

由于我们已经有了DAO层和Service层,所以可以复用自定义的MyUserDetailService对象,添加一个save方法即可,在业务层把明文密码转换成密文然后存储。

这里还需要注意的是,一般注册会让用户重复输入两次密码,如果密码不一致,就会提示信息。我自己采用的做法是另外创建一个新的带有两个密码字段的用户类,然后使用这个用户类生成表单,如果验证通过,就新的用户类的数据设置到原来的实现了UserDetails接口的User类上,再继续交给Service层和DAO层进行操作。这里还涉及到一个验证两个密码的验证器,在之前也编写过,这里就从简了。发现SIA5也是采用这个做法,看来这个另外创建一个用户类的做法是通用的做法。

新的用户类:

package cc.conyli.sia5.entity;

import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import javax.validation.constraints.NotNull;
import java.io.Serializable;

@Entity
@Data
@NoArgsConstructor(access = AccessLevel.PUBLIC, force = true)
public class UserForConfirmPassword implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    @NotNull(message = "必须填写姓名")
    private String username;

    @NotNull(message = "必须填写密码")
    private String password;

    @NotNull(message = "必须填写密码")
    private String confirm_password;

    @NotNull(message = "必须填写全名")
    private String fullname;

    @NotNull(message = "必须填写街道")
    private String street;

    @NotNull(message = "必须填写城市")
    private String city;

    @NotNull(message = "必须填写省份")
    private String state;

    @NotNull(message = "必须填写邮编")
    private String zip;

    @NotNull(message = "必须填写手机号码")
    private String phonenumber;

}

然后是控制器,主要是从一个对象把数据倒腾到另外一个对象,很简单:

package cc.conyli.sia5.controller;

import cc.conyli.sia5.entity.User;
import cc.conyli.sia5.entity.UserForConfirmPassword;
import cc.conyli.sia5.service.MyUserDetailService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@Slf4j
@Controller
@RequestMapping("/register")
public class RegisterController {

    private MyUserDetailService myUserDetailService;

    @Autowired
    public RegisterController(MyUserDetailService myUserDetailService) {
        this.myUserDetailService = myUserDetailService;
    }

    //给页面传用于验证密码的新的用户类
    @GetMapping
    public String showForm(Model model) {
        model.addAttribute("user", new UserForConfirmPassword());
        return "registration";
    }

    //取到新的用户类后验证密码是否相同,相同就将数据设置到User类上然后交给业务层
    @PostMapping
    public String processForm(@ModelAttribute("user") @Valid UserForConfirmPassword user, Errors errors) {
        if (errors.hasErrors() || !user.getPassword().equals(user.getConfirm_password())) {
            log.info(errors.toString());
            return "registration";
        }
        User newUser = new User();
        newUser.setUsername(user.getUsername());
        newUser.setPassword(user.getPassword());
        newUser.setFullname(user.getFullname());
        newUser.setCity(user.getCity());
        newUser.setState(user.getState());
        newUser.setStreet(user.getStreet());
        newUser.setZip(user.getZip());
        newUser.setPhonenumber(user.getPhonenumber());

        myUserDetailService.save(newUser);
        return "redirect:/login";
    }

    //初始化去掉两边的Trim
    @InitBinder
    public void initBinder(WebDataBinder dataBinder) {
        StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);
        dataBinder.registerCustomEditor(String.class, stringTrimmerEditor);
    }
}

表单就不放了,都是重复代码。

现在写好了注册功能,但是发现所有的路径都被Spring Security保护着,造成了你想注册就得先登录这样一个死锁。这个时候就需要来学习一下配置类中的另外一个配置方法,用于配置权限和URL访问之间的关系了。

访问管理

配置类里有两个configuration方法,有Auth的那个管理如何验证,参数是HttpSecurity的则管理如何访问。我们需要把根目录和用户注册页面给所有用户都开放,其他则需要登录。

在配置类内重写另外一个configure方法:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .authorizeRequests()
            .antMatchers("/ingredient/**", "/taco/**", "/order/**", "/cancel")
                .hasRole("USER")
            .antMatchers("/", "/**").permitAll();
}

这里的意思是所有”/ingredients/**”, “/taco/**”, “/order/**”, “/cancel”的路径都无法在不登录的情况下访问。只有首页,注册和登录路径可以访问。

这个antMatchers的顺序很重要,是从粒度小的逐步到粒度大的,如果将上边两个顺序反过来,则所有路径都能无角色访问,具体配置(原来的上边的antMatchers)会失效。

除了链式调用之外,还可以使用Spring的风格.access语法,写法略有不同,但是可以进行逻辑运算。详细情况和用法看SIA5的105-106页。

在这么配置之后,Spring默认的全部路径都要访问,然后自动跳转到/login路径就会失效。访问具体路径会报403错误,说明权限配置确实正确。

这里还要注意的是.hasRole("USER"),如果使用了ROLE_USER会报错,说是自动生成ROLE_前缀,所以无需添加。

自定义登录表单

只是403错误还不行,必须让用户进行登录。

继续链式添加:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            .authorizeRequests()
            .antMatchers("/ingredient/**", "/taco/**", "/order/**", "/cancel")
            .hasRole("USER")
            .antMatchers("/", "/**").permitAll()
        .and()
            .formLogin().loginPage("/login");
}

这里的.and()表示已经结束了URL访问权限的配置,开始配置其他内容,之后不能再有.antMatchers。

.loginPage("/login")表示到/login地址去找登录表单。这个地址可以自定义,需要编写对应的控制器,不过对于这种纯粹访问的控制器,可以采取简单的方法,就像根目录一样加一条:

package cc.conyli.sia5.config;

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").setViewName("login");
  }
}

然后就做一个叫login.html的简单模板,只要字段名称是username和password即可。

<div class="container">
    <h1 class="text-center">登录</h1>
    <form th:action="@{/login}" method="post">
        <label>User name: <input type="text" name="username"/></label>
        <label>Password: <input type="password" name="password"/></label>
        <input type="submit" value="Login">
    </form>
</div>

之后验证不成功出现403的错误就没了,会引导你到登录页面。登录成功才能继续访问。

进一步配置自定义登录表单

配置类中现在我们写到.formLogin().loginPage("/login"),仅仅写这种配置的时候,代表Spring默认会在/login等待认证的POST请求,而且默认的键名是username和password,这也是我们模板里现在设置的POST地址和INPUT字段名。

实际上这个配置可以进一步修改,可以修改POST到哪里,以及自己设置字段名:

.and()
.formLogin().loginPage("/login")
.loginProcessingUrl("/auth")
.usernameParameter("user")
.passwordParameter("pswd");

设置成这样之后,表单模板就必须修改成对应的内容才行,这里就省略了。

下一个配置是登录成功之后的跳转页。默认情况下,用户访问一个需要登录的界面,Spring Security会记录用户想访问的地址,在登录成功之后自动引导用户跳转。

如果用户直接访问登录页,则会默认跳转到首页。

默认跳转的行为也可以控制,通过.defaultSuccessUrl(URL,boolean)来实现:

.defaultSuccessUrl("/taco/form") //用户直接访问登录页的时候跳转的地址
.defaultSuccessUrl("/taco/form", true) //不管用户最初访问什么页面,登录成功后都跳到指定地址

之后是登出,如果用户需要登出,一样指定登出的地址和跳转页面。再继续添加配置,由于是登出,也需要用.and()分割开:

.and()
.logout()
    .logoutSuccessUrl("/auth");

这样配置之后,Spring Security就会在/logout路径等待POST请求,只要有POST请求,就清除session从而取消登录,之后会跳转到指定的URL。

所以在想登出的地方,只要添加一个按钮,就可以登出:

<form th:action="@{/logout}" method="post">
    <button type="submit" class="btn btn-primary">登出</button>
</form>

CSRF

Spring Security默认开启CSRF,如果使用Spring MVC 标签或者配置过的Thymeleaf+Spring Security,一般会自动生成CSRF token字段。只需要记住在Thymeleaf中如何添加CSRF字段即可,如果表单没有CSRF,就添加上:

<input type="hidden" name="_csrf" th:value="${_csrf.token}"/>

如果要关闭,就在配置后边写上:

.and()
    .csrf().disable();

将订单和用户结合起来

现在我们需要知道是哪个用户下了订单,由于访问订单页面的时候一定是需要登录的,所以可以获取用户,然后把用户添加到Order的多对一外键中。

所以首先要修改orders表,添加一栏user_id,然后外键关联到user表的主键上,然后需要修改Order类添加字段:

@ManyToOne(cascade = {CascadeType.DETACH, CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH})
@JoinColumn(name = "user_id")
private User user;

//还需要添加一个方法用于将当前用户设置成这个外键。
public void addUser(User user) {
    this.user = user;
}

这个是多对一外键,已经很熟悉了。

如何取得用户对象

这是这个操作的核心,要如何取得认证用户的身份。有如下几种方式:

  1. 给控制器方法传入一个Principal对象参数
  2. 给控制器方法传入一个Authentication对象参数
  3. 使用@AuthenticationPrincipal注解参数
  4. 使用SecurityContextHolder获取security容器上下文

这个Principal对象实际上就是一个用户对象,是实现了UserDetail接口的对象。

先来看第一种方法,先贴上完整的控制器类,这里还需要注入UserRepo才可以:

package cc.conyli.sia5.controller;

import cc.conyli.sia5.dao.OrderRepo;
import cc.conyli.sia5.dao.UserRepo;
import cc.conyli.sia5.entity.Order;
import cc.conyli.sia5.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Errors;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.support.SessionStatus;

import javax.validation.Valid;
import java.security.Principal;

@Controller
@Slf4j
@RequestMapping("/order")
@SessionAttributes("order")
public class OrderController {

    private OrderRepo orderRepo;
    private UserRepo userRepo;

    @Autowired
    public OrderController(OrderRepo orderRepo, UserRepo userRepo) {
        this.userRepo = userRepo;
        this.orderRepo = orderRepo;
    }


    @GetMapping("/form")
    public String showForm() {
        return "order";
    }

    @PostMapping("/process")
    public String processForm(@ModelAttribute("order") @Valid Order order, Errors errors, SessionStatus sessionStatus, Principal principal) {
        if (errors.hasErrors()) {
            return "order";
        }

        User user = userRepo.getUserByUsername(principal.getName());
        order.addUser(user);
        orderRepo.save(order);
        sessionStatus.setComplete();
        log.info("保存至数据库的Order是:" + order);
        return "redirect:/";
    }

    @InitBinder
    public void initBinder(WebDataBinder dataBinder) {
        StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);
        dataBinder.registerCustomEditor(String.class, stringTrimmerEditor);
    }
}

第二种方法,参数使用Authentication对象:

@PostMapping("/process")
    public String processForm(@ModelAttribute("order") @Valid Order order, Errors errors, SessionStatus sessionStatus, Authentication authentication) {
        if (errors.hasErrors()) {
            return "order";
        }

        User user = (User) authentication.getPrincipal();
        order.addUser(user);
        orderRepo.save(order);
        sessionStatus.setComplete();
        log.info("保存至数据库的Order是:" + order);
        return "redirect:/";
    }

这么写的话,要注意使用Hibernate会提示detach,需要去掉CascadeType.PERSIST,因为对象已经存在于数据库中。

第三种方法是最清爽的,非常类似于@ModelAttribute注解:

@PostMapping("/process")
    public String processForm(@ModelAttribute("order") @Valid Order order, Errors errors,
                              SessionStatus sessionStatus,
                              @AuthenticationPrincipal User user) {
        if (errors.hasErrors()) {
            return "order";
        }

        order.addUser(user);
        orderRepo.save(order);
        sessionStatus.setComplete();
        log.info("保存至数据库的Order是:" + order);
        return "redirect:/";
    }

至于最后一种方法无需在参数上做文章,而是非常Spring Security Specify的写法,从容器里获取Authentication对象,再获取认证对象,如下:

@PostMapping("/process")
    public String processForm(@ModelAttribute("order") @Valid Order order, Errors errors,SessionStatus sessionStatus) {
        if (errors.hasErrors()) {
            return "order";
        }

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        User user = (User) authentication.getPrincipal();
        order.addUser(user);
        orderRepo.save(order);
        sessionStatus.setComplete();
        log.info("保存至数据库的Order是:" + order);
        return "redirect:/";
    }

其实,知道了如何获取用户对象,也就可以改造一下应用,让应用在用户登录之后显示用户的名称了。
还有很多增强的功能可以写一写,比如角色那里,也可以通过数据库取出来角色名,然后转换成列表,这些都可以以后实现。

想到这里,还需要看一下Thymeleaf的判断功能,估计这两天要下载一下示例Demo来学习一下。