feat(auth): add DB-backed login, token management, and idempotent auth migration
This commit is contained in:
+197
-12
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user