Spring Security权限管理
学习spring boot学深以后自然要接触spring security权限管理,所谓的spring security,就是我们平时接触到的登录时面临的多用户多账户登录,还有用户登录时的安全问题和权限划分的功能。可以说,spring security在进行登录页设计的时候,提供了很多方便,而且拦截器的功能也包括在里面,直接集成就可以了,对登录页面设计也十分友好。
首先当然是导入spring security的依赖:
<!-- Spring Security 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
当然,看过我博客的同学也会发现我很喜欢使用thymeleaf作为前端的模板,而thymeleaf为了与spring security结合,也要导入一个依赖(是为了前端设计才导入的):
<!--内含thymeleaf与security结合的模板支持-->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity4</artifactId>
<version>3.0.2.RELEASE</version>
</dependency>
而在需要spring security安全控制的前端html页面中要加入:
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity4">
仅第三条xmlns是新添加的。
第一步由于我们是使用数据库进行存储我们用户的账号密码的,所以我们需要建立数据库连接,至于操作数据库可以参考我另一篇博客,我们第一步自然是要建立实体类user(使用者),而spring security要求除了实体user以为,我们还需要建立一个实体authority(权限),用于关联每个账户的权限,除此之外,还有其他很多配置要做,我们先来建立第一个实体类user:
/* *
* 登陆者实体类
* 内含不同权限不同管理权限管理
* */
@Entity
@Table(name = "user")
//继承UserDetails实现spring security的缓存机制
public class User implements UserDetails ,Serializable {
//user的序列化id
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@NotBlank
@Column(nullable = false,length = 50)
private String username;
@NotBlank
@Column(nullable = false,length = 50)
private String password;
//建立一个与Authority数据表对应的List,其中是多对多的关系
//其中蕴含了一个中间表user_authority说明两个表的关系
@ManyToMany(cascade = CascadeType.DETACH,fetch = FetchType.EAGER)
@JoinTable(name = "user_authority",joinColumns = @JoinColumn(name = "user_id",referencedColumnName = "id"),inverseJoinColumns = @JoinColumn(name = "authority_id",referencedColumnName = "id"))
private List<Authority> authorities;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
@Override
public String getPassword() {
return password;
}
public void setUsername(String username) {
this.username = username;
}
@Override
public String getUsername() {
return username;
}
public void setPassword(String password) {
this.password = password;
}
public static long getSerialVersionUID() {
return serialVersionUID;
}
public void setAuthorities(List<Authority> authorities) {
this.authorities = authorities;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
//需要将list<authority>转换为List<SimpleGrantedAuthority>,否则会拿不到角色列表
List<SimpleGrantedAuthority> simpleGrantedAuthorities=new ArrayList<>();
for(GrantedAuthority authority:this.authorities){
simpleGrantedAuthorities.add(new SimpleGrantedAuthority(authority.getAuthority()));
}
return simpleGrantedAuthorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
记住观察好我这个实体,其中spring security是要求继承userDetails的,同时也要实现userDetails的各个方法,这是为了更好实现spring security的缓存机制。而Serializable是用于序列化的,这也是必须的。另外,该实体类是与下面的authority权限实体类建立了manytomany的数据库关系的,是为了更好的管理账户的权限。
下面是建立authority权限的实体类:
/* *
* 权限类,是与user进行耦合
* */
@Entity
@Table(name = "authority")
public class Authority implements GrantedAuthority {
//authority的序列化id
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false)
private String name;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
@Override
public String getAuthority() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
这是权限实体类,十分简单,仅需继承GrantedAuthority,且实现两个参数id和name。
下面就是建立与数据库的基础连接了,也就是如果要对各个账户进行增删改查,则需要建立repository和service进行管理。第一步是要建立user的repository:
public interface UserRepository extends JpaRepository<User,Integer> {
//根据用户名查询用户
User findByUsername(String username);
}
十分简单,但是记住一定要实现一个根据用户名查找用户的方法,这在建立service中userDetailsService中要使用的。
而建立authority的repository更简单:
public interface AuthorityRepository extends JpaRepository<Authority,Integer> {
}
下面就是建立service层了,首先是service层的接口,然后才是接口的实现类,两个接口我就简单发一下:
/* *
* user的service层接口
* */
public interface UserService {
List<User> findAll();
User findById(Integer id);
User saveOrUpdateUser(User user);
void deleteById(Integer id);
}
/* *
* authority的service接口
* */
public interface AuthorityService {
//根据id查询权限
Authority getAuthorityById(Integer id);
//查询所有权限
List<Authority> findAll();
}
下面是user类接口的实现类:
@Service
public class UserServiceImpl implements UserService, UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
@Transactional
public List<User> findAll() {
return userRepository.findAll();
}
@Override
@Transactional
public User findById(Integer id) {
return userRepository.findOne(id);
}
@Override
@Transactional
public User saveOrUpdateUser(User user) {
try{
userRepository.save(user);
}catch (Exception e){
throw new RuntimeException("Add User Error: "+e.getMessage());
}
return user;
}
@Override
@Transactional
public void deleteById(Integer id) {
userRepository.delete(id);
}
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
User user = userRepository.findByUsername(s);
if(user==null){
throw new UsernameNotFoundException("用户名不存在");
}//用户不存在要抛出异常
return user;
}
}
spring security要求继承userDetailsService,然后要实现一个方法,就是loadUserByUsername,具体实现可以观察上面。
Authority权限接口实现类实现十分简单:
@Service
public class AuthorityServiceImpl implements AuthorityService {
@Autowired
private AuthorityRepository authorityRepository;
@Override
public Authority getAuthorityById(Integer id) {
return authorityRepository.findOne(id);
}
@Override
public List<Authority> findAll() {
return authorityRepository.findAll();
}
}
现在我们对账号管理就差不多做完了(除了controller,由于controller和我之前管理数据库的博客的实现差不多,也就不再展示了),对其的增删改查就需要我们设计前端页面进行。但是下面才是重点,也就是设计登录页时的各类功能,也就是spring security的核心。首先我们需要建立spring security的配置类,通过配置类进行一系列操作:
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) //启用安全认证
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//定义一个key
private static final String KEY = "scnu";
//注入UserDetailsService
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder(); //使用BCrypt加密
}
//实现方法authenticationProvider(),内含密码加密
@Bean
public AuthenticationProvider authenticationProvider(){
//DaoAuthenticationProvider用于从UserDetailsService中取出认证信息
DaoAuthenticationProvider daoAuthenticationProvider=new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder); //密码加密
return daoAuthenticationProvider;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/asserts/**","/login.html").permitAll() //静态资源可以访问
.antMatchers("/h2-console/**").permitAll() // h2控制台都可以访问
.antMatchers("/admin/**").hasRole("ADMIN") //管理页需要admin角色才可以访问
.antMatchers("/setter/**").hasRole("USER")
.and()
.formLogin() //基于form表单的访问形式
.loginPage("/login").defaultSuccessUrl("/dispath").failureUrl("/login-error") //设置登录页,成功后访问的页面和访问错误页
.and().rememberMe().key(KEY) //remember-me的设置
.and().exceptionHandling().accessDeniedPage("/403"); //账号密码错误进入403界面
http.csrf().ignoringAntMatchers("/h2-console/**"); // 禁用 H2 控制台的 CSRF 防护
http.headers().frameOptions().sameOrigin(); // 允许来自同一来源的H2 控制台的请求
}
/* *
* 认证信息从数据库从获取
* */
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService); //使用数据库存储的信息
auth.authenticationProvider(authenticationProvider()); //密码加密使用BCrypt加密算法
}
}
我们需要将该配置类继承WebSecurityConfigurerAdapter,使用注解@EnableWebSecurity声明。该类主要改造了继承的WebSecurityConfigurerAdapter中方法configure(HttpSecurity http)和configure(AuthenticationManagerBuilder auth),第一个方法是实现一些基础的配置,比如哪些页面可以访问,哪些页面需要什么权限才可以访问,成功登陆会访问什么,密码错误会访问什么,remember me等配置,我在该方法中都有注释,可以自行观察,另外,在remember me中需要一个KEY,我们需要在方法外声明,至于KEY的值我们可以自行赋值。第二个方法是配置账户信息通过数据库存储,同时密码加密的形式,我密码加密的形式是使用BCrypt加密算法,也就是说上面有多个bean配置都是为了密码加密而声明的。
现在来说一下与登录页相关的controller层的设计,控制层的设计与刚刚配置类中的方法configure(HttpSecurity http)息息相关,我们观察一下刚刚的方法:formLogin()表明是基于form表单的形式进行登录,loginPage(“/login”)指明登录页的url,defaultSuccessful(“/dispath”)指明登录成功访问的url,failureUrl(“/login-error”)指明当账号密码出现错误时访问的url,我们可以根据这些url设计controller:
/* *
* 登录页的控制层设置
* */
@Controller
public class LoginController {
@GetMapping({"/login","/"})
public String loginPage(){
return "login";
}
//发生账号密码错误时候
@GetMapping("/login-error")
public String errorMsg(Model model){
model.addAttribute("loginError",true);
model.addAttribute("errorMsg","账号密码错误!");
return "login";
}
/* *
* 该方法是根据不同用户权限跳转至不同用户所需界面
* admin和user两种用户为主
* */
@GetMapping("/dispath")
public String dispath(){
//获取当前用户的权限
Set<String> roles = AuthorityUtils.authorityListToSet(SecurityContextHolder.getContext()
.getAuthentication().getAuthorities());
//设置变量存储路径地址
String s="";
if(roles.contains("ROLE_ADMIN")){
s="redirect:/admin";
}else if(roles.contains("ROLE_USER")){
s="forward:/setter ";
} //根据权限跳转
return s;
}
}
可以观察方法dispath()中,我们是根据用户的不同权限进行跳转的。
顺便给一下登录页前端页面的form表单(基于thymeleaf):
<form class="form-signin" action="#" th:action="@{login}" method="post">
<h1 class="h3 mb-3 font-weight-normal" >请登录</h1>
<!--加入p标签,标签的颜色设置为红色,标签的内容由controller中msg获得-->
<!--使用if方法,同时变量表达式中的内置工具判断msg是否为空-->
<p style="color: red" th:text="${errorMsg}" th:if="${loginError}==true"></p>
<label class="sr-only" >用户名</label>
<input type="text" name="username" class="form-control" placeholder="请输入账号" required="" autofocus="">
<label class="sr-only">密码</label>
<input type="password" name="password" class="form-control" placeholder="请输入密码" required="">
<div class="checkbox mb-3">
<label>
<input type="checkbox" name="remember-me" >记住我
</label>
</div>
<button class="btn btn-lg btn-primary btn-block" type="submit" >登录</button>
</form>
由于在配置中我们已经声明了是基于form表单的形式,所以我们仅赋值name与相关的参数即可自动配置完。其中,remember me的功能是在之前配置类中方法configure(HttpSecurity http)中声明rememberMe().key(KEY)中导入,我们只需要在登录页form表单中功能记住我声明name为remember-me即可。
重点关注:
由于加入了spring security,所以在进行某些增删改查的时候你会发现之前的方法不行,这是因为加入了跨域防护这种烦人的东西,但是没有又不安全,所以我们需要在前端页面中加入一些参数来增加跨域防护,但其实我自己学得也不太好,所以说得也不太好,首先我们需要在head标签中加入:
<!-- CSRF -->
<meta name="_csrf" th:content="${_csrf.token}"/>
<!-- default header name is X-CSRF-TOKEN -->
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
这是与csrf跨域防护的声明,然后我们在设计按钮(button或者a标签)的时候,要使用JavaScript时进行按钮设计时,在ajax的设计前我们需要引入:
<!--// 获取 CSRF Token-->
var csrfToken = $("meta[name='_csrf']").attr("content");
var csrfHeader = $("meta[name='_csrf_header']").attr("content");
给个例子,我们首先声明了一个删除按钮:
<!--删除按钮-->
<a class="btn btn-danger btn-sm deletebtn" role="button" data-th-attr="userId=${user.id}">删除</a>
然后进行js(含ajax)设计:
<script>
$(".deletebtn").click(function () {
// 获取 CSRF Token
var csrfToken = $("meta[name='_csrf']").attr("content");
var csrfHeader = $("meta[name='_csrf_header']").attr("content");
if(window.confirm("你确定要删除吗?")) {
$.ajax({
url: "/admin/" + $(this).attr("userId"),
type: 'DELETE',
beforeSend: function (request) {
request.setRequestHeader(csrfHeader, csrfToken); // 添加 CSRF Token
},
success: function (data) {
alert("删除成功!");
//成功了刷新界面
$("#mainContainer").html(data);
}
})
return true;
}
})
</script>
大概就是这样,但是里面还有一些问题,由于我对前端知识的缺漏,所以会有一些问题,希望有大神可以指点一下。
另外,如果简单的声明一下为form表单的形式,前端会自动注入跨域防护,比如删除按钮这样子声明的话:
<!--删除按钮-->
<form th:action="@{/admin/}+${user.id}" method="post">
<input type="hidden" name="_method" value="delete">
<!--删除按钮-->
<button type="submit" class="btn btn-danger btn-sm deletebtn">删除</button>
</form>
它将会自动生成csrf防护(我也不清楚什么机制)。