用 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 项目点赞、分享。