[译]构建与实现单点登录解决方案

现代的大多数Web应用一开始所有代码都是混合在一起,随着应用复杂度的增加,这些应用就被划分为许多“模块”。当然,也有些应用在开始开发的时候,工程师们就选择了使用SOA设计方法实现。无论是使用哪一种方式,我们运行这些多个独立的应用都需要无缝交互。在下文中,我将描介绍在实现单点登录服务时发现的一些高级别挑战和解决方案。

认证与授权(Authentication vs Authorization)

我希望这两个词不是一样的,因为它肯定让很多人混淆。我经常讨论的例子是OAuth。每次我开始谈论怎样实现中央认证系统时,就有人建议我使用OAuth。关键是,OAuth是授权系统,而不是认证系统。

这很棘手,因为您实际上可能使用了OAuth对网站进行“认证”。您真正在做的是允许网站使用OAuth服务器存储的信息。OAuth服务器确实提供了伪身份验证功能,但这并不是OAuth的主要目标:OAuth中的Auth代表授权而不是身份验证。

以下简要描述这两个词:

  1. 认证:识别您是谁
  2. 授权:知道您被允许做什么,或者让别人做什么。

如果您对自己的设计感到困惑,感觉存在一些错误,问问自己是否被这两个词困惑了。本文将仅仅讨论身份验证。

一般情况

[译]构建与实现单点登录解决方案

这可能是最常见的结构,虽然我通过使用不同的编程语言描绘三个主要应用使其稍微复杂一点。我们有三个Web应用在不同的子域上运行,并通过中央认证服务共享帐户数据。

目标:

  • 保持身份验证和基本帐户数据隔离。
  • 允许用户在浏览不同应用时保持登录状态。

实现这样一个系统应该很简单。也就是说,如果您将现有应用迁移到这样的体系结构中,您将花费80%的时间来将遗留代码从身份验证中分离出来,并且思考应该集中哪些数据以及应该分发哪些数据。不幸的是,我不能告诉您该做什么,因为这是非常具体的领域。相反,让我们看看如何做“容易的部分”。

集中和隔离共享帐户数据

在这点上,您可能会将每个应用都直接与包含用户帐户数据的共享数据库表进行通信。第一步是摆脱这种做法。我们需要一个单一的接口,它是创建或更新共享帐户数据的唯一切入点。我们在数据库中的一些数据可能是只属于特定应用的,因此应该保留在每个应用中,应用之间共享的任何内容应该移动到新接口之后。

您的中央认证系统通常会存储以下信息:

  • ID
  • 名字
  • 登录/昵称
  • 电子邮件
  • 密码hash
  • 盐值
  • 创建时间戳
  • 更新时间戳
  • 帐户状态(已验证,已停用…)

不要在每个应用中都重复这些数据,而是让每个应用根据帐户ID来查询应用中特定帐户的数据。从技术上讲,您可以使用ID作为条件的一部分来查询数据库来代替使用SQL连接的方式。

我的建议是做事可以慢慢来,但一定要准确。一步一步地迁移您的数据库,以确保一切正常。一旦其它部分到位了,您可以一次迁移一个代码API,直到您的整个代码库被移动。您也许希望将您的数据库凭据更改为仅具有读取权限来控制访问。

登录工作流程

我们的每个应用已经有一个用户登录方式。我们不想改变用户体验,而是要进行透明修改,以便集中进行认证检查,而不是以本地化方式进行修改。要做到这点,最简单的方法是保留当前的登录表单,表单的数据并不是发送到本地应用,我们将它们发送到中央认证API(强烈建议使用SSL)。

登录工作流程

如上图所示,登录表单提交到身份验证应用中的端点,它可能不仅包括了登录名、电子邮件、明文密码,还包括了隐藏的回调/重定向网址等信息,以便身份验证API可以将用户的浏览器重定向到原来的应用。出于安全考虑,您可能想要列出您允许身份验证应用重定向到的域。

在内部,认证应用将根据帐户数据中的匹配记录,使用散列处理后的密码来验证标识符(电子邮件或登录名)。如果验证成功,将生成包含某些用户数据(例如:id,名字,姓氏,电子邮件,创建日期,身份验证时间戳)的令牌。如果验证失败,则不会生成令牌。最后,用户的浏览器被重定向到请求中提供的回调/重定向URL,并传递令牌。

您可能希望能有一种方法来对数据进行安全加密并允许客户端验证和信任来自受信任来源的令牌。一个很好的方案就是是使用RSA加密,为所有的客户端应用提供公钥,但私钥仅在认证服务器上可用。当然,也可以采用其它强大的加密方案。例如,另一种适当的方法是向返回的参数添加一个签名。这样客户端就可以检查参数的真实性。HMAC或DSA签名是非常好的方法,但在某些情况下,您不希望人们看到您返回的数据内容。如果您返回的是一个“移动”令牌,那就更该如此。但这是一个不同的场景。需要考虑的是,我们需要一种方法来确保发送回客户端的数据不会被篡改。您也可以确保防止受到攻击。

应用接收到附带令牌参数的GET请求。如果令牌为空或无法解密,则认证失败。针对这一点,我们需要再次向用户显示登录页面,并让他们再次尝试。如果令牌可以解密,内容应该保存在会话中,以便之后的请求可以重用数据。

我们描述了身份验证的工作流程,但是如果用户登录到应用X中,他将不会在应用Y或Z中登录。这里的诀窍是设置一个顶级域名的cookie,以便对所有的子域上运行的应用都可见。当然这个解决方案只适用于同一个域的应用,但我们稍后会看到如何处理不同域上的应用。

登录工作流程

Cookie不需要包含大量数据,它的值可以只包含帐户ID,时间戳(当进行身份验证和信任签名的时候需要用到)和签名。签名在这里至关重要,因为cookie允许用户自动登录到其它站点。我建议使用HMAC或DSA加密生成签名。DSA加密非常像RSA加密,它是依赖于公钥/私钥的非对称加密。这比基于HMAC这样共享秘密的方式提供了更多的安全性。但采取哪一种方式是取决于您。

最后,我们需要在应用中设置一个过滤器。它将自动检查顶层域中是否存在auth cookie和缺少本地会话。如果是,则在Cookie完整性验证之后,使用Cookie值中的用户ID自动创建会话。我们还可以在所有应用程序之间共享会话,但在大多数情况下,每个应用存储的数据非常具体,并且保持隔离来变得更安全、更清晰。如果会话被隔离,那么运行在不同服务上的应用集成起来也将变得更加容易。

注册

对于注册,登录时,我们可以采取以下两种方法:将用户的浏览器指向认证API,或将我们的应用程序内的S2S(Server to Server)的调用指向认证应用。将表单数据直接发送到API是减少每个客户端应用重复逻辑和流量的好方法,因此我将演示此方法。

注册

正如您所看到的,这个方法和我们以前一样登录。不同之处在于,我们只是返回一些参数(id,email和潜在的错误)而不是返回一个令牌。重定向/回调URL也显然不同于登录。您可以决定对发回的数据进行加密,但在这种情况下,我将会创建一个domain.com域级别的验证cookie,以便“客户端”应用可以自动登录用户。在重定向中发送回来的信息用于重新显示附带了用户输入的错误信息和电子邮件的注册表单。

在这点上,我们的实现几乎完成了。我们可以使用定义的凭据创建一个帐户并登录。用户可以从一个应用切换到另一个应用而无需重新登录,因为我们使用的共享签名Cookie,它只能由身份验证应用来创建,并且可以由所有“客户端”应用进行验证。我们的代码是简单,安全和高效的。

更新、删除帐户

接下来我们需要更新或删除一个帐户。在这种情况下,这是需要在“客户端”应用和身份验证/帐户应用程序之间完成的。我们将使用S2S(Server to Server)通信。为了确保应用的安全性,并提供了一个很好的方式来记录请求,每个客户端使用API​​令牌/密钥与身份验证/帐户应用进行通信。API密钥可以使用X-header进行传递,因此这个关注点不在请求参数范围之内,我们的代码可以对通过X-header传递和实际的服务实现分别进行处理认证。S2S服务应该需要有一个过滤器来验证和记录发送密钥API的请求。其余的可以直接放行。

使用不同的域

到目前为止,我们假设所有的应用都在同一个顶级域。在现实中,您会经常发现自己使用的应用在不同的域中。这意味着您不能再使用共享签名Cookie的方式。然而,有一个简单的伎俩可以让您避免在切换应用程序时要求用户重新登录。

使用不同的域

诀窍在于在应用中使用一个不同域的iframe,当本地会话不存在时, iframe将从验证/帐户应用加载一个用于验证主顶级域上是否设置了有效的Cookie的页面。如果验证有效,我们可以告诉应用用户已经全局登录,并且告诉iframe通过认证令牌与认证流程一样的方式重定向到的应用程序终端。然后应用将创建一个会话,并将用户重定向到它开始的地方。下一个请求将会看到本地会话记录,这个过程在此忽略。

如果身份验证应用没有找到签名的cookie,iframe将显示登录表单,或者根据需要将iframe主机重定向到登录表单。

使用多个应用和域时需要牢记的是,您需要保持共享的Cookie/Session同步,这意味着如果您从一个应用注销了,还需要删除认证cookie以确保用户全局登出。(这也意味着您可能总是想使用iframe来检查登录状态和自动注销用户)。

移动客户端

实现SSO解决方案的另一个部分是处理移动客户端。移动客户端需要能够注册/登录和更新帐户。但是它与S2S服务客户端不同,移动客户端应该仅允许给特定的用户修改数据。为此,我建议在登录过程中提供不透明的移动令牌。然后可以在每个请求中通过X-header发送令牌,以便服务可以对发出请求的用户进行身份验证。同样,强烈建议使用SSL。

在这种方法中,我们不使用cookie,实际上不需要SSO解决方案,而是统一的认证系统。

编写Web服务

我们的认证/帐户应用是一个Web API应用程序。

我们有3套API:

  • 公共API:可以从任何地方访问,无需身份验证
  • S2S API:通过API密钥进行身份验证,仅适用于受信任的客户端
  • 移动API:通过移动令牌进行身份验证,范围有限。

我们不需要动态HTML视图,只需简单的Web服务相关代码。虽然这有点偏离话题,但我想花一点时间来向您展示我个人如何编写Web服务应用程序。

实现Web API时,我关心的最多的就是验证传入的参数。这是我在Sony索尼工作时所提出的一种认真的工作方法,我认为每次实现一个web API时都应该使用它。事实上,我写了一个Ruby DSL库(Weasel Diesel),它允许您描述给定的服务,其传入的参数和预期的输出。这个DSL被挂接到web后端,所以您可以使用诸如Sinatra或者Rails3之类的Web引擎实现服务。根据DSL的使用情况,在处理之前验证输入参数。另一个优点是您可以根据API描述以及自动化测试生成文档。

也许您可能熟悉Grape,它是另一种用于Web服务的DSL。除了明显的风格差异,Weasel Diesel还提供以下优势:

  • 输入验证/过滤
  • 服务隔离
  • 生成文档
  • 基于契约的设计

以下是使用Weasel Diesel和Sinatra实现的一个hello world的Web服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
describe_service "hello_world" do |service|
service.formats :json
service.http_verb :get
service.disable_auth # on by default

# INPUT
service.param.string :name, :default => 'World'

# OUTPUT
service.response do |response|
response.object do |obj|
obj.string :message, :doc => "The greeting message sent back. Defaults to 'World'"
obj.datetime :at, :doc => "The timestamp of when the message was dispatched"
end
end

# DOCUMENTATION
service.documentation do |doc|
doc.overall "This service provides a simple hello world implementation example."
doc.param :name, "The name of the person to greet."
doc.example "<code>curl -I 'http://localhost:9292/hello_world?name=Matt'</code>"
end

# ACTION/IMPLEMENTATION
service.implementation do
{:message => "Hello #{params[:name]}", :at => Time.now}.to_json
end

end

简单测试验证DSL中定义的契约和调用服务时的实际输出:

1
2
3
4
5
6
7
8
class HelloWorldTest < MiniTest::Unit::TestCase

def test_response
TestApi.get "/hello_world", :name => 'Matt'
assert_api_response
end

end

如果DSL及其功能让您能感到有点吸引力,并且您有兴趣挖掘更多信息,最简单的方法是fork此分支this demo repo并开始编写自己的服务。

DSL已经在生产中使用了一年多,但为了更加友好的用户体验,也做出有一定的调整和小的变化。有空可以fork DSL repo、发送PR给我。