文章 Qiao Peng · 2 小时 前 8m read

使用OAuth2的通用RESTful业务操作

本文介绍如何在 InterSystems IRIS 中通过继承 EnsLib.HTTP.GenericOperation(或 EnsLib.REST.GenericOperation)实现 OAuth2.0 支持,包括 OAuth2.0 Client 配置、Access Token 自动获取与 Header 注入,适用于各类第三方 REST API 集成场景。


在企业集成项目中,我们经常需要通过 REST API 对接第三方平台,例如 CRM、支付系统、云服务和 Open API 网关。

这些接口大多数采用 OAuth 2.0 作为授权机制。

虽然 InterSystems IRIS 提供了功能强大的通用 HTTP / REST 业务操作类:

  • EnsLib.HTTP.GenericOperation
  • EnsLib.REST.GenericOperation

但目前它们不直接支持 OAuth2.0 Access Token 自动注入

本文将介绍一种常见且推荐的实现方式:

通过继承 GenericOperation类,自定义一个支持 OAuth2.0 的通用业务操作类(Business Operation)

实现以下能力:

  • 自动检查令牌(Token)是否有效
  • 自动获访问令牌(Access Token)
  • 自动注入授权头(Authorization Header)
  • 对业务调用透明

一、先理解 OAuth2.0 

先简单解释一下OAuth2.0,如果您已经知道,请直接跳过本章。

需要先明确一个常见概念:

OAuth2.0 不是认证(Authentication),而是授权(Authorization)

也就是说:

OAuth2.0解决的是授权问题,是给予用户访问令牌(access token)让它们可以访问受限的API。

如果是要解决单点登录、用户身份认证问题,那么用的是OpenID Connect。

OAuth2.0和OpenID Connect都提供令牌(token),但目的和类型是不同的:

类型 标准 用途 使用方
访问令牌(Access Token) OAuth2 访问 API 资源服务器(Resource Server)
身份令牌(ID Token) OpenID Connect 用户身份信息 客户端应用(Client Application)

OAuth2.0有多种授权类型(Grant Type),用于不同类型的应用场景,包括:

授权类型 访问用户资源 需要密钥 访问令牌刷新 说明
Authorization code 最常见的授权类型。授权服务器向客户端返回一个仅限一次使用的授权码。随后,客户端将该授权码兑换为访问令牌。
Authorization code with PKCE 基于代码交换的证明密钥(Proof Key for Code Exchange,简称 PKCE)是一种以安全为核心的 OAuth 授权类型。PKCE 背后的主要概念是“持有证明”。这基本上意味着,客户端应用在从授权服务器获取访问令牌之前,需要向其证明授权码是真实的。PKCE 流程包含代码验证器、代码挑战以及代码挑战方法。
Client credentials 此授权类型使用客户端凭据从资源服务器访问受保护的数据。它适用于机器间认证。
Implicit 在用户同意后,客户端应用程序会立即收到访问令牌。这种授权类型常见于客户端设备,因为此类设备无法安全地存储客户端凭据。它安全性弱与其它类型。
Device Code       该授权类型针对输入或显示能力有限的设备,例如智能电视。设备会向用户显示一个 URL 和一个设备代码。随后,用户在另一台设备上登录并输入该代码,之后该设备便会获取访问令牌。

通常OAuth2.0 服务器提供一系列端点(endpoints):

端点 说明
Authorization(必须的) 资源所有者向 OAuth 客户端授予访问受保护资源权限的授权 URL
Token(必须的) OAuth 客户端使用授权令牌兑换访问令牌和可选刷新令牌的令牌请求 URL
Issuer endpoint(必须的)/Server Discovery 发现 OAuth 2.0 / OpenID Connect 端点、功能、支持的加密算法和特性
Userinfo 用户信息端点是一个 OAuth 2.0 受保护资源,用于返回有关经过身份验证的最终用户的声明。这些声明通常由一个 JSON 对象表示,该对象包含每个声明的名称和值对集合。
Token introspection OAuth 客户端可通过该 URL 检查访问令牌
Token revocation 可用于撤销已发放给客户端的 OAuth 令牌的 URL
End session 可通过撤销 access_token 来结束会话的 URL。必须在 Authorization 头中提供该令牌,或使用会话 Cookie
Client registration 创建、访问、更新或删除客户端注册
...  

二、IRIS如何配置OAuth2.0 客户端

在我们介绍的用例中,InterSystems IRIS是作为OAuth 2.0的客户端的,所以我们要先配置OAuth2.0 客户端。

IRIS提供了完整的OAuth2.0客户端注册管理功能和管理页面。进入IRIS系统管理门户(SMP),选择系统管理>安全>OAuth 2.0>客户端

1)先配置相应的OAuth 2.0授权服务器信息

点击创建服务端描述按钮。

  1. 填写OAuth 2.0颁布者端点(Issuer endpoint),例如https://stg-id.singpass.gov.sg
  2. 选择IRIS本地的SSL/TLS配置
  3. 如果API颁布者端点同时是发现端点,可以直接点击发现并保存(Discover and Save)按钮,它会自动通过发现发现OAuth 2.0服务器的所有端点和JWT设置等,并自动完成注册。
  4. 如果API颁布者端点未提供发现端点能力,则点击人工(Edit)按钮,手动输入相应端点,并保存。注意,授权端点(Authorization endpoint)令牌端点(Token endpoint)是必须要提供的,如下图。

2)然后配置OAuth 2.0客户端信息

OAuth 2.0服务器描述列表中选择上一步创建的服务器描述,点击客户端配置按钮,然后点击新建客户端配置按钮。

在配置页面输入相关信息,以向OAuth2.0授权服务器提供客户端信息。根据授权服务器端授权类型,选择填写相关配置。我们以Authorization code类型为例,其最重要的是常规(General)客户端凭据(Client Credentials)设置。

2.1) 在常规(General)页面,以下是必须输入的:

  • 应用程序名称(Appplication name),你的应用名称
  • 客户端类型(Client Type),客户端类型
  • SSL/TLS配置(SSL/TLS configuration),用于加密通信的SSL/TLS设置
  • 授予类型(Required grant type),授权服务器的授权类型(Grant Type),示例是Authorization code
  • 身份验证类型(Authentication type),客户端需要如何向授权服务器证明自己身份,示例是form encoded body



2.2)在客户端凭据(Client Credentials)页面,以下是必须输入的:

  • 客户端 ID(Client ID) ,这是向OAuth 2.0服务器注册你的应用时获得的应用ID
  • 客户端密钥(Client secret) ,这是获得的应用密钥


 


三、创建支持 OAuth2 的通用业务操作类

EnsLib.HTTP.GenericOperation/EnsLib.REST.GenericOperation 非常适合做标准 HTTP/REST 调用,但当目标 API 使用 OAuth2 时,通常需要在请求头中加入:

Authorization: Bearer <access_token>

系统自带的 GenericOperation 并不会自动处理这一过程,因此需要自行实现:

  • Token 获取
  • Token 缓存
  • Token 刷新
  • Header 注入

我们可以创建EnsLib.HTTP.GenericOperation/EnsLib.REST.GenericOperation的子类来实现相应OAuth能力。

这个子类里,我们需要实现:

1)增加3个相关配置,这样可以在 Production 页面中直接配置它们:

  • OAuth: 是否需要使用OAuth
  • ApplicationName:注册过的应用名称。就是上一步注册的OAuth 2.0客户端应用名称
  • Scope:需要的应用权限,例如api.read

2)重载OnMessage 方法,将访问令牌(access token)加入HTTP头

示例:

Class QP.REST.GenericOperation Extends EnsLib.HTTP.GenericOperation [ System = 4 ]
{
/// 是否启用 OAuth
Property OAuth As %Boolean [ InitialExpression = 0 ];
/// OAuth Client 名称
Property ApplicationName As %String;
/// Scope
Property Scope As %String(MAXLEN = 200);

Parameter SETTINGS = "ReadRawMode,WriteRawMode,OAuth,ApplicationName,Scope";

/// 获得访问令牌并加入HTTP头,这是整个方案的核心
Method AddOAuthHeader()
{
	If (..OAuth=1) && (..ApplicationName '="")
	{
	// 检查当前 Token 是否有效
	Set isAuth=##class(%SYS.OAuth2.AccessToken).IsAuthorized(..ApplicationName,,..Scope,.accesstoken,.idtoken,.responseProperties,.error)
	Quit:$isobject(error)
	// 如果无 Token,则自动申请
	if accesstoken="" 
	{
	Set sc=##class(%SYS.OAuth2.Authorization).GetAccessTokenClient(..ApplicationName,..Scope,.properties,.error,.sessionId) 
	Set isAuth=##class(%SYS.OAuth2.AccessToken).IsAuthorized(..ApplicationName,,..Scope,.accesstoken,.idtoken,.responseProperties,.error)
	Quit:$isobject(error)
	}
	// 自动加入 Header
	Set:(isAuth) sc=##class(%SYS.OAuth2.AccessToken).AddAccessToken(..%HttpRequest,,,..ApplicationName)
	}
}

/// Invoke a remote HTTP Service given a generic HTTP request
Method OnMessage(pRequest As EnsLib.HTTP.GenericMessage, Output pResponse As EnsLib.HTTP.GenericMessage) As %Status
{
	Set tSC=$$$OK, ..%HttpRequest.Location="", ..%HttpRequest.AcceptGzip=0, ..%HttpRequest.FollowRedirect=0, ..%HttpRequest.ReadRawMode=..ReadRawMode, ..%HttpRequest.WriteRawMode=..WriteRawMode
	
	Do ..%HttpRequest.Reset()
	Do ..%HttpRequest.RemoveHeader("HOST"), ..%HttpRequest.RemoveHeader("USER-AGENT"), ..%HttpRequest.RemoveHeader("REFERER")
	#; Pass along selected HTTP headers
	Set (tReq,tURL,tCT,tLen,tNParams,tRawParams,tApp,tCfg)="", tDoNotPassThrough=","_$ZCVT(..%ExcludeOutboundHeaders,"L")_",host,cspapplication,ensconfigname,ensattribute,url,httprequest,httpversion,content-length,content-type,charencoding,translationtable,iparams,params,rawparams,"
	Set tHeaderKey="" For { Set tHeaderKey=pRequest.HTTPHeaders.Next(tHeaderKey) Quit:""=tHeaderKey Set tHeaderLwr=$ZCVT(tHeaderKey,"L")
	Set tPass=(tDoNotPassThrough'[(","_tHeaderLwr_",")) Set:tPass&&(tHeaderLwr?1"iparams_"1.N) tPass=0
	Do:tPass ..%HttpRequest.SetHeader(tHeaderKey,pRequest.HTTPHeaders.GetAt(tHeaderKey)) ; no need to handle multiple on one line
	Set:tHeaderLwr="httprequest" tReq=pRequest.HTTPHeaders.GetAt(tHeaderKey)
	Set:tHeaderLwr="url" tURL=pRequest.HTTPHeaders.GetAt(tHeaderKey)
	Set:tHeaderLwr="content-type" tCT=$TR(pRequest.HTTPHeaders.GetAt(tHeaderKey),$C(34,39)) ; remove possible " and '
	Set:tHeaderLwr="content-length" tLen=pRequest.HTTPHeaders.GetAt(tHeaderKey)
	Set:tHeaderLwr="iparams" tNParams=pRequest.HTTPHeaders.GetAt(tHeaderKey)
	Set:tHeaderLwr="rawparams" tRawParams=pRequest.HTTPHeaders.GetAt(tHeaderKey)
	Set:tHeaderLwr="cspapplication" tApp=pRequest.HTTPHeaders.GetAt(tHeaderKey)
	Set:tHeaderLwr="ensconfigname" tCfg=pRequest.HTTPHeaders.GetAt(tHeaderKey)
	}
	
	// 向http头增加访问令牌
	Do ..AddOAuthHeader()

	}

四、在 Production 中使用和配置这个业务操作

在将这个业务操作加入在 Production 后,可以通过新增的配置项对它进行配置:



总结

通过继承 EnsLib.HTTP.GenericOperation,我们可以非常优雅地为 通用REST业务操作增加 OAuth2.0 支持。