from __future__ import annotations import hashlib import hmac import secrets from datetime import datetime, timedelta from sqlalchemy import delete, select from sqlalchemy.orm import Session from .config import settings from .models import EntrySession, LoginSession, User PBKDF2_ITERATIONS = 310_000 def hash_password(password: str) -> str: salt = secrets.token_bytes(16) digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS) return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}" def verify_password(password: str, encoded: str) -> bool: try: algorithm, iterations, salt, expected = encoded.split("$", 3) if algorithm != "pbkdf2_sha256": return False actual = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), int(iterations)) return hmac.compare_digest(actual.hex(), expected) except (ValueError, TypeError): return False def validate_password(password: str) -> str | None: if len(password) < 10: return "密码至少需要 10 个字符" if not any(c.isalpha() for c in password) or not any(c.isdigit() for c in password): return "密码必须同时包含字母和数字" return None def token_hash(token: str) -> str: return hashlib.sha256((settings.secret + token).encode()).hexdigest() def entry_key_hash(key: str) -> str: return hmac.new(settings.secret.encode(), f"entry-key:{key}".encode(), hashlib.sha256).hexdigest() def create_session(db: Session, user: User, ip: str | None, user_agent: str | None) -> str: token = secrets.token_urlsafe(32) expires = datetime.now().astimezone() + timedelta(hours=settings.session_hours) db.add(LoginSession(token_hash=token_hash(token), user_id=user.id, expires_at=expires, ip_address=ip, user_agent=(user_agent or "")[:300])) db.commit() return token def get_session_user(db: Session, token: str | None) -> User | None: if not token: return None current = datetime.now().astimezone() session = db.scalar(select(LoginSession).where(LoginSession.token_hash == token_hash(token))) if not session: return None expires = session.expires_at if expires.tzinfo is None: expires = expires.replace(tzinfo=current.tzinfo) if expires <= current or not session.user.is_active: db.delete(session) db.commit() return None return session.user def revoke_session(db: Session, token: str | None) -> None: if token: db.execute(delete(LoginSession).where(LoginSession.token_hash == token_hash(token))) db.commit() def create_entry_session(db: Session, user: User, ip: str | None) -> str: token = secrets.token_urlsafe(32) expires = datetime.now().astimezone() + timedelta(minutes=30) db.add(EntrySession(token_hash=token_hash(token), user_id=user.id, expires_at=expires, ip_address=ip)) db.commit() return token def get_entry_session_user(db: Session, token: str | None) -> User | None: if not token: return None current = datetime.now().astimezone() session = db.scalar(select(EntrySession).where(EntrySession.token_hash == token_hash(token))) if not session: return None expires = session.expires_at if expires.tzinfo is None: expires = expires.replace(tzinfo=current.tzinfo) if expires <= current or not session.user.is_active or session.user.role not in {"admin", "uploader"} or not session.user.entry_key_hash: db.delete(session) db.commit() return None return session.user def revoke_entry_session(db: Session, token: str | None) -> None: if token: db.execute(delete(EntrySession).where(EntrySession.token_hash == token_hash(token))) db.commit() def revoke_user_entry_sessions(db: Session, user_id: int) -> None: db.execute(delete(EntrySession).where(EntrySession.user_id == user_id))