迟早会有一个大客户提出这个问题:我们的员工能否用公司账户登录你们的应用?他们指的不是又一个用户名和密码,而是他们已经用于其他一切事务的 Microsoft Entra ID、Okta 或 AD FS 登录方式,配合他们自己的密码策略、自己的双因素认证,以及一个在有人离职当天就能关闭账户的统一入口。
客户身份团队期望的答案是 SAML 2.0。在新登录组件概览一文中,SAML 只占了一段篇幅。这篇文章讲的是完整流程:TsgcSAMLServiceProvider 做了什么、登录页面和 Assertion Consumer Service 的代码、如何在常见身份提供商中注册你的应用程序,以及如何在今天就无需任何账户完成全部测试。
SAML 登录的工作原理
共有三方参与。你的应用程序是服务提供商(SP)。客户的目录是身份提供商(IdP)。浏览器在两者之间传递消息,因此你的服务器和 IdP 永远不会直接通信。
- 用户打开你的登录 URL。你的应用程序构建一个 AuthnRequest,并将浏览器重定向到 IdP。
- IdP 按照公司设定的任何密码、MFA 或条件访问规则完成用户登录。
- IdP 返回一个已签名的 SAMLResponse,浏览器将其 POST 到你的 Assertion Consumer Service(ACS)URL。
- 你的应用程序验证该响应,并为其中指明的用户创建自己的会话。
第四步正是 SAML 实现容易出错的地方,而这正是该组件为你完成的部分。
服务提供商,逐步讲解
TsgcSAMLServiceProvider 不是一个 HTTP 服务器。它负责构建和检查 SAML 消息,你需要从你的应用程序已有的服务器的请求处理程序中调用它,例如 TsgcWebSocketHTTPServer 或 TsgcHTTPServer。
- 描述你的应用程序。 设置
EntityID,即你应用程序的唯一名称(通常是其元数据 URL),以及AssertionConsumerServiceURL,即接收响应的 https URL。 - 描述身份提供商。 使用 IdP 元数据文档调用
LoadIdPMetadata。它会读取 IdP 的 entity ID、登录 URL 和绑定方式,以及每一个签名证书。如果没有元数据,则手动设置IdPEntityID、IdPSSOURL和IdPCertificates。 - 发送请求。
GetAuthnRequestRedirectURL返回浏览器应重定向到的 URL。对于只提供 HTTP-POST 绑定的 IdP,GetAuthnRequestPostForm会返回一个改为以 POST 方式提交请求的页面。 - 保存请求 id。 这两个方法都会返回新 AuthnRequest 的 id。将其保存在服务器上,以随机的 RelayState 或会话 cookie 作为键,并在响应到达时删除,以确保每个请求只能被回答一次。
- 处理响应。 在 ACS URL 处,使用收到的 SAMLResponse、RelayState 和保存的请求 id 调用
ProcessResponse。当返回True时,TsgcSAMLResult中保存着NameID、SessionIndex以及 IdP 发送的每个属性。当返回False时,ErrorMessage会说明原因,并触发OnSAMLError。
在启动时一次性完成 EntityID、AssertionConsumerServiceURL 和 LoadIdPMetadata 的设置后,登录页面和 ACS 可以放在一个请求处理程序中:
uses
sgcAuth_SAML_SP;
procedure TMyApp.OnCommandGet(AContext: TIdContext;
ARequestInfo: TIdHTTPRequestInfo; AResponseInfo: TIdHTTPResponseInfo);
var
vRelayState, vRequestID: string;
oResult: TsgcSAMLResult;
begin
if ARequestInfo.Document = '/saml/login' then
begin
// 1. send the browser to the identity provider
vRelayState := NewRelayState;
AResponseInfo.Redirect(FSAML.GetAuthnRequestRedirectURL(vRelayState,
vRequestID));
// 2. keep the request id, the response must answer it
AddPendingRequest(vRelayState, vRequestID);
end
else if (ARequestInfo.Document = '/saml/acs') and
SameText(ARequestInfo.Command, 'POST') then
begin
// 3. the browser posts SAMLResponse and RelayState back
vRelayState := ARequestInfo.Params.Values['RelayState'];
vRequestID := TakePendingRequest(vRelayState);
oResult := TsgcSAMLResult.Create;
try
if FSAML.ProcessResponse(ARequestInfo.Params.Values['SAMLResponse'],
vRelayState, vRequestID, oResult) then
begin
// 4. signed in: create your own session for this user
CreateUserSession(AResponseInfo, oResult.NameID, oResult.SessionIndex);
AResponseInfo.Redirect('/');
end
else
AResponseInfo.ResponseNo := 403; // log oResult.ErrorMessage
finally
oResult.Free;
end;
end;
end;
NewRelayState、AddPendingRequest、TakePendingRequest 和 CreateUserSession 代表你自己的代码:一个 GUID、一个以 RelayState 为键、每个请求 id 只发放一次的线程安全列表,以及你应用程序的会话 cookie。属性以 Name=Value 的行到达,因此 oResult.Attributes.Values['email'] 可以按名称读取其中一个。Entra ID 使用诸如 http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress 这样的 claim URI 来命名它们。
RelayState 不受 IdP 签名的保护。应将其用作查找你自己待处理请求的键,而绝不能用作未经检查就重定向的 URL。
在身份提供商中注册你的应用程序
GetMetadata 返回服务提供商的元数据:你的 entity ID 以及带有 HTTP-POST 绑定的 ACS URL。将其发布在诸如 /saml/metadata 这样的 URL 上,或保存为文件,然后提供给 IdP。每个身份提供商都会要求相同的两个值,即 SP 的 entity ID 和 ACS URL,因此以下说明主要是关于每个控制台将它们保存在何处。在所有情况下,都应保持断言加密处于关闭状态。
- Microsoft Entra ID。 Enterprise applications、New application、Create your own application (non-gallery)。在 Single sign-on 中选择 SAML,然后上传 SP 元数据或填写 Identifier (Entity ID) 和 Reply URL。分配用户或组,并加载 SAML Certificates 中显示的 App Federation Metadata Url。
- Okta。 Applications、Create App Integration、SAML 2.0。Single sign-on URL 是你的 ACS URL,并勾选“Use this for Recipient URL and Destination URL”,Audience URI 是你的 entity ID。添加 email、firstName 和 lastName 等 attribute statements,分配人员或组,并从 Sign On 选项卡加载 Metadata URL。
- AD FS。 添加一个 claims aware Relying Party Trust 并导入 SP 元数据。AD FS 只接受 https 端点。添加发送 Name ID 的 claim rules,例如将 E-Mail-Addresses 作为 E-Mail Address 发送,然后将 E-Mail Address 转换为 Name ID。IdP 元数据位于
https://<adfs-host>/FederationMetadata/2007-06/FederationMetadata.xml。 - Google Workspace。 Admin console、Apps、Web and mobile apps、Add custom SAML app。下载 IdP 元数据,输入你的 ACS URL 和 entity ID,选择 Name ID(例如主邮箱),并为你的用户启用该应用。
- Keycloak。 创建一个 Client ID 为你的 entity ID 的 SAML client,或导入 SP 元数据。Keycloak 默认对整份文档签名,因此还要启用 Sign assertions。如果启用了 Client signature required,则设置
SignAuthnRequests、SPCertificate和SPPrivateKey。IdP 元数据位于https://<host>/realms/<realm>/protocol/saml/descriptor。
无论是哪个 IdP,最后一步都是一样的:将其元数据传给 LoadIdPMetadata。当文档描述了多个实体时,第二个参数用于选择你的那个。
ProcessResponse 检查了什么
SAML 响应是一份已签名的 XML 文档,而大多数广为人知的 SAML 漏洞,都是想办法让服务提供商读取到与实际签名内容不同的东西。只有当以下每一项检查都通过时,响应才会被接受:
- 对照 IdP 证书验证签名。 响应仅使用
IdPCertificates中的证书进行验证。消息中内嵌的证书永远不会被信任,因为攻击者同样可以内嵌一个。使用默认值WantAssertionsSigned时,断言必须携带自己的签名。 - 防范签名包装(signature wrapping)攻击。 签名必须引用一个在文档中 ID 唯一的元素,验证之后只会读取被签名的那个元素。塞在已签名断言旁边的未签名断言永远不会被读取。
- 单一断言。 响应必须恰好包含一个断言,且直接位于响应之下。
- 受众(audience)与接收方(recipient)。 受众必须是你的
EntityID,接收方必须是你的AssertionConsumerServiceURL,从而拒绝为另一个应用程序签发的断言。 - 时间窗口。 NotBefore 和 NotOnOrAfter 会与 UTC 进行比对,容差为
ClockSkew秒,默认两分钟。MaxAssertionAge还可以限制断言的最大存续时间。 - InResponseTo。 响应必须回答你保存的那个请求 id。除非设置了
AllowIdPInitiated,否则未经请求、由 IdP 发起的响应会被拒绝。 - 重放缓存。 每个已接受断言的 ID 会一直保留到过期为止,因此同一份响应被提交两次会被拒绝。该缓存是线程安全的,存在于内存中。当多台服务器共用登录时,重写
DoAddToReplayCache,将 ID 保存到共享存储中。 - 默认关闭 SHA-1。 RSA-SHA1 签名和 SHA-1 摘要默认会被拒绝,除非为仍需要它们的 IdP 设置
AllowSHA1。
解析器还会拒绝 DOCTYPE 声明,因此不存在外部实体,并且会限制文档的大小和嵌套深度。颁发者(issuer)必须是你所配置的那个 IdP。第一项失败的检查会停止验证,其原因会记录在 ErrorMessage 中:记录下来,并向用户显示一个简单的“登录失败”页面。
无需账户即可试用
你不需要 Entra ID 租户就能看到 SAML 的运行效果。Mock SAML 是 mocksaml.com 上提供的一个免费测试身份提供商。它接受任意服务提供商,并从 AuthnRequest 中获取受众和 ACS URL,因此无需任何注册。
演示程序 Demos\26.Authentication\03.SAML_ServiceProvider 是一个基于 TsgcWebSocketHTTPServer 的完整服务提供商,在 http://localhost:8090 上提供 /login、/acs 和 /metadata 端点:
- 编译该演示程序,并将 libcrypto-3.dll 和 libssl-3.dll 保留在可执行文件旁边。它们位于演示程序文件夹中,OpenSSL 用它们验证 RSA 签名。
- 点击 Load IdP metadata。默认来源是 mocksaml.com 的元数据 URL。
- 点击 Start,然后点击 Open Browser,并按照登录链接操作。
- 在 mocksaml.com 上,输入 example.com 域下的任意用户名和任意密码。
- 浏览器返回到 ACS,页面会显示 NameID、SessionIndex 以及 id、email、firstName 和 lastName 等属性。
确认可以正常工作后,打开 http://localhost:8090/metadata,将其注册到你真实的 IdP,在演示程序中加载 IdP 元数据,然后再次登录。对于 AD FS,请先在启用 SSL 的情况下运行该演示程序,因为 AD FS 只接受 https。
当前的限制
- 不支持加密断言。 带有 EncryptedAssertion 或加密 NameID 的响应会被拒绝。请在 IdP 中保持断言加密处于禁用状态。断言仍会被签名,并通过 https 传输。
- 不支持单点注销(Single Logout)。 SLO 尚未实现。
SessionIndex会被返回,以便你的应用程序结束自己的会话并构建自己的注销流程。
文档
获取方式
TsgcSAMLServiceProvider 包含在 sgcWebSockets 的 Enterprise 和 All-Access 版本中,支持 Delphi 和 C++ Builder,同一个组件也是 sgcWebSockets .NET 的一部分。如果你只需要身份验证功能,sgcAuth 套件连同其他登录组件一起提供了它。所在单元为 sgcAuth_SAML_SP,在你将该组件放到窗体上之前,现有应用程序不会有任何变化。
延伸阅读
观看视频
在 eSeGeCe 频道上有一段简短视频,标题为“SAML single sign-on in Delphi with Entra ID, Okta and AD FS”。视频展示了 IDE 中的代码,以及使用该演示程序针对 mocksaml.com 进行的实时登录。
有疑问、反馈,或需要帮助接入你的身份提供商?联系我们。你会收到编写这些代码的人给出的回复。
