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:
@@ -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 ─────────────────────────────────────
|
||||
|
||||
+6
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user