From 58f8b67b28147fef1f0d7fb985350796d8f9df8e Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 21 May 2026 20:17:21 -0400 Subject: [PATCH] auth: full session-based auth with remember-me support - Seed user: shawn / Brett85!@ (SHA-256) - Sessions table with expires_at column - login endpoint supports remember_me flag (30d vs 1d expiry) - logout endpoint invalidates server-side token - auth middleware + /api/auth/me check session expiry - Frontend sends remember_me in login request body - Frontend calls /api/auth/logout on client logout - Migration function for existing databases --- server.py | 60 +++++++++++++++++++++++++++++++++++++++++++---- static/index.html | 9 ++++--- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/server.py b/server.py index 6cadbdd..357fb51 100644 --- a/server.py +++ b/server.py @@ -238,7 +238,8 @@ def _create_v2_tables(conn: sqlite3.Connection): id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, token TEXT UNIQUE NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL DEFAULT (datetime('now', '+1 day')) ); CREATE INDEX IF NOT EXISTS idx_checkins_asset_id ON checkins(asset_id); @@ -284,10 +285,25 @@ def _seed_data(conn: sqlite3.Connection): if existing == 0: conn.execute( "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", - ("admin", "057ba03d6c44104863dc7361fe4578965d1887360f90a0895882e58a6248fc86", "admin"), + ("shawn", "887f732280a2d74a8468b04ebce6feb1a4968cc7d77411d996802ef22a907da5", "admin"), ) + +def _migrate_sessions_expires_at(conn: sqlite3.Connection): + """Add expires_at column to sessions table if it doesn't exist yet.""" + try: + conn.execute("ALTER TABLE sessions ADD COLUMN expires_at TEXT") + except sqlite3.OperationalError: + pass # Column already exists + # Set default expiry for any sessions with NULL expires_at + conn.execute( + "UPDATE sessions SET expires_at = datetime(created_at, '+1 day') " + "WHERE expires_at IS NULL" + ) + conn.commit() + + def _ensure_unique_machine_id(conn: sqlite3.Connection): """Remove duplicate machine_ids (keep oldest), then create UNIQUE index.""" dupes = conn.execute(""" @@ -361,6 +377,8 @@ def _migrate_v1_to_v2(conn: sqlite3.Connection): # Seed lookup data _seed_data(conn) + # Schema migrations for existing databases + _migrate_sessions_expires_at(conn) conn.commit() @@ -380,6 +398,7 @@ def init_db(conn: sqlite3.Connection): # Fresh install or already migrated — create all tables _create_v2_tables(conn) _seed_data(conn) + _migrate_sessions_expires_at(conn) # Asset indexes — created here (not in _create_v2_tables) to avoid # failing during migration when old v1 assets table lacks machine_id. _ensure_unique_machine_id(conn) @@ -458,7 +477,7 @@ async def auth_middleware(request: Request, call_next): conn = get_db() row = conn.execute( "SELECT u.id, u.username, u.role FROM users u " - "JOIN sessions s ON u.id = s.user_id WHERE s.token = ?", + "JOIN sessions s ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > datetime('now')", (token,), ).fetchone() conn.close() @@ -1290,6 +1309,7 @@ def _user_to_dict(row: sqlite3.Row) -> dict: class LoginRequest(BaseModel): username: str password: str + remember_me: bool = False class GeofencePointCheck(BaseModel): lat: float @@ -1302,6 +1322,13 @@ class GeofencePointCheck(BaseModel): @app.post("/api/auth/login") def login(body: LoginRequest): conn = get_db() + + # Clean up any expired sessions for this user + conn.execute( + "DELETE FROM sessions WHERE user_id = ? AND expires_at < datetime('now')", + (body.username,), + ) + row = conn.execute( "SELECT * FROM users WHERE username = ?", (body.username,) ).fetchone() @@ -1315,8 +1342,15 @@ def login(body: LoginRequest): raise HTTPException(status_code=401, detail="Invalid username or password") token = _generate_token() + + # Set expiry based on remember_me + if body.remember_me: + expires = "datetime('now', '+30 days')" + else: + expires = "datetime('now', '+1 day')" + conn.execute( - "INSERT INTO sessions (user_id, token) VALUES (?, ?)", + "INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, " + expires + ")", (row["id"], token), ) conn.commit() @@ -1336,7 +1370,7 @@ def auth_me(request: Request): token = auth_header[7:] conn = get_db() row = conn.execute( - "SELECT u.* FROM users u JOIN sessions s ON u.id = s.user_id WHERE s.token = ?", + "SELECT u.* FROM users u JOIN sessions s ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > datetime('now')", (token,), ).fetchone() conn.close() @@ -1345,6 +1379,22 @@ def auth_me(request: Request): return _user_to_dict(row) +@app.post("/api/auth/logout") +def logout(request: Request): + """Invalidate the current session token.""" + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + conn = get_db() + conn.execute("DELETE FROM sessions WHERE token = ?", (token,)) + conn.commit() + conn.close() + return {"detail": "Logged out"} + + + + + # ─── Phase 0: Proximity & Geofence Check ───────────────────────────────────── diff --git a/static/index.html b/static/index.html index 6992a86..ae35211 100644 --- a/static/index.html +++ b/static/index.html @@ -1381,16 +1381,16 @@ } try { + const remember = document.getElementById('loginRemember').checked; const result = await api('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), + body: JSON.stringify({ username, password, remember_me: remember }), }); AppState.authToken = result.token; AppState.currentUser = { id: result.id, username: result.username, role: result.role }; - const remember = document.getElementById('loginRemember').checked; if (remember) { localStorage.setItem('canteen_session', JSON.stringify({ token: result.token, @@ -1416,7 +1416,10 @@ } } - function doLogout() { + async function doLogout() { + try { + await api('/api/auth/logout', { method: 'POST' }); + } catch (e) { /* ignore — still clean up locally */ } localStorage.removeItem('canteen_session'); AppState.authToken = null; AppState.currentUser = null;