feat(auth): add DB-backed login, token management, and idempotent auth migration

This commit is contained in:
2026-04-03 12:55:34 +00:00
parent 5168e52e4b
commit 06b1805b92
28 changed files with 1609 additions and 140 deletions
+197 -12
View File
@@ -12,18 +12,37 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import LedgerEntry, Portfolio, StrategySet, User
from app.domain.models import AuthToken, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
_SELECT_USER = """
SELECT
id,
username,
email,
permission,
is_active,
password_hash,
created_at,
updated_at
updated_at,
last_login_at
FROM users
"""
_SELECT_AUTH_TOKEN = """
SELECT
id,
user_id,
name,
token_prefix,
permission,
token_type,
expires_at,
last_used_at,
revoked_at,
created_at
FROM auth_tokens
"""
_SELECT_PORTFOLIO = """
SELECT
portfolio_id,
@@ -94,11 +113,44 @@ class PostgresPortfolioRepository:
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
permission TEXT NOT NULL DEFAULT 'read',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
password_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS auth_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token_prefix TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
permission TEXT NOT NULL,
token_type TEXT NOT NULL,
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created
ON auth_tokens (user_id, created_at DESC)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash_active
ON auth_tokens (token_hash)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS strategy_sets (
@@ -178,8 +230,29 @@ class PostgresPortfolioRepository:
id=row[0],
username=row[1],
email=row[2],
created_at=row[3] if len(row) > 3 else None,
updated_at=row[4] if len(row) > 4 else None,
permission=row[3] if len(row) > 3 else "read",
is_active=bool(row[4]) if len(row) > 4 else True,
password_hash=row[5] if len(row) > 5 else None,
created_at=row[6] if len(row) > 6 else None,
updated_at=row[7] if len(row) > 7 else None,
last_login_at=row[8] if len(row) > 8 else None,
)
@staticmethod
def _row_to_auth_token(row: Any) -> AuthToken:
if row is None:
raise ValueError("토큰을 찾을 수 없습니다")
return AuthToken(
id=row[0],
user_id=row[1],
name=row[2],
token_prefix=row[3],
permission=row[4],
token_type=row[5],
expires_at=row[6] if len(row) > 6 else None,
last_used_at=row[7] if len(row) > 7 else None,
revoked_at=row[8] if len(row) > 8 else None,
created_at=row[9] if len(row) > 9 else None,
)
@staticmethod
@@ -260,6 +333,15 @@ class PostgresPortfolioRepository:
row = cur.fetchone()
return self._row_to_user(row)
def get_user_by_username(self, username: str) -> User | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_USER + "WHERE username = %s", (username,))
row = cur.fetchone()
if row is None:
return None
return self._row_to_user(row)
def list_users(self) -> list[User]:
self.ensure_schema()
with self._connect().cursor() as cur:
@@ -267,23 +349,40 @@ class PostgresPortfolioRepository:
rows = cur.fetchall()
return [self._row_to_user(row) for row in rows]
def create_user(self, username: str, *, email: str | None = None) -> User:
def create_user(
self,
username: str,
*,
email: str | None = None,
password_hash: str | None = None,
permission: str = "read",
is_active: bool = True,
) -> User:
self.ensure_schema()
with self._connect().cursor() as cur:
self._ensure_user_uniqueness(cur, username=username, email=email)
user_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO users (id, username, email)
VALUES (%s, %s, %s)
RETURNING id, username, email, created_at, updated_at
INSERT INTO users (id, username, email, permission, is_active, password_hash)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, username, email, permission, is_active, password_hash, created_at, updated_at, last_login_at
""",
(user_id, username, email),
(user_id, username, email, permission, is_active, password_hash),
)
row = cur.fetchone()
return self._row_to_user(row)
def update_user(self, user_id: str, *, username: str | None = None, email: str | None = None) -> User:
def update_user(
self,
user_id: str,
*,
username: str | None = None,
email: str | None = None,
password_hash: str | None = None,
permission: str | None = None,
is_active: bool | None = None,
) -> User:
self.ensure_schema()
assignments: list[str] = []
params: list[object] = []
@@ -301,6 +400,15 @@ class PostgresPortfolioRepository:
if email is not None:
assignments.append("email = %s")
params.append(email)
if password_hash is not None:
assignments.append("password_hash = %s")
params.append(password_hash)
if permission is not None:
assignments.append("permission = %s")
params.append(permission)
if is_active is not None:
assignments.append("is_active = %s")
params.append(is_active)
if not assignments:
return self._row_to_user(existing_row)
@@ -311,7 +419,7 @@ class PostgresPortfolioRepository:
UPDATE users
SET {', '.join(assignments)}
WHERE id = %s
RETURNING id, username, email, created_at, updated_at
RETURNING id, username, email, permission, is_active, password_hash, created_at, updated_at, last_login_at
""",
(*params, user_id),
)
@@ -324,8 +432,85 @@ class PostgresPortfolioRepository:
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
cur.execute("DELETE FROM auth_tokens WHERE user_id = %s", (user_id,))
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
def update_user_last_login(self, user_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s",
(user_id,),
)
def create_auth_token(
self,
user_id: str,
*,
name: str,
token_hash: str,
token_prefix: str,
permission: str,
token_type: str,
expires_at: datetime | None = None,
) -> AuthToken:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
token_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO auth_tokens (id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, user_id, name, token_prefix, permission, token_type, expires_at, last_used_at, revoked_at, created_at
""",
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
def list_auth_tokens(self, user_id: str) -> list[AuthToken]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
_SELECT_AUTH_TOKEN + "WHERE user_id = %s ORDER BY created_at DESC, id ASC",
(user_id,),
)
rows = cur.fetchall()
return [self._row_to_auth_token(row) for row in rows]
def revoke_auth_token(self, user_id: str, token_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"UPDATE auth_tokens SET revoked_at = NOW() WHERE id = %s AND user_id = %s RETURNING id",
(token_id, user_id),
)
row = cur.fetchone()
if row is None:
raise ValueError("토큰을 찾을 수 없습니다")
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT id, user_id, permission, token_type
FROM auth_tokens
WHERE token_hash = %s
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
""",
(token_hash,),
)
row = cur.fetchone()
if row is None:
return None
cur.execute("UPDATE auth_tokens SET last_used_at = NOW() WHERE id = %s", (row[0],))
return TokenPrincipal(token_id=row[0], user_id=row[1], permission=row[2], token_type=row[3])
def get(self, portfolio_id: str) -> Portfolio:
self.ensure_schema()
with self._connect().cursor() as cur: