10.10. 儀表板的登入

這個 web 儀表板需要一個登入表單——區網上的隨機路人不應該看到後院的畫面。這正是以 cookie 為基礎的 session 與登入裝飾器要做的事。

session 是相機寫入 cookie 中的一個小型 dict。這個 cookie 會用本章稍早載入的 JWT 簽章密鑰加以簽署,因此瀏覽器可以隨身攜帶它,但若不破壞簽章就無法竄改其內容。

10.10.1. 設定 session 與登入物件

microdot.session.Session 會在 app 上安裝 session 機制。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 攻擊者劫持 session 的一道分層防禦。secure=False 是在 HTTPS 就緒前的暫時設定;一旦伺服器改用 TLS 運作,就把它切換為 True,讓 cookie 永遠不會透過純 HTTP 傳輸。

備註

跨站腳本攻擊(XSS)是這樣一類攻擊:攻擊者讓 JavaScript 在使用者所檢視的受信任頁面內執行——通常是透過未經跳脫的表單欄位、以 HTML 呈現的留言,或是有漏洞的第三方小工具。http_only 這個 cookie 旗標並不能防止 XSS;它只是讓 session cookie 落在被注入腳本搆不到的地方,因此即使 XSS 攻擊成功,也無法輕易轉化為 session 劫持。

每一個受保護的請求都會呼叫 user_loader,把 session 中儲存的 ID 轉換成處理常式將看到的使用者記錄。請讓它保持輕量——因為它運行在熱路徑上。

10.10.2. 登入表單、登入 POST 與登出

登入表單本身是一個靜態頁面,跟儀表板一樣由 /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() 會寫入 session cookie,並回傳一個 302 重新導向,指向客戶端原本嘗試前往的頁面(next= 查詢參數,若無則退回 /)。remember=True 還會額外寫入一個存活時間更長的 _remember cookie,讓 session 在瀏覽器重新啟動後仍然存在。

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):
    ...

以 token 為基礎的 API 端點(/api/login/api/ack)仍維持 token 模式——它們是給手機 App 用的,而不是瀏覽器。token 驗證與 session 驗證可以愉快地共存於同一個 app 上。

10.10.4. 全新 session 與被記住的 session

_remember cookie 能讓使用者在瀏覽器重新啟動後仍保持登入狀態,但這是一種較弱的驗證形式——瀏覽器有可能被遺留在咖啡廳裡。對於會變更密碼、註冊 API token,或任何其他值得重新驗證的路由,請改用 @login.fresh 而非 @login 來裝飾。所謂全新登入,是指使用者在本次 session 中親自輸入了密碼;被記住的登入則不是。儀表板上沒有任何功能達到那種等級,但當你需要時,這個裝飾器就在那裡。

現在儀表板在其任何路由回應之前,都會要求先登入。