Просмотр исходного кода

feat(backend): add JWT login, current-user, and menus API

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 месяцев назад
Родитель
Сommit
291ae675f6

+ 0 - 0
backend/app/api/__init__.py


+ 56 - 0
backend/app/api/auth.py

@@ -0,0 +1,56 @@
+"""鉴权路由:login / logout / current-user / menus"""
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import JSONResponse
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.core.deps import get_current_user
+from app.core.response import error_response, success_response
+from app.models.system import SysUser
+from app.schemas.auth import LoginBody
+from app.services import auth_service
+
+router = APIRouter(prefix="/web/auth", tags=["auth"])
+
+
+@router.post("/login")
+def login(body: LoginBody, request: Request, db: Annotated[Session, Depends(get_db)]):
+    ip = request.client.host if request.client else None
+    try:
+        data = auth_service.login(db, body.username, body.password, ip=ip)
+    except auth_service.AuthFailure as exc:
+        return JSONResponse(
+            status_code=401,
+            content=error_response(401, exc.message),
+        )
+    return success_response(data)
+
+
+@router.post("/logout")
+def logout(user: Annotated[SysUser, Depends(get_current_user)]):
+    return success_response({"username": user.username})
+
+
+@router.get("/current-user")
+def current_user(
+    user: Annotated[SysUser, Depends(get_current_user)],
+    db: Annotated[Session, Depends(get_db)],
+):
+    try:
+        data = auth_service.current_user_payload(db, user.id)
+    except auth_service.AuthFailure as exc:
+        return JSONResponse(
+            status_code=401,
+            content=error_response(401, exc.message),
+        )
+    return success_response(data)
+
+
+@router.get("/menus")
+def menus(
+    user: Annotated[SysUser, Depends(get_current_user)],
+    db: Annotated[Session, Depends(get_db)],
+):
+    return success_response(auth_service.menus_payload(db, user.role_id))

+ 48 - 0
backend/app/core/deps.py

@@ -0,0 +1,48 @@
+"""FastAPI 依赖:当前用户与菜单权限校验"""
+from typing import Annotated, Callable
+
+from fastapi import Depends, HTTPException
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.core.security import TokenError, safe_decode_token
+from app.crud import menu as menu_crud
+from app.crud import user as user_crud
+from app.models.system import SysUser
+
+bearer_scheme = HTTPBearer(auto_error=False)
+
+
+def get_current_user(
+    db: Annotated[Session, Depends(get_db)],
+    creds: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
+) -> SysUser:
+    if creds is None or not creds.credentials:
+        raise HTTPException(status_code=401, detail="未登录或令牌无效")
+    try:
+        payload = safe_decode_token(creds.credentials)
+    except TokenError:
+        raise HTTPException(status_code=401, detail="未登录或令牌无效") from None
+    user_id = payload.get("sub")
+    if not user_id:
+        raise HTTPException(status_code=401, detail="未登录或令牌无效")
+    user = user_crud.get_user_by_id(db, user_id)
+    if not user or user.status != "0":
+        raise HTTPException(status_code=401, detail="未登录或令牌无效")
+    return user
+
+
+def require_menu(*codes: str) -> Callable:
+    """当前用户角色若不拥有 codes 中任一权限点 → 403。"""
+    needed = set(codes)
+
+    def _dep(
+        user: Annotated[SysUser, Depends(get_current_user)],
+        db: Annotated[Session, Depends(get_db)],
+    ) -> SysUser:
+        if not menu_crud.role_has_any_menu_code(db, user.role_id, needed):
+            raise HTTPException(status_code=403, detail="无权限")
+        return user
+
+    return _dep

+ 45 - 0
backend/app/core/security.py

@@ -0,0 +1,45 @@
+"""密码哈希与 JWT 签发/校验"""
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict, Optional
+
+import bcrypt
+from jose import JWTError, jwt
+
+from app.core.config import get_settings
+
+
+def hash_password(plain: str) -> str:
+    return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
+
+
+def verify_password(plain: str, password_hash: str) -> bool:
+    try:
+        return bcrypt.checkpw(plain.encode("utf-8"), password_hash.encode("utf-8"))
+    except (ValueError, TypeError):
+        return False
+
+
+def create_access_token(sub: str, extra: Optional[Dict[str, Any]] = None) -> str:
+    settings = get_settings()
+    payload: Dict[str, Any] = {"sub": sub}
+    if extra:
+        payload.update(extra)
+    expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
+    payload["exp"] = expire
+    return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
+
+
+def decode_token(token: str) -> Dict[str, Any]:
+    settings = get_settings()
+    return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
+
+
+class TokenError(Exception):
+    """JWT 无效或过期。"""
+
+
+def safe_decode_token(token: str) -> Dict[str, Any]:
+    try:
+        return decode_token(token)
+    except JWTError as exc:
+        raise TokenError(str(exc)) from exc

+ 0 - 0
backend/app/crud/__init__.py


+ 42 - 0
backend/app/crud/menu.py

@@ -0,0 +1,42 @@
+"""菜单 / 权限 CRUD"""
+from typing import List, Set
+
+from sqlalchemy.orm import Session
+
+from app.models.system import SysMenu, SysRoleMenu
+
+
+def list_menu_codes_for_role(db: Session, role_id: str) -> List[str]:
+    rows = (
+        db.query(SysMenu.menu_code)
+        .join(SysRoleMenu, SysRoleMenu.menu_id == SysMenu.id)
+        .filter(
+            SysRoleMenu.role_id == role_id,
+            SysMenu.del_flag == "0",
+        )
+        .all()
+    )
+    return [r[0] for r in rows]
+
+
+def list_page_menus_for_role(db: Session, role_id: str) -> List[SysMenu]:
+    """角色可见的目录/菜单(M/C),按 sort 升序。"""
+    return (
+        db.query(SysMenu)
+        .join(SysRoleMenu, SysRoleMenu.menu_id == SysMenu.id)
+        .filter(
+            SysRoleMenu.role_id == role_id,
+            SysMenu.del_flag == "0",
+            SysMenu.menu_type.in_(("M", "C")),
+            SysMenu.visible == "1",
+        )
+        .order_by(SysMenu.sort.asc())
+        .all()
+    )
+
+
+def role_has_any_menu_code(db: Session, role_id: str, codes: Set[str]) -> bool:
+    if not codes:
+        return False
+    owned = set(list_menu_codes_for_role(db, role_id))
+    return bool(owned & codes)

+ 40 - 0
backend/app/crud/user.py

@@ -0,0 +1,40 @@
+"""用户 CRUD"""
+from typing import Optional, Tuple
+
+from sqlalchemy.orm import Session
+
+from app.models.system import SysRole, SysUser
+
+
+def get_user_by_username(db: Session, username: str) -> Optional[SysUser]:
+    return (
+        db.query(SysUser)
+        .filter(SysUser.username == username, SysUser.del_flag == "0")
+        .first()
+    )
+
+
+def get_user_by_id(db: Session, user_id: str) -> Optional[SysUser]:
+    return (
+        db.query(SysUser)
+        .filter(SysUser.id == user_id, SysUser.del_flag == "0")
+        .first()
+    )
+
+
+def get_role_by_id(db: Session, role_id: str) -> Optional[SysRole]:
+    return (
+        db.query(SysRole)
+        .filter(SysRole.id == role_id, SysRole.del_flag == "0")
+        .first()
+    )
+
+
+def get_user_with_role(
+    db: Session, user_id: str
+) -> Tuple[Optional[SysUser], Optional[SysRole]]:
+    user = get_user_by_id(db, user_id)
+    if not user:
+        return None, None
+    role = get_role_by_id(db, user.role_id)
+    return user, role

+ 3 - 0
backend/app/main.py

@@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
 from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 
+from app.api.auth import router as auth_router
 from app.core.config import get_settings
 from app.core.database import SessionLocal, init_db
 from app.core.response import success_response
@@ -36,6 +37,8 @@ app.add_middleware(
     allow_headers=["*"],
 )
 
+app.include_router(auth_router, prefix=settings.API_V1_PREFIX)
+
 
 @app.get(f"{settings.API_V1_PREFIX}/health")
 def health_check():

+ 0 - 0
backend/app/schemas/__init__.py


+ 50 - 0
backend/app/schemas/auth.py

@@ -0,0 +1,50 @@
+"""认证相关 Pydantic schemas(对外 camelCase)"""
+from typing import List, Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+def _to_camel(name: str) -> str:
+    parts = name.split("_")
+    return parts[0] + "".join(p.capitalize() for p in parts[1:])
+
+
+class CamelModel(BaseModel):
+    model_config = ConfigDict(
+        alias_generator=_to_camel,
+        populate_by_name=True,
+    )
+
+
+class LoginBody(BaseModel):
+    username: str
+    password: str
+
+
+class TokenData(CamelModel):
+    token: str
+    token_type: str = Field(default="Bearer", alias="tokenType")
+
+
+class CurrentUserData(CamelModel):
+    id: str
+    username: str
+    display_name: str
+    role_code: str
+    role_name: str
+
+
+class MenuNode(CamelModel):
+    id: str
+    parent_id: str
+    menu_code: str
+    menu_name: str
+    path: Optional[str] = ""
+    menu_type: str
+    sort: int = 0
+    children: List["MenuNode"] = Field(default_factory=list)
+
+
+class MenusData(CamelModel):
+    tree: List[MenuNode]
+    perm_codes: List[str] = Field(default_factory=list)

+ 135 - 0
backend/app/services/auth_service.py

@@ -0,0 +1,135 @@
+"""认证业务:登录、当前用户、菜单树、操作日志"""
+import uuid
+from typing import Any, Dict, List, Optional
+
+from sqlalchemy.orm import Session
+
+from app.core.security import create_access_token, verify_password
+from app.crud import menu as menu_crud
+from app.crud import user as user_crud
+from app.models.system import OperLog, SysMenu, SysUser
+
+
+class AuthFailure(Exception):
+    """登录失败等业务错误。"""
+
+    def __init__(self, message: str = "用户名或密码错误"):
+        self.message = message
+        super().__init__(message)
+
+
+def _uuid() -> str:
+    return str(uuid.uuid4())
+
+
+def write_oper_log(
+    db: Session,
+    *,
+    module: str,
+    action: str,
+    user_id: Optional[str] = None,
+    username: Optional[str] = None,
+    detail: Optional[str] = None,
+    ip: Optional[str] = None,
+) -> None:
+    db.add(
+        OperLog(
+            id=_uuid(),
+            user_id=user_id,
+            username=username,
+            module=module,
+            action=action,
+            detail=detail,
+            ip=ip,
+        )
+    )
+    db.commit()
+
+
+def authenticate_user(db: Session, username: str, password: str) -> SysUser:
+    user = user_crud.get_user_by_username(db, username)
+    if not user or user.status != "0":
+        raise AuthFailure()
+    if not verify_password(password, user.password_hash):
+        raise AuthFailure()
+    return user
+
+
+def login(
+    db: Session,
+    username: str,
+    password: str,
+    ip: Optional[str] = None,
+) -> Dict[str, str]:
+    try:
+        user = authenticate_user(db, username, password)
+    except AuthFailure:
+        write_oper_log(
+            db,
+            module="auth",
+            action="login_failed",
+            username=username,
+            detail="invalid credentials",
+            ip=ip,
+        )
+        raise
+
+    token = create_access_token(sub=user.id, extra={"username": user.username})
+    write_oper_log(
+        db,
+        module="auth",
+        action="login",
+        user_id=user.id,
+        username=user.username,
+        detail="login success",
+        ip=ip,
+    )
+    return {"token": token, "tokenType": "Bearer"}
+
+
+def current_user_payload(db: Session, user_id: str) -> Dict[str, Any]:
+    user, role = user_crud.get_user_with_role(db, user_id)
+    if not user or not role:
+        raise AuthFailure("用户不存在或已禁用")
+    return {
+        "id": user.id,
+        "username": user.username,
+        "displayName": user.display_name,
+        "roleCode": role.role_code,
+        "roleName": role.role_name,
+    }
+
+
+def _menu_to_node(menu: SysMenu) -> Dict[str, Any]:
+    return {
+        "id": menu.id,
+        "parentId": menu.parent_id,
+        "menuCode": menu.menu_code,
+        "menuName": menu.menu_name,
+        "path": menu.path or "",
+        "menuType": menu.menu_type,
+        "sort": menu.sort,
+        "children": [],
+    }
+
+
+def build_menu_tree(menus: List[SysMenu]) -> List[Dict[str, Any]]:
+    node_list = [_menu_to_node(m) for m in menus]
+    by_id = {n["id"]: n for n in node_list}
+    roots: List[Dict[str, Any]] = []
+    for n in node_list:
+        parent_id = n["parentId"]
+        if parent_id and parent_id != "0" and parent_id in by_id:
+            by_id[parent_id]["children"].append(n)
+        else:
+            roots.append(n)
+    return roots
+
+
+def menus_payload(db: Session, role_id: str) -> Dict[str, Any]:
+    page_menus = menu_crud.list_page_menus_for_role(db, role_id)
+    perm_codes = menu_crud.list_menu_codes_for_role(db, role_id)
+    return {
+        "tree": build_menu_tree(page_menus),
+        "permCodes": perm_codes,
+    }

+ 32 - 1
backend/tests/conftest.py

@@ -1,10 +1,13 @@
 """pytest fixtures"""
 import pytest
+from fastapi.testclient import TestClient
 from sqlalchemy import create_engine
 from sqlalchemy.orm import sessionmaker
 from sqlalchemy.pool import StaticPool
 
-from app.core.database import Base
+from app.core.database import Base, get_db
+from app.main import app as fastapi_app
+from app.services.seed import seed_if_empty
 import app.models  # noqa: F401 — register metadata
 
 
@@ -25,3 +28,31 @@ def db_session():
         session.close()
         Base.metadata.drop_all(bind=engine)
         engine.dispose()
+
+
+@pytest.fixture()
+def client():
+    """已建表并种子的 TestClient(覆盖 get_db)。"""
+    engine = create_engine(
+        "sqlite+pysqlite:///:memory:",
+        connect_args={"check_same_thread": False},
+        poolclass=StaticPool,
+    )
+    Base.metadata.create_all(bind=engine)
+    TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+    session = TestingSessionLocal()
+    seed_if_empty(session)
+
+    def _override_get_db():
+        try:
+            yield session
+        finally:
+            pass
+
+    fastapi_app.dependency_overrides[get_db] = _override_get_db
+    with TestClient(fastapi_app) as c:
+        yield c
+    fastapi_app.dependency_overrides.clear()
+    session.close()
+    Base.metadata.drop_all(bind=engine)
+    engine.dispose()

+ 64 - 0
backend/tests/test_auth.py

@@ -0,0 +1,64 @@
+"""Auth: login / current-user / menus / logout / JWT guards."""
+
+
+def _flatten(nodes):
+    """Flatten menu tree into a list of nodes."""
+    out = []
+    for n in nodes or []:
+        out.append(n)
+        out.extend(_flatten(n.get("children") or []))
+    return out
+
+
+def test_login_and_menus(client):
+    r = client.post("/api/web/auth/login", json={"username": "admin", "password": "Admin@123"})
+    assert r.status_code == 200
+    token = r.json()["data"]["token"]
+    assert r.json()["data"]["tokenType"] == "Bearer"
+
+    me = client.get("/api/web/auth/current-user", headers={"Authorization": f"Bearer {token}"})
+    assert me.status_code == 200
+    assert me.json()["data"]["roleCode"] == "admin"
+    assert me.json()["data"]["username"] == "admin"
+    assert "displayName" in me.json()["data"]
+
+    menus = client.get("/api/web/auth/menus", headers={"Authorization": f"Bearer {token}"})
+    assert menus.status_code == 200
+    data = menus.json()["data"]
+    # Prefer richer {tree, permCodes}; tree-list alone also OK
+    if isinstance(data, dict) and "tree" in data:
+        tree = data["tree"]
+        assert "feedback:handle" in data["permCodes"]
+        assert "role:grant" in data["permCodes"]
+    else:
+        tree = data
+    codes = {m["menuCode"] for m in _flatten(tree)}
+    assert "dashboard" in codes
+    assert "role" in codes
+    # Tree must only include M/C pages, not F buttons
+    assert "feedback:handle" not in codes
+
+
+def test_login_wrong_password(client):
+    r = client.post("/api/web/auth/login", json={"username": "admin", "password": "bad"})
+    assert r.status_code == 401
+    assert r.json()["code"] == 401
+
+
+def test_current_user_invalid_token(client):
+    r = client.get(
+        "/api/web/auth/current-user",
+        headers={"Authorization": "Bearer not-a-valid-token"},
+    )
+    assert r.status_code == 401
+
+
+def test_logout(client):
+    r = client.post("/api/web/auth/login", json={"username": "admin", "password": "Admin@123"})
+    token = r.json()["data"]["token"]
+    out = client.post(
+        "/api/web/auth/logout",
+        headers={"Authorization": f"Bearer {token}"},
+    )
+    assert out.status_code == 200
+    assert out.json()["code"] == 200