"""登录与权限(RBAC)。 角色 = 一组功能菜单权限(rw 可编辑 / ro 只读 / 没配就是看不到),用户挂一个角色。 菜单权限同时管两端: · 前端按 /auth/me 返回的 menus 过滤侧边栏菜单、隐藏写操作按钮; · 后端用 guard 依赖按「请求路径 -> 菜单」做二次校验,直接调接口也绕不过去。 会话是服务端 token(HttpOnly Cookie),退出登录 / 禁用用户时删 token 立即失效。 密码只存 pbkdf2 派生值,用标准库,不引额外依赖。 """ from __future__ import annotations import hashlib import hmac import json import secrets from datetime import datetime, timedelta from fastapi import Depends, HTTPException, Request from sqlalchemy.orm import Session from . import config from .db import get_db from .models import Role, User, UserSession, now SESSION_COOKIE = "unis_session" PBKDF2_ROUNDS = 120_000 # 功能菜单:键是前后端共用的权限标识,顺序即权限管理页里的展示顺序 MENUS = [ ("board", "工作看板"), ("projects", "自研产品"), ("custom", "定制项目"), ("focus", "每周工作"), ("staff", "人力资源"), ("intake", "更新录入"), ("settings", "设置"), ("rbac", "权限管理"), ] MENU_KEYS = [k for k, _ in MENUS] MENU_LABELS = dict(MENUS) LEVELS = ("ro", "rw") # 接口路径首段 -> 菜单。没列出来的(/meta 等)只要求登录,不看菜单。 SEGMENT_MENU = { "focus": "focus", "weekly": "board", "board": "board", "stats": "board", "projects": "projects", "categories": "projects", "tags": "projects", "updates": "projects", "tasks": "projects", "custom": "custom", "staff": "staff", "ai": "intake", "settings": "settings", "rbac": "rbac", "periods": "settings", } # /export/xxx 跟着对应页面走 EXPORT_MENU = { "custom": "custom", "custom-updates": "custom", "projects": "projects", "updates": "projects", "staff": "staff", "focus": "focus", } def normalize_menus(raw) -> dict: """清洗前端传来的权限表:只留已知菜单 + 合法级别。""" out: dict[str, str] = {} for key, value in (raw or {}).items(): if key in MENU_KEYS and value in LEVELS: out[key] = value return out def menus_json(menus: dict) -> str: return json.dumps(normalize_menus(menus), ensure_ascii=False, sort_keys=True) # ------------------------------------------------------------------ 密码 def hash_password(password: str) -> tuple[str, str]: salt = secrets.token_hex(16) digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), bytes.fromhex(salt), PBKDF2_ROUNDS) return digest.hex(), salt def verify_password(password: str, password_hash: str, salt: str) -> bool: try: digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), bytes.fromhex(salt), PBKDF2_ROUNDS) except ValueError: return False return hmac.compare_digest(digest.hex(), password_hash) # ------------------------------------------------------------------ 会话 def start_session(db: Session, user: User) -> str: token = secrets.token_urlsafe(32) db.add(UserSession(token=token, user_id=user.id, expires_at=now() + timedelta(days=config.SESSION_DAYS))) user.last_login_at = now() db.commit() return token def revoke_all(db: Session, user_id: int) -> None: db.query(UserSession).filter(UserSession.user_id == user_id).delete(synchronize_session=False) db.commit() def user_from_token(db: Session, token: str | None) -> User | None: if not token: return None sess = db.get(UserSession, token) if sess is None or sess.expires_at < now(): return None user = db.get(User, sess.user_id) if user is None or not user.active: return None return user # ------------------------------------------------------------------ 依赖 READ_METHODS = ("GET", "HEAD", "OPTIONS") def resolve_rule(path: str, method: str): """请求 -> (菜单, 是否写操作);菜单为 None 表示只要登录就能访问。""" parts = [x for x in path.split("/") if x] # /api/custom/projects -> [...] if len(parts) >= 2 and parts[0] == "api" and parts[1] == "export": menu = EXPORT_MENU.get(parts[2] if len(parts) > 2 else "") elif len(parts) >= 2 and parts[0] == "api": menu = SEGMENT_MENU.get(parts[1]) else: menu = None if menu is None: return None # 周期表:所有页面顶栏都要读,只有新建/删除算设置页的写操作 if len(parts) >= 2 and parts[1] == "periods" and method in READ_METHODS: return None return menu, method not in READ_METHODS def ensure_seed(db: Session) -> list[str]: """首次启动(或升级后)补内置角色与管理员账号,返回新建的提示项。""" notes: list[str] = [] presets = [ ("管理员", "全部菜单可编辑,含设置与权限管理", {k: "rw" for k in MENU_KEYS}, True), ("部门成员", "业务菜单可编辑,不能改设置与权限", {k: "rw" for k in MENU_KEYS if k not in ("settings", "rbac")}, True), ("只读访客", "只能查看,不能修改任何数据", {k: "ro" for k in MENU_KEYS if k != "rbac"}, True), ] role_map: dict[str, Role] = {r.name: r for r in db.query(Role).all()} for name, desc, menus, builtin in presets: role = role_map.get(name) if role is None: role = Role(name=name, description=desc, menus=menus_json(menus), is_builtin=builtin) db.add(role) db.flush() role_map[name] = role notes.append(f"已创建内置角色「{name}」") if not db.query(User).count(): pw_hash, salt = hash_password(config.ADMIN_PASSWORD) db.add(User(name=config.ADMIN_USERNAME, username=config.ADMIN_USERNAME, password_hash=pw_hash, password_salt=salt, role_id=role_map["管理员"].id, active=True)) db.commit() notes.append(f"已创建管理员账号 {config.ADMIN_USERNAME}(请尽快在用户设置里改密码)") db.commit() return notes def guard(request: Request, db: Session = Depends(get_db)) -> User | None: """挂在 /api 子应用上的总闸:先验登录,再按菜单权限放行。""" path = request.url.path if path.rstrip("/") == "/api/auth/login": return None user = user_from_token(db, request.cookies.get(SESSION_COOKIE)) if user is None: raise HTTPException(status_code=401, detail="登录已失效,请重新登录") if path.startswith("/api/rbac/"): # 权限管理自身必须显式授权,普通用户即使能读别的菜单也不能碰 if "rbac" not in user.menus: raise HTTPException(status_code=403, detail="没有「权限管理」的访问权限") if request.method not in READ_METHODS and user.menus.get("rbac") != "rw": raise HTTPException(status_code=403, detail="「权限管理」是只读权限,不能修改") return user rule = resolve_rule(path, request.method) if rule is None: return user menu, need_write = rule level = user.menus.get(menu) if not level: raise HTTPException(status_code=403, detail=f"没有「{MENU_LABELS.get(menu, menu)}」的访问权限") if need_write and level != "rw": raise HTTPException(status_code=403, detail=f"「{MENU_LABELS.get(menu, menu)}」是只读权限,不能修改") return user def current_user(request: Request, db: Session = Depends(get_db)) -> User: user = user_from_token(db, request.cookies.get(SESSION_COOKIE)) if user is None: raise HTTPException(status_code=401, detail="请先登录") return user