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
This commit is contained in:
2026-05-21 20:17:21 -04:00
parent 921a40a560
commit 58f8b67b28
2 changed files with 61 additions and 8 deletions
+55 -5
View File
@@ -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 ─────────────────────────────────────