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支持从不同的来源获取登录信息,包括:
- 内存中存储认证信息
- 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
,需要特殊的密码存储格式。
要让上边的密码变成明文验证,有两个方法(现在只有第一个方法有效了):
- 一是写成
password("{noop}password")
- 二是使用
User.withDefaultPasswordEncoder()
和相关的UserDetailService一起使用。
JDBC存储和获取用户信息
JDBC的方法在之前学习过,需要符合Spring规定的Schema去创建数据表。 其核心就是一句,auth.jdbcAuthentication().dataSource(securityDataSource)
,然后还可以覆盖默认的查询。这些知道就好,关键是后边通过JPA去查询。
实现自己的验证-JPA实现
由于用户验证的本质是两个工作,一个是去哪里获得用户名和密码的信息,一个是提供验证服务,自定义的话,需要实现Spring里提供的若干个接口或者继承角色类:- 自定义的
User Entity
类 实现-->UserDetails
接口 - 自定义的
UserDetailsService
类 实现-->UserDetailService
接口 - 自定义
Authority
类 继承-->GrantedAuthority
类
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}的形式,就可以进行明文密码验证了。 如果需要自定义的密码验证器,有两种方式:
- 如果确定了密码方式,可以直接在数据库中以类似{bcrypt}方式开头来写入密文
- 在控制类里配置一个密码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; }这个是多对一外键,已经很熟悉了。
如何取得用户对象
这是这个操作的核心,要如何取得认证用户的身份。有如下几种方式:- 给控制器方法传入一个Principal对象参数
- 给控制器方法传入一个Authentication对象参数
- 使用@AuthenticationPrincipal注解参数
- 使用SecurityContextHolder获取security容器上下文
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来学习一下。