- store.py: passwords now PBKDF2-HMAC-SHA256 (390k iters, random salt,
constant-time compare). Legacy unsalted-SHA256 hashes upgrade transparently
on successful login — zero impact on existing users.
- Sessions expire after 30 days (lazy cleanup on token lookup).
- Rate limiting on register (5/hr/IP), login (10/15min per IP AND per
username), chat (30/hr/IP) — SQLite-backed, shared across gunicorn workers.
- Registration now requires 8+ char passwords.
- New /api/chat/stream SSE endpoint: citations first ('meta'), streamed
answer deltas, 'done'. Client auto-falls back to non-streaming.
- nginx: proxy_buffering off for SSE.
314 lines
11 KiB
Python
314 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Astraea data layer — no-KYC auth, profiles (avatar + About Me), and settings.
|
|
SQLite at /opt/astraea/astraea.db. stdlib-only.
|
|
|
|
Security model (v1.1):
|
|
- Passwords: PBKDF2-HMAC-SHA256, 390k iterations (OWASP 2023 rec), 16-byte salt,
|
|
constant-time compare. Legacy unsalted-SHA256 hashes transparently upgrade
|
|
to PBKDF2 on successful login (zero user impact).
|
|
- Sessions: 30-day sliding expiry, cleaned up lazily.
|
|
"""
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import secrets
|
|
import sqlite3
|
|
import time
|
|
|
|
DB_PATH = "/opt/astraea/astraea.db"
|
|
|
|
# ── Password hashing (PBKDF2, stdlib) ────────────────────────────────────────
|
|
PBKDF2_ITERATIONS = 390_000
|
|
PBKDF2_PREFIX = "pbkdf2_sha256$" # format: pbkdf2_sha256$<iters>$<salt_hex>$<hash_hex>
|
|
SESSION_TTL = 30 * 24 * 3600 # 30 days
|
|
|
|
|
|
def hash_password(pw):
|
|
"""Hash a password with PBKDF2-HMAC-SHA256 + random salt. Returns a
|
|
self-describing string so algorithms can evolve independently."""
|
|
salt = secrets.token_bytes(16)
|
|
dk = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, PBKDF2_ITERATIONS)
|
|
return f"{PBKDF2_PREFIX}{PBKDF2_ITERATIONS}${salt.hex()}${dk.hex()}"
|
|
|
|
|
|
def _legacy_sha256(pw):
|
|
return hashlib.sha256(pw.encode()).hexdigest()
|
|
|
|
|
|
def _verify_password(stored_hash, pw):
|
|
"""Return (ok, needs_upgrade). Handles both legacy unsalted SHA-256 and
|
|
PBKDF2 hashes. Uses constant-time comparisons."""
|
|
if stored_hash.startswith(PBKDF2_PREFIX):
|
|
try:
|
|
_, iters, salt_hex, hash_hex = stored_hash.split("$", 3)
|
|
dk = hashlib.pbkdf2_hmac("sha256", pw.encode(),
|
|
bytes.fromhex(salt_hex), int(iters))
|
|
return hmac.compare_digest(dk.hex(), hash_hex), False
|
|
except Exception:
|
|
return False, False
|
|
# legacy unsalted sha256 (64 hex chars)
|
|
return hmac.compare_digest(stored_hash, _legacy_sha256(pw)), True
|
|
|
|
|
|
def _conn():
|
|
c = sqlite3.connect(DB_PATH)
|
|
c.row_factory = sqlite3.Row
|
|
return c
|
|
|
|
|
|
def init_db():
|
|
c = _conn()
|
|
c.executescript("""
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
token TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE TABLE IF NOT EXISTS profiles (
|
|
user_id INTEGER PRIMARY KEY,
|
|
display_name TEXT DEFAULT '',
|
|
avatar INTEGER DEFAULT 0,
|
|
about_me TEXT DEFAULT '',
|
|
profile_pic TEXT DEFAULT '',
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
user_id INTEGER PRIMARY KEY,
|
|
twilio_sid TEXT DEFAULT '',
|
|
twilio_auth_token TEXT DEFAULT '',
|
|
twilio_from TEXT DEFAULT '',
|
|
smtp_host TEXT DEFAULT '',
|
|
smtp_port INTEGER DEFAULT 587,
|
|
smtp_user TEXT DEFAULT '',
|
|
smtp_pass TEXT DEFAULT '',
|
|
email_from TEXT DEFAULT '',
|
|
no_contact_order INTEGER DEFAULT 0,
|
|
partner_name TEXT DEFAULT '',
|
|
domestic_violence INTEGER DEFAULT 0,
|
|
protection_order INTEGER DEFAULT 0,
|
|
safety_notes TEXT DEFAULT '',
|
|
tts_voice TEXT DEFAULT 'aria'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
agent_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
citations TEXT DEFAULT '',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE TABLE IF NOT EXISTS files (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
category TEXT DEFAULT 'Other',
|
|
filename TEXT NOT NULL,
|
|
orig_name TEXT NOT NULL,
|
|
mime TEXT DEFAULT '',
|
|
size INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
""")
|
|
c.commit()
|
|
# migrations for pre-existing DBs
|
|
for col, ddl in [("domestic_violence", "INTEGER DEFAULT 0"),
|
|
("protection_order", "INTEGER DEFAULT 0"),
|
|
("safety_notes", "TEXT DEFAULT ''")]:
|
|
try:
|
|
c.execute(f"ALTER TABLE settings ADD COLUMN {col} {ddl}")
|
|
c.commit()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
c.execute("ALTER TABLE profiles ADD COLUMN profile_pic TEXT DEFAULT ''")
|
|
c.commit()
|
|
except Exception:
|
|
pass
|
|
# speed up session expiry cleanup
|
|
try:
|
|
c.execute("CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)")
|
|
c.commit()
|
|
except Exception:
|
|
pass
|
|
c.close()
|
|
|
|
|
|
def user_from_token(token):
|
|
"""Resolve a session token to a user id. Sessions expire after 30 days;
|
|
expired tokens are deleted on sight (lazy cleanup)."""
|
|
if not token:
|
|
return None
|
|
c = _conn()
|
|
cutoff = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(time.time() - SESSION_TTL))
|
|
c.execute("DELETE FROM sessions WHERE created_at < ?", (cutoff,))
|
|
row = c.execute("SELECT user_id FROM sessions WHERE token=?", (token,)).fetchone()
|
|
c.commit()
|
|
c.close()
|
|
return row["user_id"] if row else None
|
|
|
|
|
|
def create_user(username, password):
|
|
c = _conn()
|
|
c.execute("INSERT INTO users (username, password_hash) VALUES (?,?)",
|
|
(username, hash_password(password)))
|
|
c.commit()
|
|
uid = c.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"]
|
|
c.execute("INSERT OR IGNORE INTO profiles (user_id) VALUES (?)", (uid,))
|
|
c.execute("INSERT OR IGNORE INTO settings (user_id) VALUES (?)", (uid,))
|
|
c.commit()
|
|
c.close()
|
|
return uid
|
|
|
|
|
|
def issue_token(uid):
|
|
token = secrets.token_hex(32)
|
|
c = _conn()
|
|
c.execute("INSERT INTO sessions (token, user_id) VALUES (?,?)", (token, uid))
|
|
c.commit()
|
|
c.close()
|
|
return token
|
|
|
|
|
|
def authenticate(username, password):
|
|
"""Verify credentials. Legacy (unsalted-SHA256) hashes upgrade in place on
|
|
successful login — the user notices nothing. Returns user id or None."""
|
|
c = _conn()
|
|
row = c.execute("SELECT id, password_hash FROM users WHERE username=?",
|
|
(username,)).fetchone()
|
|
c.close()
|
|
if not row:
|
|
return None
|
|
ok, needs_upgrade = _verify_password(row["password_hash"], password)
|
|
if not ok:
|
|
return None
|
|
if needs_upgrade:
|
|
try:
|
|
c = _conn()
|
|
c.execute("UPDATE users SET password_hash=? WHERE id=?",
|
|
(hash_password(password), row["id"]))
|
|
c.commit()
|
|
c.close()
|
|
except Exception:
|
|
pass # upgrade is best-effort; auth already succeeded
|
|
return row["id"]
|
|
|
|
|
|
def get_profile(uid):
|
|
c = _conn()
|
|
row = c.execute("SELECT * FROM profiles WHERE user_id=?", (uid,)).fetchone()
|
|
c.close()
|
|
if not row:
|
|
return {"display_name": "", "avatar": 0, "about_me": "", "profile_pic": ""}
|
|
return {"display_name": row["display_name"], "avatar": row["avatar"],
|
|
"about_me": row["about_me"], "profile_pic": row["profile_pic"] or ""}
|
|
|
|
|
|
def set_profile(uid, display_name=None, avatar=None, about_me=None, profile_pic=None):
|
|
cur = get_profile(uid)
|
|
dn = cur["display_name"] if display_name is None else display_name
|
|
av = cur["avatar"] if avatar is None else avatar
|
|
am = cur["about_me"] if about_me is None else about_me
|
|
pp = cur.get("profile_pic", "") if profile_pic is None else profile_pic
|
|
c = _conn()
|
|
c.execute("""INSERT INTO profiles (user_id, display_name, avatar, about_me, profile_pic, updated_at)
|
|
VALUES (?,?,?,?,?, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
display_name=excluded.display_name, avatar=excluded.avatar,
|
|
about_me=excluded.about_me, profile_pic=excluded.profile_pic, updated_at=CURRENT_TIMESTAMP""",
|
|
(uid, dn, av, am, pp))
|
|
c.commit()
|
|
c.close()
|
|
return {"display_name": dn, "avatar": av, "about_me": am, "profile_pic": pp}
|
|
def get_settings(uid):
|
|
c = _conn()
|
|
row = c.execute("SELECT * FROM settings WHERE user_id=?", (uid,)).fetchone()
|
|
c.close()
|
|
if not row:
|
|
return {}
|
|
# strip auth secrets from the read-back for safety, but return everything to owner
|
|
return dict(row)
|
|
|
|
|
|
def save_message(uid, agent_id, role, content, citations=None):
|
|
c = _conn()
|
|
c.execute("INSERT INTO messages (user_id, agent_id, role, content, citations) VALUES (?,?,?,?,?)",
|
|
(uid, agent_id, role, content, json.dumps(citations or [])))
|
|
c.commit()
|
|
c.close()
|
|
|
|
|
|
def get_conversation(uid, agent_id, limit=60):
|
|
c = _conn()
|
|
rows = c.execute(
|
|
"SELECT role, content, citations FROM messages WHERE user_id=? AND agent_id=? ORDER BY id DESC LIMIT ?",
|
|
(uid, agent_id, limit)).fetchall()
|
|
c.close()
|
|
out = []
|
|
for r in reversed(rows):
|
|
try:
|
|
cit = json.loads(r["citations"]) if r["citations"] else []
|
|
except Exception:
|
|
cit = []
|
|
out.append({"role": r["role"], "content": r["content"], "citations": cit})
|
|
return out
|
|
|
|
|
|
def clear_conversation(uid, agent_id):
|
|
c = _conn()
|
|
c.execute("DELETE FROM messages WHERE user_id=? AND agent_id=?", (uid, agent_id))
|
|
c.commit()
|
|
c.close()
|
|
|
|
|
|
def set_settings(uid, **fields):
|
|
allowed = {"twilio_sid", "twilio_auth_token", "twilio_from", "smtp_host", "smtp_port",
|
|
"smtp_user", "smtp_pass", "email_from", "no_contact_order", "partner_name",
|
|
"domestic_violence", "protection_order", "safety_notes", "tts_voice"}
|
|
updates = {k: v for k, v in fields.items() if k in allowed}
|
|
if not updates:
|
|
return get_settings(uid)
|
|
cols = ", ".join(f"{k}=?" for k in updates)
|
|
c = _conn()
|
|
c.execute(f"UPDATE settings SET {cols} WHERE user_id=?", (*updates.values(), uid))
|
|
c.commit()
|
|
c.close()
|
|
return get_settings(uid)
|
|
|
|
|
|
# ── Document vault ──
|
|
def add_file(uid, category, filename, orig_name, mime, size):
|
|
c = _conn()
|
|
c.execute("INSERT INTO files (user_id, category, filename, orig_name, mime, size) VALUES (?,?,?,?,?,?)",
|
|
(uid, category, filename, orig_name, mime, size))
|
|
c.commit()
|
|
fid = c.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
c.close()
|
|
return fid
|
|
|
|
|
|
def list_files(uid):
|
|
c = _conn()
|
|
rows = c.execute("SELECT * FROM files WHERE user_id=? ORDER BY created_at DESC, id DESC", (uid,)).fetchall()
|
|
c.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def get_file(uid, fid):
|
|
c = _conn()
|
|
row = c.execute("SELECT * FROM files WHERE id=? AND user_id=?", (fid, uid)).fetchone()
|
|
c.close()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def delete_file(uid, fid):
|
|
c = _conn()
|
|
c.execute("DELETE FROM files WHERE id=? AND user_id=?", (fid, uid))
|
|
c.commit()
|
|
c.close()
|