從源碼看Spring Security之採坑筆記(Spring Boot篇)

【本文版權歸微信公衆號"代碼藝術"(ID:onblog)全部,如果轉載請務必保留本段原創聲明,違者必究。如果文章有不足之處,歡迎關注微信公衆號私信與我進行交流!】

一:嘮嗑

  • 鼓搗了兩天的Spring Security,踩了很多坑。若是你在學Spring Security,剛好又是使用的Spring Boot,那麼給我點個贊吧!這篇博客將會讓你瞭解Spring Security的各類坑!
  • 閱讀前說一下,這篇博客是我一字一字打出來的,轉載務必註明出處哦!
  • 另外,本文已受權微信公衆號「後端技術精選」獨家發佈

二:開始

1.準備

  • Spring boot 1.5
  • Mysql 5.7
  • 導入依賴
<!-- Web工程 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 數據庫相關 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- security 核心 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- thymeleaf 模板-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- 能夠在HTML使用sec標籤操做Security -->
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity4</artifactId>
</dependency><br />

2.開啓Security並配置

package cn.zyzpp.security.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
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;

/**
 * Create by yster@foxmail.com 2018/6/10/010 18:07
 */
@EnableWebSecurity
public class MySerurityConfig extends WebSecurityConfigurerAdapter {
    /*本身實現下面兩個接口*/
    @Autowired
    private AuthenticationProvider authenticationProvider;
    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/", "/signIn").permitAll()//全部人均可以訪問
                .antMatchers("/leve/1").hasRole("VIP1") //設置訪問角色
                .antMatchers("/leve/2").hasRole("VIP2")
                .antMatchers("/leve/3").hasAuthority("VIP2")//設置訪問權限
                .anyRequest().authenticated() //其餘全部資源都須要認證,登錄後訪問
                .and()
                .formLogin()//開啓自動配置的受權功能
                .loginPage("/login")    //自定義登陸頁(controller層須要聲明)
                .usernameParameter("username")  //自定義用戶名name值
                .passwordParameter("password")  //自定義密碼name值
                .failureUrl("/login?error") //登陸失敗則重定向到此URl
                .permitAll() //登陸頁均可以訪問
                .and()
                .logout()//開啓自動配置的註銷功能
                .logoutSuccessUrl("/")//註銷成功後返回到頁面並清空Session
                .and()
                .rememberMe()
                .rememberMeParameter("remember")//自定義rememberMe的name值,默認remember-Me
                .tokenValiditySeconds(604800);//記住個人時間/秒
    }

    /*定義認證規則*/
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    /*  保存用戶信息到內存中
        auth.inMemoryAuthentication()
             .withUser("張三").password("123456").roles("VIP1")
             .and()
             .withUser("李四").password("123456").roles("VIP2");
    */

        /*自定義認證*/
        auth.authenticationProvider(authenticationProvider);
        auth.userDetailsService(userDetailsService);//不定義的話rememberMe報錯
    }

    /*忽略靜態資源*/
    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers("/resources/static/**");
    }

}

講一下:

  • 咱們基本不會把用戶信息保存在內存中,因此咱們自定義認證方法。這裏我推薦閱讀 認證(Authentication)與源碼解讀 瞭解。
  • 自定義認證也有兩種方法,第一是注入DaoAuthenticationProvider(org.springframework.security.authentication.dao)
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider(){
    DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
    daoAuthenticationProvider.setUserDetailsService(userDetailsService);//獲取用戶信息
    daoAuthenticationProvider.setPasswordEncoder(new Md5PasswordEncoder());//MD5加密
    daoAuthenticationProvider.setSaltSource(new SaltSource() {  //加鹽
        @Override
        public Object getSalt(UserDetails user) {
            return user.getUsername();
        }
    });
    return daoAuthenticationProvider;
}
  • 而後改一下設置
auth.authenticationProvider(authenticationProvider);
  • 這種方法我並不推薦,由於咱們把密碼錯誤的異常交給了Security底層去拋出,然而拋出的消息只是Bad credentials 這樣的消息提示你會須要?
  • 因此咱們使用第二種方法,以下:

3.自定義AuthenticationProvider接口實現類

package cn.zyzpp.security.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.encoding.Md5PasswordEncoder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

/**
 * Create by yster@foxmail.com 2018/6/21/021 15:53
 * Authentication 是一個接口,用來表示用戶認證信息的
 */
@Component
public class MyAuthenticationProvider implements AuthenticationProvider{
    @Autowired
    private MyUserDetailsService userDetailsService;

    @Override
    public Authentication authenticate(Authentication authentication){
        //1.獲取用戶輸入的用戶名 密碼
        String username = authentication.getName();
        String password = (String) authentication.getCredentials();
        //2.關於MD5加密:
        //由於咱們是自定義Authentication,因此必須手動加密加鹽而不須要再配置。
        password = new Md5PasswordEncoder().encodePassword(password,username);
        //3.由輸入的用戶名查找該用戶信息,內部拋出異常
        UserDetails user = userDetailsService.loadUserByUsername(username);
        //4.密碼校驗
        if (!password.equals(user.getPassword())) {
            throw new DisabledException("---->UserName :" + username + " password error!");
        }
        return new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return (UsernamePasswordAuthenticationToken.class
                .isAssignableFrom(aClass));
    }

}

講一下:

  • 這裏說Security的一個坑:
  • 相信你也看到了有的教程上說拋出UsernameNotFoundException 用戶找不到,BadCredentialsException 壞的憑據,但這兩個類都是繼承自AuthenticationException抽象類,當你拋出這倆異常時,Security底層會捕捉到你拋出的異常,如圖:
  • 看到了吧,AuthenticationException異常並不會被拋出,debug調式一下,你就會感覺到它的曲折歷程,至關感人!而後莫名其妙的被換掉了,並且無解。
  • 沒錯,你沒看錯,AccountStatusException異常被直接拋出了,這正是咱們須要的;有的同窗可能想到了自定義異常,但咱們是結合Security框架,要按人家的規則來,不信你試試。
  • 附一些經常使用異常
<span class="hljs-comment">/* 
   AuthenticationException經常使用的的子類:(會被底層換掉,不推薦使用)
   UsernameNotFoundException 用戶找不到
   BadCredentialsException 壞的憑據

   AccountStatusException用戶狀態異常它包含以下子類:(推薦使用)
   AccountExpiredException 帳戶過時
   LockedException 帳戶鎖定
   DisabledException 帳戶不可用
   CredentialsExpiredException 證書過時
/</span>

4.自定義UserDetailsService接口實現類

package cn.zyzpp.security.config;

import cn.zyzpp.security.entity.Role;
import cn.zyzpp.security.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

/**
 * 進行認證的時候須要一個 UserDetailsService 來獲取用戶的信息 UserDetails,
 * 其中包括用戶名、密碼和所擁有的權限等。
 * Create by yster@foxmail.com 2018/6/21/021 15:56
 */
@Component
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private UserService userService;

    /*
     * 採坑筆記:
     * new SimpleGrantedAuthority("...")時
     * 加前戳是Role,經過hasRole()獲取,用來認證角色;
     * 不加前戳是Authoritiy,經過hasAuthority()獲取,用來鑑定權限;
     * 總結:加前戳是角色,不加前戳是權限。此前戳只用於本類。
     */
    String role_ = "ROLE_";

    @Override
    public UserDetails loadUserByUsername(String username) {
        //1.業務層根據username獲取該用戶
        cn.zyzpp.security.entity.User user = userService.findUserByUserName(username);
        if (user == null) {
            //這裏咱們不拋出UsernameNotFoundException由於Security會把咱們拋出的該異常捕捉並換掉;
            //這裏要明確Security拋出的異常沒法被ControllerAdvice捕捉到,沒法進行統一異常處理;
            //而咱們只須要打印正確的異常消息便可,Security自動把異常添加到HttpServletRequest或HttpSession中
            throw new DisabledException("---->UserName :" + username + " not found!");
        }
        //2.從業務層獲取用戶權限並轉爲Authorities
        List<GrantedAuthority> authorities = new ArrayList<>();
        for (Role role : user.getRoleList()) {
            authorities.add(new SimpleGrantedAuthority(role.getName()));//設置權限
            authorities.add(new SimpleGrantedAuthority(role_ + role.getName()));//設置角色
        }
        //3.返回Spring定義的User對象
        return new User(username, user.getPassword(), authorities);
    }

}

講一下:

【本文版權歸微信公衆號"代碼藝術"(ID:onblog)全部,如果轉載請務必保留本段原創聲明,違者必究。如果文章有不足之處,歡迎關注微信公衆號私信與我進行交流!】
  • 咱們在保存用戶信息到內存中時是這樣的
auth.inMemoryAuthentication()
    .withUser("張三")
    .password("123456")
    .roles("ROLE_VIP1")
    .authorities("VIP1")
  • 角色和權限是分開設置的,但咱們在自定義時只有權限設置,
authorities.add(new SimpleGrantedAuthority("權限名"));
  • 定義之後你會發現這真真真…的是權限,不是角色,聯想到上面Security的角色和權限實際上是不一樣的,我想我應該是錯過了什麼?
  • 而後翻看Security源碼:

這裏寫圖片描述

  • 翻譯過來:若是調用hasRole(「ADMIN」)或hasRole(「ROLE_ADMIN」)

方法時,當Role前綴爲」ROLE_」(默認)時將使用ROLE_ADMIN角色。html

  • 而咱們在把用戶信息保存到內存時,底層是這樣的:

這裏寫圖片描述

  • 解讀一下就是在調用.roles("ROLE_VIP1")方法註冊Role時,先經過role.startsWith("ROLE_")斷言輸入的角色名是不是"ROLE_"開頭的,若是不是,補充"RELE_"前戳。
  • 因此,Security解決角色和權限分開的依據就是是否含有"ROLE_"前戳,該默認前戳也是能夠本身修改的。
  • ok,繼續咱們的Security學習之路。

5.獲取Security登陸異常信息

package cn.zyzpp.security.controller;

import cn.zyzpp.security.service.UserService;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

/**
 * Create by yster@foxmail.com 2018/6/10/010 18:35
 */
@Controller
public class MyController {
    @Autowired
    UserService userService;

    @Autowired
    HttpSession session;
    @Autowired
    HttpServletRequest request;

    /*ModelMap的Key*/
    final String ERROR = "error";

    /**
     * 自定義登陸頁並進行異常信息提示
     * 須要在Security中設置
     */
    @RequestMapping(value = "/login")
    public String login(ModelMap modelMap){
      /*
       security的AuthenticationException異常自動保存在request或session中
       官方默認保存在Session,但咱們自定義過多。我測試是在request中。
       因此在html頁面還須要搭配th:if="${param.error!=null}"檢查Url是否有參數error
        */
        String key = WebAttributes.AUTHENTICATION_EXCEPTION;

        if (session.getAttribute(key)!=null){
//            System.out.println("request");
            AuthenticationException exception = (AuthenticationException) session.getAttribute(key);
            modelMap.addAttribute(ERROR,exception.getMessage());
        }
        if (request.getAttribute(key)!=null){
//            System.out.println("session");
            AuthenticationException exception = (AuthenticationException) request.getAttribute(key);
            modelMap.addAttribute(ERROR,exception.getMessage());
        }

        return "login";
    }

}

自定義login登陸頁面

  • Security規定如果GET訪問則是請求頁面,POST訪問則爲提交登陸
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <title>登陸頁面</title>
</head>
<body>
<form th:action="@{/login}" method="post">
    用戶名:<input type="text" placeholder="username" name="username" required=""/><br/>
    密碼:<input type="password" placeholder="password" name="password" required=""/><br/>
    記住我:<input type="checkbox" name="remember"/>
    <input type="submit" value="提交"/>
    <span th:if="${param.error!=null}" th:text="${error}"/>
</form>
</body>
</html>

講一下:

  • 若是你debug追蹤一下,你就能夠了解Security的運行原理
  • Security的SimpleUrlAuthenticationFailureHandler(簡單認證故障處理)會把異常保存到requestsession中,forwardToDestination默認爲false,也就是保存在session,實際咱們測試是保存在request


6.在view層使用Security

6.1 使用HTML sec標籤 (推薦)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity4">
<head>
    <meta charset="UTF-8"/>
    <title>首頁</title>
</head>
<body>
<div sec:authorize="isAuthenticated()">
    <form th:action="@{/logout}" method="POST">
        <input type="submit" value="註銷" />
    </form>
    user:<b sec:authentication="name"></b><br/>
    <!-- principal對應org.springframework.security.core.userdetails.User類 -->
    Role:<b sec:authentication="principal.authorities"></b>
</div>
<div sec:authorize="!isAuthenticated()">
    <h2>遊客你好!</h2>請<a th:href="@{/login}">登陸</a>
</div>
<div sec:authorize="hasRole('VIP1')">
    <h2>ROLE_VIP1_可見</h2>
</div>
<div sec:authorize="hasRole('VIP2')">
    <h2>ROLE_VIP2_可見</h2>
</div>
<div sec:authorize="hasAuthority('VIP1')">
    <h2>Authority:VIP1_可見</h2>
</div>
</body>
</html>

6.2 編碼獲取用戶登陸信息

  • 下面爲我本身寫的方法,看看就好!
/**
 * 不使用sec標籤(不推薦)
 * 在Controller獲取用戶信息
 */
@RequestMapping("/index")
public String index1(ModelMap model){
    userAndRoles(model);
    return "index";
}

/**
 * Security輔助方法:獲取用戶信息
 */
private void userAndRoles(ModelMap model) {
    //從Security獲取當前用戶會話
    Object principal = SecurityContextHolder.getContext()
            .getAuthentication()
            .getPrincipal();
    User user = null;
    //判斷用戶已經登陸
    if (principal instanceof User){
        user = (User) principal;
        //遍歷迭代器獲取用戶權限
        Iterator<GrantedAuthority> iterator = user.getAuthorities().iterator();
        List<String> roles = new ArrayList<>();
        while (iterator.hasNext()){
            roles.add(iterator.next().getAuthority());
        }
        //保存角色信息
        model.addAttribute("roles",roles.toString());
    }
    //保存用戶信息,未登陸爲空
    model.addAttribute("user",user);
}

6.權限及用戶的Entity類

  • 權限表
/**
 * 權限表
 * Create by yster@foxmail.com 2018/6/21/021 18:00
 */
@Entity
@Table(name = "role")
public class Role {
    @Id
    @GeneratedValue
    private int id;
    private String name;
    ...
}
  • 用戶表
/**
 * Create by yster@foxmail.com 2018/6/21/021 17:59
 */
@Entity
@Table(name = "user",uniqueConstraints = {@UniqueConstraint(columnNames="username")})
public class User {
    @Id
    @GeneratedValue
    private int id;
    private String username;
    private String password;
    @OneToMany(cascade={CascadeType.ALL}, fetch=FetchType.EAGER)
    @JoinColumn(name = "r_id")
    private List<Role> roleList;
    ....
}

  • 關於Security的部分先到這裏,之因此寫這篇博客,源於網上的相關資料略少,坑略多,畢竟作伸手黨作慣了,一些坑踩的仍是不容易的!

2019/1/9補充

Spring Security在方法級別上的保護

Spring Security從2.0版本開始,提供了方法級別的安全支持,並提供了 JSR-250 的支持。寫一個配置類 SecurityConfig 繼承 WebSecurityConfigurationAdapter,並加上相關注解,就能夠開啓方法級別的保護。java

@EnableWebSecurity
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SerurityConfig extends WebSecurityConfigurerAdapter {
}

在上面的配置代碼中,@EnableGlobalMethodSecurity(prePostEnabled = true) 註解開啓了方法級別的保護,括號後面的參數可選,可選的參數以下。mysql

  • prePostEnabled:Spring Security 的 Pre 和 Post 註解是否可用,即 @PreAuthorize 和 @PostAuthorize 是否可用。
  • secureEnabled:Spring Security 的 @Service 註解是否可用。
  • jsr250Enabled:Spring Security 對 JSR-250 的註解是否可用。

通常來講,只會用到 prePostEnabled。由於 即 @PreAuthorize 註解比 @PostAuthorize 註解更適合方法級別的安全控制,而且支持 Spring EL 表達式,適合 Spring 開發者。其中,@PreAuthorize 註解會在進入方法錢進行權限驗證,@PostAuthorize 註解在方法執行後再進行權限驗證。web

如何在方法上寫權限註解呢?
例若有權限點字符串「ROLE_ADMIN」,在方法上能夠寫爲 @PreAuthorize(「hasRole(‘ADMIN’)」),也能夠寫爲 @PreAuthorize(「hasAuthority(‘ROLE_ADMIN’)」),這兩者是等價的。加多個權限點,能夠寫爲 @PreAuthorize(「hasRole(‘ADMIN’,‘USER’)」)、@PreAuthorize(「hasAuthority(‘ROLE_ADMIN’,‘ROLE_USER’)」)。spring

版權聲明

【本文版權歸微信公衆號"代碼藝術"(ID:onblog)全部,如果轉載請務必保留本段原創聲明,違者必究。如果文章有不足之處,歡迎關注微信公衆號私信與我進行交流!】sql

相關文章
相關標籤/搜索