from __future__ import annotations import base64 import hashlib import hmac import json import os from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Annotated, Any from fastapi import Depends, HTTPException, Security from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core.config import get_settings from app.db.session import get_db from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole from app.schemas.database import AuthSession, LoginUser PBKDF2_ITERATIONS = 600000 ROLE_TITLE_MAP = { "ADMIN": "总控驾驶舱", "SALES": "订单与客户协同", "PLANNER": "MRP与排产控制", "PURCHASER": "采购执行中心", "WAREHOUSE": "仓储作业中心", "SUPERVISOR": "车间调度中心", "OPERATOR": "现场报工中心", "FINANCE": "成本与财务中心", "EQUIP_ADMIN": "设备维护中心", } @dataclass class AuthContext: user: User employee: Employee department: Department | None role_codes: list[str] role_names: list[str] permission_codes: list[str] bearer_scheme = HTTPBearer(auto_error=False, description="登录接口返回的 Bearer Token") def hash_password(password: str, salt: str | None = None) -> str: salt_bytes = os.urandom(16) if salt is None else salt.encode("utf-8") digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt_bytes, PBKDF2_ITERATIONS) return ( f"pbkdf2_sha256${PBKDF2_ITERATIONS}$" f"{base64.urlsafe_b64encode(salt_bytes).decode('utf-8')}$" f"{base64.urlsafe_b64encode(digest).decode('utf-8')}" ) def verify_password(password: str, stored_hash: str) -> bool: try: algorithm, iterations, salt_b64, digest_b64 = stored_hash.split("$", 3) except ValueError: return False if algorithm != "pbkdf2_sha256": return False salt = base64.urlsafe_b64decode(salt_b64.encode("utf-8")) expected = base64.urlsafe_b64decode(digest_b64.encode("utf-8")) actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations)) return hmac.compare_digest(actual, expected) def get_auth_context(db: Session, username: str) -> AuthContext: stmt = ( select(User, Employee, Department) .join(Employee, Employee.id == User.employee_id) .outerjoin(Department, Department.id == User.dept_id) .where(User.username == username, User.status == "ACTIVE") ) row = db.execute(stmt).first() if not row: raise HTTPException(status_code=401, detail="用户名或密码错误") user, employee, department = row role_rows = db.execute( select(Role.role_code, Role.role_name) .join(UserRole, UserRole.role_id == Role.id) .where(UserRole.user_id == user.id, Role.status == "ACTIVE") .order_by(Role.id) ).all() role_codes = [row[0] for row in role_rows] role_names = [row[1] for row in role_rows] permission_rows = db.execute( select(Permission.permission_code) .join(RolePermission, RolePermission.permission_id == Permission.id) .join(UserRole, UserRole.role_id == RolePermission.role_id) .where( UserRole.user_id == user.id, Permission.status == "ACTIVE", ) .group_by(Permission.permission_code) .order_by(Permission.permission_code) ).all() permission_codes = [row[0] for row in permission_rows] if user.is_super_admin or "ADMIN" in role_codes: for permission_code in ("MENU_SYSTEM_EXTENSION", "MENU_SYSTEM_PERMISSION"): if permission_code not in permission_codes: permission_codes.append(permission_code) return AuthContext( user=user, employee=employee, department=department, role_codes=role_codes, role_names=role_names, permission_codes=permission_codes, ) def login_with_password(db: Session, username: str, password: str) -> AuthSession: context = get_auth_context(db, username) if not verify_password(password, context.user.password_hash): raise HTTPException(status_code=401, detail="用户名或密码错误") context.user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None) db.add(context.user) db.commit() return build_auth_session(context) def build_login_user(context: AuthContext) -> LoginUser: title = ROLE_TITLE_MAP.get(context.role_codes[0], "工业风管理台") if context.role_codes else "工业风管理台" return LoginUser( user_id=context.user.id, username=context.user.username, employee_id=context.employee.id, mobile=context.employee.mobile, display_name=context.user.nickname or context.employee.employee_name, title=title, dept_name=context.department.dept_name if context.department else None, role_codes=context.role_codes, role_names=context.role_names, permission_codes=context.permission_codes, ) def build_auth_session(context: AuthContext) -> AuthSession: access_token, expires_at = create_access_token(context) return AuthSession( access_token=access_token, token_type="bearer", expires_at=expires_at, **build_login_user(context).model_dump(), ) def next_employee_code(db: Session) -> str: total = db.scalar(select(func.count(Employee.id))) or 0 return f"EMP-{int(total) + 1:04d}" def create_access_token(context: AuthContext) -> tuple[str, datetime]: settings = get_settings() now = datetime.now(timezone.utc) expires_at = now + timedelta(minutes=settings.auth_token_expire_minutes) header = {"alg": "HS256", "typ": "JWT"} payload = { "sub": context.user.username, "uid": context.user.id, "iat": int(now.timestamp()), "exp": int(expires_at.timestamp()), } encoded_header = _urlsafe_b64encode(json.dumps(header, separators=(",", ":")).encode("utf-8")) encoded_payload = _urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")) signing_input = f"{encoded_header}.{encoded_payload}" signature = _sign_token(signing_input, settings.auth_secret_key) return f"{signing_input}.{signature}", expires_at def decode_access_token(token: str) -> dict[str, Any]: settings = get_settings() try: encoded_header, encoded_payload, signature = token.split(".", 2) except ValueError as exc: raise HTTPException(status_code=401, detail="登录凭证无效") from exc signing_input = f"{encoded_header}.{encoded_payload}" expected_signature = _sign_token(signing_input, settings.auth_secret_key) if not hmac.compare_digest(expected_signature, signature): raise HTTPException(status_code=401, detail="登录凭证无效") try: payload = json.loads(_urlsafe_b64decode(encoded_payload).decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc: raise HTTPException(status_code=401, detail="登录凭证无效") from exc expires_at = payload.get("exp") username = payload.get("sub") if not isinstance(expires_at, int) or not username: raise HTTPException(status_code=401, detail="登录凭证无效") if expires_at <= int(datetime.now(timezone.utc).timestamp()): raise HTTPException(status_code=401, detail="登录已过期,请重新登录") return payload def get_current_auth_context( credentials: Annotated[HTTPAuthorizationCredentials | None, Security(bearer_scheme)], db: Session = Depends(get_db), ) -> AuthContext: if credentials is None or credentials.scheme.lower() != "bearer": raise HTTPException(status_code=401, detail="未登录或登录已过期") payload = decode_access_token(credentials.credentials) return get_auth_context(db, str(payload["sub"])) def require_authenticated_user( context: Annotated[AuthContext, Depends(get_current_auth_context)], ) -> AuthContext: return context def require_system_admin( context: Annotated[AuthContext, Depends(get_current_auth_context)], ) -> AuthContext: if context.user.is_super_admin or "ADMIN" in context.role_codes: return context raise HTTPException(status_code=403, detail="仅系统管理员可访问系统管理") def _sign_token(signing_input: str, secret_key: str) -> str: digest = hmac.new(secret_key.encode("utf-8"), signing_input.encode("utf-8"), hashlib.sha256).digest() return _urlsafe_b64encode(digest) def _urlsafe_b64encode(data: bytes) -> str: return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=") def _urlsafe_b64decode(data: str) -> bytes: padding = "=" * (-len(data) % 4) return base64.urlsafe_b64decode(f"{data}{padding}".encode("utf-8"))