240 lines
6.7 KiB
Python
240 lines
6.7 KiB
Python
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from .auth_utils import (
|
|
generate_session_token,
|
|
hash_password,
|
|
hash_session_token,
|
|
normalize_username,
|
|
validate_password,
|
|
validate_username,
|
|
verify_password,
|
|
)
|
|
from .config import read_bool_env, read_int_env, settings
|
|
from .models import AuthAuditLogORM, AuthSessionORM, AuthUserORM
|
|
|
|
|
|
ROLE_ADMIN = "admin"
|
|
ROLE_VIEWER = "viewer"
|
|
VALID_ROLES = {ROLE_ADMIN, ROLE_VIEWER}
|
|
|
|
|
|
SESSION_COOKIE_NAME = settings.AUTH_SESSION_COOKIE_NAME
|
|
SESSION_TTL_HOURS = read_int_env("SESSION_TTL_HOURS", 12)
|
|
COOKIE_SECURE = read_bool_env("AUTH_COOKIE_SECURE", False)
|
|
COOKIE_SAMESITE = (settings.AUTH_COOKIE_SAMESITE or "lax").strip().lower()
|
|
if COOKIE_SAMESITE not in {"lax", "strict", "none"}:
|
|
COOKIE_SAMESITE = "lax"
|
|
|
|
|
|
def get_cookie_options() -> Dict[str, Any]:
|
|
max_age = SESSION_TTL_HOURS * 3600
|
|
return {
|
|
"key": SESSION_COOKIE_NAME,
|
|
"httponly": True,
|
|
"secure": COOKIE_SECURE,
|
|
"samesite": COOKIE_SAMESITE,
|
|
"max_age": max_age,
|
|
"path": "/",
|
|
}
|
|
|
|
|
|
def _normalize_role(role: Optional[str]) -> str:
|
|
value = (role or ROLE_VIEWER).strip().lower()
|
|
if value not in VALID_ROLES:
|
|
raise ValueError(f"Invalid role: {role}")
|
|
return value
|
|
|
|
|
|
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[AuthUserORM]:
|
|
normalized = normalize_username(username)
|
|
if not normalized:
|
|
return None
|
|
result = await db.execute(
|
|
select(AuthUserORM).where(AuthUserORM.username == normalized)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def list_users(db: AsyncSession) -> List[AuthUserORM]:
|
|
result = await db.execute(
|
|
select(AuthUserORM).order_by(AuthUserORM.created_at.desc(), AuthUserORM.id.desc())
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
async def create_user(
|
|
db: AsyncSession,
|
|
username: str,
|
|
password: str,
|
|
role: str = ROLE_VIEWER,
|
|
is_active: bool = True,
|
|
created_by: Optional[str] = None,
|
|
) -> AuthUserORM:
|
|
ok, reason = validate_username(username)
|
|
if not ok:
|
|
raise ValueError(reason)
|
|
ok, reason = validate_password(password)
|
|
if not ok:
|
|
raise ValueError(reason)
|
|
|
|
normalized = normalize_username(username)
|
|
normalized_role = _normalize_role(role)
|
|
exists = await get_user_by_username(db, normalized)
|
|
if exists:
|
|
raise ValueError("用户名已存在。")
|
|
|
|
user = AuthUserORM(
|
|
username=normalized,
|
|
password_hash=hash_password(password),
|
|
role=normalized_role,
|
|
is_active=bool(is_active),
|
|
created_by=created_by,
|
|
)
|
|
db.add(user)
|
|
await db.flush()
|
|
return user
|
|
|
|
|
|
async def update_user(
|
|
db: AsyncSession,
|
|
user: AuthUserORM,
|
|
role: Optional[str] = None,
|
|
is_active: Optional[bool] = None,
|
|
password: Optional[str] = None,
|
|
) -> AuthUserORM:
|
|
if role is not None:
|
|
user.role = _normalize_role(role)
|
|
if is_active is not None:
|
|
user.is_active = bool(is_active)
|
|
if password is not None:
|
|
ok, reason = validate_password(password)
|
|
if not ok:
|
|
raise ValueError(reason)
|
|
user.password_hash = hash_password(password)
|
|
await db.flush()
|
|
return user
|
|
|
|
|
|
async def authenticate_user(
|
|
db: AsyncSession,
|
|
username: str,
|
|
password: str,
|
|
) -> Optional[AuthUserORM]:
|
|
user = await get_user_by_username(db, username)
|
|
if not user or not user.is_active:
|
|
return None
|
|
if not verify_password(password, user.password_hash):
|
|
return None
|
|
return user
|
|
|
|
|
|
async def create_session(
|
|
db: AsyncSession,
|
|
user: AuthUserORM,
|
|
ip_address: Optional[str],
|
|
user_agent: Optional[str],
|
|
) -> Tuple[str, datetime]:
|
|
token = generate_session_token()
|
|
expires_at = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=SESSION_TTL_HOURS)
|
|
|
|
session = AuthSessionORM(
|
|
token_hash=hash_session_token(token),
|
|
user_id=user.id,
|
|
expires_at=expires_at,
|
|
ip_address=(ip_address or "")[:64] or None,
|
|
user_agent=(user_agent or "")[:512] or None,
|
|
)
|
|
user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
db.add(session)
|
|
await db.flush()
|
|
return token, expires_at
|
|
|
|
|
|
async def get_user_by_session_token(db: AsyncSession, token: Optional[str]) -> Optional[AuthUserORM]:
|
|
if not token:
|
|
return None
|
|
token_hash = hash_session_token(token)
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
result = await db.execute(
|
|
select(AuthUserORM)
|
|
.join(AuthSessionORM, AuthSessionORM.user_id == AuthUserORM.id)
|
|
.where(
|
|
AuthSessionORM.token_hash == token_hash,
|
|
AuthSessionORM.is_revoked == False,
|
|
AuthSessionORM.expires_at > now,
|
|
AuthUserORM.is_active == True,
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def revoke_session(db: AsyncSession, token: Optional[str]) -> bool:
|
|
if not token:
|
|
return False
|
|
token_hash = hash_session_token(token)
|
|
result = await db.execute(
|
|
select(AuthSessionORM).where(
|
|
AuthSessionORM.token_hash == token_hash,
|
|
AuthSessionORM.is_revoked == False,
|
|
)
|
|
)
|
|
session = result.scalar_one_or_none()
|
|
if not session:
|
|
return False
|
|
session.is_revoked = True
|
|
await db.flush()
|
|
return True
|
|
|
|
|
|
async def add_audit_log(
|
|
db: AsyncSession,
|
|
action: str,
|
|
user: Optional[AuthUserORM] = None,
|
|
user_id: Optional[int] = None,
|
|
username: Optional[str] = None,
|
|
resource: Optional[str] = None,
|
|
detail: Optional[Dict[str, Any]] = None,
|
|
ip_address: Optional[str] = None,
|
|
) -> None:
|
|
resolved_user_id = user_id
|
|
resolved_username = username
|
|
|
|
if user is not None:
|
|
if resolved_user_id is None:
|
|
try:
|
|
resolved_user_id = user.id
|
|
except Exception:
|
|
resolved_user_id = None
|
|
if resolved_username is None:
|
|
try:
|
|
resolved_username = user.username
|
|
except Exception:
|
|
resolved_username = None
|
|
|
|
entry = AuthAuditLogORM(
|
|
user_id=resolved_user_id,
|
|
username=resolved_username,
|
|
action=action,
|
|
resource=resource,
|
|
detail=detail or None,
|
|
ip_address=(ip_address or "")[:64] or None,
|
|
)
|
|
db.add(entry)
|
|
await db.flush()
|
|
|
|
|
|
def to_user_payload(user: AuthUserORM) -> Dict[str, Any]:
|
|
return {
|
|
"id": user.id,
|
|
"username": user.username,
|
|
"role": user.role,
|
|
"is_active": user.is_active,
|
|
"created_at": user.created_at,
|
|
"last_login_at": user.last_login_at,
|
|
}
|