用 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 串中抽取用戶名、密碼,所以要這樣發送登錄請求。
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 主要有:
- restTokenValidationFilter - 查找請求中的token並進行校驗
- RestAuthenticationFilter - 判斷請求URL地址是否是登錄地址,是的話抽取用戶名、密碼進行身份驗證,返回 access_token
- restExceptionTranslationFilter - 將異常類轉換爲 REST 響應
項目 GIT 地址
github https://github.com/yangbo/grails_tutorials.git
歡迎給 git 項目點贊、分享。