247 lines
8.8 KiB
Python
247 lines
8.8 KiB
Python
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"))
|