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,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
token TEXT UNIQUE NOT NULL,
|
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);
|
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:
|
if existing == 0:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
|
"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):
|
def _ensure_unique_machine_id(conn: sqlite3.Connection):
|
||||||
"""Remove duplicate machine_ids (keep oldest), then create UNIQUE index."""
|
"""Remove duplicate machine_ids (keep oldest), then create UNIQUE index."""
|
||||||
dupes = conn.execute("""
|
dupes = conn.execute("""
|
||||||
@@ -361,6 +377,8 @@ def _migrate_v1_to_v2(conn: sqlite3.Connection):
|
|||||||
|
|
||||||
# Seed lookup data
|
# Seed lookup data
|
||||||
_seed_data(conn)
|
_seed_data(conn)
|
||||||
|
# Schema migrations for existing databases
|
||||||
|
_migrate_sessions_expires_at(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -380,6 +398,7 @@ def init_db(conn: sqlite3.Connection):
|
|||||||
# Fresh install or already migrated — create all tables
|
# Fresh install or already migrated — create all tables
|
||||||
_create_v2_tables(conn)
|
_create_v2_tables(conn)
|
||||||
_seed_data(conn)
|
_seed_data(conn)
|
||||||
|
_migrate_sessions_expires_at(conn)
|
||||||
# Asset indexes — created here (not in _create_v2_tables) to avoid
|
# Asset indexes — created here (not in _create_v2_tables) to avoid
|
||||||
# failing during migration when old v1 assets table lacks machine_id.
|
# failing during migration when old v1 assets table lacks machine_id.
|
||||||
_ensure_unique_machine_id(conn)
|
_ensure_unique_machine_id(conn)
|
||||||
@@ -458,7 +477,7 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT u.id, u.username, u.role FROM users u "
|
"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,),
|
(token,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -1290,6 +1309,7 @@ def _user_to_dict(row: sqlite3.Row) -> dict:
|
|||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
remember_me: bool = False
|
||||||
|
|
||||||
class GeofencePointCheck(BaseModel):
|
class GeofencePointCheck(BaseModel):
|
||||||
lat: float
|
lat: float
|
||||||
@@ -1302,6 +1322,13 @@ class GeofencePointCheck(BaseModel):
|
|||||||
@app.post("/api/auth/login")
|
@app.post("/api/auth/login")
|
||||||
def login(body: LoginRequest):
|
def login(body: LoginRequest):
|
||||||
conn = get_db()
|
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(
|
row = conn.execute(
|
||||||
"SELECT * FROM users WHERE username = ?", (body.username,)
|
"SELECT * FROM users WHERE username = ?", (body.username,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -1315,8 +1342,15 @@ def login(body: LoginRequest):
|
|||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
token = _generate_token()
|
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(
|
conn.execute(
|
||||||
"INSERT INTO sessions (user_id, token) VALUES (?, ?)",
|
"INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, " + expires + ")",
|
||||||
(row["id"], token),
|
(row["id"], token),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -1336,7 +1370,7 @@ def auth_me(request: Request):
|
|||||||
token = auth_header[7:]
|
token = auth_header[7:]
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute(
|
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,),
|
(token,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -1345,6 +1379,22 @@ def auth_me(request: Request):
|
|||||||
return _user_to_dict(row)
|
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 ─────────────────────────────────────
|
# ─── Phase 0: Proximity & Geofence Check ─────────────────────────────────────
|
||||||
|
|||||||
+6
-3
@@ -1381,16 +1381,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const remember = document.getElementById('loginRemember').checked;
|
||||||
const result = await api('/api/auth/login', {
|
const result = await api('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password, remember_me: remember }),
|
||||||
});
|
});
|
||||||
|
|
||||||
AppState.authToken = result.token;
|
AppState.authToken = result.token;
|
||||||
AppState.currentUser = { id: result.id, username: result.username, role: result.role };
|
AppState.currentUser = { id: result.id, username: result.username, role: result.role };
|
||||||
|
|
||||||
const remember = document.getElementById('loginRemember').checked;
|
|
||||||
if (remember) {
|
if (remember) {
|
||||||
localStorage.setItem('canteen_session', JSON.stringify({
|
localStorage.setItem('canteen_session', JSON.stringify({
|
||||||
token: result.token,
|
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');
|
localStorage.removeItem('canteen_session');
|
||||||
AppState.authToken = null;
|
AppState.authToken = null;
|
||||||
AppState.currentUser = null;
|
AppState.currentUser = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user