应用授权开发指引

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
timestampDMHub生成的时间戳
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. 注意事项

  1. 项目模板中内置了Token对象,用于保存refresh token,位于grails-app/domain/app/Token.groovy中,可以按需增加字段。Token定义代码如下:

    package app
    
    class Token {
        Integer tenantId
        Date lastUpdated
        Date dateCreated
        String refreshToken
    }
  1. 在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“更改为开发者的名字。

  1. 定时刷新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()
        }
    
    }
  1. 调用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&timestamp=1337178173&state=f85632530bf277ec9ac6

将参数转换成字典:

{
 
  “code”: “0907a61c0c8d55e99db179b68161bc00”
 
  “hmac”: “f20ebaf94f9f50afbac7846afb146e6770f99b30f3a5e24615939eda2d92ac68”
 
  “timestamp”: “1337178173”
 
  “state”: “f85632530bf277ec9ac6”
 
}

将hmac和state移除并按键值排序:

{
 
  “code”: “0907a61c0c8d55e99db179b68161bc00”
 
  “timestamp”: “1337178173”
 
}

将键值对中的%替换成%25, &替换成%26, =替换成%3D,然后拼接成字符串,键与值用=隔开,多个键值对用&链接,如下:

code=0907a61c0c8d55e99db179b68161bc00&timestamp=1337178173

使用apache.commons.codec.digest.HmacUtils.hmacSha256Hex(),以client_secret做秘钥,上面字符做值,生成校验码,并比较两个值:

String secret = client_secret
 
String message = “code=0907a61c0c8d55e99db179b68161bc00&timestamp=1337178173”
 
String digest = HmacUtils.hmacSha256Hex(secret, message)
 
Assert.isTrue(digest, “f20ebaf94f9f50afbac7846afb146e6770f99b30f3a5e24615939eda2d92ac68”)