用 Grails 的 Spring Security REST 插件實現REST API 的用戶登錄、權限控制功能

用 Grails 的 Spring Security REST 插件實現REST API 的權限控制功能

本教程的目標是

  • 用 Grails-Spring-Security-REST plugin實現對“REST API”的進行保護,即只有登錄用戶纔可以訪問。
  • 更進一步,對不同的API賦予不同的角色,特定的 URL 只允許擁有特定“角色Role”權限的用戶訪問。

JWT 技術簡介

JWT 全稱是 JSON Web Token,即JSON Web令牌。是一種緊湊的、URL安全的方式,用來表示要在雙方之間傳遞的“聲明”。
JWT由三部分組成,分別是 Header,Claim(就是聲明),簽名。每一部分都會被BASE64編碼,看上去是這樣的:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

其中 Header 用base64解碼後,是這樣的:

{
  "typ": "JWT",
  "alg": "HS256"
}

聲明(Claim)也就是需要傳遞的信息主體,是這樣的:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

其中 sub 是規定好的,name 和 admin 是應用自己設定的。

參考資料:

瞭解 grails-spring-security-rest plugin

org.grails.plugins:spring-security-rest 從版本 3.0.0.RC1 開始,只使用JWT作爲Token的保存機制,其他基於數據庫、存儲的實現
拆分到額外的包中去了。

因爲 Grails 從3.2.1版本開始支持 CORS,所以,本插件也天然支持 CORS。

在 application.groovy 中有一個必須填寫的配置項 grails.plugin.springsecurity.rest.token.storage.jwt.secret
它表示 JWT 使用的祕鑰。

本插件使用 JWT 進行身份驗證的流程遵守 rfc6750 Bearer Token 規範
這裏 Bearer 是持票人的意思,就是持有Token這個令牌的人。

RFC6750規範的內容核心是:

  • 使用 Header 提交 JWT 時,放在 “Authentication” 字段中,且格式是 "Authentication: Bearer "

  • 使用 POST 表單提交時,使用參數名 “access_token”,且 content-type 是 “application/x-www-form-urlencoded”

  • 使用 GET 提交JWT時,使用參數名 “access_token”

  • 當訪問被保護的資源且沒有體統 JWT 時,返回401 Unauthorized 狀態碼且消息頭中要有 WWW-Authenticate 字段,例如:

    HTTP/1.1 401 Unauthorized
    WWW-Authenticate: Bearer realm=“example”

本插件還支持“匿名”訪問,即對某些URL不要求身份驗證。

Plugin 網址和文檔

開始編碼

1. 首先安裝spring-security-rest插件

就是添加 gradle 依賴,代碼如下。

build.gradle

dependencies {
    //Other dependencies
    compile "org.grails.plugins:spring-security-rest:3.0.0"
}

2. 開發一個 REST API Controller

添加一個 Contract 合同領域對象。

domain/Contract.groovy

class Contract {
    // 合同名
    String name
    // 合同簽訂日期
    Date signDate

    Date dateCreated
    Date lastUpdated

    static constraints = {
    }
}

添加一個 Service 來初始化數據庫內容。

services/ContractService.groovy

@Transactional
class ContractService {

    /**
     * 爲開發環境創建初始化數據
     */
    def populateForDevelopEnv() {
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
        new Contract(name: "一期", signDate: simpleDateFormat.parse("2017-09-03 00:00:00")).save()
        new Contract(name: "二期", signDate: simpleDateFormat.parse("2017-10-30 00:00:00")).save()
        new Contract(name: "三期", signDate: simpleDateFormat.parse("2018-01-10 00:00:00")).save()
        new Contract(name: "四期", signDate: simpleDateFormat.parse("2018-03-07 00:00:00")).save()
        new Contract(name: "五期", signDate: simpleDateFormat.parse("2018-10-05 00:00:00")).save()
        new Contract(name: "六期", signDate: simpleDateFormat.parse("2019-01-20 00:00:00")).save()
    }

    def list(Map params) {
        Contract.list(params)
    }
}

遇到一個問題,service的@Transactional註解方法,不能保存數據到數據庫中。原來是 Domain 對象在save時做validation失敗了,
默認情況下grails會忽略這個錯誤,只是不保存,而不會拋出異常,需要打開配置纔會顯式地拋出異常,如下:

application.yaml

grails:
    gorm:
        failOnError: true

不論開發環境還是生產,打開這個開關都是有必要的,除非每次執行完數據庫操作後,都檢查或者顯示實體對象的錯誤信息。

這個錯誤是因爲將默認的兩個 Domain 屬性寫錯名字了,正確的是:

Date dateCreated
Date lastUpdated

我錯誤地寫成了:

Date dateCreated
Date dateUpdated    // 寫錯了!!!

然後添加一個 ContractController,其中的 list 方法以json模式返回所有的合同。

ContractController.groovy*

class ContractController {
    static responseFormats = ["json", "html"]
    ContractService contractService

    static allowedMethods = [save: "POST", update: "PUT", delete: "DELETE"]

    /**
     * REST API list
     */
    def list(){
        respond contractService.list(params)
    }
}

讓一個Controller同時支持HTML和JSON格式

技巧就是利用 URLMapping,讓 /api/開頭的請求都用 json 格式,而常規路徑用 html 格式。代碼如下:

static mappings = {
    "/$controller/$action?/$id?(.$format)?"{
        // 普通url用返回html
        format = "html"
        constraints {
            // apply constraints here
        }
    }
    "/api/$controller/$action/$id?"{
        // api 固定返回 json
        format = "json"
    }
    "/"(view:"/index")
    "500"(view:'/error')
    "404"(view:'/notFound')
}

到這裏,一個REST API就開發好了,下面我們需要對它進行安全保護,只允許登錄用戶能訪問。

3. 走一遍 grails-spring-security-core 插件需要做的事情

因爲 security rest 插件依賴了 security core 插件,所以需要執行 security core 的一些基本配置才能行,其實 security core
插件只是將 Token 的存儲方式換成了 JWT 而已。

  • 創建 User、Role 類

    grails s2-quickstart com.telecwin.grails.tutorials User Role

  • 在 Bootstrap.groovy 中創建初始用戶和角色

  • 配置登出地址可以使用GET訪問,方便調試

4. 配置 security rest 特有的屬性

  • 首先添加 JWT 密鑰。

application.yml

grails:
  plugin:
    springsecurity:
      rest:
        token:
          storage:
            jwt:
              # 至少 32 字節
              secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
  • 爲普通url和api url分別配置不同的過濾器,沒有就創建一個 conf/application.groovy 文件,在 Grails 4 中是沒有這個文件的。

application.groovy

grails.plugin.springsecurity.filterChain.chainMap = [
    [pattern: '/assets/**',      filters: 'none'],
    [pattern: '/**/js/**',       filters: 'none'],
    [pattern: '/**/css/**',      filters: 'none'],
    [pattern: '/**/images/**',   filters: 'none'],
    [pattern: '/**/favicon.ico', filters: 'none'],
    // Stateless chain for API, 注意順序,這個必須放在 /** 的前面,否則不起作用
    [
            pattern: '/api/**',
            filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'
    ],
    // Traditional, stateful chain
    [
            pattern: '/**',
            filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter'
    ]
]
  • 給 Controller 添加訪問權限要求

這裏使用一個技巧,就是將 @Secured 註解從方法移動到類上,這樣就不必對每個方法都書寫一次相同的角色註解了。

ContractController.groovy

@Secured("ROLE_USER")
class ContractController {
    static responseFormats = ["json", "html"]
    ContractService contractService

    static allowedMethods = [save: "POST", update: "PUT", delete: "DELETE"]

    /**
     * REST API list
     */
    def list() {
        respond contractService.list(params)
    }
    ...
}

如果出現 IllegalStateException 異常,請重新啓動 grails 程序,可能是因爲熱重載功能失效了。

到這裏,用 grails-spring-security-rest 保護 REST API 就開發完成了。

5. 添加 User 類

@GrailsCompileStatic
@SuppressWarnings("unused")
class User {
   // 自己定義需要的屬性
}

6. 添加 Role 類

import grails.compiler.GrailsCompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import static grails.gorm.hibernate.mapping.MappingBuilder.*

@GrailsCompileStatic
@EqualsAndHashCode(includes = 'authority')
@ToString(includes = 'authority', includeNames = true, includePackage = false)
@SuppressWarnings("unused")
class Role implements Serializable {

    private static final long serialVersionUID = 893264892

    String authority

    static constraints = {
        authority nullable: false, blank: false, unique: true
    }

    // IDEA 報錯的處理辦法,參考 https://stackoverflow.com/questions/60113220/grails-gorm-class-with-grailscompilestatic-annotation-shows-in-the-static-mappi
    static final mapping = orm {
        cache{
            enabled true
        }
    }
}

7. 添加 UserRole 類,記錄User和Role的關聯關係

import grails.compiler.GrailsCompileStatic
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import static grails.gorm.hibernate.mapping.MappingBuilder.*

@GrailsCompileStatic
@EqualsAndHashCode(includes = 'authority')
@ToString(includes = 'authority', includeNames = true, includePackage = false)
@SuppressWarnings("unused")
class Role implements Serializable {

    private static final long serialVersionUID = 893264892

    String authority

    static constraints = {
        authority nullable: false, blank: false, unique: true
    }

    // IDEA 報錯的處理辦法,參考 https://stackoverflow.com/questions/60113220/grails-gorm-class-with-grailscompilestatic-annotation-shows-in-the-static-mappi
    static final mapping = orm {
        cache{
            enabled true
        }
    }
}

8. 添加 User 的密碼加密器

在 User 類的 password 字段保存到數據庫時,只能保存密碼 Hash 後的內容,而不是密碼原文,這個過程叫 Password Hashing。默認使用的算法是 bcrypt algorithm。這個功能是通過 GORM 的 Domain interceptor 實現的。

第一步,編寫一個 UserPasswordEncoderListener 類,放在 src/main/groovy 下。
第二步,將這個 listener bean 註冊到 spring 中。

UserPasswordEncoderListener.groovy

@CompileStatic
@SuppressWarnings(["unused", "SpringJavaAutowiredMembersInspection"])
class UserPasswordEncoderListener {

    @Autowired
    SpringSecurityService springSecurityService

    @Listener(User)
    void onPreInsertEvent(PreInsertEvent event) {
        encodePasswordForEvent(event)
    }

    @Listener(User)
    void onPreUpdateEvent(PreUpdateEvent event) {
        encodePasswordForEvent(event)
    }

    private void encodePasswordForEvent(AbstractPersistenceEvent event) {
        if (event.entityObject instanceof User) {
            User u = event.entityObject as User
            if (u.password && ((event instanceof PreInsertEvent) || (event instanceof PreUpdateEvent && u.isDirty('password')))) {
                event.getEntityAccess().setProperty('password', encodePassword(u.password))
            }
        }
    }

    private String encodePassword(String password) {
        springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password) : password
    }
}

註冊 bean,spring/resource.groovy

beans = {
    userPasswordEncoderListener(UserPasswordEncoderListener)
}

9. 配置 REST 登錄驗證地址

下面的配置可以放在 /config/application.groovy 也可以放在 /conf/application.yml

grails.plugin.springsecurity.rest.login.active = true
// 這個是登錄端點,也就是攔截器識別的登錄URL,看到這個地址,攔截器就會從請求中抽取用戶名、密碼進行登錄驗證
grails.plugin.springsecurity.rest.login.endpointUrl = /api/user/login
grails.plugin.springsecurity.rest.login.failureStatusCode = 401

10. 測試登錄請求

REST plugin 默認從請求的 json 串中抽取用戶名、密碼,所以要這樣發送登錄請求。
REST登錄請求

11. Debug

顯示 security 相關 filter 的日誌非常有用,可以幫助我們定位各種不生效的問題。

添加下面內容到 logback.groovy 文件即可。

logger("org.springframework.security", DEBUG, ['STDOUT'], false)
logger("grails.plugin.springsecurity", DEBUG, ['STDOUT'], false)
logger("org.pac4j", DEBUG, ['STDOUT'], false)

12. 其他

當登錄請求參數中沒有 userName、password 參數時,會報告 404 狀態碼,而不是參數無效。

關於 filter

REST 的 filter 主要有:

  1. restTokenValidationFilter - 查找請求中的token並進行校驗
  2. RestAuthenticationFilter - 判斷請求URL地址是否是登錄地址,是的話抽取用戶名、密碼進行身份驗證,返回 access_token
  3. restExceptionTranslationFilter - 將異常類轉換爲 REST 響應

項目 GIT 地址

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

歡迎給 git 項目點贊、分享。

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