如何使用SAML和OAuth跨地区调用受保护的应用程序

279 阅读5分钟

本博文展示了如何在以用户为中心的场景中支持授权(范围、角色),即从不同子账户(在不同地区)的应用程序中调用 REST 端点。
使用的技术。SAP BTP, Cloud Foundry, XSUAA, SAML2, OAuth2, Destination, OAuth2SAMLBearerAsertion, Node.js,
本博文完全建立在之前博文:介绍教程中详细描述的场景之上。

快速链接:
快速指南
示例代码

内容

0.介绍
1.后台应用
2.前端应用程序
3.信任配置
4.目的地
5.运行
6.清理
附录1:后端应用程序的示例代码
附录2:前端应用程序的示例代码 前端应用程序的示例代码
附录3:目的地配置的示例代码

0.简介

在我们的例子中,我们有一个提供服务的应用程序,我们称之为 "后端应用程序",它被部署在新加坡地区的一个试用账户(ap21)。
我们想从一个 "前端应用 "中调用它,该应用被部署在不同地区的不同试用账户中(us10)。
我们这个场景的挑战是跨越不同子账户和地区的界限来验证一个用户。
解决方案是定义信任(基于SAML)并使用一个 "OAuth2SAMLBearerAssertion "类型的目标。

在之前的教程中,我们着重描述了如何配置信任和目的地,以实现对受OAuth保护的后端应用的认证。
但是,我们忽略了授权方面。
所以这就是今天的挑战:
除了用OAuth保护后端应用,我们还需要一个范围。而我们希望前端应用发送的JWT令牌中包含该范围。

如何解决这个难题?
解决方案比预期的要简单得多:
我们像往常一样进行,在后端子账户中,我们把角色集合分配给 "前端 "用户。
这是由于配置的信任而实现的。

下图显示了该场景和相关组件。

在图中我们可以看到,一个用户访问了前端应用程序。
这个用户被身份提供者知道,所以登录(通过approuter和xsuaa)是成功的。
前端应用程序调用了通过xsuaa保护的后端应用程序,并定义了一个范围。
这个范围包含在一个角色集合中。

第二个子账户定义了对前端子账户的信任。
因此,前端IDP的用户可以通过信任在后端使用。
所以前端用户可以在角色集合中配置。
就这样,当为前端用户获取JWT令牌时,它将包含后端范围。

基本上,这已经是本教程要传授的所有知识了。然而,好奇的读者可能希望通过下面的步骤描述。
大部分解释在以前的博客中给出,所以今天的教程会快得多。
如果没有删除,你可以重新使用以前的部署。
新读者可以安全地从头开始创建一切,并翻阅以前的帖子以获得解释。

前提条件

前面的教程和这个先决条件部分是先决条件。

准备工作

我们使用这里创建的项目。

1.创建后端应用程序

我们使用与上一个教程中相同的后台应用程序,但有两点不同。

  1. 我们在安全配置中定义一个范围。
  2. 我们在我们的应用程序代码中强制执行该范围。

1.0.准备工作

我们登录到代表后台的试用账户。
在我的例子中:
cf login -a api.cf.ap21.hana.ondemand.com -o backendorg

1.1.创建XSUAA服务实例

今天,我们不仅要用OAuth保护来保护我们的后端应用,还要用范围来保护。
,这可以确保只有被分配了授权的用户(由管理员)才能调用我们的端点。
我们加强安全描述符来定义一个范围,这个范围被包裹在一个角色模板中。
为了更方便,我们还定义了一个角色集合,可以分配给一个用户(由管理员)。

"scopes": [{
    "name": "$XSAPPNAME.backendscope"
}],    
"role-templates": [{
    "name": "BackendRole",
    "scope-references": ["$XSAPPNAME.backendscope"]
}],
"role-collections": [{
      "name": "Backend_Roles",
      "role-template-references": ["$XSAPPNAME.BackendRole"]

从头开始创建实例的命令:
cf cs xsuaa application backendXsuaa -c backend-security.json
更新现有实例的命令:
cf upd-service backendXsuaa -c backend-security.json

运行命令后,我们可以检查角色和角色集合在后端子账户仪表盘上是否可用。

1.2.创建后端应用程序

在我们的应用程序代码中,我们现在可以检查所需的范围是否在传入的JWT令牌中可用。

app.get('/endpoint', passport.authenticate('JWT', {session: false}), (req, res) => {
    const authInfo = req.authInfo  
    console.log(`===> [AUDIT] backendapp accessed by user '${authInfo.getGivenName()}' from subdomain '${authInfo.getSubdomain()}' with oauth client: '${authInfo.getClientId()}'`)
    const isScopeAvailable = authInfo.checkScope(UAA_CREDENTIALS.xsappname + '.backendscope')
    if (! isScopeAvailable) {
        // res.status(403).end('Forbidden. Missing authorization.') // Don't fail during prototyping
    } 

    res.json({
        'message': `Backend app successfully called. Scope available: ${isScopeAvailable}`,
        'jwtToken': authInfo.getAppToken()})
})

在第一个测试中,我使用了注释硬检查,而是在响应中发送信息。
,完整的示例代码可以在附录1中找到。

1.3.部署

部署后,我们可以记下服务端点的URL。
在我的例子中:
backend.cfapps.ap21.hana.ondemand.com/endpoint

2.创建前端应用程序

前端应用程序在前面的文章中已经解释过了,没有什么不同。
所以我们通过创建过程,没有进一步的评论。

2.0.准备工作

我们登录到用于前端的试用账户。
在我的例子中:
cf login -a api.cf.us10.hana.ondemand.com -o frontendorg

2.1.创建XSUAA服务实例

创建命令:
cf cs xsuaa application frontendXsuaa -c frontend-security.json

2.2.创建目的地服务实例

创建命令:
cf cs destination lite frontendDestination

2.3.创建核心应用程序

完整的示例代码可以在附录2中找到。

2.4.创建Approuter

完整的示例代码可以在附录2中找到。

2.5.部署

部署后,我们会得到应用程序的入口URL,但在所有配置完成前我们不会使用它。

3.配置信任

信任配置与前面教程中的描述没有区别。
简要描述。

3.1.前台子账户

从前端子账户下载IdP元数据->连接->目的地

3.2.后端子账户

在子账户配置信任 -> 安全 -> 信任配置 -> 新
名称:"Frontend_IDP"
可供用户登录:禁用

4.创建目的地

创建目的地在之前的博文中有详细描述。
所以今天我们可以直接在
frontend_subaccount -> Connectivity -> Destinations -> Import Destination
目的地配置可以从附录3中复制(和修改)。

#clientKey=<< Existing password/certificate removed on export >>
#tokenServicePassword=<< Existing password/certificate removed on export >>
Description=Destination pointing to backend app endpoint in backend account
Type=HTTP
authnContextClassRef=urn\:oasis\:names\:tc\:SAML\:2.0\:ac\:classes\:PreviousSession
audience=https\://backendsubdomain.authentication.ap21.hana.ondemand.com
Authentication=OAuth2SAMLBearerAssertion
Name=destination_to_backend
tokenServiceURL=https\://backendsubdomain.authentication.ap21.hana.ondemand.com/oauth/token/alias/backendsubdomain.azure-ap21
ProxyType=Internet
URL=https\://backend.cfapps.ap21.hana.ondemand.com/endpoint
nameIdFormat=urn\:oasis\:names\:tc\:SAML\:1.1\:nameid-format\:emailAddress
tokenServiceURLType=Dedicated
tokenServiceUser=sb-backendxsuaa\!t7722

导入后,无论如何,我们需要手动输入敏感信息:clientid/secret。
为了获得所需的信息,我们需要查看我们部署的后端应用程序的环境变量。
例如,通过这些命令:
cf login -a api.cf.ap21.hana.ondemand.com -o backendorg
cf env backend

然后找到XSUAA绑定的部分,将属性复制到目标配置中。
在我的例子中:
"clientid": "sb-backendxsuaa!t7722"
"clientecret": "msWms8tylSHWi4HJ7pTPhHNwaiM="

记住:
"客户端密钥" <-clientid
"令牌服务用户" <-clientid
"令牌服务密码" <-clientsecret

5.运行场景

在完成了两个必要的配置后,我们(仍然)不能调用我们的应用程序。

5.1.分配角色

我们首先需要把我们在安全配置文件中定义的角色分配给我们的用户。
今天,我们需要一个额外的步骤,我们需要在前端和后端都分配角色。

5.1.1.分配前台角色

我们的前端应用程序需要一个具有uaa.user范围的角色,如前面的教程所述。
为了分配这个角色,我们要登录到我们的前端试用账户。

5.1.2.分配后端角色

这一步是新的。
挑战:
我们有一个(前端)用户,他访问我们的(前端)应用程序,该应用程序在某个外国地区(如us10)的(前端)账户中运行。
这个用户只能在该账户中使用,不能在后端账户中使用。
所以我们不能在XSUAA级别使用 "grant "语句将后端角色分配给前端用户(如这里所述)。
尽管如此,解决方案是不同的,而且更简单。

在我们定义了对Frontend_Subaccount的IDP的信任之后,我们就可以访问这个身份提供者的用户了。
因此,我们可以继续前进,登录到后端子账户,将(前端)用户分配到我们(后端)应用中需要的(后端)角色。

  • 登录到我们的后端试用账户
  • 进入安全 -> 角色集合
    角色集合 "Backend_Roles "已经存在,BackendRole也已经配置好了。
  • 切换到 "编辑 "模式
  • 转到 "用户 "部分
  • 选择信任的 "Frontend_IDP"。
  • 在 "ID "字段和 "E-Mail "字段中输入(前端)用户的电子邮件。
  • 保存。

5.2.运行

为了启动流程,我们通过approuter URL进入我们的前端应用程序。
在我的例子中:
frontendrouter.cfapps.us10.hana.ondemand.com/tofrontend/…

结果,我们的浏览器应该成功显示4个JWT部分。

5.2.1.范围

JWT信息已经在之前的博文中详细解释过了。
今天,我们还有一个额外的快乐因素:范围信息。
我们可以看到,由后端应用定义和验证的backendscope已经进入了JWT令牌,由前端应用发送。
这让我们很高兴。
至少,当我第一次看到它的时候,我很高兴。

6.清理工作

前台子账户。

手动删除目标配置。
删除工件。

cf login -aapi.cf.us10.hana.ondemand.com-o frontendorg
cf d frontend -r -f
cf d frontendrouter -r -f
cf ds frontendXsuaa -f
cf ds frontendDestination -f

后台子账户。

手动删除信任配置。
删除工件。

cf login -aapi.cf.ap21.hana.ondemand.com-o backendorg
cf d backend -r -f
cf ds backendXsuaa -f

摘要

在今天的教程中,我们学习了如何在跨子账户的情况下配置授权(范围)。
前面的教程已经表明,在配置了子账户之间的信任后,认证是可能的
今天我们看到,我们可以在这个信任的基础上,为来自国外身份提供者的用户分配一个角色。因此,当一个前端用户登录到前端应用程序时,他将收到后端角色,通过目的地调用后端应用程序。

快速指南

  • 在后端应用中,我们定义一个范围和一个角色模板。
  • 在后端子账户中,我们用前端子账户的IDP元数据(连接)创建一个新的信任配置。
  • 在后端子账户中,我们现在可以把前端IDP的用户分配到后端角色集合中。

链接

见上一篇博文的链接部分。

附录1:后端应用程序的示例代码

backend-security.json

{
    "xsappname": "backendxsuaa",
    "tenant-mode": "dedicated",
    "scopes": [{
        "name": "$XSAPPNAME.backendscope"
    }],    
    "role-templates": [{
        "name": "BackendRole",
        "description": "Role required for Backend Application",
        "scope-references": ["$XSAPPNAME.backendscope"]
    }],
    "role-collections": [{
          "name": "Backend_Roles",
          "role-template-references": ["$XSAPPNAME.BackendRole"]
        }
    ]
}

manifest.yml

---
applications:
  - name: backend
    path: app
    memory: 64M
    routes:
    - route: backend.cfapps.ap21.hana.ondemand.com
    services:
      - backendXsuaa 

应用程序

package.json

{
  "dependencies": {
    "@sap/xsenv": "latest",
    "@sap/xssec": "latest",
    "express": "^4.17.1",
    "passport": "^0.4.0"  
  }
}

server.js

const xsenv = require('@sap/xsenv')
const UAA_CREDENTIALS = xsenv.getServices({myXsuaa: {tag: 'xsuaa'}}).myXsuaa

const express = require('express')
const app = express();
const xssec = require('@sap/xssec')
const passport = require('passport')
const JWTStrategy = xssec.JWTStrategy
passport.use('JWT', new JWTStrategy(UAA_CREDENTIALS))
app.use(passport.initialize())
app.use(express.json())


// start server
app.listen(process.env.PORT)

app.get('/endpoint', passport.authenticate('JWT', {session: false}), (req, res) => {
    const authInfo = req.authInfo  
    console.log(`===> [AUDIT] backendapp accessed by user '${authInfo.getGivenName()}' from subdomain '${authInfo.getSubdomain()}' with oauth client: '${authInfo.getClientId()}'`)
    const isScopeAvailable = authInfo.checkScope(UAA_CREDENTIALS.xsappname + '.backendscope')
    if (! isScopeAvailable) {
        //res.status(403).end('Forbidden. Missing authorization.') // Don't fail during prototyping
    } 

    res.json({
        'message': `Backend app successfully called. Scope available: ${isScopeAvailable}`,
        'jwtToken': authInfo.getAppToken()})
})

附录2:前端应用程序的示例代码

frontend-security.json

{
    "xsappname": "frontendxsuaa",
    "tenant-mode": "dedicated",
    "role-templates": [{
        "name": "uaaUserDefaultRole",
        "description": "Default role uaa.user required for user centric scenarios",
        "scope-references": ["uaa.user"]
    }],
    "role-collections": [{
          "name": "Frontend_Roles",
          "role-template-references": [ "$XSAPPNAME.uaaUserDefaultRole" ]
        }
    ]    
}

manifest.yml

---
applications:
  - name: frontend
    path: app
    memory: 64M
    routes:
    - route: frontend.cfapps.us10.hana.ondemand.com
    services:
      - frontendXsuaa
      - frontendDestination
  - name: frontendrouter
    routes:
    - route: frontendrouter.cfapps.us10.hana.ondemand.com
    path: approuter
    memory: 128M
    env:
      destinations: >
        [
          {
            "name":"destination_frontend",
            "url":"https://frontend.cfapps.us10.hana.ondemand.com",
            "forwardAuthToken": true
          }
        ]      
    services:
      - frontendXsuaa

应用程序

package.json

{
  "dependencies": {
    "@sap/destinations": "latest",
    "@sap/xsenv": "latest",
    "@sap/xssec": "^3.2.13",
    "express": "^4.17.1",
    "node-fetch": "2.6.2",
    "passport": "^0.4.0"
  }
}

server.js

const xsenv = require('@sap/xsenv')

const INSTANCES = xsenv.getServices({
    myXsuaa: {tag: 'xsuaa'},
    myDestination: {tag: 'destination'}
})
const XSUAA_CREDENTIALS = INSTANCES.myXsuaa
const DESTINATION_CREDENTIALS = INSTANCES.myDestination

const fetch = require('node-fetch')
const xssec = require('@sap/xssec')
const passport = require('passport')
const JWTStrategy = xssec.JWTStrategy
passport.use('JWT', new JWTStrategy(XSUAA_CREDENTIALS))
const express = require('express')
const app = express();
app.use(passport.initialize())
app.use(express.json())


// start server
app.listen(process.env.PORT)

 
// calling destination service with user token and token exchange
app.get('/homepage', passport.authenticate('JWT', {session: false}), async (req, res) => {

    const userJwtToken = req.authInfo.getAppToken()
 
    // instead of fetching token for destination service with client creds, we HAVE to use token exchange, must be user for princip propag
    const destJwtToken = await _doTokenExchange(userJwtToken)

    // read destination
    const destination = await _readDestination('destination_to_backend', destJwtToken)
    const samlbearerJwtToken = destination.authTokens[0].value

    // call backend app endpoint
    const response = await _callBackend(destination)
    const responseJson = JSON.parse(response)
    const responseJwtTokenDecoded = decodeJwt(responseJson.jwtToken)

    // print token info to browser
    const htmlUser = _formatClaims(userJwtToken) 
    const htmlDest = _formatClaims(destJwtToken) 
    const htmlBearer = _formatClaims(samlbearerJwtToken) 

    res.send(`  <h4>JWT after user login</h4>${htmlUser}
                <h4>JWT after token exchange</h4>${htmlDest}
                <h4>JWT issued by OAuth2SAMLBearerAssertion destination</h4>${htmlBearer}
                <h4>Response from Backend</h4>${responseJson.message}. The token: <p>${JSON.stringify(responseJwtTokenDecoded)}</p>`)

})


/* HELPER */

async function _readDestination(destinationName, jwtToken, userToken){
    const destServiceUrl = `${DESTINATION_CREDENTIALS.uri}/destination-configuration/v1/destinations/${destinationName}`
    const options = {
       headers: { Authorization: 'Bearer ' + jwtToken}
    }
    const response = await fetch(destServiceUrl, options)
    const responseJson = await response.json() 
    return responseJson
}

async function _doTokenExchange (bearerToken){
    return new Promise ((resolve, reject) => {
       xssec.requests.requestUserToken(bearerToken, DESTINATION_CREDENTIALS, null, null, null, null, (error, token)=>{
          resolve(token)
       })  
    })  
}

async function _callBackend (destination){  
    const backendUrl = destination.destinationConfiguration.URL
    const options = {
       headers: { 
          Authorization : destination.authTokens[0].http_header.value  // contains the "Bearer" plus space
       }
    }
    const response = await fetch(backendUrl, options)
    const responseText = await response.text()
    return responseText
}

function decodeJwt(jwtEncoded){
    return new xssec.TokenInfo(jwtEncoded).getPayload()
}

function _formatClaims(jwtEncoded){
    // const jwtDecodedJson = new xssec.TokenInfo(jwtEncoded).getPayload()
    const jwtDecodedJson = decodeJwt(jwtEncoded)
    console.log(`===> The full JWT: ${JSON.stringify(jwtDecodedJson)}`)
 
     const claims = new Array()
     claims.push(`issuer: ${jwtDecodedJson.iss}`)
     claims.push(`<br>client_id: ${jwtDecodedJson.client_id}</br>`)
     claims.push(`grant_type: ${jwtDecodedJson.grant_type}`)
     claims.push(`<br>scopes: ${jwtDecodedJson.scope}</br>`)
     claims.push(`ext_attr: ${JSON.stringify(jwtDecodedJson.ext_attr)}`)
     claims.push(`<br>aud: ${jwtDecodedJson.aud}</br>`)  
     claims.push(`origin: ${jwtDecodedJson.origin}`)
     claims.push(`<br>name: ${jwtDecodedJson.given_name}</br>`)
     claims.push(`xs.system.attributes: ${JSON.stringify(jwtDecodedJson['xs.system.attributes'])}`)
     return claims.join('')
}


approuter

package.json

{
    "dependencies": {
        "@sap/approuter": "latest"
    },
    "scripts": {
        "start": "node node_modules/@sap/approuter/approuter.js"
    }
}

xs-app.json

{
  "authenticationMethod": "route",
  "routes": [
    {
      "source": "^/tofrontend/(.*)$",
      "target": "$1",
      "destination": "destination_frontend",
      "authenticationType": "xsuaa"
    }
  ]
}

附录3:目的地配置的示例代码

destination_to_backend

#clientKey=<< Existing password/certificate removed on export >>
#tokenServicePassword=<< Existing password/certificate removed on export >>
#
#Fri Jun 10 07:09:11 UTC 2022
Description=Destination pointing to backend app endpoint in backend account
Type=HTTP
authnContextClassRef=urn\:oasis\:names\:tc\:SAML\:2.0\:ac\:classes\:PreviousSession
audience=https\://backendsubdomain.authentication.ap21.hana.ondemand.com
Authentication=OAuth2SAMLBearerAssertion
Name=destination_to_backend
tokenServiceURL=https\://backendsubdomain.authentication.ap21.hana.ondemand.com/oauth/token/alias/backendsubdomain.azure-ap21
ProxyType=Internet
URL=https\://backend.cfapps.ap21.hana.ondemand.com/endpoint
nameIdFormat=urn\:oasis\:names\:tc\:SAML\:1.1\:nameid-format\:emailAddress
tokenServiceURLType=Dedicated
tokenServiceUser=sb-backendxsuaa\!t7722