组件测试:改建遗留系统的起点

在遗留系统中工作,无论是开发新功能,还是对旧功能进行修改,抑或是通过重构以期重拾其往日的雄风,都会面临大量的挑战。这些挑战主要来自于流失的业务知识、失传的技术和腐坏的代码等。一般来说,改建遗留系统通常会先对其添加必要的测试,再开展重构和重新设计等一系列工序,从而提升其内建质量。

Martin Fowler 在微服务的测试策略的分享中,详细讨论了各种测试方法及其适用场景。在该讨论中,他介绍了组件测试

组件是在大型系统中封装良好的、可独立替换的中间子系统。对这样的组件进行单独的测试有很多好处,通过将测试范围限制在组件之内,就能在对组件所封装的行为进行验收测试的同时,维持相较于高层测试更好的执行效率。在微服务架构中,组件也就是服务本身。

Martin Fowler 还按照测试时调用组件的方式,以及在对组件所依赖的存储或服务构建测试替身时,测试替身位于进程内部还是进程外部来把组件测试分为进程内和进程外两种形态。

实践中,为遗留系统添加单元测试和端到端的界面测试都会遇到其对应的困难,而我们发现组件测试却能由于其关注行为的特点在单元测试和端到端测试之间取得平衡,对于改建遗留系统来说,它提供了一个不错的起点。

避开单元测试实践的被动

遗留系统从最初发布到现在,早已过去多年,当初的开发人员早已离开,徒留一段代码给后来者。在遗留系统上的工作通常要求不能破坏现有其他功能,只能按要求“恰好”地修改。作为敏捷开发人员,第一步的计划就是使用单元测试来保障已有功能不被破坏。但团队很快就会发现遗留系统使用的技术失传已久,新的团队中基本没人了解,要基于这样的技术来构建单元测试寸步难行。对于一个没有任何自动化测试的老旧系统来说,往往也意味着其内部设计耦合度之高,想理解清楚就已经很吃力了,更遑论可测试性。

在下面的示例代码里,我们无法方便地对其中的 StockService 中所依赖的 WebClient 实例进行模拟,从而无法对 GetUpdatedStock 的功能进行测试:

  public class StockService {
    public IEnumerable<Stock> GetUpdatedStock(){
        var stockContent = new WebClient().DownloadString("https://stocks.com/stocks.json");
        var stocks = JsonConvert.DeserializeObject<List<StockResponse>>(stockContent);
        return stocks.Select(ToStock);
    }

    private static Stock ToStock(StockResponse resp)
    {
        //  对象转换逻辑略
    }
  }

另一方面,在老旧系统上的开发工作,往往也意味着接下来需要对其进行较大规模的重构,以利于更好的可维护性,更轻松地添加新功能。在这种背景之下,即使为系统添加了单元测试,接下来的重构又会使得细粒度的单元测试成为一种浪费——重构势必要修改代码设计,导致单元测试也需要跟着一起修改。

相比于单元测试的矛盾,组件测试关注 Web 应用本身的功能和行为,而不是其中某个单独的层次。实际上,很多遗留系统甚至连清晰的层次化设计都没有。组件测试对 Web 应用公开的 API 或 Web 页面源码测试,在避免陷入代码细节设计不良带来的被动局面的同时,能够保障 Web 应用的行为的正确性,而这也正是我们为遗留系统添加单元测试想保障的。组件测试关注的是业务行为,而不是代码实现细节。因此不会随着代码实现细节的变化而受到影响。所以组件测试不会限制重构手法的施展,也不会在调整设计时带来额外的修改测试的负担,相反它却可以给重构提供有力的保障,帮助确保重构的安全性。

绕过端到端界面测试的窘境

在改建遗留系统开展的实践中,不少团队为了摆脱单元测试的被动局面,尝试过为其添加端到端界面自动化测试的策略。这样几乎可以完全忽略代码细节,而直接关注业务场景。相对来说,只需要能做到自动地部署 Web 应用和必要的依赖(比如数据库),就可以对应用开展测试了。但实际执行过程中,团队发现要为老旧系统构建这样的一种环境,并不容易。端到端集成测试需要在真实的 Web 应用程序实例上运行测试,并且要求各项基础设施也尽可能地真实,包括数据库、缓存设施等。因此,要想让端到端的集成测试在持续集成环境自动地运行,就要求应用程序及其所依赖的基础设施有自动化部署的能力。老旧系统往往自动化程度很低,无法自动完成部署以开展端到端集成测试。即使 Web 应用本身的部署并不复杂,它依赖的其他服务也很难自动地部署,比如 SMTP 服务器等。

测试金字塔中,端到端测试界面测试位于较高的层次。这意味着即使成功地构建了自动化的环境,还是会由于测试所依赖的资源较多,造成测试成本相对较高的状况。由于端到端测试集成了系统的多个层次,测试用例的运行也就比低层次的测试用例更脆弱,而运行速度也会更慢。

这些挑战和特点决定了我们很难在短时间里为遗留系统添加足够的端到端界面测试案例以保障接下来的改建工作。在开展组件测试时,则完全不需要担心端到端测试的这些问题。组件测试通过一定的方法模拟并隔离 Web 应用的外部依赖,避免了复杂的部署和配置外部依赖的过程。更小型、专用的模拟层的启动和运行速度都可以根据测试的需求来定制;如果采用进程内的组件测试,更是可以进一步提高测试案例的运行效率与稳定性。

组件测试最佳实践

把 Web 应用本身看作单元测试中的被测试的单元,将 Web 应用的外部依赖都用测试替身进行模拟和隔离,并按业务场景测试组件中提供的 API 或 Web 页面的行为,即为组件测试。在进程内组件测试的实践方法中,我们直接在测试代码中自动地构建 Web 应用所需的依赖项,启动被测试的服务,然后调用要测试的 API 并执行断言。下面的代码演示了这样的测试的大体流程:动态地创建一个关系型数据库,启动 Web 应用,利用 Web 应用中 repository 的准备测试所需的数据,然后调用被测试的接口并对结果进行断言。

[Fact]
public async void should_handle_search_request_with_mocked_database()
{
   var sqliteConnection = DatabaseUtils.CreateInMemorryDatabase(out var databaseOptions);
   var appServices = SetupApplication(sqliteConnection, out var client);

   var jim = new Employee {Id = 12, Name = "Jim"};
   appServices.GetService<IRepository<Employee>>().Save(jim);

   var response = await client.GetAsync("/employees/search/im");
   var employeeString = await response.Content.ReadAsStringAsync();

   Assert.Equal("Jim(id=12)", employeeString);
}

在进程内运行的组件测试,可以选择以合适的方式对 Web 应用的依赖进行模拟。以数据访问层为例,我们可以直接对 DAO 类进行模拟,也可以在需要测试事务支持的时候为测试构建真实数据库实例,并在测试运行结束时清理这些临时创建的资源。既能享受上文所述的行为测试的稳定性,又可以获得代码级模拟的灵活性。

具体地,由于要在测试代码中按需启动应用程序,这对 Web 应用程序的基础设施提出一些要求。如果我们基于 ASP.NET WEB API 或者 Spring Boot 等框架开发应用,那么框架就已经提供了这种能力。对数据层进行模拟时,在简单的情形中可以采用内存重新实现的 RepositoryStub,必要时也可以采用内存中运行的嵌入式数据库,例如 SqlLiteH2 数据库,并且使用数据框架动态地在数据库创建必要的表结构(Schema),Entify Framework Code First 以及 Hibernate 等流行的 ORM 框架均具有这样的能力。对于外部的 HTTP 依赖,同样可以采用临时实现的 Stub 对象,也可以采用社区中流行的 mockhttpClient-driver 这样的工具库。这里,本文也准备了一份简单的示例程序供读者参考,提供了 C# 版本Java 版本 可用。

组件测试在形式上看,是一种单元测试,而从测试范围上看,它又是一种集成测试,在一些场合,我们形象地把它理解为“集成的单元测试”。但它与单元测试的关注点是有所区分的。在编写组件测试的用例时,不要过于关注代码逻辑细节,而应该从业务场景出发关注 Web 应用的行为。比如在一个用户注册的 API 进行测试时,可针对注册 API 成功的场景测试给出的响应是正确的,并给用户发送了一封确认邮件,而不是向 API 提供多个用户名用例并测试哪些用户名是合法的(那些应该由测试用户名验证程序的单元测试覆盖)。

与进程内组件测试相比,进程外的组件测试则直接对部署后的服务进行测试,更具有集成性,但由于进程外的组件测试在运行之前需要对 Web 服务进行部署和启动,因而其成本更大;测试运行时由于需要通过网络调用,所以效率也会相对较低。所以在进程外运行组件测试并没有什么优势。它只是在进程内组件测试无法高效开展时的一种妥协。除非要改建的遗留系统的外部依赖无法高效地基于代码进行设置、不能通过代码在进程内启动,否则应该优先采用进程内的组件测试。

总结

没有人愿意每天与遗留系统为伍,但总有些约束让我们不得不妥协。基于遗留系统开展工作,总是会遇到很多挑战。在实践中,我们发现在遗留系统的改建过程中,组件测试总是能够在我们遭遇困境时,给出令人满意的答案。

在实践组件测试时,如果一开始不能做到在进程内进行组件测试,可以先从进程外开展,而后逐步实现更稳定高效的进程内组件测试。需要注意的是,组件测试在改建遗留系统的过程中,能成为在现时约束下的一种可贵折衷。但它并不能代替其他类型的测试,我们依然需要借助其他类型的测试来对应用进行更完整的保障。组件测试只测试了应用(组件)内部的行为,因而在必要时可能要采用契约测试等方式来关注系统间的交互行为的正确性。在开发新功能时,我们还是要优先考虑成本最小、最利于保障系统设计的单元测试;而在保障业务场景时,必要的端到端界面测试依然是必不可少的。


更多精彩洞见,请关注微信公众号:思特沃克

Share

登录工程:现代Web应用中的身份验证技术

“登录工程”的前两篇文章分别介绍了《传统Web应用中的身份验证技术》,以及《现代Web应用中的典型身份验证需求》,接下来是时候介绍适应于现代Web应用中的身份验证实践了。

登录系统

首先,我们要为“登录”做一个简要的定义,令后续的讲述更准确。之前的两篇文章有意无意地混淆了“登录”与“身份验证”的说法,因为在本篇之前,不少“传统Web应用”都将对身份的识别看作整个登录的过程,很少出现像企业应用环境中那样复杂的情景和需求。但从之前的文章中我们看到,现代Web应用对身份验证相关的需求已经向复杂化发展了。

我们有必要重新认识一下登录系统。登录指的是从识别用户身份,到允许用户访问其权限相应的资源的过程。举个例子,在网上买好了票之后去影院观影的过程就是一个典型的登录过程:我们先去取票机,输入验证码取票;接着拿到票去影厅检票进入。取票的过程即身份验证,它能够证明我们拥有这张票;而后面检票的过程,则是授权访问的过程。之所以要分成这两个过程,最直接的原因还是业务形态本身具有复杂性——如果观景过程是免费匿名的,也就免去了这些过程。

在登录的过程中,“鉴权”与“授权”是两个最关键的过程。接下来要介绍的一些技术和实践,也包含在这两个方面中。虽然现代Web应用的登录需求比较复杂,但只要处理好了鉴权和授权两个方面,其余各个方面的问题也将迎刃而解。在现代Web应用的登录工程实践中,需要结合传统Web应用的典型实践,以及一些新的思路,才能既解决好登录需求,又能符合Web的轻量级架构思路。

解析常见的登录场景

在简单的Web系统中,典型的鉴权也就是要求用户输入并比对用户名和密码的过程,而授权则是确保会话Cookie存在。而在稍微复杂的Web系统中,则需要考虑多种鉴权方式,以及多种授权场景。上一篇文章中所述的“多种登录方式”和“双因子鉴权”就是多种鉴权方式的例子。有经验的人经常调侃说,只要理解了鉴权与授权,就能清晰地理解登录系统了。不光如此,这也是安全登录系统的基础所在。

鉴权的形式丰富多彩,有传统的用户名密码对、客户端证书,有人们越来越熟悉的第三方登录、手机验证,以及新兴的扫码和指纹等方式,它们都能用于对用户的身份进行识别。在成功识别用户之后,在用户访问资源或执行操作之前,我们还需要对用户的操作进行授权。

在一些特别简单的情形中——用户一经识别,就可以无限制地访问资源、执行所有操作——系统直接对所有“已登录的人”放行。比如高速公路收费站,只要车辆有合法的号牌即可放行,不需要给驾驶员发一张用于指示“允许行驶的方向或时间”的票据。除了这类特别简单的情形之外,授权更多时候是比较复杂的工作。

在单一的传统Web应用中,授权的过程通常由会话Cookie来完成——只要服务器发现浏览器携带了对应的Cookie,即允许用户访问资源、执行操作。而在浏览器之外,例如在Web API调用、移动应用和富 Web 应用等场景中,要提供安全又不失灵活的授权方式,就需要借助令牌技术。

令牌

令牌是一个在各种介绍登录技术的文章中常被提及的概念,也是现代Web应用系统中非常关键的技术。令牌是一个非常简单的概念,它指的是在用户通过身份验证之后,为用户分配的一个临时凭证。在系统内部,各个子系统只需要以统一的方式正确识别和处理这个凭证即可完成对用户的访问和操作进行授权。在上文所提到的例子中,电影票就是一个典型的令牌。影厅门口的工作人员只需要确认来客手持印有对应场次的电影票即视为合法访问,而不需要理会客户是从何种渠道取得了电影票(比如自行购买、朋友赠予等),电影票在本场次范围内可以持续使用(比如可以中场出去休息等)、过期作废。通过电影票这样一个简单的令牌机制,电影票的出售渠道可以丰富多样,检票人员的工作却仍然简单轻松。

从这个例子也可以看出令牌并非什么神奇的机制,只是一种很常见的做法。还记得第一篇文章中所述的“自包含的Cookie”吗?那实际上就是一个令牌而已,而且在令牌中写有关于有效性的内容——正如一个电影票上会写明场次与影厅编号一样。可见,在Web安全系统中引入令牌的做法,有着与传统场合一样的妙用。在安全系统中,令牌经常用于包含安全上下文信息,例如被识别的用户信息、令牌的颁发来源、令牌本身的有效期等。另外,在必要时可以由系统废止令牌,在它下次被使用用于访问、操作时,用户被禁止。

由于令牌有这些特殊的妙用,因此安全行业对令牌标准的制定工作一直没有停止过。在现代化Web系统的演进过程中,流行的方式是选用基于Web技术的“简单”的技术来代替相对复杂、重量级的技术。典型地,比如使用JSON-RPC或REST接口代替了SOAP格式的服务调用,用微服务架构代替了SOA架构等等。而适用于Web技术的令牌标准就是Json Web Token(JWT),它规范了一种基于JSON的令牌的简单格式,可用于安全地封装安全上下文信息。

OAuth 2、Open ID Connect

令牌在广为使用的OAuth技术中被采用来完成授权的过程。OAuth是一种开放的授权模型,它规定了一种供资源拥有方与消费方之间简单又直观的交互方法,即从消费方向资源拥有方发起使用AccessToken(访问令牌)签名的HTTP请求。这种方式让消费方应用在无需(也无法)获得用户凭据的情况下,只要用户完成鉴权过程并同意消费方以自己的身份调用数据和操作,消费方就可以获得能够完成功能的访问令牌。OAuth简单的流程和自由的编程模型让它很好地满足了开放平台场景中授权第三方应用使用用户数据的需求。不少互联网公司建设开放平台,将它们的用户在其平台上的数据以 API 的形式开放给第三方应用来使用,从而让用户享受更丰富的服务。

OAuth在各个开放平台的成功使用,令更多开发者了解到它,并被它简单明确的流程所吸引。此外,OAuth协议规定的是授权模型,并不规定访问令牌的数据格式,也不限制在整个登录过程中需要使用的鉴权方法。人们很快发现,只要对OAuth进行合适的利用即可将其用于各种自有系统中的场景。例如,将 Web 服务视作资源拥有方,而将富Web应用或者移动应用视作消费方应用,就与开放平台的场景完全吻合。

另一个大量实践的场景是基于OAuth的单点登录。OAuth并没有对鉴权的部分做规定,也不要求在握手交互过程中包含用户的身份信息,因此它并不适合作为单点登录系统来使用。然而,由于OAuth的流程中隐含了鉴权的步骤,因而仍然有不少开发者将这一鉴权的步骤用作单点登录系统,这也俨然衍生成为一种实践模式。更有人将这个实践进行了标准化,它就是Open ID Connect——基于OAuth的身份上下文协议,通过它即可以JWT的形式安全地在多个应用中共享用户身份。接下来,只要让鉴权服务器支持较长的会话时间,就可以利用OAuth为多个业务系统提供单点登录功能了。

我们还没有讨论OAuth对鉴权系统的影响。实际上,OAuth对鉴权系统没有影响,在它的框架内,只是假设已经存在了一种可用于识别用户的有效机制,而这种机制具体是怎么工作的,OAuth并不关心。因此我们既可以使用用户名密码(大多数开放平台提供商都是这种方式),也可以使用扫码登录来识别用户,更可以提供诸如“记住密码”,或者双因子验证等其他功能。

汇总

上面罗列了大量术语和解释,那么具体到一个典型的Web系统中,又应该如何对安全系统进行设计呢?综合这些技术,从端到云,从Web门户到内部服务,本文给出如下架构方案建议:

推荐为整个应用的所有系统、子系统都部署全程的HTTPS,如果出于性能和成本考虑做不到,那么至少要保证在用户或设备直接访问的Web应用中全程使用HTTPS。

用不同的系统分别用作身份和登录,以及业务服务。当用户登录成功之后,使用OpenID Connect向业务系统颁发JWT格式的访问令牌和身份信息。如果需要,登录系统可以提供多种登录方式,或者双因子登录等增强功能。作为安全令牌服务(STS),它还负责颁发、刷新、验证和取消令牌的操作。在身份验证的整个流程的每一个步骤,都使用OAuth及JWT中内置的机制来验证数据的来源方是可信的:登录系统要确保登录请求来自受认可的业务应用,而业务在获得令牌之后也需要验证令牌的有效性。

在Web页面应用中,应该申请时效较短的令牌。将获取到的令牌向客户端页面中以httponly的方式写入会话Cookie,以用于后续请求的授权;在后绪请求到达时,验证请求中所携带的令牌,并延长其时效。基于JWT自包含的特性,辅以完备的签名认证,Web 应用无需额外地维护会话状态。

在富客户端Web应用(单页应用),或者移动端、客户端应用中,可按照应用业务形态申请时效较长的令牌,或者用较短时效的令牌、配合专用的刷新令牌使用。

在Web应用的子系统之间,调用其他子服务时,可灵活使用“应用程序身份”(如果该服务完全不直接对用户提供调用),或者将用户传入的令牌直接传递到受调用的服务,以这种方式进行授权。各个业务系统可结合基于角色的访问控制(RBAC)开发自有专用权限系统。

作为工程师,我们不免会考虑,既然登录系统的需求可能如此复杂,而大家面临的需求在很多时候又是如此类似,那么有没有什么现成(Out of Box)的解决方案呢?自然是有的。IdentityServer是一个完整的开发框架,提供了普通登录到OAuth和Open ID Connect的完整实现;Open AM是一个开源的单点登录与访问管理软件平台;而Microsoft Azure AD和AWS IAM则是公有云上的身份服务。几乎在各个层次都有现成的方案可用。使用现成的产品和服务,能够极大地缩减开发成本,尤其为创业团队快速构建产品和灵活变化提供更有力的保障。

本文简单解释了登录过程中所涉及的基本原理,以及现代Web应用中用于身份验证的几种实用技术,希望为您在开发身份验证系统时提供帮助。现代Web应用的身份验证需求多变,应用本身的结构也比传统的Web应用更复杂,需要架构师在明确了登录系统的基本原理的基础之上,灵活利用各项技术的优势,恰到好处地解决问题。

登录工程的系列文章到此就全部结束了,欢迎就文章内容提供反馈。


更多精彩洞见,请关注微信公众号:思特沃克

Share

登录工程:现代 Web 应用的典型身份验证需求

朋友就职于某大型互联网公司。前不久,在闲聊间我问他日常工作的内容,他说他所在部门只负责一件事,即用户与登录。

而他的具体工作则是为各个业务子网站提供友好的登录部件(Widget),从而统一整个网站群的登录体验,同时也能令业务开发者不用花费额外的精力去关注用户鉴权。这很有趣。

可以看出,在一个现代Web应用中,围绕“登录”这一需求,俨然已经衍生出了一个新的工程。不管是我们面临的需求,还是解决这些需求所运用的方法与工具,都已经超出了传统Web应用身份验证技术的范畴。

之前一篇文章中,我聊到传统Web应用中的身份验证技术,文章中列出的一些方法在之前很长一段时间内,为满足大量的Web应用中身份验证的需求提供了思路。在这篇文章里,我将简要介绍现代Web应用中几种典型的身份验证需求。

形式多样的鉴权

考虑这样一个场景:我们在电脑上登录了微软账号,电脑里的“邮件”应用能够自动同步邮件;我们登录Web版本的Outlook邮件服务,如果在邮件里发现了重要的工作安排,将其添加到日历中,很快电脑里的“日历”应用便能够将这些日程显示到Windows桌面上。

这个场景包含了多个鉴权过程。至少涉及了对Web版本Outlook服务的鉴权,也涉及了对离线版本的邮件应用的鉴权。要能够支持同一批用户既能够在浏览器中登录,又能够在移动端或本地应用登录(例如 Windows UWP 应用程序),就需要开发出能够为两种应用程序服务的鉴权体系。

在浏览器里,我们通常假设用户不信任浏览器,用户通过与服务器建立的临时浏览器会话完成操作。会话开始时,用户被重定向到特定页面进行登录。登录完成后,用户通过持续与服务器交互来延续临时会话的时长;一旦用户一段时间不与服务器交互,则他的会话很快就会过期(被服务器强制登出)。

在移动应用中,情况有所不同。相对来说,安装在移动设备中的应用程序更受用户信任,移动设备本身的安全性也比浏览器更好。另一方面,将用户重定向到一个网页去登录的做法,并不能提供很好的用户体验——更重要的是,用户在使用移动设备时,时间是碎片化的。我们无法要求用户必须在特定时间内完成操作,也就基本没有会话的概念:我们需要找到一种能够安全地在设备中相对持久地存储用户凭据的方法,并且Web应用服务器可能需要配合这种方式来完成鉴权。此外,移动设备也不是绝对安全的,一旦设备丢失,将给用户带来安全风险。所以需要在服务器端提供一种机制来取消已登录设备的访问权限。

(图片来自:http://docs.identityserver.io/en/release/intro/big_picture.html)

方便用户的多种登录方式

“输入用户名和密码”作为标准的登录凭据被广泛用于各种登录场景。不过,在Web应用、尤其是互联网应用中,网站运营方越来越发现使用用户名作为用户标识确实给网站提供了便利,但对用户来说却并不是那么有帮助:用户很可能会忘记自己的用户名。

用户在使用不同网站的过程中,为了不忘记用户名,只好使用相同的用户名。如果恰好在某个网站遇到了该用户名被占用的情况,他就不得不临时为这个网站拟一个新的用户名,于是这个新用户名很快就被忘记了。

在注册时,越来越多的网站要求用户提供电子邮箱地址或者手机号码,有的网站还支持让用户以多种方式登录。比如,提供一种让用户在使用了一种方式注册之后,还能绑定其他登录方式的功能。绑定完成之后,用户可以选用他喜欢的登录方式。它隐含了一个网站与用户共同的认知:联系方式的拥有者即为用户本人,这种“从属”关系能够用于证实用户的身份。当用户下次在注册新网站时遇到“邮件地址已被注册”,或者“手机号已被注册”的时候,基本可以确定自己曾经注册过这个网站了。

(图片来自:http://cargocollective.com/)

另外,登录过程中所支持的联系方式也呈现出多样性。电子邮件服务在很多场景中逐渐被形式多样的其他联系方式(比如手机、微信等)所取代,不少人根本没有使用邮件的习惯,如果网站只提供邮箱注册的途径,有时候还会遭到那些不经常使用电子邮箱的用户的反感。所以支持多种登录方式成为了很多网站的迫切需求。

双因子鉴权:增强型登录过程

上一节中提到的“从属”关系不光可以帮助用户判断自己是否注册过一个网站,也可以帮助网站在忘记密码时进行临时认证,从而帮助用户完成新密码的设置。如果将这种从属关系用于正常登录过程中的进一步验证,就构成了双因子鉴权。

双因子鉴权要求用户在登录过程中提供两种形式不同的凭据,只有两种验证都成功才能继续操作。现代化Web应用正在越来越多地使用这种增强型验证方式来保护关键操作的安全性。例如,查看和修改个人信息,以及修改登录密码等。

相信不少人还记得QQ密码保护问题的机制,它使得盗号者即使盗取了QQ密码,在不知道密码保护问题的情况下,也无法修改现有密码,让账号拥有者得以及时挽回损失。

双因子的原理在于:两种验证因子性质不一致,冒用身份者同时获得用户这两种信息的机率十分低,从而能有效地保护账号的安全。在QQ密码保护的例子里,密码是一种每次登录时都会使用的固定文本、相对容易被盗;而密码保护问题却是不怎么频繁设置和更改的、隐秘的、个人关联性极强的,不容易被盗。

(图片来自:http://bit.ly/2kFc492)

现代化Web应用形式多样,设备种类繁多,场景复杂多变,而为了更好地保护用户账号的安全,很多应用开始将双因子验证作为登录过程中的鉴权步骤。而为了兼具安全和便利的特点,一些应用还要求运用一些优化策略以提高用户体验。比如,仅在用户在新的设备上登录、一段时间未登录之后的再次登录、在不常用的地点登录、修改联系信息和密码、转移账户资产等关键操作时要求双因子鉴权。

单点登录:还是需要精心设计

以前,一般只有大型网站、向用户提供多种服务的时候(比如,网易公司运营网易门户和网易邮箱等多种服务),才会有单点登录的迫切需求。但在现代化Web系统中,无论是从业务的多元化还是从架构的服务化来考虑,对服务的划分都更细致了。

从整个企业的业务模式(例如网易门户和网易邮箱),到某项业务的具体流程(例如京东订单和京东支付),再到某个流程中的具体步骤(例如短信验证与支付扣款),“服务”这一概念越来越轻量级,于是人们不得不创造了“微服务”这个新的品类词汇来拓展认知空间。

(图片来自:http://cargocollective.com/)

在这整个的演变过程中,出于安全的需要,身份验证的需求都是一直存在的,而且粒度越来越细。以前我们更关注用户在多个子站点的统一登录体验,现在我们还需要关注用户在多个子流程中的统一登录体验,以及在多个步骤中的统一登录体验。而这些流程和步骤,很可能是独立的Web系统(微服务),也有可能是一个用户界面(独立应用),还有可能是一个第三方系统(接口集成)。

可以说,单点登录的需求有增无减,只不过当开发者对这种模式已经习以为常,不再意识到这也是一个能够专门讨论的话题。

考虑与用户系统集成,与业务系统分离

在讨论安全时,分不开的两个部分就是鉴权(Authentication)与授权(Authorization)。

鉴权的过程是向用户发起质询(Challenge),完成身份验证工作。这正是登录所解决的问题。通常在登录系统成功识别用户之后,就会将接下来的工作直接交给业务系统来完成。由于各个系统中的授权模型可能与业务形态有关系,因此登录与业务系统分离是很自然的设计。

在对安全要求更严格的企业或企业应用中,可能需要专门的访问管理机制,不过,这样的做法在互联网应用中很少见。但在互联网Web应用中,授权的范畴也包含一个很小的公有部分,是各个业务系统所共有的:即用户状态。我们希望在各业务子系统之间共享用户状态:用户被锁定之后,他在所有业务系统都被锁定;用户被注销之后,所有业务系统中有关他的数据都被封存。

(图片来自:http://cargocollective.com/)

另外在多个业务系统中,还可能会共用用户的基本资料和偏好设置等数据。比如,类似于邮件地址这样的资料,它可以作为登录凭据,也可以作为一个基本的联系方式。如果用户在一个子系统设置了偏好语言,其他子系统则直接使用该设置即可。这样,开发一个“用户”系统的想法也就应运而生了。由于与用户的状态等基础信息的关系很紧密,登录与用户系统之间的集成是很自然的,将登录子系统直接作为这个用户系统的一部分也不失为一种不错的实践。

与第三方集成:迎接更多用户

“即得”是一个开放式文档共享应用,特点是“无需登录,即传即得”,它利用长时间有效的Cookie来标识用户,从而免除了人们使用应用之前必须注册登录的繁琐步骤。

这种做法的风险是,如果用户有及时清理浏览器Cookie的习惯,那很可能导致用户再一次登录时不再被识别。不过从这样一个小例子中,却容易看出登录的真正作用,就是Web应用识别用户的过程,当下次同一个用户再次使用时,Web应用就能够知道“这就是上次来过的那个用户”。

如果识别用户这一需求能够在不需要用户注册的前提下搞定,岂不两全齐美?基于第三方身份提供方的接口来识别已经在其他平台注册的用户,并将其转化为自己应用中的用户,这种方式完全可行,并且大量的开发人员已经有了丰富的实践。

从 2010 年开始就有不少的大型互联网公司开始推出开放平台服务,让第三方应用通过Web接口与这些互联网服务交互,从而为他们提供更丰富多彩的功能。在这个过程中,一些应用不为这些平台提供扩展,却巧辟蹊径地利用了这些开放平台的身份识别接口来免除新用户注册的过程,从而为自己的产品快速导入用户。不少网站都提供“使用微博账号登录”功能,相信读者一定体验过。

(图片来自:http://bit.ly/2kFi3e8)

如果你的应用需要向第三方提供用户,那么我们的角色就由“从上下文中读取用户身份”变成了“向上下文中写入用户身份”了。如果你正好有过与各互联网公司开放平台的接口打交道的经历,这时候,你就可以体验一把提供开放、安全上下文的挑战了。如果……你的平台既希望让其他平台的用户能够平滑接入,又希望向其他平台公开自己的用户,那可能是另一番更有趣的挑战。这个过程,也可以作为生物验证之外的另一种间接消除密码的实践方式吧。

登录,现在实实在在地成为了一个独立的工程。尤其在形态多样的基于Web的应用,以及这些Web应用本身所依赖的各色后端服务快速生长的过程中,各种鉴权需求随之而来。如何在保障各个环节中安全的同时,又为用户提供良好的体验,成为一个挑战。

另外,个人信息泄露的事件频繁被曝光,它们导致的社会问题也开始被更多人关注和重视,作为IT系统支撑者的工程师们有责任了解事关安全的基础知识,并掌握必要的技能去保护用户数据和企业利益。

我会在接下来的文章中介绍解决典型登录需求的具体技术方案,以及相关领域的安全实践常识。


更多精彩洞见,请关注微信公众号:思特沃克

Share

开发人员的客户思维

都说产品与开发之间的矛盾由来已久。在很多互联网企业,都发生过类似这样的一幕:

工程师日以继夜,终于在约定的时间里交付产品——虽然这在产品经理看来可能还只能算个高保真的原型。产品经理体验了这个原型之后,发现一些与期待不符的地方,提出了改进意见。工程师带着泛起充满自信的笑容,再次进入了封闭的开发阶段。

programmers-and-users

类似这样的过程持续往复下去,开发工程师和产品经理对对方的耐心都会受到挑战:

产品:新的方案也就是改了一种排列方式,数据都是一样的,再花点时间不就能搞定了么?

开发:你知道上次那个推荐算法,我花了多久才做出来的么?你说改就改?

产品:可我已经跟老板回复了,说咱们三天就能搞定!

开发:……

在互联网企业里,开发人员作为产品的直接生产者,地位受到优待;工程师作为“创客”所具有的自豪感及自信心也理所应当。直到随着项目的持续,业务越来越复杂,工程师终于不能在期待的时间里顺利交付功能,即使加班加点已在不知不觉中成为习惯。

开发人员与客户思维

在大量的团队里,大家表面看似春意盎然、合作愉快,实际却危机四伏。问题的原因可能很复杂,而从开发人员的角度来说,一个很重要的因素在于开发人员普遍缺乏客户思维。

2-mind

这样的开发人员也能交付能够工作的产品,但从产品设计人员的角度来说,要么他们交付的产品在细节上与需求有较大出入(或多或少,或错),要么就是花费了大量时间,却没人知道他们在做什么,也无法估计一项需求到底需要多久才能开发完成。

开发人员大多有相似的特性,他们擅长解决问题,却不擅长与人沟通。甚至一些人还有“技术至上”的自负心理,认为测试人员和业务分析师等其他角色可有可无。这或许与他们理工科的成长背景有一定的关系。“因为、所以、得证” 这是数学里常见的论证步骤,理工科的同学们擅长运用已有命题推理出一个个新的命题,这一特点在软件开发人员这里有着很好的体现。那些曾在算法练习中用过的代码片断就像一段段积木,当产品设计人员提出一个想法,开发人员就心生一计:这事儿没问题!似乎,接下来就缺时间了。

3-need-more-time

事实却不会那么简单。一个需求的提出,必然有其商业上的考量,其所在的业务场景、适用的范围和限制,以及要实现的可度量目标。在实现过程中,还需要考虑不同的解决方案,各个方案中可能存在的风险,以及需要投入的成本。在团队中,只有所有人都对业务有一致的理解,所有的努力都朝着一致的方向,才有可能获得成功。

有客户思维的开发人员,能够把工作当作为客户提供服务:自己是服务提供方,而同事、老板就是客户。他们积极地从客户角度思考需求的真正来源,在开发过程中与客户保持沟通,适时给出合理的建议。最终在更高效完成工作的同时,建立更顺畅的协作机制,培养出更健康友好的团队关系。客户思维也能够培养开发人员转变视角的习惯和能力,令其习惯于分析价值并作出决策,既而为职业和事业的发展带来更多可能。

思考并沟通

当接到一个新的需求,无论是初次提及,还是后续反馈,首先要思考的是为什么会有这个需求产生,它解决了什么问题、提供了什么价值。虽然开发人员很聪明,却很容易忽略这样一个其实很简单的部分。大部分开发人员的思维方式真的就如同数学证明那样,习惯于接受指令并醉心于实现一些看起来很酷的功能。

4-cool

然而,如果一开始不弄清楚需求的前因后果,就会出现在做了一半、甚至完成了之后,才发现最终得到了一个与设计人员的期待并不符合的产品。其他情况,由于开发团队内部理解不一致导致接口不兼容、由于前期没有沟通清楚而导致返工浪费等情况更是数不胜数。

举一个实际发生过的例子。

作为一个基于浏览器来管理的电商网站运营方,产品经理希望管理员能够在浏览器中即时收到网站用户下的新订单,而不再需要隔一段时间去刷新浏览器,以便做好发货准备。

在拿到这样的需求之后,工程师很兴奋。他开始着手研究服务器推送的各种技术,并深陷其中不可自拔,学习了长轮循、WebSocket等技术。三天过去了,他终于成功地完成了相关开发工作,急切地找产品经理要演示其进展。可没想到,产品经理却并不买账,没等工程师演示,就黑着脸向他回复,“这三天里,我两次向你询问进展,你都说‘快了’。可我一直没见什么动静。后来,我已经请旁边的阿哲搞定了,他只花了一小时!”

5-what

工程师转向阿哲,却发现阿哲用了一个每隔5秒向服务器再取一次数据的“笨方法”。工程师感到委屈不已,向产品经理解释自己的方案比阿哲的方案更有效率,也更先进……

在这个例子里,工程师自认为的高效和先进似乎并不是产品经理所关心的。产品经理作为功能设计者,自然更关心其功能价值,而不是技术方法是否先进。另外,对需求里的“即时收到新订单消息”里“即时”的理解,工程师一开始就将自己的臆测加了进去。

不妨考虑一下,需求的价值是使管理员更早知道新订单到来,但这个“即时性”要求有多高呢?显然没有达到秒级,大概,分钟级也是能接受的——毕竟之前管理员是手动刷新浏览器去完成这个需求的,这说明新订单并没有频繁到需要秒级通知。因此,不管是工程师提前想到了这个结论,还是与产品经理及时沟通了自己的技术方案计划,都可以提早防止浪费。

6-work-place

在工作中,如果只将产品经理视为规则制定者,将领导视为发号施令的老板,我们便会失去思考的机会。逐渐地,思考的能力也将失去。但如果将他们视为客户,那么就更容易理解客户与我们之间可能存在的误解,毕竟大家术业有专攻。这时,不少人便会考虑客户可能的隐藏的想法,耐心地沟通核对,态度也端正友好。

灵活地给出建议

对于一家IT公司来说,开发人员是当之无愧的宝贝,各企业为了招来优秀的工程师,都不惜重金。他们是那么的天才,似乎什么问题到了他们那儿都有解决方案。是的,其实一个用技术能够解决的问题,往往都有很多种解决方案,有些方案甚至不涉及技术。在拥有天才一面的同时,开发人员也相当的耿直,有时候甚至过于耿直,过早地将精力集中到技术方案上,而这时的方案往往还只是开发人员一厢情愿的期盼,不一定是客观上合适的方案。令人不安的是,与这些技术人员合作的业务分析人员和管理人员却没有办法预测或是验证其中的风险。

7-work

在手机支付的概念在技术圈风声水起时,有人正对“刷手机乘公交”的想法感到兴奋,在一边走一边与朋友分享的时候,正好有公交车到站。只见朋友伸出手机在刷卡机边轻轻一滑,“嘀”的一声,刷卡成功!他好奇地问朋友,你是怎么做到的?朋友淡定地翻开手机盖,从中缓缓抽出一张公交卡。

虽然这只是一个笑话,但现实中类似的情形却在真实的发生着,就像上一节中提到的例子一样。 如果开发人员拥有客户思维,就应该在真正行动之前,考虑多个可能的方案、权衡其中的优劣,及时向客户阐明这些方案的利弊;根据对需求的理解,以及客户提供的更多信息,给出具有可操作性的建议。对于一些经验丰富的开发人员来说,给出有价值的建议早就成为了他们的工作习惯,这也正是能体现他们更具专业性的行为之一。

不过,对于老油条们来说,也需要警惕:请注意保持对客户的尊重。作为客户,他们有时候显得不太专业,甚至不太友好。但开发人员,请一定尊重自己的客户。客户的最终目的是解决问题,而解决方案不一定要花哨炫酷,或是技术先进——开发人员应该在合适的时机,让客户知道他们可以做出选择,而不是由开发人员自行决定。即使开发人员自己有什么偏好,也不应该直接或间接地强加于客户,那样只会画蛇添足、招致反感。

《软技能》一书中指出了一个事实,虽然听起来有点残酷:当我们为了谋生而一头扎进代码的世界里时,其实与小时候老家镇上铁匠铺里的铁匠并没有什么区别。那样的我们,不用考虑顾客为何需要打造一件那么奇形怪状的铁器;在顾客一而再地提出挑剔意见时,我们一开始争辩,后来丧气,最后麻木了。那样的我们,数十年如一日,作为铁匠的技艺愈加纯熟。直到有一天,一种叫做“铸造机床”的远在天边的东西,夺去了我们的饭碗。

8-mechanic

如果养成了思考的习惯,拥有为客户提供专业服务的能力,随时都能换个地方另起炉灶。实际上,企业的价值正是体现在它为客户解决的问题上。习惯将工作视作服务客户,把自己当作一个企业去思考,也就具有更独立的人格,为今后真正做出良好的商业决策积累经验、奠定基础。 一旦拥有了这样的心态,开发人员也就不会只关注完成手头的工作,还知道要计划接下来的职业发展,关注自己和同事的成长;也不会因为觉得作为开发人员去帮老板实现梦想没有意义而烦燥不安。很快,开发人员这种聪明的人种就会成为有思路、有规划的进步青年。

 

更多精彩洞见,请关注微信公众号:ThoughtWorks

Share

登录工程:传统 Web 应用中的身份验证技术

标题中的 “传统Web应用” 这一说法并没有什么官方定义,只是为了与“现代化Web应用”做比较而自拟的一个概念。所谓“现代化Web应用”指的是那些基于分布式架构思想设计的,面向多个端提供稳定可靠的高可用服务,并且在需要时能够横向扩展的Web应用。相对而言,传统Web应用则主要是直接面向PC用户的Web应用程序,采用单体架构较多,也可能在内部采用SOA的分布式运算技术。

一直以来,传统Web应用为构成互联网发挥了重要作用。因此传统Web应用中的身份验证技术经过几代的发展,已经解决了不少实际问题,并最终沉淀了一些实践模式。

1-security

在讲述多种身份鉴权技术之前,要强调一点:在构建互联网Web应用过程中,无论使用哪种技术,在传输用户名和密码时,请一定要采用安全连接模式。因为无论采用何种鉴权模型,都无法保护用户凭据在传输过程中不被窃取。

Basic和Digest鉴权

基于HTTP的Web应用离不开HTTP本身的安全特性中关于身份鉴权的部分。虽然HTTP标准定义了好几种鉴权方式,但真正供Web应用开发者选择的并不多,这里简要回顾一下曾经被广泛运用过的Basic 和 Digest鉴权。

不知道读者是否熟悉一种最直接向服务器提供身份的方式,即在URL中直接写上用户名和密码:

 http://user:passwd@www.server.com/index.html

这就是Basic鉴权的一种形式。

Basic和Digest是通过在HTTP请求中直接包含用户名和密码,或者它们的哈希值来向服务器传输用户凭据的方法。Basic鉴权直接在每个请求的头部或URL中包含明文的用户名或密码,或者经过Base64编码过的用户名或密码;而Digest则会使用服务器返回的随机值,对用户名和密码拼装后,使用多次MD5哈希处理后再向服务器传输。服务器在处理每个请求之前,读取收到的凭据,并鉴定用户的身份。

2-password

Basic和Digest鉴权有一系列的缺陷。它们需要在每个请求中提供凭据,因此提供“记住登录状态”功能的网站中,不得不将用户凭据缓存在浏览器中,增加了用户的安全风险。Basic鉴权基本不对用户名和密码等敏感信息进行预处理,所以只适合于较安全的安全环境,如通过HTTPS安全连接传输,或者局域网。

看起来更安全的Digest在非安全连接传输过程中,也无法抵御中间人通过篡改响应来要求客户端降级为Basic鉴权的攻击。Digest鉴权还有一个缺陷:由于在服务器端需要核对收到的、由客户端经过多次MD5哈希值的合法性,需要使用原始密码做相同的运算,这让服务器无法在存储密码之前对其进行不可逆的加密。Basic 和Digest鉴权的缺陷决定了它们不可能在互联网Web应用中被大量采用。

简单实用的登录技术

对于互联网Web应用来说,不采用Basic或Digest鉴权的理由主要有两个:

1. 不能接受在每个请求中发送用户名和密码凭据
2. 需要在服务器端对密码进行不可逆的加密

因此,互联网Web应用开发已经形成了一个基本的实践模式,能够在服务端对密码强加密之后存储,并且尽量减少鉴权过程中对凭据的传输。其过程如下图所示:

3-traditional-cookie

这一过程的原理很简单,专门发送一个鉴权请求,只在这个请求头中包含原始用户名和密码凭据,经服务器验证合法之后,由服务器发给一个会话标识(Session ID),客户端将会话标识存储在 Cookie 中,服务器记录会话标识与经过验证的用户的对应关系;后续客户端使用会话标识、而不是原始凭据去与服务器交互,服务器读取到会话标识后从自身的会话存储中读取已在第一个鉴权请求中验证过的用户身份。为了保护用户的原始凭据在传输中的安全,只需要为第一个鉴权请求构建安全连接支持。

服务端的代码包含首次鉴权和后续检查并授权访问的过程:

IUser _user_;  
if( validateLogin( nameFromReq, pwdFromReq, out _user _)){  
  Session["CurrentUser"] = _user_;  
}

(首次鉴权)

 IUser _user_ = Session["CurrentUser"] as IUser;  
 if( _user_ == null ){  
     Response.Redirect( "/login?return_uri=" + 
     Request.Url.ToString() );  
     return;  
 }

(后续检查并拒绝未识别的用户)

类似这样的技术简易方便,容易操作,因此大量被运用于很多互联网Web应用中。它在客户端和传输凭据过程中几乎没有做特殊处理,所以在这两个环节尤其要注意对用户凭据的保护。不过,随着我们对系统的要求越来越复杂,这样简易的实现方式也有一些明显的不足。比如,如果不加以封装,很容易出现在服务器应用程序代码中出现大量对用户身份的重复检查、错误的重定向等;不过最明显的问题可能是对服务器会话存储的依赖,服务器程序的会话存储往往在服务器程序重启之后丢失,因此可能会导致用户突然被登出的情况。虽然可以引入单独的会话存储程序来避免这类问题,但引入一个新的中间件就会增加系统的复杂性。

传统Web应用中身份验证最佳实践

上文提到的简单实用的登录技术已经可以帮助建立对用户身份验证的基本图景,在一些简单的应用场景中已经足够满足需求了。然而,用户鉴权就是有那种“你可以有很多种方法,就是不怎么优雅” 的问题。

最佳实践指的是那些经过了大量验证、被证明有用的方法。而用户鉴权的最佳实践就是使用自包含的、含有加密内容的 Cookie 作为替代凭据。其鉴权过程与上文所提到基于会话标识的技术没有什么区别,而主要区别在于不再颁发会话标识,取而代之的是一个代表身份的、经过加密的 “身份 Cookie”。

4-fingerprint

1. 只在鉴权请求中发送一次用户名和密码凭据
2. 成功凭据之后,由服务器生成代表用户身份的 Cookie,发送给客户端
3. 客户端在后续请求中携带上一步中收到的 “身份 Cookie”
4. 服务器解密”身份 Cookie”,并对需要访问的资源予以授权

这样,我们消除了对服务器会话存储的依赖,Cookie本身就有有效期的概念,因此顺便能够轻松提供“记住登录状态”的功能。

另外,由于解密Cookie、既而检查用户身份的操作相对繁琐,工程师不得不考虑对其抽取专门的服务,最终采用了面向切面的模式对身份验证的过程进行了封装,而开发时只需要使用一些特性标注(Attribute Annotation)对特定资源予以标记,即可轻松完成身份验证预处理。

传统Web应用中的单点登录

单点登录的需求在向用户提供多种服务的企业普遍存在,出发点是希望用户在一个站点中登录之后,在其他兄弟站点中就不需要再次登录。

如果多个子站所在的顶级域名一致,基于上文所述的实践,可以基于Cookie共享实现最简单的单点登录:在多个子站中使用相同的加密、解密配置,并且在用户登录成功后设置身份 Cookie时将domain值设置为顶级域名即可。这样,只要在其中一个网站登录,其身份 Cookie将在用户访问其他子站时也一起带上。不过实际情况中,这个方案的应用场景很有限,毕竟各个子站使用的用户数据模型可能不完全一致,而加密密钥多处共享也增加了服务器应用程序的安全风险。另外,这种方式与“在多个网站中分别存储相同的用户名与密码”的做法相似,可以说是一种“相同的登录”(Same Sign-On),而不是“单点登录”(Single Sign-On)。

对于单点登录需求来说,域名相同与否并不是最大的挑战,集成登录系统对各个子站点的系统在设计上的影响才是。我们希望便利用户的同时,也期待各个子系统仍拥有独立用户身份、独立管理和运维的灵活性。因此我们引入独立的鉴权子站点。

5-kick_gif_7

当用户到达业务站点A时,被重定向到鉴权站点;登录成功之后,用户被重定向回到业务站点 A、同时附加一个指示“已有用户登录”的令牌串——此时业务站点A使用令牌串,在服务器端从鉴权子站点查询并记录当前已登录的用户。当用户到达业务站点B时,执行相同流程。由于已有用户登录,所以用户登录的过程会被自动省略。

这样的单点登录系统能够较好地解决在多个站点中共享用户登录状态的需求。不过,如果在编程实践过程中略有差池,就会让用户陷入巨大的安全风险中。例如,在上述重定向过程中,一旦鉴权系统未能验证返回URL的合法性,就容易导致用户被钓鱼网站利用。在传统Web应用开发实践中,被广泛部署的身份验证体系是比较重量级的WS-Federation 和 SMAL 等鉴权协议和相对轻量级的 OpenID 等技术。

总结

本文简要总结了在传统Web应用中,被广泛使用的几种典型用户登录时的鉴权处理流程。总体来说,在单体 Web 应用中,身份验证过程并不复杂,只要稍加管理,可以较轻松地解决用户鉴权的问题。但在传统 Web 应用中,为了解决单点登录的需求,人们也尝试了多种方式,最终仍然只有使用一些较复杂的方案才能较好地解决问题。

在现代化 Web 应用中,围绕登录这一需求,俨然已经衍生出了一个新的工程。“登录工程” 并不简单,在后续篇目中将会介绍现代化 Web 应用的典型需求及解决方法。

Share