目录
应用授权开发指引
1. 开始之前
在开始开发之前,请确认已经在开放平台获取到了clientId, clientSecret,并设置了授权回调地址redirect_uri。
授权的流程如下:
在模板项目plugin-quick-starter中已经预置了授权的示例代码OauthController.groovy,并在UrlMapping.groovy中配置了相关的请求路径:
"/oauth2"(controller: "oauth", action: "index", method: "GET")
"/oauth2/callback"(controller: "oauth", action: "callback")
这两个接口将用于授权流程中DM Hub对应用服务的回调。
为什么要做授权?
做授权有两种情况。第一种是应用安装时的授权,获取相应的接口调用权限。第二种是在接口调用时进行鉴权,特别是对于部署在产品之外通过公网进行调用的情况,需要确保调用方是合法的用户。
对于第二种情况(主要针对公网部署的应用),由于在每次接口调用时都需要进行判断,最简单都方法是使用拦截器对这些请求进行拦截,然后进行统一的授权处理。在拦截器中,首先判断session中是否已经包含了用户信息,如果没有则说明这是一个新的session,或者session已经过期,这时需要发起授权;如果session 中已经存在了user信息,则说明该session已经授权过了,无需再次授权。
示例项目可以参考代码grails-app/controllers/interceptor/TenantInterceptor.groovy:
TenantInterceptor() {
matchAll()
//.except(uri: "/dist/**")
.except(uri: "/oauth2")
.except(uri: "/oauth2/callback")
.except(controller: 'ping', action: 'pong')
}
boolean before() {
/**
在公有云应用中,使用下面的代码来获取当前tenant和用户的信息。
*/
Map user = (Map)session.getAttribute('user')
if (!user) {
redirect(uri: "/oauth2?${request.queryString?:''}", absolute: true)
} else {
def tenantId
try{
log.info("get user: ${user as JSON}")
tenantId = user.tenantId as Long
} catch (Exception e) {
log.error ("invalid tenant id ${user as JSON}", e)
render status : 400, text : "invalid tenant id"
return false
}
currentTenant.set(tenantId)
}
return true
}
上面的例子中,建议对静态资源(例如前端页面)也进行拦截(上面的示例中已对.except(uri: "/dist/**")进行了注释),这样在访问页面时就可以对session是否过期进行判断。
如果是对接口调用进行拦截,此时redirect并不会生效,需要在前端页面进行跳转,此时可以返回重定向的信息给前端,由前端来完成授权重定向。为了兼容对页面和接口拦截的两种情况,可以做如下改动:
Map user = (Map)session.getAttribute('user')
if (!user) {
//根据request路径判断,如果是页面请求,则做重定向
redirect(uri: "/oauth2?${request.queryString?:''}", absolute: true)
//如果是接口调用,此处返回特定格式的返回值,可自行定义,以下返回值只是示例
render [uri: "/oauth2?${request.queryString?:''}"] as JSON
return false
}
当然也可以用两个interceptor,分别拦截页面请求和接口请求。在接口请求的情况下,前端页面在收到返回的结果时,来完成跳转。
对于部署在产品环境内部的私有部署应用,不需要每次进行授权,因此只需要考虑安装时的授权。
2. 授权开发流程
步骤1
当用户在应用市场点击安装应用时,DM Hub会跳转到开放平台中设置的授权回调地址redirect_uri,在模板项目中即为UrlMapping中定义的/oauth2,并带参数redirectUrl,即应用市场的链接。如果是对接口进行鉴权发起对授权,例如上面章节中interceptor中发起的授权,则不带redirectUrl。
应用在接收到/oauth2的请求后,需要区分这两种情况,并做相应的标识,授权流程结束后要根据该标识来决定跳转到哪个页面。建议的做法是使用state参数。可参考示例项目中grails-app/controllers/app/OauthController.groovy的index方法和grails-app/services/app/OauthService.groovy中的getCodeUrl方法。
OauthController.groovy:
def index() {
def url = oauthService.getCodeUrl(params.redirectUrl)
redirect(url: url)
}
OauthService.groovy:
//state设置为install,说明本次授权是安装时的授权
def state
//安装应用时,请求会带有参数redirectUrl
if(redirectUrl){
state = "install"
}
//没有redirectUrl则说明是非安装时的授权
else{
state = new Date().getTime().toString()
}
在上面的示例中,如果redirectUrl不为空,则是安装授权,将state设置为“install”,否则将state设置为其他值,如时间戳。然后将state连同其他参数一起传入下一步的请求中。
步骤2
应用在/oauth2请求中发起向DM Hub的授权请求,请求格式如下:
GET https://{app.convertlab.com}/oauth2/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code&state={state}
其中app.convertlab.com需替换为对应部署环境的app服务域名。如果需要支持DM Hub多云环境,请参考第4章节“DM Hub公有云多环境支持”部分,获取对应环境的授权域名。
请求参数说明如下:
参数名称 | 参数说明 |
---|---|
client_id | 在开放平台创建应用时获取的clientId |
redirect_uri | 必须以在开放平台中设置的授权回调地址为前缀 |
response_type | 该值必须为code字符 |
state | 应用服务提供的随机数,DMHub会原样回传,起到安全校验的效果 |
项目中的示例代码可以参考grails-app/controllers/app/OauthController.groovy和grails-app/services/app/OauthService.groovy。
OauthService.groovy中的示例代码为:
OAuthClientRequest request = OAuthClientRequest
.authorizationLocation("${appServerUrl}/oauth2/authorize")
.setClientId(clientId)
.setRedirectURI(getCallbackUri())
.setResponseType(ResponseType.CODE.toString())
.setState(state)
.buildQueryMessage()
return request.getLocationUri()
此处的appServerUrl即为产品的授权域名。
步骤3
DM Hub的授权服务接收到授权请求时,会跳转到授权页面(如果用户没有登录DMHub的话,会先提示用户登录),提示用户授权。用户授权完成后,会跳转到前一步授权请求参数中的redirect_uri,格式如下:
GET {redirect_uri}?code={code}&tamp={timestamp}&hmac={hmac}&state={state}
参数说明如下:
参数名称 | 参数说明 |
---|---|
code | 授权码,使用该授权码可以获取access_token |
timestamp | DMHub生成的时间戳 |
hmac | 由code和timestamp生成的校验码,校验码的生成过程在后面章节介绍 |
state | 前一步设置的state参数 |
应用在接收到该请求时需要根据code获取access token(见步骤4),并根据授权标识(安装授权或鉴权授权)来跳转到对应的页面(见步骤6)。
步骤4
应用根据code获取access_token,请求格式如下:
POST
https://{app.convertlab.com}/oauth2/token
Content-Type: application/x-www-form-urlencoded
Payload:
client_id={client_id}&client_secret={client_secret}&grant_type=authorization_code&code={code}&redirect_uri={redirect_uri}
其中app.convertlab.com需替换为对应部署环境的app服务域名。如果需要支持DM Hub多云环境,请参考第4章节“DM Hub公有云多环境支持”部分,获取对应环境的授权域名。
参数说明如下:
参数名称 | 参数说明 |
---|---|
client_id | 在开放平台创建应用时获取的clientId |
client_secret | 在开放平台创建应用时获取的clientSecret |
grant_type | 固定为authorization_code |
code | 上一步返回的code |
redirect_uri | 必须和第2步中设置的redirect_uri一致 |
返回的数据结构如下:
{
“access_token”: “your_access_token”,
“token_type”: “authorization_code”,
“expires_in”: 7200,
“refresh_token”: “your_refresh_token”
}
access_token的有效期为2小时,过期之前需要重新获取。refresh_token一定要保留好,用于在access_token过期之前获取新的access_token,丢失以后需要用户重新授权。
示例代码参考grails-app/services/app/OauthService.groovy中的getUserAndRefreshToken方法:
def url = "https://{appServerUrl}/oauth2/token"
OAuthClientRequest oAuthClientRequest = OAuthClientRequest
.tokenLocation(url)
.setGrantType(GrantType.AUTHORIZATION_CODE)
.setClientId(clientId)
.setClientSecret(clientSecret)
.setRedirectURI(getCallbackUri())
.setCode(code)
.buildQueryMessage()
OAuthClient oAuthClient = new OAuthClient(new URLConnectionClient())
def res = oAuthClient.accessToken(oAuthClientRequest)
log.info("getToken result ${res as JSON}")
def accessToken = res.accessToken
if (!accessToken) {
return CommonErrors.VALIDATION_ERROR.errorObject("get accessToken failed")
}
其中appServerUrl为对应部署环境的app服务域名。如果需要支持DM Hub多云环境,请参考第4章节“DM Hub公有云多环境支持”部分,获取对应环境的授权域名。
步骤5
获取DM Hub当前登录用户的信息,并存放在session中,后续调用应用服务接口时,应用服务应该检查session中的用户信息,对请求的合法性进行检查。项目中的示例代码可以参考grails-app/controllers/app/OauthController.groovy和grails-app/services/app/OauthService.groovy。
OauthService.groovy中的代码如下:
def getUserUrl = "https://{apiServerUrl}/v1/users/current"
OAuthClientRequest bearerClientRequest = new OAuthBearerClientRequest(getUserUrl).setAccessToken(accessToken).buildQueryMessage()
OAuthResourceResponse resourceResponse = oAuthClient.resource(bearerClientRequest, OAuth.HttpMethod.GET, OAuthResourceResponse.class)
log.info("get user result: ${resourceResponse.body}")
try {
def user = new JsonSlurper().parseText(resourceResponse.body)
if (user['tenantId']) {
saveToken(user['tenantId'], res.refreshToken)
} else if (user['error_code']) {
user = [error: [code: user['error_code'], message: user['error_description']]]
}
return user
} catch (Exception e) {
log.error("get user failed", e)
return CommonErrors.GENERAL_ERROR.errorObject("get user failed")
}
其中apiServerUrl为对应部署环境的open API域名。如果需要支持DM Hub多云环境,请参考第4章节“DM Hub公有云多环境支持”部分,获取对应环境的API域名。
OauthController.groovy中的代码如下:
def user = oauthService.getUserAndRefreshToken(request)
if (user['error']) {
return render(user as JSON)
}
session.setAttribute("user", user)
步骤6
应用在根据code获取access token完成后,需要根据步骤3的URL参数中的授权标识(state)来做对应的跳转。
如果是安装授权则跳转到应用市场,完成安装,跳转地址为:
https://{appServerUrl}/application/mktapp/index.html?install=cp_sample#/market 其中appServerUrl为对应部署环境的app服务域名。如果需要支持DM Hub多云环境,请参考第4章节“DM Hub公有云多环境支持”部分,获取对应环境的授权域名。
其中cp_sample为应用到ID,需要按需进行替换。
如果是鉴权安装,则直接跳转到应用首页。
跳转的示例代码参见OauthController.groovy中的callback方法:
def callback() {
//授权完成后调用该接口,参数为:code={code}&tamp={timestamp}&hmac={hmac}&state={state}
def user = oauthService.getUserAndRefreshToken(request)
if (user['error']) {
return render(user as JSON)
}
session.setAttribute("user", user)
//跳回应用市场,将cp_sample替换为应用到ID
if(params.state && params.state.equals("install"))
{
redirect(url: "${appServerUrl}/application/mktapp/index.html?install=cp_sample#/market")
}
//跳转到应用首页,注意更换为正确的路径
else{
redirect(url: "/dist/index.html")
}
}
步骤7
access_token过期前,使用refresh_token获取新的access_token,访问参数如下:
POST
https://{app.convertlab.com}/oauth2/token
Content-Type: application/x-www-form-urlencoded
payload:
client_id={client_id}&client_secret={client_secret}&grant_type=refresh_token&refresh_token={refresh_token}
其中app.convertlab.com需替换为对应部署环境的app服务域名。如果需要支持DM Hub多云环境,请参考第4章节“DM Hub公有云多环境支持”部分,获取对应环境的授权域名。
参数说明如下:
参数名称 | 参数说明 |
---|---|
client_id | 在开放平台创建应用时获取的clientId |
client_secret | 在开放平台创建应用时获取的clientSecret |
grant_type | 固定为refresh_token |
refresh_token | 获取access token时返回的refresh_token值 |
步骤8
根据access token调用DM Hub的open API。
3. 注意事项
项目模板中内置了Token对象,用于保存refresh token,位于grails-app/domain/app/Token.groovy中,可以按需增加字段。Token定义代码如下:
package app class Token { Integer tenantId Date lastUpdated Date dateCreated String refreshToken }
在grails-app/migrations/changelog-initial.groovy中有创建token表的示例,代码已注释,可按需去掉注释或新增changeset。源码如下:
databaseChangeLog = { // changeSet(author: "your name", id: "create table token") { // preConditions(onFail: "MARK_RAN") { // not { // tableExists(tableName: "token") // } // } // createTable(tableName: "token") { // column(autoIncrement: "true", name: "id", type: "BIGINT") { // constraints(primaryKey: "true") // } // column(name: "version", type: "BIGINT") { // constraints(nullable: "false") // } // column(name: "date_created", type: "DATETIME") // column(name: "last_updated", type: "DATETIME") // column(name: "tenant_id", type: "INT") // column(name: "refresh_token", type: "VARCHAR(255)") // } // } }
请将示例中author的默认值“your name“更改为开发者的名字。
定时刷新token的示例代码位于grails-app/jobs/app/TokenRefreshJob.groovy中,定时每6000秒(100分钟)刷新一次。对刷新接口的调用已注释,代码如下:
package app class TokenRefreshJob { def accessTokenCacheService static triggers = { simple name:"TokenRefreshJob", startDelay: 2000 , repeatInterval: 6000000 //refresh every 6000s } def execute(){ // log.info("==== Starting RefreshTokenJob ====") // accessTokenCacheService.refreshAll() } }
- 调用open API的接口示例代码参考grails-app/services/app/DmhubInternalServuce.groovy。
4. DM Hub公有云多环境支持
如果应用在私有云中部署,或者支持公有云,但是只对特定tenant提供服务,请忽略本节。
由于DM Hub的公有云产品部署了多个云环境,且对应不同的域名,如果公网上部署的应用需要支持DM Hub多个云环境,则需要按照下面的步骤进行特别处理。
4.1 在授权流程步骤2.1中,当用户在应用市场点击安装应用时,跳转到应用的/oauth2接口时,除了带有redirectUrl参数之外,还带有getOpenAppIdToken参数。该参数用来获取安装应用的云环境信息,是个一次性token,使用后即失效,且有效期为10分钟。
应用在从URL中获取到该参数后,需要调用开放平台的接口,获取安装该应用的云环境信息,并对域名进行一些安全校验。
根据token获取DM Hub云环境的方式为:
GET https://{extension.convertlab.com}/cloudinfo?token=xxxxx
参数说明:
token即为从URL中获取到的getOpenAppIdToken,为授权时临时生成的令牌,一次使用有效
返回值:
{
"openAppId": "xxxxx",
"authDomain": "app.convertlab.com",
"apiDomain": "api.convertlab.com",
"productType": "dmhub",
"productVersion": "1.78"
}
其中extension.convertlab.com需要替换为具体的域名。
应用在获取到返回值后,需要保存openAppId(DM Hub产品部署环境的唯一标识)和对应的授权域名(authDomain)、API域名(apiDomain)的对应关系,并且把openAppId存放到session中。后续接口调用时,根据openAppId来查找应用中保存的设置信息,找到对应的API域名进行调用。
同时,建议应用从请求头中获取到Referer,并和接口返回的授权域名进行对比,确保是一个合法的授权请求。
4.2 当在应用市场点击已安装的应用时,会打开该应用的首页,此时URL参数中也会带上openAppId。应用在发起向后台的请求时,也需要将该参数添加到请求的URL参数中,或放置在请求头中。
在章节1中的TenantInterceptor例子中,需要将拦截到的请求参数或请求头中的openAppId放置在session中。
4.3 应用在读写数据时,需要根据openAppId和tenantId进行区分。
5. 附录
使用HMAC校验码
*如果使用了org.apache.oltu.oauth2.client 库,会自动校验hmac
DMHub发送给第三方应用程序的请求都会带有hmac校验码,用于校验请求确实是从DMHub发送过来的。校验过程如下:
请求参数如下:
code=0907a61c0c8d55e99db179b68161bc00&hmac=f20ebaf94f9f50afbac7846afb146e6770f99b30f3a5e24615939eda2d92ac68×tamp=1337178173&state=f85632530bf277ec9ac6
将参数转换成字典:
{
“code”: “0907a61c0c8d55e99db179b68161bc00”
“hmac”: “f20ebaf94f9f50afbac7846afb146e6770f99b30f3a5e24615939eda2d92ac68”
“timestamp”: “1337178173”
“state”: “f85632530bf277ec9ac6”
}
将hmac和state移除并按键值排序:
{
“code”: “0907a61c0c8d55e99db179b68161bc00”
“timestamp”: “1337178173”
}
将键值对中的%替换成%25, &替换成%26, =替换成%3D,然后拼接成字符串,键与值用=隔开,多个键值对用&链接,如下:
code=0907a61c0c8d55e99db179b68161bc00×tamp=1337178173
使用apache.commons.codec.digest.HmacUtils.hmacSha256Hex(),以client_secret做秘钥,上面字符做值,生成校验码,并比较两个值:
String secret = client_secret
String message = “code=0907a61c0c8d55e99db179b68161bc00×tamp=1337178173”
String digest = HmacUtils.hmacSha256Hex(secret, message)
Assert.isTrue(digest, “f20ebaf94f9f50afbac7846afb146e6770f99b30f3a5e24615939eda2d92ac68”)