12.5 创建安全的REST服务

    我们可以将应用程序的安全分为两个部分考虑:验证和授权。我们需要知道用户是谁,并且需要确保用户有运行某个WSGI应用程序的授权。如果使用用于确保凭证间加密传输的HTTP的Authorization头,实现这种安全机制就相对容易。

    如果我们使用SSL,就可以简单地使用HTTP基本授权(HTTP Basic Authorization)模式。这个版本的Authorization头会在每个请求中包含一个用户名和密码。对于更复杂的验证机制,可以使用HTTP摘要授权(HTTP Digest Authorization),这种授权需要与服务器交换信息并且获得名为 nonce 的数据,这块数据用于以更安全的方式创建摘要。

    通常,我们会尽早地在执行过程中完成身份认证的处理。这意味着前端的WSGI应用程序会检查Authorization头并且更新环境信息或者返回一个错误。理论上,我们将使用一个提供了这种功能的完整的 Web 框架。关于这方面的更多信息,请阅读下节中对这些 Web 框架的讨论。

    关于安全性的主题,最重要的建议可能是下面这条。

    永远不要保存密码 唯一可以存储的是一个“加盐”的重复加密的哈希密码。密码本身必须是不可恢复的,仔细研究“Salted Password Hashing”或者下载一个实现了这种加密的可信库。永远不要存储一个明文或者编码后的密码。

    下面的示例类向我们展示了salted password hashing是如何工作的。

    from hashlib import sha256
    import os
    class Authentication:
       iterations= 1000
       def init( self, username, password ):
         """Works with bytes. Not Unicode strings."""
         self.username= username
         self.salt= os.urandom(24)
         self.hash= self.iterhash( self.iterations, self.salt, username, password)
       @staticmethod
       def iterhash( iterations, salt, username, password ):
         seed= salt+b":"+username+b":"+password
         for i in range(iterations):
           seed= sha256( seed ).digest()
         return seed
       def eq( self, other ):
         return self.username == other.username and self.hash == other.hash
       def __hash
    ( self, other ):
         return hash(self.hash)
       def __repr
    ( self ):
         salt_x= "".join( "{0:x}".format(b) for b in self.salt )
         hash_x= "".join( "{0:x}".format(b) for b in self.hash )
         return "{username} {iterations:d}:{salt}:{hash}".format(
    username=self.username, iterations=self.iterations,
            salt=salt_x, hash=hash_x)
       def match( self, password ):
         test= self._iter_hash( self.iterations, self.salt, self.
    username, password )
         return self.hash == test #
    Constant Time is Best

    这个类为一个给定的用户名定义了Authentication对象。这个对象包括用户名、一个每次设置或者重设密码时都会创建的唯一的随机salt。类中也提供了用于确定给定密码与原始密码是否能够生成相同哈希码的方法——match()

    请注意,我们没有保存密码。我们只保存了密码的哈希码。在比较函数上写了注释(“# Constant Time is Best”)。一种需要用固定时间运行的不是特别快的算法非常适合作为这种比较的算法。我们还没有实现它。

    我们也包含了一个相等性测试和一个哈希测试用于强调这个对象是不可变的。我们不能修改任何值。当用于改变密码时,只能丢弃并且重建整个Authentication对象。还有一个功能是用slots节省存储空间。

    请注意,这些算法需要字节字符串而不是Unicode字符串。我们需要字节或者是ASCII编码的Unicode用户名或密码。以下是如何创建用户集合的代码。

    class Users( dict ):
       def init( self, args, **kw ):
         super().init(
    args, **kw )
         # Can never match — keys are the same.
         self[""]= Authentication( b"dummy", b"Doesn't Matter" )
       def add( self, authentication ):
         if authentication.username == "":
           raise KeyError( "Invalid Authentication" )
         self[authentication.username]= authentication
       def match( self, username, password ):
         if username in self and username != "":
           return self[username].match(password)
         else:
           return self[""].match(b"Something which doesn't match")

    我们扩展了dict,增加了用于保存Authentication实例的add()方法和一个用于确定用户是否在字典中并且拥有正确凭证的match()方法。

    请注意,match()这个比较方法的时间复杂度必须是常数。当客户端提供了一个未知的用户名时,我们会创建一个额外的假用户。通过与假用户比较,虽然结果永远是失败的,但是执行时间不会为错误的验证信息提供任何提示。如果我们简单地返回False,那么找到一个不匹配的用户名会比找到不匹配的密码快。

    我们特别禁止了设置验证或者匹配值为””的用户名。这样做的目的是确保假用户名永远不会成为一个可能可以匹配的正确用户名,并且任何尝试匹配它的操作都会失败。下面是我们创建的一个示例用户。

    users = Users()
    users.add( Authentication(b"Aladdin", b"open sesame") )

    如果想看到这个类内部发生了什么,可以手动创建一个用户。

    >>> al= Authentication(b"Aladdin", b"open sesame")
    >>> al
    b'Aladdin' 1000:16f56285edd9326282da8c6aff8d602a682bbf83619c7f:9b86a2a
    d1ae0345029ae11de402ba661ade577df876d89b8a3e182d887a9f7

    这里的salt是一个24字节字符串,当用户的密码被创建或者修改时,它都会被重置。这个哈希码是包含了用户名、密码和salt的重复散列。

    WSGI验证程序

    一旦我们能够保存用户和验证信息,就可以查看请求的Authentication头了。下面的WSGI应用程序查看Authentication头,并且为正确的用户更新环境信息。

    import base64
    class Authenticate( WSGI ):
       def init( self, users, targetapp ):
         self.users= users
         self.targetapp= target_app
       def __call
    ( self, environ, start_response ):
         if 'HTTP_AUTHORIZATION' in environ:
           scheme, credentials = environ['HTTP_AUTHORIZATION'].split()
           if scheme == "Basic":
             username,password=base64.b64decode( credentials ).split(b":")
           if self.users.match(username, password):
             environ['Authenticate.username']= username
             return self.target_app(environ, start_response)
         status = '401 UNAUTHORIZED'
         headers = [('Content-type', 'text/plain; charset=utf-8'),
           ('WWW-Authenticate', 'Basic realm="roulette@localhost"')]
         start_response(status, headers)
         return [ "Not authorized".encode('utf-8') ]

    这个WSGI应用程序除了一个目标应用程序之外,还包含一个用户池。当我们创建这个Authenticate类的实例时,会提供另一个WSGI应用程序——target_app,这个封装的程序只会看到通过验证的用户请求。当Authenticate程序被调用时,它会执行一些测试以确保请求来自一个已经通过验证的用户。

    • 必须提供一个HTTP Authorization头。这个头以HTTP_AUTHORIZATION为键保存在environ字典中。
    • 头的验证模式必须是Basic。
    • Basic模式提供的验证信息必须是用base64编码的username+b":"+password,这段信息必须和某个已定义用户的验证信息匹配。

    如果上面的所有测试都通过了,就可以用已验证用户的用户名来更新environ字典。然后,目标程序就会被调用。

    然后,处理授权的程序就会知道当前用户是已经经过验证的。这种解耦的设计是WSGI程序的一个优雅的特性,我们把身份验证放在了另外一个地方。