SpringBoot框架整合SpringSecurity實現安全訪問控制

qushaming發表於2019-01-28

一、 前言:
專案捨棄了原本的SSH框架,改用Spring Boot框架,並且要引入Spring Security為系統提供安全訪問控制解決方案,接下來記錄一下這兩天在Spring Boot中引入Spring Security 的過程。主要參考了以下專案、部落格和手冊:(目前最新的Spring Security版本為5.0.4,我使用的是5.0.3,前三個連結中用的應該都是Spring Security 4.x或更早版本,想學習最新的Spring Security還是好好讀以下第四個連結,Spring Security 5官方文件)

我對Spring Security也還是一知半解,勉強配置好可以使用。可能有一些理解是錯誤的,或者有一些說法不嚴謹,請各位見諒和指正。等這段時間忙過去要從頭看一下英文版官方手冊。

https://github.com/lenve/vhr

https://www.cnkirito.moe/categories/Spring-Security/

https://blog.csdn.net/code__code/article/details/53885510

https://docs.spring.io/spring-security/site/docs/5.0.3.RELEASE/reference/htmlsingle/

-------------------------------------------------------------------------------------------------------------------

二、 Spring Security的原理簡介:
Spring Security的安全訪問控制分為Authentication(認證)和Authorization(授權,也叫“訪問控制”)。認證指的是使用者登入的資訊驗證,判斷你賬號密碼是否正確;授權指的是當使用者訪問一個頁面時判斷他有沒有這個許可權。

一般流程為:

①當使用者登入時,前端將使用者輸入的使用者名稱、密碼資訊傳輸到後臺,後臺用一個類物件將其封裝起來,通常使用的是UsernamePasswordAuthenticationToken這個類。

②程式負責驗證這個類物件。驗證方法是呼叫Service根據username從資料庫中取使用者資訊到實體類的例項中,比較兩者的密碼,如果密碼正確就成功登陸,同時把包含著使用者的使用者名稱、密碼、所具有的許可權等資訊的類物件放到SecurityContextHolder(安全上下文容器,類似Session)中去。

③使用者訪問一個資源的時候,首先判斷是否是受限資源。如果是的話還要判斷當前是否未登入,沒有的話就跳到登入頁面。

④如果使用者已經登入,訪問一個受限資源的時候,程式要根據url去資料庫中取出該資源所對應的所有可以訪問的角色,然後拿著當前使用者的所有角色一一對比,判斷使用者是否可以訪問。

三、 開發步驟
1. 新建專案
(開發環境IDEA企業版)

新建一個專案,選擇Spring Initializr,選擇下一步,寫好專案的各種資訊,選擇下一步,選擇引入哪些dependency。這裡要注意一下,對於一個Spring boot專案來說,最簡單的就是選擇Web下的Web這個依賴,然後點下一步,確認專案的地址,完成。其他的依賴我們可以在寫專案的過程中用到哪個新增哪個,只要在pom.xml檔案中新增一個dependency標籤就好了。當然,你也可以在建立專案的時候就把要用到的依賴選中,這樣專案建立完後pom.xml裡面就已經有了這幾個dependency的標籤了。下面是我選的幾個dependency。

建立完,pom.xml檔案中的dependency是這樣的。

2. 準備工作
在正式寫程式碼之前先做幾個準備工作。為了專案的程式碼分層要分包,再就是把預設的application.properties刪掉換成application.yml。

其中,bean是用來存放Entity實體類;component包存放一些Component類,這些類大多被@Component註解,是框架執行過程中需要用到的類;config用來存放一些於Security相關的配置類;controller包存放Controller類,根據前端訪問的地址決定如何向前端返回資料;repository包用來存放repository介面,這些介面是用來從資料庫中取資料封裝成物件的;service存放一些service類,需要依賴Repository介面,實現一些功能邏輯。

3. 建庫
MySQL資料庫。使用者角色與許可權管理細分為5個表——使用者表、使用者-角色表、角色表、角色-許可權表、許可權表。

以上就是五個表最簡單的結構,幾條用來測試的簡單資料。

至於使用者表為什麼用users而不用user命名,是因為user在MySQL中是關鍵字,最好避免使用,所以使用users。不過沒關係,在實體類中我們還是用User作為類名的。如今的Spring Boot框架將程式與資料庫的耦合度又降低了一個檔次,在程式中配置資料庫連線和ORM的時候,只有很少地方要註明一下資料庫的表名字。

使用者的密碼是使用BCrypt加密後儲存到資料庫中的。BCrypt是Spring Security官方推薦的加密方式,在Spring Secueity框架中也提供了這種加密方式,後面會詳細介紹。

4. 屬性配置檔案
接下來寫專案的屬性配置檔案application.yml。

port是伺服器的埠,context-path是訪問地址字首,這樣我們專案的入口就是http://localhost:8080/mysecurity/。接下來是jdbc的配置,有jdbc經驗的一看就懂(不要忘記填密碼)。接下來,show-sql為true使得專案進行資料庫查詢的時候在控制檯列印sql語句,方便我們常看和排查錯誤,ddl-auto有幾個屬性可選——update、create、create-drop、none、validate,關於它們的區別不懂的話百度一下就明白選哪個了。

如果你是第一次使用yml檔案,要注意一個細節,在“:”後面寫引數的時候要先打一個空格,這是yml檔案格式規定。

5. bean包
在bean包裡建立實體類。拿users表來舉例。為users表建立實體類的時候,類名、成員變數名字可以不和資料庫的表名、列名對應,只要用註解(@Table、@Column)說明一下就好了。另外要在public class User頭上加一個@Entity註解。然後新增getter、setter,最好加一個無參構造方法。

如果主鍵在資料庫中是自增主鍵,那麼在實體類中要這麼註解一下。這個註解是用來宣告自增主鍵和它的增長策略。

6. repository包
建立完實體類後,要建立repository介面,這就相當於DAO,作用是從資料庫取出資料放到對應的實體類中。注意repository是介面型別,且需要繼承JpaRepository<T, ID>。前面的T指的是取出資料後封裝到哪個類中,後面的ID指的是它的主鍵型別,下面是我的user的repository介面。

一般情況下這樣就可以了,不用宣告函式。使用的時候是這個樣子的:

@Autowired
private UserRepository userRepository;
 
public List<User> findAllUsers() {
    List<User> userList = userRepository.findAll();
    return userList;
}


我猜是spring boot幫你把這個介面實現瞭然後把例項注入到userRepository。所以,雖然你寫的是介面,後面卻可以使用,而且它還幫你實現了幾個方法。

repository介面使用起來很方便,有些方法不需要寫,它已經隱示提供了。比如,雖然你的介面裡面什麼都沒寫,但已經可以呼叫userRepository.findAll()方法了。還有一些方法只需要你在介面裡面寫一行抽象函式宣告,不需要有函式體(但是必須按要求給函式命名),就可以在Service裡面呼叫了。

以下這種方式允許你按列名查詢資料,同樣不需要你寫函式體,但是要注意這個抽象函式的命名:findByXxxx(··· ···)。

public interface ResourceRepository extends JpaRepository<Resource, Long> {
    
    public List<Resource> findByUrl(String url);
}


如果你有自己的sql語句,那麼你的repository要另外繼承一個JpaSpecificationExecutor<T>,T是你的實體類。這樣就允許你自己寫sql語句。

比如RoleRepository這個介面。這個介面功能比較重要,因為Spring Security框架中某些地方需要根據當前使用者查詢他對應的所有Role,也有些地方需要根據使用者訪問的URL查詢這個URL對應的所有Role。一種實現方法是在User、Resource的實體類裡面新增一個屬性:

@OneToMany
@JoinColumn
private List<Role> roles;
使用OneToMany註解,使Repository在從資料庫中取users表的記錄存放到User實體類中的時候,還會將每條user記錄對應的role表中那幾條記錄一起取出來放到List<Role>中。是一種很方便的方式。如果不使用這種方式,我們也可以在取User的時候只取User,不取它關聯的資料,而是在使用那些資料的時候再從資料庫查詢出來填充到List<Role>中去。

第一種方法簡化了使用,但是並沒有效率的提升,而且有時我們不需要使用到關聯資料,而只用到User本身的資料的時候,它還是幫我們從資料庫中把無用資料取了出來。這樣不僅浪費了空間還降低了效率,因此我們用第二種方法。

這就是RoleRepository重要的原因,它負責根據Url返回它對應的所有Role,根據使用者名稱返回使用者對應的所有Role。

package com.xbk.myspringsecurity.security.repository;
 
import com.xbk.myspringsecurity.security.bean.Role;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
 
import java.util.List;
 
public interface RoleRepository extends JpaRepository<Role, Long>, JpaSpecificationExecutor<Role> {
 
    //自定義sql語句並且開啟本地sql
    //根據使用者名稱查詢該使用者所有許可權
    @Query(value = "select r.* from role r, user_role ur where ur.username = ?1 and ur.rid = r.id", nativeQuery = true)
    public List<Role> findRolesOfUser(String username);
 
    //根據resource的主鍵查詢resource允許的所有許可權
    @Query(value = "select r.* from role r, resource_role rr where rr.res_id = ?1 and rr.rid = r.id", nativeQuery = true)
    public List<Role> findRolesOfResource(long resourceId);
}


nativeQuery的意思是是否開啟本地Sql,預設為false。@Query註解設定了一些寫SQL語句的規則,簡化了自定義sql語句的形式。你也可以不使用它提供的方式,開啟nativeQuery,在資料庫中怎麼寫sql就怎麼在這裡寫sql。

7. service包
service包裡面都是一些Service類,主要是對Repository做了封裝。另外一些邏輯程式碼和資料庫操作程式碼以及事務管理要寫在Service的函式裡面。裡面主要包括UserService、RoleService、ResourceService等類。

package com.xbk.myspringsecurity.security.service;
 
import com.xbk.myspringsecurity.security.bean.Role;
import com.xbk.myspringsecurity.security.repository.RoleRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
 
import java.util.List;
 
@Service
public class RoleService {
 
    @Autowired
    private RoleRepository roleRepository;
 
    public List<Role> getRolesOfUser(String username)
    {
        return roleRepository.findRolesOfUser(username);
    }
 
    public List<Role> getRolesOfResource(long id)
    {
        return roleRepository.findRolesOfResource(id);
    }
}


UserService和其他的Service類有些不同,一定要特別注意UserService這個類!看3.8。
8. 為框架實現UserDetails介面和UserDetailsService介面
在我們的程式中,必須要有一個類,實現UserDetailsService這個介面並且重寫它的loadUserByUsername(String s)這個函式。另外也必須要有一個類,實現UserDetails介面並重寫它其中的幾個方法。

為什麼呢?這涉及到Spring Security框架的認證的原理。在使用者登入的時候,程式將使用者輸入的的使用者名稱和密碼封裝成一個類物件。然後根據使用者名稱去資料庫中查詢使用者的資料,封裝成一個類物件放在記憶體中。注意,一個是使用者輸入的資料,一個是資料庫中的資料。將兩個物件比對,如果密碼正確,就把使用者資訊的封裝(包含著身份資訊、細節資訊等)存到SecurityContextHolder中(類似Session),使用的時候還要取出來。

而這個過程中,從資料庫中取出的使用者資訊的封裝不是簡單的User例項,而是一個實現了UserDetails這個介面的類的物件,這個物件裡面不僅有使用者的賬號密碼資訊,還有一些判斷賬號是否可用、判斷賬號是否過期、判斷賬號是否被鎖定的函式。

在驗證過程中,負責根據使用者輸入的使用者名稱返回資料庫中使用者資訊的封裝這個功能的就是Service,它實現了UserDetailsService,重寫了它的loadUserByUsername(String s)方法,這個方法就是根據使用者名稱返回了UserDetails的一個具體實現。

圖片是兩個介面的原始碼。

有些人習慣直接使User實體類實現UserDetails介面,這樣在這個類裡面不僅要寫users表的屬性,還要重寫UserDetails的方法,耦合度較高。User實體類原本只用來與資料庫形成ORM對映,現在卻要為框架提供其他功能。因此我們不這麼做,而是重新寫一個類,名叫UserDetailsImpl,實現UserDetails介面,新增一個List<Role>屬性,重寫介面的方法。在框架要用到UserDetails的地方,我們先把User查出來,然後用User去構造一個UserDetails。

這個UserDetailsImpl暫時放在Bean包裡面。

package com.xbk.myspringsecurity.security.bean;
 
import com.xbk.myspringsecurity.security.service.RoleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
 
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
 
//一定要有一個類,實現UserDetails介面,供程式呼叫
public class UserDetailsImpl implements UserDetails {
 
    private String username;
    private String password;
    //包含著使用者對應的所有Role,在使用時呼叫者給物件注入roles
    private List<Role> roles;
 
    @Autowired
    private RoleService roleService;
 
    public void setRoles(List<Role> roles) {
        this.roles = roles;
    }
 
    //無參構造
    public UserDetailsImpl() {
    }
 
    //用User構造
    public UserDetailsImpl(User user) {
        this.username = user.getUsername();
        this.password = user.getPassword();
    }
 
    //用User和List<Role>構造
    public UserDetailsImpl(User user, List<Role> roles) {
        this.username = user.getUsername();
        this.password = user.getPassword();
        this.roles = roles;
    }
 
    public List<Role> getRoles()
    {
        return roles;
    }
 
    @Override
    //返回使用者所有角色的封裝,一個Role對應一個GrantedAuthority
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        for(Role role : roles) {
            authorities.add(new SimpleGrantedAuthority(role.getRoleName()));
        }
        return authorities;
    }
 
    @Override
    public String getPassword() {
        return password;
    }
 
    @Override
    public String getUsername() {
        return username;
    }
 
    @Override
    //判斷賬號是否已經過期,預設沒有過期
    public boolean isAccountNonExpired() {
        return true;
    }
 
    @Override
    //判斷賬號是否被鎖定,預設沒有鎖定
    public boolean isAccountNonLocked() {
        return true;
    }
 
    @Override
    //判斷信用憑證是否過期,預設沒有過期
    public boolean isCredentialsNonExpired() {
        return true;
    }
 
    @Override
    //判斷賬號是否可用,預設可用
    public boolean isEnabled() {
        return true;
    }
}


UserDetailsService也需要被實現,我們在寫UserService時直接實現這個介面就可以。所以UserService跟其他Service有些不同。

package com.xbk.myspringsecurity.security.service;
 
import com.xbk.myspringsecurity.security.bean.User;
import com.xbk.myspringsecurity.security.bean.UserDetailsImpl;
import com.xbk.myspringsecurity.security.repository.UserRepository;
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 javax.transaction.Transactional;
import java.util.List;
 
@Service
//框架需要使用到一個實現了UserDetailsService介面的類
public class UserService implements UserDetailsService{
 
    @Autowired
    private UserRepository userRepository;
 
    @Autowired
    private RoleService roleService;
 
    @Transactional
    public List<User> getAllUser()
    {
        return userRepository.findAll();
    }
 
    @Transactional
    public List<User> getByUsername(String username)
    {
        return userRepository.findByUsername(username);
    }
 
    @Override
    //重寫UserDetailsService介面裡面的抽象方法
    //根據使用者名稱 返回一個UserDetails的實現類的例項
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        System.out.println("查詢使用者:" + s);
        User user = getByUsername(s).get(0);
        if(user == null)
        {
            throw new UsernameNotFoundException("沒有該使用者");
        }
 
        //查到User後將其封裝為UserDetails的實現類的例項供程式呼叫
        //用該User和它對應的Role實體們構造UserDetails的實現類
        return new UserDetailsImpl(user, roleService.getRolesOfUser(user.getUsername()));
    }
}


簡單來講就是程式接收到了使用者輸入的使用者名稱,交給了UserService,它根據使用者名稱去資料庫中取到使用者的資訊,封裝到實體類User的例項中,然後使用該User例項,再利用RoleService(封裝了RoleRopository)查出該User對用的roles,構造一個UserDetailsImpl的物件,把這個物件返回給程式。

9. Component包
(1) 實現FilterInvocationSecurityMetadataSource介面
寫一個類,實現FilterInvocationSecurityMetadataSource這個介面,供系統呼叫,放在Component包中。作用是在使用者請求一個地址的時候,截獲這個地址,告訴程式訪問這個地址需要哪些許可權角色。不要忘記寫@Component註解。

package com.xbk.myspringsecurity.security.component;
 
import com.xbk.myspringsecurity.security.bean.Resource;
import com.xbk.myspringsecurity.security.bean.Role;
import com.xbk.myspringsecurity.security.service.ResourceService;
import com.xbk.myspringsecurity.security.service.RoleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.SecurityConfig;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.stereotype.Component;
 
import java.util.Collection;
import java.util.List;
 
@Component
//接收使用者請求的地址,返回訪問該地址需要的所有許可權
public class FilterInvocationSecurityMetadataSourceImpl implements FilterInvocationSecurityMetadataSource {
 
    @Autowired
    private ResourceService resourceService;
 
    @Override
    //接收使用者請求的地址,返回訪問該地址需要的所有許可權
    public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
        //得到使用者的請求地址,控制檯輸出一下
        String requestUrl = ((FilterInvocation) o).getRequestUrl();
        System.out.println("使用者請求的地址是:" + requestUrl);
 
        //如果登入頁面就不需要許可權
        if ("/login".equals(requestUrl)) {
            return null;
        }
 
        Resource resource = resourceService.getResourceByUrl(requestUrl);
 
        //如果沒有匹配的url則說明大家都可以訪問
        if(resource == null) {
            return SecurityConfig.createList("ROLE_LOGIN");
        }
 
        //將resource所需要到的roles按框架要求封裝返回(ResourceService裡面的getRoles方法是基於RoleRepository實現的)
        List<Role> roles = resourceService.getRoles(resource.getId());
        int size = roles.size();
        String[] values = new String[size];
        for (int i = 0; i < size; i++) {
            values[i] = roles.get(i).getRoleName();
        }
        return SecurityConfig.createList(values);
    }
 
    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }
 
    @Override
    public boolean supports(Class<?> aClass) {
        return false;
    }
}


(2) 實現AccessDecisionManager介面
寫一個類,實現AccessDecisionManager,放在Component包中。這個類的作用是接收上面那個類返回的訪問當前url所需要的許可權列表(decide方法的第三個引數),再結合當前使用者的資訊(decide方法的第一個引數),決定使用者是否可以訪問這個url。不要忘記寫@Component註解。

package com.xbk.myspringsecurity.security.component;
 
import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
 
import java.util.Collection;
import java.util.Iterator;
 
@Component
//Security需要用到一個實現了AccessDecisionManager介面的類
//類功能:根據當前使用者的資訊,和目標url涉及到的許可權,判斷使用者是否可以訪問
//判斷規則:使用者只要匹配到目標url許可權中的一個role就可以訪問
public class AccessDecisionManagerImpl implements AccessDecisionManager{
    @Override
    public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
 
        //迭代器遍歷目標url的許可權列表
        Iterator<ConfigAttribute> iterator = collection.iterator();
        while (iterator.hasNext()) {
            ConfigAttribute ca = iterator.next();
 
            String needRole = ca.getAttribute();
            if ("ROLE_LOGIN".equals(needRole)) {
                if (authentication instanceof AnonymousAuthenticationToken) {
                    throw new BadCredentialsException("未登入");
                } else
                    return;
            }
 
            //遍歷當前使用者所具有的許可權
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            for (GrantedAuthority authority : authorities) {
                if (authority.getAuthority().equals(needRole)) {
                    return;
                }
            }
        }
 
        //執行到這裡說明沒有匹配到應有許可權
        throw new AccessDeniedException("許可權不足!");
    }
 
    @Override
    public boolean supports(ConfigAttribute configAttribute) {
        return true;
    }
 
    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}


(3)實現AccessDeniedHandler介面
寫一個類,實現AccessDeniedHandler,放在Component保包中。作用是自定義403響應內容。不要忘記寫@Component註解。

package com.xbk.myspringsecurity.config;
 
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
 
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
 
@Component
//自定義403響應內容
public class MyAccessDeniedHandler implements AccessDeniedHandler {
 
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setCharacterEncoding("UTF-8");
        PrintWriter out = response.getWriter();
        out.write("{\"status\":\"error\",\"msg\":\"許可權不足,請聯絡管理員!\"}");
        out.flush();
        out.close();
    }
}


10. Config包
寫一個類,繼承WebSecurityConfigurerAdapter類,這是Spring Security的重頭戲,是一個配置類,需要放在Config包中。

不要忘記寫@Configuration註解。

package com.xbk.myspringsecurity.config;
 
import com.xbk.myspringsecurity.service.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.config.annotation.ObjectPostProcessor;
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.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
 
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
 
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
 
    @Autowired
    private UserService userService;
 
    //根據一個url請求,獲得訪問它所需要的roles許可權
    @Autowired
    MyFilterInvocationSecurityMetadataSource myFilterInvocationSecurityMetadataSource;
 
    //接收一個使用者的資訊和訪問一個url所需要的許可權,判斷該使用者是否可以訪問
    @Autowired
    MyAccessDecisionManager myAccessDecisionManager;
 
    //403頁面
    @Autowired
    MyAccessDeniedHandler myAccessDeniedHandler;
 
    /**定義認證使用者資訊獲取來源,密碼校驗規則等*/
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        /**有以下幾種形式,使用第3種*/
        //inMemoryAuthentication 從記憶體中獲取
        //auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder()).withUser("user1").password(new BCryptPasswordEncoder().encode("123123")).roles("USER");
 
        //jdbcAuthentication從資料庫中獲取,但是預設是以security提供的表結構
        //usersByUsernameQuery 指定查詢使用者SQL
        //authoritiesByUsernameQuery 指定查詢許可權SQL
        //auth.jdbcAuthentication().dataSource(dataSource).usersByUsernameQuery(query).authoritiesByUsernameQuery(query);
 
        //注入userDetailsService,需要實現userDetailsService介面
        auth.userDetailsService(userDetailsService).passwordEncoder(new BCryptPasswordEncoder());
    }
 
    //在這裡配置哪些頁面不需要認證
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/", "/noAuthenticate");
    }
 
    /**定義安全策略*/
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()       //配置安全策略
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                        o.setSecurityMetadataSource(myFilterInvocationSecurityMetadataSource);
                        o.setAccessDecisionManager(myAccessDecisionManager);
                        return o;
                    }
                })
//                .antMatchers("/hello").hasAuthority("ADMIN")
                .and()
                .formLogin()
                .loginPage("/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .permitAll()
                .failureHandler(new AuthenticationFailureHandler() {
                    @Override
                    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
                        httpServletResponse.setContentType("application/json;charset=utf-8");
                        PrintWriter out = httpServletResponse.getWriter();
                        StringBuffer sb = new StringBuffer();
                        sb.append("{\"status\":\"error\",\"msg\":\"");
                        if (e instanceof UsernameNotFoundException || e instanceof BadCredentialsException) {
                            sb.append("使用者名稱或密碼輸入錯誤,登入失敗!");
                        } else if (e instanceof DisabledException) {
                            sb.append("賬戶被禁用,登入失敗,請聯絡管理員!");
                        } else {
                            sb.append("登入失敗!");
                        }
                        sb.append("\"}");
                        out.write(sb.toString());
                        out.flush();
                        out.close();
                    }
                })
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
                        httpServletResponse.setContentType("application/json;charset=utf-8");
                        PrintWriter out = httpServletResponse.getWriter();
//                        ObjectMapper objectMapper = new ObjectMapper();
                        String s = "{\"status\":\"success\",\"msg\":"  + "}";
                        out.write(s);
                        out.flush();
                        out.close();
                    }
                })
                .and()
                .logout()
                .permitAll()
                .and()
                .csrf()
                .disable()
                .exceptionHandling()
                .accessDeniedHandler(myAccessDeniedHandler);
    }
 
}


下面我們來剖析一下這個類中的幾個方法。

(1) protected void configure(AuthenticationManagerBuilder auth)
 這個方法的作用是定義認證使用者資訊獲取來源、密碼校驗規則。認證使用者資訊來源有三種,第一種是記憶體獲取。

auth.inMemoryAuthentication().withUser("user1").password("123123").roles("USER");
這是直接在程式碼中中寫好使用者名稱、密碼、角色,如果不止一個使用者,那麼要在後面繼續寫 .and().withUser("user2")……。這是在執行時將使用者資訊儲存到記憶體裡面,前端發過來的使用者名稱和密碼就跟記憶體中的使用者資訊比較,是最簡單的方式,但是可擴充套件性最差。可以用來測試,專案中一般不會使用這種方法。

第二種方式是使用jdbcAuthentication從資料庫中獲取,但是預設是以security提供的表結構,可擴充套件性低。

//usersByUsernameQuery 指定查詢使用者SQL
//authoritiesByUsernameQuery 指定查詢許可權SQL
auth.jdbcAuthentication().dataSource(dataSource).usersByUsernameQuery(query).authoritiesByUsernameQuery(query);
第三種方式是注入userDetailsService,也就是我們的UserService,這種方法可擴充套件性最高。

auth.userDetailsService(userService);
注意,以上說的三種方法的實現都是基於Security4及更早版本,這些版本還沒有修改密碼儲存和加密。而在Spring Security 5中,如果還是這麼寫,那麼應該會報一個異常——There is no PasswordEncoder mapped for the id “null”;這是因為Spring security 5.0中新增了多種加密方式,也改變了密碼儲存的格式。

簡單來說,security5中密碼的儲存格式是:{id}加密後密碼。前面的id是加密方式,id可以是bcrypt、sha256等,後面跟著的是加密後的密碼。也就是說,程式拿到傳過來的密碼的時候,會首先查詢被“{”和“}”包括起來的id,來確定後面的密碼是被怎麼樣加密的,如果找不到就認為id是null。具體可以看另一篇部落格。

前端傳密碼過來時我們沒有為它加密,也沒有為它加"{id}",於是程式找不到加密方式,預設id為null,這也就是為什麼我們的程式會報錯:There is no PasswordEncoder mapped for the id “null”。

因此我們一方面要對前端傳過來的密碼進行加密,另一方面也要對後端的使用者資訊資料來源裡面的使用者密碼加密處理。

使用BCrypt加密方式。

第一種方式,記憶體獲取,修改之後是這樣的。

auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder()).withUser("user1").password(new BCryptPasswordEncoder().encode("123123")).roles("USRE");
在inMemoryAuthentication()後面多了".passwordEncoder(new BCryptPasswordEncoder())",這相當於登陸時用BCrypt加密方式對使用者密碼進行處理。以前的".password("123456")" 變成了 ".password(new BCryptPasswordEncoder().encode("123456"))" ,這相當於對記憶體中的密碼進行Bcrypt編碼加密。比對時一致,說明密碼正確,允許登陸。

第二種方法不做了解。

第三種方法修改之後是這樣的。

auth.userDetailsService(userService).passwordEncoder(new BCryptPasswordEncoder());
這就是要對使用者輸入的密碼加密處理。另外,我們在資料庫的使用者表中儲存使用者賬號密碼資訊的時候,儲存的應該是使用BCrypt加密處理後的使用者密碼,而不是使用者原始密碼。所以在使用者註冊和修改密碼的程式碼中不要忘記對密碼用BCrypt加密處理。

(2) public void configure(WebSecurity web)
 這個方法的作用時宣告哪些頁面不需要許可權驗證。

(3) protected void configure(HttpSecurity http) 
這個方法的作用是配置安全策略。以http.authorizeRequests()開頭,需要什麼新增什麼,每一個小模組用.and()連線。

.formLogin()用來配置登陸頁面。簡單的配置如下:

.formLogin()
.permitAll()   //允許所有人訪問
不宣告登入頁面則使用security自帶的登陸頁面。如果想使用自定義的登入頁面,首先要修改成這個樣子。

.formLogin()
.loginPage("/login")               //指定登入頁面
.usernameParameter("username")     //指定頁面中對應使用者名稱的引數名稱
.passwordParameter("password")     //指定頁面中對應密碼的引數名稱
.permitAll()
這樣,在你訪問受限頁面時,如果當前沒有登陸,那麼位址列會跳轉到一個登陸的url,具體是哪個url就要看你在.loginPage()裡面怎麼寫的了。

比如我這裡寫的是/login,那麼我訪問http://localhost:8080/security/adminPage (管理員頁面)時,位址列重定向成http://localhost:8080/security/login。

那我們怎麼將自己寫的自定義登陸頁面與上面這個url連線起來呢?

首先寫一個Controller類,這個類負責根據請求的url向前端返回字串、資料、網頁等。

package com.xbk.myspringsecurity.security.controller;
 
 
import com.xbk.myspringsecurity.security.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
@Controller
public class SecurityController {
    
    @GetMapping(value = "/login")
    public String login()
    {
        return "myLoginPage";
    }
}


我們需要寫一個html檔案,起名為myLoginPage.html,放在專案資料夾-src-main-resources-templates資料夾下。

前提是要在pom.xml 中引入thymeleaf這個依賴。

這樣當你訪問受限資源時,網址重定向到/login這個url,又因為你在Controller裡面生命了訪問/login時呼叫public String login()方法,返回了myLoginPage,於是程式就去templates資料夾下尋找對應頁面。

.successHandler()和.failureHandler()定義了登陸頁面的登陸成功和登入失敗後所做的事情。

.logout()配置了退出的相關操作。使用者訪問/logout就可以退出登入。

.logout()
.permitAll()     //宣告使用者退出頁面允許所有人訪問
11. 結束
配置好這些後基本就完成了。

相關文章