用 Grails 的 spring-security-core 插件實現用戶登錄、訪問控制功能

Grails 的 Spring Security Core 插件使用教程

本教程的目標是

  • 用 SpringSecurityCore plugin實現對“URL”的保護,即只有登錄用戶纔可以訪問。
  • 更進一步,對不同的URL資源賦予不同的角色,特定的 URL 只允許擁有特定“角色Role”的用戶訪問。

核心概念

首先了解一下 JavaSecure 技術和 SpringSecurity的核心概念:

  • Java Security 中使用的術語和概念:

    • Java安全中使用術語“主體”(Subject)來表示訪問請求的來源,通俗說就是某個“人”、“登錄的這個用戶(人、組織等)”。
      一個主體可以是任何的實體(實體通俗說就是某種東西,可以是人或者組織)。
    • 一個主體可以有多個不同的“身份標識”(Principal)。例如登錄網站的“某個人(subject)”就會有一個“用戶”對象作爲Principal身份標識,代表登錄的這個人。
      比如一個應用的用戶這類主體,就可以有用戶名、身份證號碼和手機號碼等多種身份標識。
      除了身份標識之外,一個主體還可以有公開或是私有的安全相關的憑證(Credential),包括密碼和密鑰等。
  • 和認證相關的概念:

    • 認證(Authentication):通過讓用戶輸入“用戶名、密碼”等證明信息來確認該用戶的真實身份。
    • 身份標識(Principal):主體的某種ID標識,可以是用戶真實姓名、賬號的用戶名、手機號碼、身份證號等。通常也被當成“用戶”這個概念。
      爲什麼身份標識Principal可以當成“用戶”,是因爲一個人可以在不同網站有不同的“身份標識”,即不同的“用戶”賬號。
      這個主體在網站的“用戶賬號”就是該主體在這個網站的“身份的標識”Principal。
    • 憑證(credentials):用來驗證用戶身份的東西,可以是“密碼”、“證書”、“短信驗證碼”、“指紋”等各類憑據。
  • 和授權相關的概念:

    • 要意識到授權包括兩個動作,“授權”和“鑑權”。
    • 權限(authorities):即訪問某個資源、執行某個操作的權利。也就是 permissions(許可)。
    • 授予的權限(granted authorities):這裏是名詞,而不是動詞,表示某個主體已經被授予或者說分配了的權限。注意不是授權動作。
    • 訪問控制(Access Control):也稱爲鑑權,即決定已認證的主體是否有權利訪問本資源、URL或執行方法等操作。
    • 角色(Role):代表某種工作職責和權利範圍,例如“管理員(admin)”、“編輯(editor)”等。角色會用在兩個地方,
      分配權限時和執行訪問控制時,即授權和鑑權時。注意,角色也只是實現訪問控制的一種方式,還有其他的實現方式。
    • 角色組(Group):一組角色的集合,是爲了更方便地給用戶分配多個角色而設計的概念。
    • 棄權(abstain):放棄投票權。
    • 肯定式的(affirmative):只要有一個投票者允許訪問,則認爲有權訪問的一種投票機制。
    • 基於共識的(Consensus Based):基於共識的投票機制,是指只要大多數同意則認爲投票通過,有權利訪問。
    • 一致性的(Unanimous Based):一致性投票機制,要求所有投票者都同意或都棄權纔算通過。
    • 可否決的(Vetoable):只要有一票否決,就認爲投票不通過的機制。

理解 SpringSecurity 的工作原理

1、認證

首先要仔細閱讀的是 Principle 身份標識類。它是 Oracle公司的 Java Security 規範定義的,這個接口定義如下:

public interface Principal {

    /**
     * 和另外一個 Principle(身份標識)比較,看是否相同。
     *
     * @return true 表示相同,false 表示不同.
     */
    public boolean equals(Object another);

    /**
     * 返回能表示本身份標識的字符串
     */
    public String toString();

    /**
     * Returns a hashcode for this principal.
     */
    public int hashCode();

    /**
     * 返回本身份標識的名稱。
     * 例如:用戶名類型的身份標識返回的就是“用戶名”,身份證類型身份標識返回的就是“身份證號”。
     */
    public String getName();

    /**
     * 如果本身份標識代表的就是參數指定的主體,則返回true,否則返回 false。
     *
     * @since 1.8
     */
    public default boolean implies(Subject subject) {
        if (subject == null)
            return false;
        return subject.getPrincipals().contains(this);
    }
}

接下來需要閱讀的是 Spring Security 定義的 Authentication 接口,它繼承自 Principle,代表認證相關信息,
包括身份標識、憑據、認證的結果以及權限,定義如下:

public interface Authentication extends Principal, Serializable {

    /**
     * 已經授予本認證標識的權限。因爲 Principle 代表的是一個主體(Subject),所以其實等價於是授予 主體 的權限。
     * 這個權限是由 AuthenticationManager 設置的。
     */
    Collection<? extends GrantedAuthority> getAuthorities();

    /**
     * 證明本身份標識有效的“憑據(Credential)”。通常就是“密碼”,也可以是其他 AuthenticationManager 認識的東西。
     * 由調用方/使用方來設置本憑據。
     */
    Object getCredentials();

    /**
     * 存放額外的認證請求信息,例如 IP地址,整數序列號等。可以是 null。
     */
    Object getDetails();

    /**
     * 返回正在被認證或已經認證過的“身份標識”對象。一般就是登錄的“用戶”對象。
     */
    Object getPrincipal();

    /**
     * 返回 true 表示已經認證成功過了。也就是說可以信任已經發出的 token(令牌)。
     */
    boolean isAuthenticated();

    /**
     * 設置 isAuthenticated 屬性。true 表示可以信任已經發出的令牌(Token),false 表示不再信任該令牌。
     */
    void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

SpringSecurity 中,認證這個動作,是由 AuthenticationManager 接口的實現類來完成的。

public interface AuthenticationManager {

  Authentication authenticate(Authentication authentication)
    throws AuthenticationException;

}

AuthenticationManager 接口是一個策略接口(Strategy Interface),表示它可能有不同的實現類來實現不同的“策略”,通俗說就是
實現不同的認證模式。默認是由 ProviderManager 類來實現這個接口。ProviderManager 實現認證的模式就是將認證工作代理給一組
更具體的 AuthenticationProvider 來做。這樣的設計模式就是一個“責任鏈”模式。

ProviderManager 可以有一個parent(父)ProviderManager對象,當它自己的所有 providers 認證提供者都不能決定本次認證時,
它就會詢問它的父對象,來完成認證。這樣就形成了一個樹形結構,以便對資源進行邏輯分組,比如所有的“/api/”用一個
認證管理器,而“/user/
”用另外一個認證管理器。根節點就代表了公共認證器,這就可以形成一個樹形結構,結構如下圖所示。
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-WwIvjYDR-1586921961312)(./doc_images/authentication.png)]

AuthenticationProvider 接口能夠讓調用方知道本對象是否支持對指定類型的 Authentication 對象進行身份驗證。

public interface AuthenticationProvider {
    /**
     * 執行身份認證
     */
    Authentication authenticate(Authentication authentication) throws AuthenticationException;
    
    /**
     * 是否支持指定類的 Authentication 認證。
     */
    boolean supports(Class<?> authentication);
} 

UserDetails 和 UserDetailsService

SpringSecurity框架中,爲“身份標識”(principle)這個概念提供了一個具體的定義,即 UserDetails 接口。這個接口定義了
userName, password, authorities(權限), expired(過期), lock(鎖定), credentialExpire(密碼過期), enable(用戶可用) 這樣一些
屬性。UserDetails 接口的具體對象,就是 Authentication.getPrinciple() 所返回的對象,它是SpringSecurity框架與具體應用程序
之間的一個適配器,讓SpringSecurity可用適應各種不同的應用程序。

怎麼創建這個 UserDetails 對象呢?

它這是由 UserDetailsService 接口創建的,這個接口只有一個方法:

public interface UserDetailsService {
    /**
     * 通過用戶名查找對象
     *
     * @return 一個完整的用戶詳情對象,不能是null
     *
     * @throws UsernameNotFoundException 如果用戶不存在或沒有任何授權(GrantedAuthority)
     */
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

怎麼來創建、配置一個 AuthenticationManager 呢?

用 AuthenticationManagerBuilder 這個工具類可以創建和配置 AuthenticationManager,例如下面的代碼演示了創建一個頂層、全局的
AuthenticationManager。

@Configuration
public class ApplicationSecurity extends WebSecurityConfigurerAdapter {
   ... // web stuff here
  @Autowired
  public void initialize(AuthenticationManagerBuilder builder, DataSource dataSource) {
    builder.jdbcAuthentication().dataSource(dataSource).withUser("dave").password("secret").roles("USER");
  }
}

2、授權和鑑權(訪問控制)以及資源的權限

SpringSecurity 中,決定一個用戶是否有權限訪問某資源,是由 AccessDecisionVoter 接口的具體實現類來完成的。這個接口有下面的
方法:

boolean supports(ConfigAttribute attribute);

boolean supports(Class<?> clazz);

int vote(Authentication authentication, S object,
        Collection<ConfigAttribute> attributes);

其中的 vote 方法是關鍵。vote 方法決定一個“認證”對象(即 Authentication 對象),是否能訪問某個資源 S object。
資源的所有者爲了進一步描述“允許誰訪問本資源”這種規則,於是就用一組 ConfigAttribute 對象來描述,
這就是 Collection attributes 參數。ConfigAttribute 是一個接口,它只有一個簡單的方法,返回一個字符串,
這個字符串將描述這種“訪問規則”,最常見的是返回“用戶角色(User Role)”的定義,例如“ROLE_ADMIN 或者 ROLE_AUDIT”。

下面是我之前錯誤的理解:

原來我認爲“角色”就是不同權限的集合,一個用戶擁有了某個“角色”那麼他就有了一組對應的權限。
從這個意義上說,我之前理解的“角色”其實對應SpringSecurity中的“角色組”,
而之前我理解的“權限”對應SpringSecurity的“角色(Role)”。

其實 SpringSecurity 中權限是由 GrantedAuthorities 接口表示的,它只有一個返回String的方法 getAuthority(),因此
權限通常就是用“角色”來表示的,如“ROLE_ADMIN”表示管理員權限,也就是說角色也代表了它所擁有的權限。

在 Spring Security 中,給用戶授予的權限由 Authenticate 接口的 authorities 方法提供,這個“權限”通常就是一組分配給
用戶的角色字符串,如“ROLE_ADMIN”等。

Grails Spring-Security-Core plugin 使用教程

文檔

工作總覽

  1. 引入依賴包 ‘org.grails.plugins:spring-security-core:4.0.0.RC3’
  2. 創建不安全的 web 應用
  3. 創建 spring-security-core 配置和必要的領域對象

1.創建不安全的 web 應用

創建一個 Contract Domain對象。

創建一個 Controller,列出所有的合同。

可以用 grails 命令,方便地創建 domain、controller 和 view。

2.引入依賴包 ‘org.grails.plugins:spring-security-core:4.0.0.RC3’

build.gradle 文件中添加一行依賴聲明

dependencies {
  compile 'org.grails.plugins:spring-security-core:4.0.0.RC3'
}

3.創建 spring-security-core 配置和必要的領域對象

$ grails s2-quickstart com.mycompany.myapp User Role

注意:

  • 包名不能省略。

  • 要檢查領域名稱是否是數據庫的保留關鍵字,例如有的數據庫就不能使用 User、Group、Role作爲表名。最好避免使用這些常見的名稱
    作爲領域對象名。如果一定要使用,需要用mapping指定引用模式,如:

    static mapping = {
    table ‘user
    }

執行 s2-quickstart 命令後,會創建以下文件:

resources.groovy
application.groovy
Role.groovy
User.groovy
UserRole.groovy
UserPasswordEncoderListener.groovy

然後,我們在 BootStrap.groovy 中初始化數據庫內容。

def init = { servletContext ->
    environments {
        development {
            def dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
            new Contract(name: "一期", signDate: dateFormat.parse("2017-09-01 00:00:00")).save()
            new Contract(name: "二期", signDate: dateFormat.parse("2018-01-10 00:00:00")).save()
            new Contract(name: "三期", signDate: dateFormat.parse("2019-10-15 00:00:00")).save()

            def user = new User(username: "yangbo", password: "123").save()
            def role = new Role(authority: "ROLE_ADMIN").save()
            UserRole.withTransaction {
                UserRole.create(user, role)
            }
            assert UserRole.count == 1
        }
    }
}

注意:UserRole 必須用withTransaction,因爲 init 閉包不會在事務中或者 session 中運行,需要顯式創建一個事務,
因爲 UserRole.create() save時設置了flush=false,即不會立即保存到數據庫中。

方法是在 application.yml 中添加下面內容:

plugins:
    springsecurity:
        logout:
            postOnly: false

告訴 grails spring-security-core plugin 支持 GET 模式的登出,這樣方便測試,否則要編寫一個 form 來提交登出,測試比較費事。

這時,訪問 contract controller (/contract/index),就會跳轉到 login 頁面,輸入正確的用戶名密碼後,就能進入 /home 頁面,但訪問
/contract 頁面還是提示沒有權限,這是因爲沒有配置訪問 /contract url 所需的權限。

spring-security-ui plugin 支持用戶、角色的創建界面,但 core 插件是沒有的。

測試 RememberMe 功能

登錄時勾選上“Remember Me”,就可以在關閉瀏覽器重新打開瀏覽器後,自動完成登錄,訪問需要登錄的URL。

注意,如果重啓了web服務,那麼記住的token就會失效,需要重新登錄,如果想要避免這種情況,需要使用“持久化記住我”模式的實現。

這是通過在 cookie 中記錄了一個 token,然後通過 token 驗證用戶是否已經成功登錄來實現的。

Token 的格式類似於“yangbo:1583156448794:dda4994f2c2cf3e2afac0cc5169a0bc4”,即

“username : expiryTime : Md5Hex(username:expiryTime:password:key)”

這樣的格式。具體實現可以查看 TokenBasedRememberMeServices 類。另外一種更安全的實現方法是持久性Token,
由 PersistentTokenBasedRememberMeServices 類實現。

到這裏,我們已經完成了最基本的“安全化一個web應用”的開發。

下一步工作

  • 添加 security UI,使用 security-ui plugin對用戶、角色、權限進行管理,實現用戶註冊、找回密碼、ACL 等功能。
  • 使用 Group 簡化角色的分配
  • 使用 grails-spring-security-rest plugin 實現無狀態的 REST 安全化。

值得一讀的 SpringSecurity 文檔

  • https://docs.spring.io/spring-security/site/docs/current/reference/html5/#overall-architecture
  • https://docs.spring.io/spring-security/site/docs/current/reference/html5/#tech-intro-authentication

其他安全相關的 plugins

Grails 相關技巧

Spring Security 使用注意事項:

  • 在 Spring Security 中,需要給每一個被保護的URL映射一個角色(Role),可以使用“層級角色”(Hierarchical Roles)技術
    來簡化這個映射配置。
  • 在 Spring Security 中,想要方便地給一個用戶一次性地分配多個角色,可以將多個角色定義爲一個“角色組”(Group),然後
    給這個用戶授予“角色組”即可。

如何讓 Spring Security 支持多租戶的 SaaS 場景?

可以用 filter + ThreadLocale 的方式。即先用過濾器在 spring security filter 之前設置好線程本地變量 tenantId,然後
實現一個自定義的 UserDetailService,在查找用戶時通過線程本地變量獲取 tenantId 並且作爲查詢用戶的條件。

如果我們使用 JWT 來提供 REST API,那麼我們還需要自定義 JWT 的 claims 內容,將 tenantId 寫入 claims 中。

參考文章

GIT 項目地址

github https://github.com/yangbo/grails_tutorials.git

歡迎給 github 項目點贊,分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章