10.10. 仪表盘登录

网页仪表盘需要一个登录表单——局域网上的陌生人不应该看到院子里的画面。这正是基于 cookie 的会话和登录装饰器要做的事情。

会话是摄像头写入 cookie 的一个小字典。该 cookie 用本章前面加载的 JWT 签名密钥进行签名,因此浏览器可以随身携带它,但无法在不使签名失效的情况下篡改其内容。

10.10.1. 设置会话和登录对象

microdot.session.Sessionapp 上安装会话机制。microdot.login.Login 添加了 login_required 风格的装饰器以及 login_user / logout_user 辅助函数:

# auth/login.py
from microdot.session import Session
from microdot.login import Login

Session(app, secret_key=SECRET,
        cookie_options={'http_only': True, 'secure': False})
login = Login()

USERS = {
    'owner': {'id': 'owner', 'password_hash': load_password_hash()},
}

@login.user_loader
async def load_user(user_id):
    return USERS.get(user_id)

http_only=True 让页面上的 JavaScript 无法读取 cookie——这是针对 XSS 攻击者劫持会话的一层防御。secure=False 是 HTTPS 就绪之前的占位设置;一旦服务器通过 TLS 运行,就把它改成 True,这样 cookie 就永远不会以明文 HTTP 传输。

备注

跨站脚本(XSS)是这样一类攻击:攻击者设法让 JavaScript 在用户看到的受信任页面中执行——通常是通过未转义的表单字段、以 HTML 渲染的评论,或者一个有漏洞的第三方小部件。http_only 这个 cookie 标志并不能防止 XSS;它只是让会话 cookie 不被注入的脚本触及,因此一次成功的 XSS 攻击无法被轻易转化为会话劫持。

user_loader 会在每个受保护的请求上被调用,把存储在会话中的 ID 转换成处理函数将看到的用户记录。让它保持轻量——它运行在热路径上。

10.10.2. 登录表单、登录提交、登出

登录表单本身是从 /sdcard/static/ 提供的静态页面,就和仪表盘一样。该表单以 POST 方式提交到 /login

from microdot import redirect

@app.get('/login')
async def login_form(request):
    return Response.send_file('/sdcard/static/login.html')

@app.post('/login')
async def do_login(request):
    form = request.form
    user = USERS.get(form.get('user'))
    if not user or not check_password(user, form.get('pass')):
        return redirect('/login?error=1')
    return await login.login_user(request, user, remember=True)

@app.post('/logout')
async def logout(request):
    await login.logout_user(request)
    return redirect('/login')

microdot.login.Login.login_user() 写入会话 cookie,并返回一个 302 重定向,指向客户端最初尝试访问的页面(next= 查询参数,缺省时回退到 /)。remember=True 还会写入一个存活时间更长的 _remember cookie,这样会话就能在浏览器重启后依然有效。

microdot.login.Login.logout_user() 清除两个 cookie,随后的重定向把浏览器送回到表单。

10.10.3. 保护仪表盘

为每个面向仪表盘的路由都加上 @login 装饰器,这样未经认证的请求得到的就是一个跳转到 /login 的 302,而不是内容本身:

@app.get('/<path:filename>')
@login
async def static(request, filename):
    ...

@app.get('/config')
@login
async def get_config(request):
    ...

@app.post('/config')
@login
async def set_config(request):
    ...

@app.get('/events')
@login
@with_sse
async def events(request, sse):
    ...

@app.get('/control')
@login
@with_websocket
async def control(request, ws):
    ...

基于令牌的 API 端点(/api/login/api/ack)仍然保持基于令牌的方式——它们是给手机应用用的,不是给浏览器用的。令牌认证和会话认证可以在同一个 app 上愉快地共存。

10.10.4. 全新会话与记住的会话

_remember cookie 让用户在浏览器重启后仍保持登录状态,但这是一种较弱的认证形式——浏览器可能被遗忘在了某家咖啡馆里。对于修改密码、注册 API 令牌,或做任何值得重新认证的事情的路由,请改用 @login.fresh 而不是 @login 来装饰。全新登录是指用户在次会话中亲手输入了密码;记住的登录则不是。仪表盘里没有任何东西达到那个门槛,但当你需要时,这个装饰器就在那里。

现在,仪表盘的任何路由在响应之前都需要先登录。