10.10. Login for the dashboard

The web dashboard needs a login form – random people on the LAN shouldn’t see the yard. That’s what cookie-backed sessions and the login decorator do.

A session is a small dict the cam writes into a cookie. The cookie is signed with the JWT-signing secret loaded earlier in the chapter, so the browser can carry it around but can’t tamper with the contents without invalidating the signature.

10.10.1. Set up the session and login objects

microdot.session.Session installs the session machinery on app. microdot.login.Login adds the login_required-style decorator and the login_user / logout_user helpers:

# 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 keeps JavaScript on the page from reading the cookie – a layered defense against an XSS attacker hijacking the session. secure=False is a placeholder until HTTPS is in place; flip it to True once the server runs over TLS so the cookie never travels over plain HTTP.

Note

Cross-site scripting (XSS) is the class of attacks where the attacker gets JavaScript executing inside the user’s view of a trusted page – typically through an unescaped form field, an HTML-rendered comment, or a vulnerable third-party widget. The http_only cookie flag doesn’t prevent XSS; it just keeps the session cookie out of reach of the injected script, so a successful XSS can’t trivially be turned into session hijacking.

The user_loader is called on every protected request to turn the ID stored in the session into the user record the handler will see. Keep it cheap – it runs on the hot path.

10.10.2. Login form, login post, logout

The login form itself is a static page served from /sdcard/static/ just like the dashboard. The form POSTs to /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() writes the session cookie and returns a 302 redirect to whatever page the client originally tried to reach (next= query argument, falling back to /). remember=True also writes a longer-lived _remember cookie so the session survives a browser restart.

microdot.login.Login.logout_user() clears both cookies and a follow-up redirect sends the browser back to the form.

10.10.3. Protecting the dashboard

Decorate every dashboard-facing route with @login so an unauthenticated request gets a 302 to /login instead of the content:

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

The token-based API endpoints (/api/login, /api/ack) stay token-based – they’re for the phone app, not the browser. Token auth and session auth happily coexist on the same app.

10.10.4. Fresh vs remembered sessions

The _remember cookie keeps the user logged in across browser restarts, but it’s a weaker form of auth – the browser could have been left in a cafe. For routes that change passwords, register API tokens, or do anything else worth re-authenticating for, decorate with @login.fresh instead of @login. A fresh login is one where the user typed their password this session; a remembered login is not. The dashboard has nothing that rises to that bar, but the decorator is there when you need it.

The dashboard now requires a login before any of its routes respond.