feat: add session auth api
This commit is contained in:
@@ -1,3 +1,121 @@
|
|||||||
|
from django.contrib.auth import authenticate, login, logout
|
||||||
|
from django.middleware.csrf import get_token
|
||||||
from ninja import Router
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
from apps.accounts.models import MenuItem
|
||||||
|
from apps.accounts.permissions import PERMISSIONS
|
||||||
|
from apps.accounts.schemas import CurrentUserOut, LoginIn
|
||||||
|
|
||||||
router = Router(tags=["accounts"])
|
router = Router(tags=["accounts"])
|
||||||
|
|
||||||
|
|
||||||
|
def permission_codes_for_user(user) -> list[str]:
|
||||||
|
if user.is_superuser:
|
||||||
|
return sorted(permission.codename for permission in PERMISSIONS)
|
||||||
|
codes = set()
|
||||||
|
for permission in user.get_all_permissions():
|
||||||
|
if permission.startswith("system."):
|
||||||
|
codes.add(permission.removeprefix("system."))
|
||||||
|
return sorted(codes)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_menu(menu: MenuItem, children: list[dict]) -> dict:
|
||||||
|
return {
|
||||||
|
"id": menu.id,
|
||||||
|
"code": menu.code,
|
||||||
|
"title": menu.title,
|
||||||
|
"path": menu.path,
|
||||||
|
"icon": menu.icon,
|
||||||
|
"permission_code": menu.permission_code,
|
||||||
|
"is_visible": menu.is_visible,
|
||||||
|
"is_enabled": menu.is_enabled,
|
||||||
|
"children": children,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def menu_tree_for_permissions(permission_codes: list[str]) -> list[dict]:
|
||||||
|
allowed = set(permission_codes)
|
||||||
|
menus = list(
|
||||||
|
MenuItem.objects.filter(is_visible=True, is_enabled=True)
|
||||||
|
.select_related("parent")
|
||||||
|
.order_by("sort_order", "id")
|
||||||
|
)
|
||||||
|
children_by_parent: dict[int | None, list[MenuItem]] = {}
|
||||||
|
for menu in menus:
|
||||||
|
if menu.permission_code and menu.permission_code not in allowed:
|
||||||
|
continue
|
||||||
|
children_by_parent.setdefault(menu.parent_id, []).append(menu)
|
||||||
|
|
||||||
|
def build(parent_id: int | None) -> list[dict]:
|
||||||
|
result = []
|
||||||
|
for menu in children_by_parent.get(parent_id, []):
|
||||||
|
result.append(serialize_menu(menu, build(menu.id)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
return build(None)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_current_user(user) -> dict:
|
||||||
|
permission_codes = permission_codes_for_user(user)
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"display_name": user.get_full_name() or user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"is_superuser": user.is_superuser,
|
||||||
|
"roles": [{"id": group.id, "name": group.name} for group in user.groups.all()],
|
||||||
|
"user_groups": [
|
||||||
|
{
|
||||||
|
"id": group.id,
|
||||||
|
"name": group.name,
|
||||||
|
"code": group.code,
|
||||||
|
"description": group.description,
|
||||||
|
"is_active": group.is_active,
|
||||||
|
"created_at": group.created_at,
|
||||||
|
"updated_at": group.updated_at,
|
||||||
|
}
|
||||||
|
for group in user.business_groups.all()
|
||||||
|
],
|
||||||
|
"permissions": permission_codes,
|
||||||
|
"menus": menu_tree_for_permissions(permission_codes),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def require_login(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise HttpError(401, "Authentication required")
|
||||||
|
return request.user
|
||||||
|
|
||||||
|
|
||||||
|
def require_permission(request, codename: str):
|
||||||
|
user = require_login(request)
|
||||||
|
if user.is_superuser or user.has_perm(f"system.{codename}"):
|
||||||
|
return user
|
||||||
|
raise HttpError(403, "Permission denied")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/csrf")
|
||||||
|
def csrf(request):
|
||||||
|
return {"csrfToken": get_token(request)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/login", response=CurrentUserOut)
|
||||||
|
def login_user(request, payload: LoginIn):
|
||||||
|
user = authenticate(request, username=payload.username, password=payload.password)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise HttpError(401, "Invalid username or password")
|
||||||
|
login(request, user)
|
||||||
|
return serialize_current_user(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/logout")
|
||||||
|
def logout_user(request):
|
||||||
|
logout(request)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/me", response=CurrentUserOut)
|
||||||
|
def current_user(request):
|
||||||
|
user = require_login(request)
|
||||||
|
return serialize_current_user(user)
|
||||||
|
|||||||
@@ -1 +1,59 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class LoginIn(Schema):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RoleOut(Schema):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserGroupOut(Schema):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
code: str
|
||||||
|
description: str = ""
|
||||||
|
is_active: bool = True
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MenuOut(Schema):
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
title: str
|
||||||
|
path: str
|
||||||
|
icon: str
|
||||||
|
permission_code: str
|
||||||
|
is_visible: bool
|
||||||
|
is_enabled: bool
|
||||||
|
children: list["MenuOut"] = []
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentUserOut(Schema):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
email: str
|
||||||
|
is_superuser: bool
|
||||||
|
roles: list[RoleOut]
|
||||||
|
user_groups: list[UserGroupOut]
|
||||||
|
permissions: list[str]
|
||||||
|
menus: list[MenuOut]
|
||||||
|
|
||||||
|
|
||||||
|
class BasicUserOut(Schema):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
email: str
|
||||||
|
is_active: bool
|
||||||
|
is_staff: bool
|
||||||
|
is_superuser: bool
|
||||||
|
roles: list[RoleOut] = []
|
||||||
|
user_groups: list[UserGroupOut] = []
|
||||||
|
|||||||
37
backend/apps/accounts/tests/test_auth_api.py
Normal file
37
backend/apps/accounts/tests/test_auth_api.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management import call_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_me_and_logout_flow(client, db):
|
||||||
|
call_command("bootstrap_governance")
|
||||||
|
|
||||||
|
login = client.post(
|
||||||
|
"/api/accounts/auth/login",
|
||||||
|
data={"username": "admin", "password": "admin12345"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
|
assert login.json()["username"] == "admin"
|
||||||
|
|
||||||
|
me = client.get("/api/accounts/auth/me")
|
||||||
|
assert me.status_code == 200
|
||||||
|
payload = me.json()
|
||||||
|
assert payload["username"] == "admin"
|
||||||
|
assert "system.user.manage" in payload["permissions"]
|
||||||
|
assert any(menu["code"] == "system" for menu in payload["menus"])
|
||||||
|
|
||||||
|
logout = client.post("/api/accounts/auth/logout")
|
||||||
|
assert logout.status_code == 200
|
||||||
|
assert client.get("/api/accounts/auth/me").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_rejects_invalid_password(client, db):
|
||||||
|
get_user_model().objects.create_user(username="u", password="right")
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/accounts/auth/login",
|
||||||
|
data={"username": "u", "password": "wrong"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
@@ -77,6 +77,11 @@ CORS_ALLOWED_ORIGINS = os.getenv(
|
|||||||
"CORS_ALLOWED_ORIGINS",
|
"CORS_ALLOWED_ORIGINS",
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
).split(",")
|
).split(",")
|
||||||
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
CSRF_TRUSTED_ORIGINS = os.getenv(
|
||||||
|
"CSRF_TRUSTED_ORIGINS",
|
||||||
|
"http://localhost:5173,http://127.0.0.1:5173",
|
||||||
|
).split(",")
|
||||||
|
|
||||||
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||||
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
|
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ from django.contrib import admin
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
|
from apps.accounts.api import router as accounts_router
|
||||||
from apps.audit.api import router as audit_router
|
from apps.audit.api import router as audit_router
|
||||||
from apps.solutions.api import router as solutions_router
|
from apps.solutions.api import router as solutions_router
|
||||||
from apps.system.api import router as system_router
|
from apps.system.api import router as system_router
|
||||||
from apps.tickets.api import router as tickets_router
|
from apps.tickets.api import router as tickets_router
|
||||||
|
|
||||||
api = NinjaAPI(title="AI Ticket Platform API", version="0.1.0")
|
api = NinjaAPI(title="AI Ticket Platform API", version="0.1.0")
|
||||||
|
api.add_router("/accounts", accounts_router)
|
||||||
api.add_router("/system", system_router)
|
api.add_router("/system", system_router)
|
||||||
api.add_router("/audit", audit_router)
|
api.add_router("/audit", audit_router)
|
||||||
api.add_router("/tickets", tickets_router)
|
api.add_router("/tickets", tickets_router)
|
||||||
|
|||||||
Reference in New Issue
Block a user