"""Auth business logic — signup, login, API key management, credential CRUD.""" from __future__ import annotations from typing import Optional from .models import User, OdooCredential, ApiKeyInfo from .crypto import ( hash_password, verify_password, generate_api_key, verify_api_key, encrypt_credential, decrypt_credential, generate_reset_token, hash_reset_token, ) from ..database import db_execute, db_commit, db_fetchone, db_fetchall RESET_TOKEN_TTL_MINUTES = 30 class AuthError(Exception): pass # ─── OdooInstanceConfig re-export for connection layer ──────────────────────── class OdooInstanceConfig: def __init__(self, name: str, url: str, db: str, username: str, credential: str): self.name = name self.url = url self.db = db self.username = username self._credential = credential @property def credential(self) -> str: return self._credential # ─── Users ──────────────────────────────────────────────────────────────────── def signup(email: str, password: str) -> tuple[User, str]: """Create a new user. Returns (user, full_api_key). API key shown only once.""" email = email.strip().lower() if not email or not password: raise AuthError("Email and password are required.") if len(password) < 8: raise AuthError("Password must be at least 8 characters.") existing = db_fetchone("SELECT id FROM users WHERE email = ?", (email,)) if existing: raise AuthError("An account with this email already exists.") pw_hash = hash_password(password) full_key, key_hash, key_prefix = generate_api_key() db_execute( "INSERT INTO users (email, password_hash, api_key_hash, api_key_prefix) VALUES (?, ?, ?, ?)", (email, pw_hash, key_hash, key_prefix), ) db_commit() row = db_fetchone("SELECT * FROM users WHERE email = ?", (email,)) user = _row_to_user(row) return user, full_key def login(email: str, password: str) -> User: """Verify email + password. Returns the user or raises AuthError.""" email = email.strip().lower() row = db_fetchone("SELECT * FROM users WHERE email = ?", (email,)) if not row or not verify_password(password, row["password_hash"]): raise AuthError("Invalid email or password.") if not row["is_active"]: raise AuthError("Account is disabled.") return _row_to_user(row) def get_user_by_api_key(api_key: str) -> Optional[User]: """Look up a user by their API key. Returns None if not found/invalid. Checks two places: the legacy users.api_key_hash column (kept for backward compatibility), and the api_keys table for named/scoped keys created via POST /api/keys — a named key is a real credential and must authenticate here too, not just show up in the Tokens list. If the legacy key matches but has since been backfilled into api_keys (via list_api_keys) and explicitly revoked there, it's rejected — otherwise "revoking" the legacy key in the UI would silently do nothing. """ if not api_key or not api_key.startswith("mtom_"): return None prefix = api_key[:12] rows = db_fetchall("SELECT * FROM users WHERE api_key_prefix = ? AND is_active = 1", (prefix,)) for row in rows: if verify_api_key(api_key, row["api_key_hash"]): revoked = db_fetchone( "SELECT 1 FROM api_keys WHERE user_id = ? AND key_hash = ? AND is_active = 0", (row["id"], row["api_key_hash"]), ) if revoked: return None return _row_to_user(row) key_rows = db_fetchall( "SELECT * FROM api_keys WHERE key_prefix = ? AND is_active = 1 AND revoked_at IS NULL", (prefix,), ) for key_row in key_rows: if verify_api_key(api_key, key_row["key_hash"]): user_row = db_fetchone("SELECT * FROM users WHERE id = ? AND is_active = 1", (key_row["user_id"],)) if user_row: return _row_to_user(user_row) return None def regenerate_api_key(user_id: int) -> str: """Generate a new API key for the user. Returns the full key (shown once). Also syncs the backfilled 'Default (legacy)' row in api_keys (see list_api_keys) if one exists, so the Tokens page doesn't show a stale prefix/hash for a key that no longer works after rotation.""" full_key, key_hash, key_prefix = generate_api_key() db_execute( "UPDATE users SET api_key_hash = ?, api_key_prefix = ? WHERE id = ?", (key_hash, key_prefix, user_id), ) db_execute( "UPDATE api_keys SET key_hash = ?, key_prefix = ? WHERE user_id = ? AND name = 'Default (legacy)'", (key_hash, key_prefix, user_id), ) db_commit() return full_key def get_user_by_id(user_id: int) -> Optional[User]: row = db_fetchone("SELECT * FROM users WHERE id = ?", (user_id,)) return _row_to_user(row) if row else None # ─── Admin-only user management ──────────────────────────────────────────── def list_all_users() -> list[User]: """Admin-only: list every user, no ownership scoping.""" rows = db_fetchall("SELECT * FROM users ORDER BY created_at DESC") return [_row_to_user(r) for r in rows] def set_user_active(user_id: int, is_active: bool) -> bool: """Admin-only enable/disable. Takes effect immediately for new get_user_by_api_key() lookups and new /api/login attempts (both already check is_active) — no changes needed to that existing logic.""" cur = db_execute("UPDATE users SET is_active = ? WHERE id = ?", (int(is_active), user_id)) db_commit() return cur.rowcount > 0 def delete_user(user_id: int) -> bool: """Admin-only permanent delete. Cascading cleanup of odoo_credentials, api_keys, password_reset_tokens, tool_call_events is already handled by the ON DELETE CASCADE / SET NULL foreign keys declared in database.py.""" cur = db_execute("DELETE FROM users WHERE id = ?", (user_id,)) db_commit() return cur.rowcount > 0 # ─── Multiple API Keys ────────────────────────────────────────────────────── def create_api_key(user_id: int, name: str, instance_name: Optional[str] = None) -> tuple[int, str]: """Create a new named API key. If instance_name is set, this key is scoped to that one Odoo connection only — see server.py's call_tool wrapper for enforcement. Returns (key_id, full_key) — full_key shown once.""" full_key, key_hash, key_prefix = generate_api_key() cur = db_execute( "INSERT INTO api_keys (user_id, name, key_hash, key_prefix, instance_name) VALUES (?, ?, ?, ?, ?)", (user_id, name, key_hash, key_prefix, instance_name), ) db_commit() return cur.lastrowid, full_key def list_api_keys(user_id: int) -> list[ApiKeyInfo]: """List all named API keys for a user. Lazily backfills a 'Default (legacy)' row mirroring users.api_key_hash the first time this is called, so the legacy signup key shows up in the UI like any other key.""" rows = db_fetchall( "SELECT * FROM api_keys WHERE user_id = ? ORDER BY created_at", (user_id,) ) if not rows: user_row = db_fetchone("SELECT api_key_hash, api_key_prefix FROM users WHERE id = ?", (user_id,)) if user_row and user_row["api_key_hash"]: db_execute( "INSERT INTO api_keys (user_id, name, key_hash, key_prefix) VALUES (?, ?, ?, ?)", (user_id, "Default (legacy)", user_row["api_key_hash"], user_row["api_key_prefix"]), ) db_commit() rows = db_fetchall( "SELECT * FROM api_keys WHERE user_id = ? ORDER BY created_at", (user_id,) ) return [_row_to_api_key(r) for r in rows] def revoke_api_key(user_id: int, key_id: int) -> bool: """Revoke a named API key. Returns False if not found/not owned by user.""" cur = db_execute( """UPDATE api_keys SET revoked_at = datetime('now'), is_active = 0 WHERE id = ? AND user_id = ? AND is_active = 1""", (key_id, user_id), ) db_commit() return cur.rowcount > 0 def get_api_key_scope_for_mcp(api_key: str) -> tuple[Optional[int], Optional[str]]: """Resolve which api_keys row (if any) this raw key belongs to, plus its instance scope (None = unscoped, can access any of the user's Odoo connections). Used at MCP-session-start for analytics attribution (api_key_id) and per-call instance enforcement (instance_name) — see server.py's call_tool wrapper. Returns (None, None) only if the key has never been backfilled into api_keys at all (harmless — analytics rows for that session carry api_key_id=NULL until the user's first /api/keys view triggers the backfill). The caller already validated the key via get_user_by_api_key before calling this.""" if not api_key or not api_key.startswith("mtom_"): return None, None prefix = api_key[:12] rows = db_fetchall( "SELECT id, key_hash, instance_name FROM api_keys WHERE key_prefix = ? AND is_active = 1 AND revoked_at IS NULL", (prefix,), ) for row in rows: if verify_api_key(api_key, row["key_hash"]): db_execute("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?", (row["id"],)) db_commit() return row["id"], row["instance_name"] return None, None def _row_to_api_key(row) -> ApiKeyInfo: return ApiKeyInfo( id=row["id"], name=row["name"], key_prefix=row["key_prefix"], created_at=row["created_at"], last_used_at=row["last_used_at"], revoked_at=row["revoked_at"], is_active=bool(row["is_active"]), instance_name=row["instance_name"], ) # ─── Password Reset ───────────────────────────────────────────────────────── def request_password_reset(email: str) -> Optional[str]: """Look up user by email. If found, create a reset token (expires in RESET_TOKEN_TTL_MINUTES) and return the raw token for the caller to email or log. Returns None if the email isn't found — callers must respond identically either way to avoid leaking which emails are registered.""" email = email.strip().lower() row = db_fetchone("SELECT id FROM users WHERE email = ?", (email,)) if not row: return None raw_token, token_hash = generate_reset_token() db_execute( """INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, datetime('now', ?))""", (row["id"], token_hash, f"+{RESET_TOKEN_TTL_MINUTES} minutes"), ) db_commit() return raw_token def reset_password(raw_token: str, new_password: str) -> None: """Verify the reset token and update the user's password. Raises AuthError if the token is missing/expired/already used. On success, also revokes all active API keys for that user as a post-reset security measure.""" if len(new_password) < 8: raise AuthError("Password must be at least 8 characters.") token_hash = hash_reset_token(raw_token) row = db_fetchone( """SELECT * FROM password_reset_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > datetime('now')""", (token_hash,), ) if not row: raise AuthError("Invalid or expired reset link.") user_id = row["user_id"] pw_hash = hash_password(new_password) db_execute("UPDATE users SET password_hash = ? WHERE id = ?", (pw_hash, user_id)) db_execute("UPDATE password_reset_tokens SET used_at = datetime('now') WHERE id = ?", (row["id"],)) db_execute( "UPDATE api_keys SET revoked_at = datetime('now'), is_active = 0 WHERE user_id = ? AND is_active = 1", (user_id,), ) db_commit() def _row_to_user(row) -> User: return User( id=row["id"], email=row["email"], api_key_prefix=row["api_key_prefix"], is_active=bool(row["is_active"]), created_at=row["created_at"], ) # ─── Odoo Credentials ───────────────────────────────────────────────────────── def upsert_odoo_credential( user_id: int, instance_name: str, url: str, database_name: str, odoo_username: str, credential: str, is_api_key: bool = True, ) -> OdooCredential: """Add or replace an Odoo instance credential for a user.""" enc = encrypt_credential(credential) existing = db_fetchone( "SELECT id FROM odoo_credentials WHERE user_id = ? AND instance_name = ?", (user_id, instance_name), ) if existing: db_execute( """UPDATE odoo_credentials SET url=?, database_name=?, odoo_username=?, credential_enc=?, is_api_key=? WHERE user_id=? AND instance_name=?""", (url, database_name, odoo_username, enc, int(is_api_key), user_id, instance_name), ) else: db_execute( """INSERT INTO odoo_credentials (user_id, instance_name, url, database_name, odoo_username, credential_enc, is_api_key) VALUES (?, ?, ?, ?, ?, ?, ?)""", (user_id, instance_name, url, database_name, odoo_username, enc, int(is_api_key)), ) db_commit() row = db_fetchone( "SELECT * FROM odoo_credentials WHERE user_id=? AND instance_name=?", (user_id, instance_name), ) return _row_to_cred(row) def list_odoo_credentials(user_id: int) -> list[OdooCredential]: rows = db_fetchall("SELECT * FROM odoo_credentials WHERE user_id=? ORDER BY instance_name", (user_id,)) return [_row_to_cred(r) for r in rows] def delete_odoo_credential(user_id: int, instance_name: str) -> bool: cur = db_execute( "DELETE FROM odoo_credentials WHERE user_id=? AND instance_name=?", (user_id, instance_name), ) db_commit() return cur.rowcount > 0 def get_odoo_instance_configs(user_id: int) -> list[OdooInstanceConfig]: """Return decrypted Odoo configs for this user, ready for ConnectionManager.""" creds = list_odoo_credentials(user_id) return [ OdooInstanceConfig( name=c.instance_name, url=c.url, db=c.database_name, username=c.odoo_username, credential=decrypt_credential(c.credential_enc), ) for c in creds ] def _row_to_cred(row) -> OdooCredential: return OdooCredential( id=row["id"], user_id=row["user_id"], instance_name=row["instance_name"], url=row["url"], database_name=row["database_name"], odoo_username=row["odoo_username"], credential_enc=row["credential_enc"], is_api_key=bool(row["is_api_key"]), created_at=row["created_at"], )