114 lines
3.6 KiB
Python
114 lines
3.6 KiB
Python
"""PostgreSQL-backed user store for authentication.
|
|
|
|
Manages a `users` table with hashed passwords and roles.
|
|
Provides lookup by username for the login flow.
|
|
Table DDL is auto-applied on first connection.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
import psycopg2
|
|
import psycopg2.extras
|
|
from loguru import logger
|
|
from passlib.context import CryptContext
|
|
|
|
from app.config.settings import settings
|
|
|
|
|
|
# bcrypt context — work factor 12 is a good production default.
|
|
_PWD_CTX = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
|
|
# DDL executed once to ensure the table exists.
|
|
_CREATE_TABLE_SQL = """
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
username VARCHAR(100) UNIQUE NOT NULL,
|
|
hashed_pw TEXT NOT NULL,
|
|
role VARCHAR(50) NOT NULL DEFAULT 'readonly',
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
"""
|
|
|
|
|
|
@dataclass
|
|
class UserRecord:
|
|
"""A single row from the users table."""
|
|
|
|
id: str
|
|
username: str
|
|
hashed_pw: str
|
|
role: str
|
|
is_active: bool
|
|
|
|
|
|
class PostgresUserStore:
|
|
"""Read and verify users stored in the PostgreSQL users table.
|
|
|
|
The connection is opened on first use and shared for the lifetime
|
|
of the singleton instance wired by bootstrap.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialise the store and ensure the users table exists."""
|
|
self._conn = psycopg2.connect(
|
|
host=settings.postgres_host,
|
|
port=settings.postgres_port,
|
|
user=settings.postgres_user,
|
|
password=settings.postgres_password,
|
|
dbname=settings.postgres_db,
|
|
cursor_factory=psycopg2.extras.RealDictCursor,
|
|
)
|
|
self._conn.autocommit = True
|
|
self._ensure_table()
|
|
|
|
def _ensure_table(self) -> None:
|
|
"""Create the users table if it does not already exist."""
|
|
with self._conn.cursor() as cur:
|
|
# Enable pgcrypto so gen_random_uuid() is available for UUID primary keys.
|
|
try:
|
|
cur.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto;")
|
|
except Exception:
|
|
self._conn.rollback()
|
|
cur.execute(_CREATE_TABLE_SQL)
|
|
|
|
def get_by_username(self, username: str) -> Optional[UserRecord]:
|
|
"""Return a UserRecord for the given username, or None if not found."""
|
|
with self._conn.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT id, username, hashed_pw, role, is_active "
|
|
"FROM users WHERE username = %s",
|
|
(username,),
|
|
)
|
|
row = cur.fetchone()
|
|
if row is None:
|
|
return None
|
|
return UserRecord(
|
|
id=str(row["id"]),
|
|
username=row["username"],
|
|
hashed_pw=row["hashed_pw"],
|
|
role=row["role"],
|
|
is_active=row["is_active"],
|
|
)
|
|
|
|
def verify_password(self, plain: str, hashed: str) -> bool:
|
|
"""Return True if `plain` matches the stored bcrypt hash."""
|
|
return _PWD_CTX.verify(plain, hashed)
|
|
|
|
def authenticate(self, username: str, password: str) -> Optional[UserRecord]:
|
|
"""Return the UserRecord if credentials are valid, else None."""
|
|
user = self.get_by_username(username)
|
|
if user is None or not user.is_active:
|
|
return None
|
|
if not self.verify_password(password, user.hashed_pw):
|
|
return None
|
|
return user
|
|
|
|
@staticmethod
|
|
def hash_password(plain: str) -> str:
|
|
"""Hash a plain-text password with bcrypt."""
|
|
return _PWD_CTX.hash(plain)
|