10.9. 面向程序化客户端的认证

Bearer 令牌是手机、配套微控制器和云端工作进程向服务器进行认证的方式——客户端在每个请求的 Authorization 头中放入一个令牌,服务器对其进行校验。配套的手机应用在所有者对运动通知点击“已查看”时会 POST 一个“ack”;这些请求需要一个令牌。

10.9.1. 签名密钥

该密钥是摄像头用于签发和验证令牌的私钥。在摄像头首次启动时,从硬件 RNG 生成 32 个随机字节,将其持久化到文件系统,并在之后每次启动时重用相同的字节:

# auth/tokens.py
import os

try:
    with open('secret.bin', 'rb') as f:
        SECRET = f.read()
except OSError:
    SECRET = os.urandom(32)
    with open('secret.bin', 'wb') as f:
        f.write(SECRET)

os.urandom() 是每个摄像头移植版本上适用于密码学的随机源——在大多数移植版本上,它直接读取芯片的硬件随机数生成器。该文件位于摄像头的工作目录中(如果挂载了 SD 卡则为 /sdcard,否则为 /flash),并且永不离开摄像头。删除 secret.bin 并重新启动会轮换密钥,并使在旧密钥下签发的每个令牌失效。

备注

machine.unique_id() 不能作为替代。在某些摄像头移植版本上,它还被用于派生网络 MAC 地址,这意味着它的值会随摄像头发送的每个数据包一起传播——这并非签名密钥所需要的属性。

10.9.2. 签发令牌

手机首先需要一种获取令牌的方式。一个短期有效的 JSON Web Token(JWT)——一个 base64 编码的 JSON 负载加上附加的签名——足以应付第一步:所有者用密码换取一个令牌,然后使用该令牌直到它过期:

import jwt
import time

@app.post('/api/login')
async def api_login(request):
    creds = request.json
    if creds.get('user') != 'owner' or creds.get('pass') != load_password():
        abort(401)
    token = jwt.encode({
        'sub': 'owner',
        'exp': int(time.time()) + 3600,
    }, SECRET)
    return {'token': token}

手机 POST 一次 {user, pass},将返回的令牌存入本地存储,并在之后的每个请求中将其作为 Authorization: Bearer <token> 包含进去。

exp 声明是一个 Unix 时间戳。下一节中的验证器依赖此声明来自动拒绝过期的令牌——这意味着摄像头的挂钟时间必须设置好,否则每个令牌看起来要么远在未来,要么已经过期。NTP 同步方法请参阅 时间与 NTP

10.9.3. 使用 TokenAuth 验证令牌

microdot.auth.TokenAuth 是面向需要 Authorization: Bearer <token> 头的路由的装饰器工厂。验证器回调接收令牌字符串,并返回应附加到请求上的任意身份对象——或返回 None 以拒绝:

from microdot.auth import TokenAuth

tokens = TokenAuth()

@tokens.authenticate
async def check_token(request, token):
    try:
        claims = jwt.decode(token, SECRET)
    except jwt.exceptions.PyJWTError:
        return None
    return claims['sub']

@app.post('/api/ack')
@tokens
async def ack(request):
    body = request.json
    if not body or 'count' not in body:
        abort(400, 'missing count')
    return {'ok': True, 'user': request.g.current_user}

当签名错误、令牌格式不正确或 exp 声明已过期时,jwt.decode() 会抛出 jwt.exceptions.PyJWTError(或其子类)。验证器会吞掉所有这些异常并返回 None,因此 microdot 会以 401 响应,而处理器不会看到任何东西。

当验证器返回一个非 None 的值时,microdot 会将其存储在 request.g.current_user 上,以便处理器能够读取它——在本例中它是 JWT 的 sub(subject,主体)声明。

10.9.4. 面向封闭网络的 BasicAuth

对于只会从可信网络访问的管理端点,microdot.auth.BasicAuth 更为简单——浏览器会弹出一个原生的用户名/密码对话框,并在每个请求中以 Authorization: Basic ... 头发送它们:

from microdot.auth import BasicAuth

basic = BasicAuth(realm='Backyard cam admin')

@basic.authenticate
async def check_basic(request, username, password):
    if username == 'owner' and password == load_password():
        return 'owner'
    return None

@app.get('/admin')
@basic
async def admin(request):
    return 'hi ' + request.g.current_user

除非连接采用 HTTPS,否则凭据是明文传输的,因此这种方式只在可信网络上或在采用 HTTPS 的情况下才有意义。

/api/ack/api/login 现在能够理解令牌;/admin 则要求 Basic 认证。