mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-03 06:04:20 +08:00
feat: persist multi-user authentication
This commit is contained in:
@@ -13,13 +13,17 @@ from .program import router as program_router
|
||||
from .rss import router as rss_router
|
||||
from .search import router as search_router
|
||||
from .setup import router as setup_router
|
||||
from .tokens import router as tokens_router
|
||||
from .update import router as update_router
|
||||
from .users import router as users_router
|
||||
|
||||
__all__ = "v1"
|
||||
|
||||
# API 1.0
|
||||
v1 = APIRouter(prefix="/v1")
|
||||
v1.include_router(auth_router)
|
||||
v1.include_router(users_router)
|
||||
v1.include_router(tokens_router)
|
||||
v1.include_router(passkey_router)
|
||||
v1.include_router(log_router)
|
||||
v1.include_router(program_router)
|
||||
|
||||
@@ -1,38 +1,31 @@
|
||||
from datetime import timedelta
|
||||
"""Database-backed authentication endpoints."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, status
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from module.models import APIResponse
|
||||
from module.models.user import User, UserUpdate
|
||||
from module.security.api import (
|
||||
active_user,
|
||||
auth_user,
|
||||
check_login_ip,
|
||||
get_current_user,
|
||||
update_user_info,
|
||||
from module.application.auth import (
|
||||
AuthenticationError,
|
||||
AuthenticationService,
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
)
|
||||
from module.security.jwt import create_access_token, decode_token
|
||||
|
||||
from .response import u_response
|
||||
from module.models import APIResponse
|
||||
from module.models.user import UserPublic, UserUpdate
|
||||
from module.security.api import check_login_ip, get_auth_service, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
_TOKEN_EXPIRY_DAYS = 1
|
||||
_TOKEN_MAX_AGE = 86400
|
||||
|
||||
# Pseudo-username returned by get_current_user() for callers authenticated via
|
||||
# a bearer API token (settings.security.login_tokens) rather than a cookie
|
||||
# session. Such callers have no real session to refresh/logout/update.
|
||||
_API_TOKEN_USER = "api_token_user"
|
||||
|
||||
AuthService = Annotated[AuthenticationService, Depends(get_auth_service)]
|
||||
CurrentUser = Annotated[str, Depends(get_current_user)]
|
||||
|
||||
def _issue_token(username: str, response: Response) -> dict:
|
||||
"""Create a JWT, set it as an HttpOnly cookie, and return the bearer payload."""
|
||||
token = create_access_token(
|
||||
data={"sub": username}, expires_delta=timedelta(days=_TOKEN_EXPIRY_DAYS)
|
||||
)
|
||||
|
||||
def _issue_token(token: str, response: Response) -> dict[str, str]:
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=token,
|
||||
@@ -44,49 +37,70 @@ def _issue_token(username: str, response: Response) -> dict:
|
||||
|
||||
|
||||
@router.post("/login", response_model=dict, dependencies=[Depends(check_login_ip)])
|
||||
async def login(response: Response, form_data=Depends(OAuth2PasswordRequestForm)):
|
||||
"""Authenticate with username/password and issue a session token."""
|
||||
user = User(username=form_data.username, password=form_data.password)
|
||||
resp = await auth_user(user)
|
||||
if resp.status:
|
||||
return _issue_token(user.username, response)
|
||||
return u_response(resp)
|
||||
async def login(
|
||||
response: Response,
|
||||
service: AuthService,
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
):
|
||||
"""Authenticate credentials and issue a persisted session."""
|
||||
try:
|
||||
_user, token = await service.login(form_data.username, form_data.password)
|
||||
except AuthenticationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
) from exc
|
||||
return _issue_token(token, response)
|
||||
|
||||
|
||||
@router.get("/refresh_token", response_model=dict)
|
||||
async def _refresh_cookie(
|
||||
response: Response,
|
||||
token: str | None,
|
||||
service: AuthenticationService,
|
||||
) -> dict[str, str]:
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
user = await service.refresh_session(token)
|
||||
if user is not None:
|
||||
return _issue_token(token, response)
|
||||
exchanged = await service.exchange_legacy_jwt(token)
|
||||
if exchanged is None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
_user, persisted_token = exchanged
|
||||
return _issue_token(persisted_token, response)
|
||||
|
||||
|
||||
@router.post("/refresh_token", response_model=dict)
|
||||
async def refresh(
|
||||
response: Response,
|
||||
token: str = Cookie(None),
|
||||
current_user: str = Depends(get_current_user),
|
||||
service: AuthService,
|
||||
token: str | None = Cookie(None),
|
||||
):
|
||||
"""Refresh the current session token and update the active-user timestamp."""
|
||||
if current_user == _API_TOKEN_USER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="API token authentication has no cookie session to refresh.",
|
||||
)
|
||||
payload = decode_token(token)
|
||||
username = payload.get("sub") if payload else None
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
|
||||
)
|
||||
active_user.add(username)
|
||||
return _issue_token(username, response)
|
||||
"""Extend a persisted session or exchange a legacy JWT cookie."""
|
||||
return await _refresh_cookie(response, token, service)
|
||||
|
||||
|
||||
@router.get("/refresh_token", response_model=dict, deprecated=True)
|
||||
async def refresh_legacy_get(
|
||||
response: Response,
|
||||
service: AuthService,
|
||||
token: str | None = Cookie(None),
|
||||
):
|
||||
"""Compatibility alias; clients should use POST."""
|
||||
response.headers["Deprecation"] = "true"
|
||||
response.headers["Warning"] = '299 - "Use POST /auth/refresh_token"'
|
||||
return await _refresh_cookie(response, token, service)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=APIResponse)
|
||||
async def logout(
|
||||
response: Response,
|
||||
token: str = Cookie(None),
|
||||
current_user: str = Depends(get_current_user),
|
||||
service: AuthService,
|
||||
token: str | None = Cookie(None),
|
||||
):
|
||||
"""Invalidate the session and clear the token cookie."""
|
||||
if current_user != _API_TOKEN_USER:
|
||||
payload = decode_token(token)
|
||||
username = payload.get("sub") if payload else None
|
||||
if username:
|
||||
active_user.remove(username)
|
||||
"""Revoke the current persisted session and clear its cookie."""
|
||||
if token:
|
||||
await service.logout(token)
|
||||
response.delete_cookie(key="token")
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
@@ -94,32 +108,33 @@ async def logout(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserPublic)
|
||||
async def me(current_user: CurrentUser, service: AuthService):
|
||||
if current_user in {_API_TOKEN_USER, "dev_user"}:
|
||||
raise HTTPException(status_code=400, detail="No database user for this session")
|
||||
try:
|
||||
return await service.get_user(current_user)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/update", response_model=dict)
|
||||
async def update_user(
|
||||
user_data: UserUpdate,
|
||||
response: Response,
|
||||
token: str = Cookie(None),
|
||||
current_user: str = Depends(get_current_user),
|
||||
current_user: CurrentUser,
|
||||
service: AuthService,
|
||||
):
|
||||
"""Update credentials for the current user and re-issue a fresh token."""
|
||||
if current_user == _API_TOKEN_USER:
|
||||
"""Update the current account and rotate all of its sessions."""
|
||||
if current_user in {_API_TOKEN_USER, "dev_user"}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
status_code=400,
|
||||
detail="API token authentication cannot update user credentials.",
|
||||
)
|
||||
payload = decode_token(token)
|
||||
old_user = payload.get("sub") if payload else None
|
||||
if not old_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
|
||||
)
|
||||
if await update_user_info(user_data, old_user):
|
||||
# Re-issue the token under the *new* username (if it changed) and
|
||||
# migrate the active-user session entry, so the old username isn't
|
||||
# left stranded (unable to log out) and the new one isn't rejected
|
||||
# as "not in active_user" on the very next request.
|
||||
new_username = user_data.username or old_user
|
||||
if new_username != old_user:
|
||||
active_user.remove(old_user)
|
||||
active_user.add(new_username)
|
||||
return {**_issue_token(new_username, response), "message": "update success"}
|
||||
try:
|
||||
_user, token = await service.update_current_user(current_user, user_data)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {**_issue_token(token, response), "message": "update success"}
|
||||
|
||||
@@ -4,12 +4,13 @@ Passkey 管理 API
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from sqlmodel import select
|
||||
|
||||
from module.application.auth import AuthenticationService
|
||||
from module.conf import settings
|
||||
from module.database.engine import async_session_factory
|
||||
from module.database.passkey import PasskeyDatabase
|
||||
@@ -22,13 +23,13 @@ from module.models.passkey import (
|
||||
PasskeyList,
|
||||
)
|
||||
from module.models.user import User
|
||||
from module.security.api import active_user, check_login_ip, get_current_user
|
||||
from module.security.api import check_login_ip, get_auth_service, get_current_user
|
||||
from module.security.auth_strategy import PasskeyAuthStrategy
|
||||
from module.security.jwt import create_access_token
|
||||
from module.security.webauthn import get_webauthn_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/passkey", tags=["passkey"])
|
||||
AuthService = Annotated[AuthenticationService, Depends(get_auth_service)]
|
||||
|
||||
_GENERIC_ERROR = "Internal server error."
|
||||
|
||||
@@ -233,6 +234,7 @@ async def login_with_passkey(
|
||||
auth_data: PasskeyAuthFinish,
|
||||
response: Response,
|
||||
request: Request,
|
||||
service: AuthService,
|
||||
):
|
||||
"""
|
||||
使用 Passkey 登录(替代密码登录)
|
||||
@@ -251,9 +253,7 @@ async def login_with_passkey(
|
||||
if not username:
|
||||
raise HTTPException(status_code=500, detail="Failed to determine username")
|
||||
|
||||
token = create_access_token(
|
||||
data={"sub": username}, expires_delta=timedelta(days=1)
|
||||
)
|
||||
token = await service.issue_session_for_username(username)
|
||||
response.set_cookie(
|
||||
key="token",
|
||||
value=token,
|
||||
@@ -261,7 +261,6 @@ async def login_with_passkey(
|
||||
max_age=86400,
|
||||
samesite="strict",
|
||||
)
|
||||
active_user.add(username)
|
||||
return {"access_token": token, "token_type": "bearer"}
|
||||
|
||||
raise HTTPException(status_code=resp.status_code, detail=resp.msg_en)
|
||||
|
||||
50
backend/src/module/api/tokens.py
Normal file
50
backend/src/module/api/tokens.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Database-backed API and MCP token management."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
from module.application.auth import AuthenticationService, NotFoundError
|
||||
from module.models.auth import ApiTokenCreate, ApiTokenCreated, ApiTokenPublic
|
||||
from module.security.api import get_auth_service, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/tokens", tags=["tokens"])
|
||||
AuthService = Annotated[AuthenticationService, Depends(get_auth_service)]
|
||||
CurrentUser = Annotated[str, Depends(get_current_user)]
|
||||
|
||||
|
||||
@router.get("", response_model=list[ApiTokenPublic])
|
||||
async def list_tokens(_current_user: CurrentUser, service: AuthService):
|
||||
return await service.list_api_tokens()
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ApiTokenCreated,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_token(
|
||||
data: ApiTokenCreate,
|
||||
current_user: CurrentUser,
|
||||
service: AuthService,
|
||||
):
|
||||
token, raw_token = await service.create_api_token_for_username(
|
||||
current_user,
|
||||
name=data.name,
|
||||
scope=data.scope,
|
||||
expires_at=data.expires_at,
|
||||
)
|
||||
return {**ApiTokenPublic.model_validate(token).model_dump(), "token": raw_token}
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def revoke_token(
|
||||
token_id: int,
|
||||
_current_user: CurrentUser,
|
||||
service: AuthService,
|
||||
):
|
||||
try:
|
||||
await service.revoke_api_token(token_id)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
54
backend/src/module/api/users.py
Normal file
54
backend/src/module/api/users.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Authenticated multi-user management API."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from module.application.auth import (
|
||||
AuthenticationService,
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
)
|
||||
from module.models.user import UserCreate, UserPublic, UserUpdate
|
||||
from module.security.api import get_auth_service, get_current_user
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/users",
|
||||
tags=["users"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
AuthService = Annotated[AuthenticationService, Depends(get_auth_service)]
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserPublic])
|
||||
async def list_users(service: AuthService):
|
||||
return await service.list_users()
|
||||
|
||||
|
||||
@router.post("", response_model=UserPublic, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(data: UserCreate, service: AuthService):
|
||||
try:
|
||||
return await service.create_user(data)
|
||||
except ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserPublic)
|
||||
async def update_user(user_id: int, data: UserUpdate, service: AuthService):
|
||||
try:
|
||||
return await service.update_user(user_id, data)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(user_id: int, service: AuthService):
|
||||
try:
|
||||
await service.delete_user(user_id)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return None
|
||||
15
backend/src/module/application/__init__.py
Normal file
15
backend/src/module/application/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Application use cases shared by transport adapters."""
|
||||
|
||||
from .auth import (
|
||||
AuthenticationError,
|
||||
AuthenticationService,
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthenticationError",
|
||||
"AuthenticationService",
|
||||
"ConflictError",
|
||||
"NotFoundError",
|
||||
]
|
||||
176
backend/src/module/application/auth.py
Normal file
176
backend/src/module/application/auth.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Async authentication and user-management use cases."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from module.models.auth import ApiToken
|
||||
from module.models.user import User, UserCreate, UserUpdate
|
||||
from module.ports import AuthUnitOfWork
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ConflictError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AuthenticationService:
|
||||
def __init__(
|
||||
self,
|
||||
database_factory: Callable[[], AuthUnitOfWork],
|
||||
*,
|
||||
legacy_token_decoder: Callable[[str], dict[str, Any] | None] | None = None,
|
||||
) -> None:
|
||||
self._database_factory = database_factory
|
||||
self._legacy_token_decoder = legacy_token_decoder or (lambda _token: None)
|
||||
|
||||
@staticmethod
|
||||
def _user_id(user: User) -> int:
|
||||
if user.id is None:
|
||||
raise RuntimeError("Persisted user has no primary key")
|
||||
return user.id
|
||||
|
||||
async def login(self, username: str, password: str) -> tuple[User, str]:
|
||||
async with self._database_factory() as db:
|
||||
user = await db.user.authenticate_credentials(username, password)
|
||||
if user is None:
|
||||
raise AuthenticationError("Invalid username or password")
|
||||
return user, await db.auth.create_session(self._user_id(user))
|
||||
|
||||
async def authenticate_session(self, token: str) -> User | None:
|
||||
async with self._database_factory() as db:
|
||||
return await db.auth.authenticate_session(token)
|
||||
|
||||
async def authenticate_api_token(
|
||||
self, token: str, *, scope: str = "api"
|
||||
) -> User | None:
|
||||
async with self._database_factory() as db:
|
||||
return await db.auth.authenticate_api_token(token, scope=scope)
|
||||
|
||||
async def authenticate_legacy_jwt(self, token: str) -> User | None:
|
||||
payload = self._legacy_token_decoder(token)
|
||||
username = payload.get("sub") if payload else None
|
||||
if not username:
|
||||
return None
|
||||
async with self._database_factory() as db:
|
||||
user = await db.user.find_user(username)
|
||||
return user if user and user.enabled else None
|
||||
|
||||
async def exchange_legacy_jwt(self, token: str) -> tuple[User, str] | None:
|
||||
user = await self.authenticate_legacy_jwt(token)
|
||||
if user is None:
|
||||
return None
|
||||
async with self._database_factory() as db:
|
||||
return user, await db.auth.create_session(self._user_id(user))
|
||||
|
||||
async def refresh_session(
|
||||
self, token: str, *, ttl: timedelta = timedelta(days=1)
|
||||
) -> User | None:
|
||||
async with self._database_factory() as db:
|
||||
return await db.auth.refresh_session(token, ttl=ttl)
|
||||
|
||||
async def logout(self, token: str) -> bool:
|
||||
async with self._database_factory() as db:
|
||||
return await db.auth.revoke_session(token)
|
||||
|
||||
async def issue_session_for_username(self, username: str) -> str:
|
||||
async with self._database_factory() as db:
|
||||
user = await db.user.find_user(username)
|
||||
if user is None or not user.enabled:
|
||||
raise AuthenticationError("User is not available")
|
||||
return await db.auth.create_session(self._user_id(user))
|
||||
|
||||
async def get_user(self, username: str) -> User:
|
||||
async with self._database_factory() as db:
|
||||
user = await db.user.find_user(username)
|
||||
if user is None:
|
||||
raise NotFoundError("User not found")
|
||||
return user
|
||||
|
||||
async def list_users(self) -> list[User]:
|
||||
async with self._database_factory() as db:
|
||||
return await db.user.list_users()
|
||||
|
||||
async def create_user(self, data: UserCreate) -> User:
|
||||
try:
|
||||
async with self._database_factory() as db:
|
||||
return await db.user.create_user(data.username, data.password)
|
||||
except ValueError as exc:
|
||||
raise ConflictError(str(exc)) from exc
|
||||
|
||||
async def update_user(self, user_id: int, data: UserUpdate) -> User:
|
||||
try:
|
||||
async with self._database_factory() as db:
|
||||
user = await db.user.update_user_by_id(user_id, data)
|
||||
if data.password or data.enabled is False:
|
||||
await db.auth.revoke_user_sessions(user_id)
|
||||
return user
|
||||
except ValueError as exc:
|
||||
if str(exc) == "User not found":
|
||||
raise NotFoundError(str(exc)) from exc
|
||||
raise ConflictError(str(exc)) from exc
|
||||
|
||||
async def update_current_user(
|
||||
self, username: str, data: UserUpdate
|
||||
) -> tuple[User, str]:
|
||||
async with self._database_factory() as db:
|
||||
current = await db.user.find_user(username)
|
||||
if current is None:
|
||||
raise NotFoundError("User not found")
|
||||
try:
|
||||
user = await db.user.update_user_by_id(self._user_id(current), data)
|
||||
except ValueError as exc:
|
||||
raise ConflictError(str(exc)) from exc
|
||||
user_id = self._user_id(user)
|
||||
await db.auth.revoke_user_sessions(user_id)
|
||||
token = await db.auth.create_session(user_id)
|
||||
return user, token
|
||||
|
||||
async def delete_user(self, user_id: int) -> None:
|
||||
try:
|
||||
async with self._database_factory() as db:
|
||||
user = await db.user.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("User not found")
|
||||
if user.enabled and await db.user.enabled_count() <= 1:
|
||||
raise ConflictError("Cannot delete the last enabled user")
|
||||
await db.auth.purge_user_credentials(user_id)
|
||||
if not await db.user.delete_user(user_id):
|
||||
raise NotFoundError("User not found")
|
||||
except ValueError as exc:
|
||||
raise ConflictError(str(exc)) from exc
|
||||
|
||||
async def create_api_token_for_username(
|
||||
self,
|
||||
username: str,
|
||||
*,
|
||||
name: str,
|
||||
scope: str,
|
||||
expires_at: datetime | None = None,
|
||||
) -> tuple[ApiToken, str]:
|
||||
async with self._database_factory() as db:
|
||||
user = await db.user.find_user(username)
|
||||
if user is None:
|
||||
raise NotFoundError("User not found")
|
||||
return await db.auth.create_api_token(
|
||||
self._user_id(user),
|
||||
name=name,
|
||||
scope=scope,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
async def list_api_tokens(self) -> list[ApiToken]:
|
||||
async with self._database_factory() as db:
|
||||
return await db.auth.list_api_tokens()
|
||||
|
||||
async def revoke_api_token(self, token_id: int) -> None:
|
||||
async with self._database_factory() as db:
|
||||
if not await db.auth.revoke_api_token(token_id):
|
||||
raise NotFoundError("API token not found")
|
||||
7
backend/src/module/composition.py
Normal file
7
backend/src/module/composition.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Application composition root: concrete adapters are wired only here."""
|
||||
|
||||
from module.application.auth import AuthenticationService
|
||||
from module.database import Database
|
||||
from module.security.jwt import decode_token
|
||||
|
||||
auth_service = AuthenticationService(Database, legacy_token_decoder=decode_token)
|
||||
@@ -28,6 +28,7 @@ from module.update import (
|
||||
first_run,
|
||||
from_30_to_31,
|
||||
from_31_to_32,
|
||||
migrate_legacy_auth_tokens,
|
||||
run_migrations,
|
||||
)
|
||||
|
||||
@@ -201,6 +202,7 @@ class AppContext:
|
||||
self._start_info()
|
||||
if not Checker.check_database():
|
||||
await first_run()
|
||||
await migrate_legacy_auth_tokens()
|
||||
logger.info("No db file exists, create database file.")
|
||||
self.first_run_boot = True
|
||||
self._startup_done = True
|
||||
@@ -227,6 +229,7 @@ class AppContext:
|
||||
# Always check schema version and run pending migrations,
|
||||
# in case a previous migration was interrupted or failed.
|
||||
await run_migrations()
|
||||
await migrate_legacy_auth_tokens()
|
||||
if not Checker.check_img_cache():
|
||||
logger.info("No image cache exists, create image cache.")
|
||||
await cache_image()
|
||||
|
||||
243
backend/src/module/database/auth.py
Normal file
243
backend/src/module/database/auth.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""Async persistence adapter for sessions and bearer tokens."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import col, delete, select
|
||||
|
||||
from module.models.auth import ApiToken, AuthSession
|
||||
from module.models.passkey import Passkey
|
||||
from module.models.user import User
|
||||
|
||||
DEFAULT_SESSION_TTL = timedelta(days=1)
|
||||
|
||||
|
||||
def _utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class AuthDatabase:
|
||||
"""Repository that stores only SHA-256 token digests."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
user_id: int,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
ttl: timedelta = DEFAULT_SESSION_TTL,
|
||||
) -> str:
|
||||
issued_at = _utc(now or datetime.now(timezone.utc))
|
||||
raw_token = secrets.token_urlsafe(48)
|
||||
self.session.add(
|
||||
AuthSession(
|
||||
user_id=user_id,
|
||||
token_hash=_hash_token(raw_token),
|
||||
created_at=issued_at,
|
||||
last_seen_at=issued_at,
|
||||
expires_at=issued_at + ttl,
|
||||
)
|
||||
)
|
||||
await self.session.commit()
|
||||
return raw_token
|
||||
|
||||
async def authenticate_session(
|
||||
self, raw_token: str, *, now: datetime | None = None
|
||||
) -> User | None:
|
||||
if not raw_token:
|
||||
return None
|
||||
current = _utc(now or datetime.now(timezone.utc))
|
||||
result = await self.session.execute(
|
||||
select(AuthSession).where(
|
||||
AuthSession.token_hash == _hash_token(raw_token),
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
auth_session = result.scalars().first()
|
||||
if auth_session is None or _utc(auth_session.expires_at) <= current:
|
||||
return None
|
||||
user = await self.session.get(User, auth_session.user_id)
|
||||
if user is None or not user.enabled:
|
||||
return None
|
||||
return user
|
||||
|
||||
async def refresh_session(
|
||||
self,
|
||||
raw_token: str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
ttl: timedelta = DEFAULT_SESSION_TTL,
|
||||
) -> User | None:
|
||||
current = _utc(now or datetime.now(timezone.utc))
|
||||
user = await self.authenticate_session(raw_token, now=current)
|
||||
if user is None:
|
||||
return None
|
||||
result = await self.session.execute(
|
||||
select(AuthSession).where(AuthSession.token_hash == _hash_token(raw_token))
|
||||
)
|
||||
auth_session = result.scalar_one()
|
||||
auth_session.last_seen_at = current
|
||||
auth_session.expires_at = current + ttl
|
||||
self.session.add(auth_session)
|
||||
await self.session.commit()
|
||||
return user
|
||||
|
||||
async def revoke_session(
|
||||
self, raw_token: str, *, now: datetime | None = None
|
||||
) -> bool:
|
||||
if not raw_token:
|
||||
return False
|
||||
result = await self.session.execute(
|
||||
select(AuthSession).where(
|
||||
AuthSession.token_hash == _hash_token(raw_token),
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
auth_session = result.scalars().first()
|
||||
if auth_session is None:
|
||||
return False
|
||||
auth_session.revoked_at = _utc(now or datetime.now(timezone.utc))
|
||||
self.session.add(auth_session)
|
||||
await self.session.commit()
|
||||
return True
|
||||
|
||||
async def revoke_user_sessions(
|
||||
self, user_id: int, *, now: datetime | None = None
|
||||
) -> int:
|
||||
current = _utc(now or datetime.now(timezone.utc))
|
||||
result = await self.session.execute(
|
||||
select(AuthSession).where(
|
||||
AuthSession.user_id == user_id,
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
sessions = list(result.scalars().all())
|
||||
for auth_session in sessions:
|
||||
auth_session.revoked_at = current
|
||||
self.session.add(auth_session)
|
||||
await self.session.commit()
|
||||
return len(sessions)
|
||||
|
||||
async def create_api_token(
|
||||
self,
|
||||
user_id: int,
|
||||
*,
|
||||
name: str,
|
||||
scope: str,
|
||||
now: datetime | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> tuple[ApiToken, str]:
|
||||
if scope not in {"api", "mcp"}:
|
||||
raise ValueError("Unsupported API token scope")
|
||||
issued_at = _utc(now or datetime.now(timezone.utc))
|
||||
raw_token = f"ab_{scope}_{secrets.token_urlsafe(36)}"
|
||||
token = ApiToken(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
scope=scope,
|
||||
token_hash=_hash_token(raw_token),
|
||||
prefix=raw_token[:12],
|
||||
created_at=issued_at,
|
||||
expires_at=_utc(expires_at) if expires_at else None,
|
||||
)
|
||||
self.session.add(token)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(token)
|
||||
return token, raw_token
|
||||
|
||||
async def import_api_token(
|
||||
self,
|
||||
user_id: int,
|
||||
raw_token: str,
|
||||
*,
|
||||
name: str,
|
||||
scope: str,
|
||||
now: datetime | None = None,
|
||||
) -> ApiToken:
|
||||
token_hash = _hash_token(raw_token)
|
||||
result = await self.session.execute(
|
||||
select(ApiToken).where(ApiToken.token_hash == token_hash)
|
||||
)
|
||||
existing = result.scalars().first()
|
||||
if existing is not None:
|
||||
return existing
|
||||
if scope not in {"api", "mcp"}:
|
||||
raise ValueError("Unsupported API token scope")
|
||||
token = ApiToken(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
scope=scope,
|
||||
token_hash=token_hash,
|
||||
prefix=raw_token[:12],
|
||||
created_at=_utc(now or datetime.now(timezone.utc)),
|
||||
)
|
||||
self.session.add(token)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(token)
|
||||
return token
|
||||
|
||||
async def authenticate_api_token(
|
||||
self,
|
||||
raw_token: str,
|
||||
*,
|
||||
scope: str,
|
||||
now: datetime | None = None,
|
||||
) -> User | None:
|
||||
if not raw_token:
|
||||
return None
|
||||
current = _utc(now or datetime.now(timezone.utc))
|
||||
result = await self.session.execute(
|
||||
select(ApiToken).where(
|
||||
ApiToken.token_hash == _hash_token(raw_token),
|
||||
ApiToken.scope == scope,
|
||||
ApiToken.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
token = result.scalars().first()
|
||||
if token is None:
|
||||
return None
|
||||
if token.expires_at is not None and _utc(token.expires_at) <= current:
|
||||
return None
|
||||
user = await self.session.get(User, token.user_id)
|
||||
if user is None or not user.enabled:
|
||||
return None
|
||||
token.last_used_at = current
|
||||
self.session.add(token)
|
||||
await self.session.commit()
|
||||
return user
|
||||
|
||||
async def revoke_api_token(
|
||||
self, token_id: int, *, now: datetime | None = None
|
||||
) -> bool:
|
||||
token = await self.session.get(ApiToken, token_id)
|
||||
if token is None or token.revoked_at is not None:
|
||||
return False
|
||||
token.revoked_at = _utc(now or datetime.now(timezone.utc))
|
||||
self.session.add(token)
|
||||
await self.session.commit()
|
||||
return True
|
||||
|
||||
async def list_api_tokens(self) -> list[ApiToken]:
|
||||
result = await self.session.execute(
|
||||
select(ApiToken).order_by(col(ApiToken.created_at).desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def purge_user_credentials(self, user_id: int) -> None:
|
||||
"""Remove credentials before deleting their owning user."""
|
||||
await self.session.execute(
|
||||
delete(AuthSession).where(AuthSession.user_id == user_id)
|
||||
)
|
||||
await self.session.execute(delete(ApiToken).where(ApiToken.user_id == user_id))
|
||||
await self.session.execute(delete(Passkey).where(Passkey.user_id == user_id))
|
||||
await self.session.commit()
|
||||
@@ -7,6 +7,7 @@ from sqlmodel import SQLModel
|
||||
from module.models import Bangumi, User
|
||||
|
||||
from .aria2 import Aria2GidDatabase
|
||||
from .auth import AuthDatabase
|
||||
from .bangumi import BangumiDatabase
|
||||
from .engine import async_engine, async_session_factory
|
||||
from .inbox import InboxDatabase
|
||||
@@ -45,6 +46,7 @@ class Database:
|
||||
self.bangumi = BangumiDatabase(self.session)
|
||||
self.user = UserDatabase(self.session)
|
||||
self.aria2 = Aria2GidDatabase(self.session)
|
||||
self.auth = AuthDatabase(self.session)
|
||||
self.inbox = InboxDatabase(self.session)
|
||||
self.llm_credential = LLMCredentialDatabase(self.session)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from pydantic_core import PydanticUndefined
|
||||
from sqlalchemy import Connection, Engine, inspect, text
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from module.models import Bangumi, User
|
||||
from module.models import ApiToken, AuthSession, Bangumi, User
|
||||
from module.models.inbox import InboxMessage
|
||||
from module.models.llm_credential import LLMCredential
|
||||
from module.models.passkey import Passkey
|
||||
@@ -35,6 +35,8 @@ TABLE_MODELS: list[type[SQLModel]] = [
|
||||
Passkey,
|
||||
InboxMessage,
|
||||
LLMCredential,
|
||||
AuthSession,
|
||||
ApiToken,
|
||||
]
|
||||
|
||||
# already_applied 守卫:接收 inspector,返回该迁移是否已生效
|
||||
@@ -383,6 +385,94 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
),
|
||||
table_exists("llmcredential"),
|
||||
),
|
||||
Migration(
|
||||
18,
|
||||
"add multi-user account state and audit columns",
|
||||
(
|
||||
"ALTER TABLE user ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT 1",
|
||||
"ALTER TABLE user ADD COLUMN created_at TIMESTAMP NOT NULL "
|
||||
"DEFAULT '1970-01-01 00:00:00'",
|
||||
"ALTER TABLE user ADD COLUMN updated_at TIMESTAMP NOT NULL "
|
||||
"DEFAULT '1970-01-01 00:00:00'",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_user_username ON user(username)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_user_enabled ON user(enabled)",
|
||||
),
|
||||
all_checks(
|
||||
column_exists("user", "enabled"),
|
||||
column_exists("user", "created_at"),
|
||||
column_exists("user", "updated_at"),
|
||||
index_exists("user", "ix_user_username"),
|
||||
index_exists("user", "ix_user_enabled"),
|
||||
),
|
||||
(
|
||||
(
|
||||
"ALTER TABLE user ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT 1",
|
||||
column_exists("user", "enabled"),
|
||||
),
|
||||
(
|
||||
"ALTER TABLE user ADD COLUMN created_at TIMESTAMP NOT NULL "
|
||||
"DEFAULT '1970-01-01 00:00:00'",
|
||||
column_exists("user", "created_at"),
|
||||
),
|
||||
(
|
||||
"ALTER TABLE user ADD COLUMN updated_at TIMESTAMP NOT NULL "
|
||||
"DEFAULT '1970-01-01 00:00:00'",
|
||||
column_exists("user", "updated_at"),
|
||||
),
|
||||
(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_user_username ON user(username)",
|
||||
index_exists("user", "ix_user_username"),
|
||||
),
|
||||
(
|
||||
"CREATE INDEX IF NOT EXISTS ix_user_enabled ON user(enabled)",
|
||||
index_exists("user", "ix_user_enabled"),
|
||||
),
|
||||
),
|
||||
),
|
||||
Migration(
|
||||
19,
|
||||
"create persistent sessions and scoped API tokens",
|
||||
(
|
||||
"""CREATE TABLE IF NOT EXISTS auth_session (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES user(id),
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
last_seen_at TIMESTAMP NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
revoked_at TIMESTAMP
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_auth_session_user_id "
|
||||
"ON auth_session(user_id)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_auth_session_token_hash "
|
||||
"ON auth_session(token_hash)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_auth_session_expires_at "
|
||||
"ON auth_session(expires_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_auth_session_revoked_at "
|
||||
"ON auth_session(revoked_at)",
|
||||
"""CREATE TABLE IF NOT EXISTS api_token (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES user(id),
|
||||
name VARCHAR(64) NOT NULL,
|
||||
scope VARCHAR(8) NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
prefix VARCHAR(16) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
last_used_at TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
revoked_at TIMESTAMP
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_api_token_user_id ON api_token(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_api_token_scope ON api_token(scope)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_api_token_token_hash "
|
||||
"ON api_token(token_hash)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_api_token_expires_at "
|
||||
"ON api_token(expires_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_api_token_revoked_at "
|
||||
"ON api_token(revoked_at)",
|
||||
),
|
||||
all_checks(table_exists("auth_session"), table_exists("api_token")),
|
||||
),
|
||||
)
|
||||
|
||||
# 由迁移列表派生,新增迁移时无需手动同步
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import select
|
||||
from sqlmodel import col, func, select
|
||||
|
||||
from module.models import ResponseModel
|
||||
from module.models.user import User, UserUpdate
|
||||
@@ -23,6 +25,90 @@ class UserDatabase:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
async def find_user(self, username: str) -> User | None:
|
||||
result = await self.session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def authenticate_credentials(
|
||||
self, username: str, password: str
|
||||
) -> User | None:
|
||||
user = await self.find_user(username)
|
||||
if (
|
||||
user is None
|
||||
or not user.enabled
|
||||
or not verify_password(password, user.password)
|
||||
):
|
||||
return None
|
||||
return user
|
||||
|
||||
async def get_user_by_id(self, user_id: int) -> User | None:
|
||||
return await self.session.get(User, user_id)
|
||||
|
||||
async def list_users(self) -> list[User]:
|
||||
result = await self.session.execute(select(User).order_by(col(User.username)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create_user(self, username: str, password: str) -> User:
|
||||
if await self.find_user(username) is not None:
|
||||
raise ValueError("Username already exists")
|
||||
user = User(username=username, password=get_password_hash(password))
|
||||
self.session.add(user)
|
||||
try:
|
||||
await self.session.commit()
|
||||
except IntegrityError as exc:
|
||||
await self.session.rollback()
|
||||
raise ValueError("Username already exists") from exc
|
||||
await self.session.refresh(user)
|
||||
return user
|
||||
|
||||
async def _enabled_count(self) -> int:
|
||||
result = await self.session.execute(
|
||||
select(func.count()).select_from(User).where(User.enabled.is_(True))
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def enabled_count(self) -> int:
|
||||
return await self._enabled_count()
|
||||
|
||||
async def update_user_by_id(self, user_id: int, data: UserUpdate) -> User:
|
||||
user = await self.session.get(User, user_id)
|
||||
if user is None:
|
||||
raise ValueError("User not found")
|
||||
if data.enabled is False and user.enabled and await self._enabled_count() <= 1:
|
||||
raise ValueError("Cannot disable the last enabled user")
|
||||
if data.username is not None and data.username != user.username:
|
||||
if await self.find_user(data.username) is not None:
|
||||
raise ValueError("Username already exists")
|
||||
user.username = data.username
|
||||
if data.password is not None:
|
||||
user.password = get_password_hash(data.password)
|
||||
if data.enabled is not None:
|
||||
user.enabled = data.enabled
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
self.session.add(user)
|
||||
try:
|
||||
await self.session.commit()
|
||||
except IntegrityError as exc:
|
||||
await self.session.rollback()
|
||||
raise ValueError("Username already exists") from exc
|
||||
await self.session.refresh(user)
|
||||
return user
|
||||
|
||||
async def set_enabled(self, user_id: int, enabled: bool) -> User:
|
||||
return await self.update_user_by_id(user_id, UserUpdate(enabled=enabled))
|
||||
|
||||
async def delete_user(self, user_id: int) -> bool:
|
||||
user = await self.session.get(User, user_id)
|
||||
if user is None:
|
||||
return False
|
||||
if user.enabled and await self._enabled_count() <= 1:
|
||||
raise ValueError("Cannot delete the last enabled user")
|
||||
await self.session.delete(user)
|
||||
await self.session.commit()
|
||||
return True
|
||||
|
||||
async def auth_user(self, user: User) -> ResponseModel:
|
||||
statement = select(User).where(User.username == user.username)
|
||||
result = await self.session.execute(statement)
|
||||
@@ -34,7 +120,7 @@ class UserDatabase:
|
||||
msg_en="Incorrect password format",
|
||||
msg_zh="密码格式不正确",
|
||||
)
|
||||
if not db_user:
|
||||
if not db_user or not db_user.enabled:
|
||||
return ResponseModel(
|
||||
status_code=401,
|
||||
status=False,
|
||||
@@ -61,13 +147,9 @@ class UserDatabase:
|
||||
db_user = result.scalars().first()
|
||||
if not db_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if update_user.username:
|
||||
db_user.username = update_user.username
|
||||
if update_user.password:
|
||||
db_user.password = get_password_hash(update_user.password)
|
||||
self.session.add(db_user)
|
||||
await self.session.commit()
|
||||
return db_user
|
||||
if db_user.id is None:
|
||||
raise RuntimeError("Persisted user has no primary key")
|
||||
return await self.update_user_by_id(db_user.id, update_user)
|
||||
|
||||
async def add_default_user(self):
|
||||
statement = select(User)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""MCP access control: configurable IP whitelist and bearer token authentication."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from module.composition import auth_service
|
||||
from module.conf import settings
|
||||
from module.security.ip_allowlist import ( # noqa: F401 (re-exported for existing importers)
|
||||
_is_allowed,
|
||||
@@ -29,8 +31,15 @@ class McpAccessMiddleware(BaseHTTPMiddleware):
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
if token and token in settings.security.mcp_tokens:
|
||||
return await call_next(request)
|
||||
if token:
|
||||
user = await auth_service.authenticate_api_token(token, scope="mcp")
|
||||
if user is not None:
|
||||
return await call_next(request)
|
||||
if any(
|
||||
secrets.compare_digest(token, legacy)
|
||||
for legacy in settings.security.mcp_tokens
|
||||
):
|
||||
return await call_next(request)
|
||||
|
||||
# Check IP whitelist
|
||||
client_host = request.client.host if request.client else None
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .auth import ApiToken, AuthSession
|
||||
from .bangumi import Bangumi, BangumiUpdate, Episode, Notification
|
||||
from .config import Config
|
||||
from .inbox import InboxMessage
|
||||
|
||||
63
backend/src/module/models/auth.py
Normal file
63
backend/src/module/models/auth.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Persistent authentication models."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class AuthSession(SQLModel, table=True):
|
||||
"""Revocable browser or bearer session stored as a token hash."""
|
||||
|
||||
__tablename__ = "auth_session"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", index=True)
|
||||
token_hash: str = Field(unique=True, index=True, max_length=64)
|
||||
created_at: datetime = Field(default_factory=utc_now)
|
||||
last_seen_at: datetime = Field(default_factory=utc_now)
|
||||
expires_at: datetime = Field(index=True)
|
||||
revoked_at: datetime | None = Field(default=None, index=True)
|
||||
|
||||
|
||||
class ApiToken(SQLModel, table=True):
|
||||
"""Hashed API or MCP bearer token."""
|
||||
|
||||
__tablename__ = "api_token"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", index=True)
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
scope: str = Field(index=True, max_length=8)
|
||||
token_hash: str = Field(unique=True, index=True, max_length=64)
|
||||
prefix: str = Field(max_length=16)
|
||||
created_at: datetime = Field(default_factory=utc_now)
|
||||
last_used_at: datetime | None = None
|
||||
expires_at: datetime | None = Field(default=None, index=True)
|
||||
revoked_at: datetime | None = Field(default=None, index=True)
|
||||
|
||||
|
||||
class ApiTokenCreate(SQLModel):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
scope: Literal["api", "mcp"] = "api"
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class ApiTokenPublic(SQLModel):
|
||||
id: int
|
||||
user_id: int
|
||||
name: str
|
||||
scope: Literal["api", "mcp"]
|
||||
prefix: str
|
||||
created_at: datetime
|
||||
last_used_at: datetime | None
|
||||
expires_at: datetime | None
|
||||
revoked_at: datetime | None
|
||||
|
||||
|
||||
class ApiTokenCreated(ApiTokenPublic):
|
||||
token: str
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -5,11 +6,19 @@ from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
id: int = Field(default=None, primary_key=True)
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
username: str = Field(
|
||||
"admin", min_length=4, max_length=20, regex=r"^[a-zA-Z0-9_]+$"
|
||||
"admin",
|
||||
min_length=4,
|
||||
max_length=20,
|
||||
regex=r"^[a-zA-Z0-9_]+$",
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
password: str = Field("", min_length=8)
|
||||
enabled: bool = Field(default=True, index=True)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class UserUpdate(SQLModel):
|
||||
@@ -17,6 +26,20 @@ class UserUpdate(SQLModel):
|
||||
None, min_length=4, max_length=20, regex=r"^[a-zA-Z0-9_]+$"
|
||||
)
|
||||
password: Optional[str] = Field(None, min_length=8)
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class UserCreate(SQLModel):
|
||||
username: str = Field(min_length=4, max_length=20, regex=r"^[a-zA-Z0-9_]+$")
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
|
||||
class UserPublic(SQLModel):
|
||||
id: int
|
||||
username: str
|
||||
enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UserLogin(SQLModel):
|
||||
|
||||
5
backend/src/module/ports/__init__.py
Normal file
5
backend/src/module/ports/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Dependency-inversion contracts used by application services."""
|
||||
|
||||
from .auth import AuthUnitOfWork
|
||||
|
||||
__all__ = ["AuthUnitOfWork"]
|
||||
75
backend/src/module/ports/auth.py
Normal file
75
backend/src/module/ports/auth.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Protocols required by authentication application services."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from types import TracebackType
|
||||
from typing import Protocol, Self
|
||||
|
||||
from module.models.auth import ApiToken
|
||||
from module.models.user import User, UserUpdate
|
||||
|
||||
|
||||
class UserRepository(Protocol):
|
||||
async def authenticate_credentials(
|
||||
self, username: str, password: str
|
||||
) -> User | None: ...
|
||||
|
||||
async def find_user(self, username: str) -> User | None: ...
|
||||
|
||||
async def list_users(self) -> list[User]: ...
|
||||
|
||||
async def create_user(self, username: str, password: str) -> User: ...
|
||||
|
||||
async def update_user_by_id(self, user_id: int, data: UserUpdate) -> User: ...
|
||||
|
||||
async def delete_user(self, user_id: int) -> bool: ...
|
||||
|
||||
async def get_user_by_id(self, user_id: int) -> User | None: ...
|
||||
|
||||
async def enabled_count(self) -> int: ...
|
||||
|
||||
|
||||
class SessionTokenRepository(Protocol):
|
||||
async def create_session(self, user_id: int) -> str: ...
|
||||
|
||||
async def authenticate_session(self, raw_token: str) -> User | None: ...
|
||||
|
||||
async def refresh_session(
|
||||
self, raw_token: str, *, ttl: timedelta
|
||||
) -> User | None: ...
|
||||
|
||||
async def revoke_session(self, raw_token: str) -> bool: ...
|
||||
|
||||
async def revoke_user_sessions(self, user_id: int) -> int: ...
|
||||
|
||||
async def create_api_token(
|
||||
self,
|
||||
user_id: int,
|
||||
*,
|
||||
name: str,
|
||||
scope: str,
|
||||
expires_at: datetime | None = None,
|
||||
) -> tuple[ApiToken, str]: ...
|
||||
|
||||
async def authenticate_api_token(
|
||||
self, raw_token: str, *, scope: str
|
||||
) -> User | None: ...
|
||||
|
||||
async def list_api_tokens(self) -> list[ApiToken]: ...
|
||||
|
||||
async def revoke_api_token(self, token_id: int) -> bool: ...
|
||||
|
||||
async def purge_user_credentials(self, user_id: int) -> None: ...
|
||||
|
||||
|
||||
class AuthUnitOfWork(Protocol):
|
||||
user: UserRepository
|
||||
auth: SessionTokenRepository
|
||||
|
||||
async def __aenter__(self) -> Self: ...
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
traceback: TracebackType | None,
|
||||
) -> bool | None: ...
|
||||
@@ -2,10 +2,13 @@ import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Cookie, Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
from module.application.auth import AuthenticationService
|
||||
from module.composition import auth_service
|
||||
from module.conf import settings
|
||||
from module.database import Database
|
||||
from module.models.user import User, UserUpdate
|
||||
@@ -88,26 +91,52 @@ def check_login_ip(request: Request):
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(request: Request, token: str | None = Cookie(None)):
|
||||
def get_auth_service() -> AuthenticationService:
|
||||
return auth_service
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
token: str | None = Cookie(None),
|
||||
service: Annotated[AuthenticationService | None, Depends(get_auth_service)] = None,
|
||||
):
|
||||
"""FastAPI dependency that validates the current session.
|
||||
|
||||
Accepts authentication via (in order of precedence):
|
||||
1. DEV_AUTH_BYPASS when ``AB_DEV_NO_AUTH=1`` is set in the environment.
|
||||
2. ``Authorization: Bearer <token>`` header matching ``login_tokens``.
|
||||
3. HttpOnly ``token`` cookie containing a valid JWT with an active session.
|
||||
2. A persisted API token or session in the ``Authorization`` header.
|
||||
3. An HttpOnly cookie containing a persisted session.
|
||||
4. Legacy plaintext config tokens/JWT cookies during the migration window.
|
||||
"""
|
||||
if DEV_AUTH_BYPASS:
|
||||
return "dev_user"
|
||||
# Check bearer token bypass
|
||||
service = service or auth_service
|
||||
# Database tokens are authoritative. Plaintext configuration tokens remain
|
||||
# readable for one migration window and are removed after startup import.
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
api_token = auth_header[7:]
|
||||
user = await service.authenticate_api_token(api_token, scope="api")
|
||||
if user is not None:
|
||||
return user.username
|
||||
user = await service.authenticate_session(api_token)
|
||||
if user is not None:
|
||||
return user.username
|
||||
if api_token and any(
|
||||
secrets.compare_digest(api_token, t) for t in settings.security.login_tokens
|
||||
):
|
||||
return "api_token_user"
|
||||
if not token:
|
||||
raise UNAUTHORIZED
|
||||
user = await service.authenticate_session(token)
|
||||
if user is not None:
|
||||
return user.username
|
||||
|
||||
# Accept legacy JWT cookies for one compatibility period. New logins no
|
||||
# longer issue JWTs; /auth/refresh_token exchanges one for a DB session.
|
||||
user = await service.authenticate_legacy_jwt(token)
|
||||
if user is not None:
|
||||
return user.username
|
||||
payload = verify_token(token)
|
||||
username = payload.get("sub") if payload else None
|
||||
if not username or username not in active_user:
|
||||
|
||||
@@ -84,7 +84,7 @@ class PasskeyAuthStrategy(AuthStrategy):
|
||||
select(User).where(User.id == passkey.user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
if not user or not user.enabled:
|
||||
return ResponseModel(
|
||||
status_code=401,
|
||||
status=False,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .auth import migrate_legacy_auth_tokens
|
||||
from .cross_version import cache_image, from_30_to_31, from_31_to_32, run_migrations
|
||||
from .data_migration import data_migration
|
||||
from .startup import first_run, start_up
|
||||
|
||||
51
backend/src/module/update/auth.py
Normal file
51
backend/src/module/update/auth.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""One-way migration of legacy plaintext bearer tokens into the database."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from module.conf import settings
|
||||
from module.database import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def migrate_legacy_auth_tokens(
|
||||
*,
|
||||
database_factory: Callable[[], Database] = Database,
|
||||
runtime_settings=settings,
|
||||
) -> int:
|
||||
login_tokens = list(runtime_settings.security.login_tokens)
|
||||
mcp_tokens = list(runtime_settings.security.mcp_tokens)
|
||||
if not login_tokens and not mcp_tokens:
|
||||
return 0
|
||||
|
||||
async with database_factory() as db:
|
||||
users = [user for user in await db.user.list_users() if user.enabled]
|
||||
if not users:
|
||||
logger.warning("[Auth] Legacy tokens remain in config: no enabled user")
|
||||
return 0
|
||||
owner = users[0]
|
||||
if owner.id is None:
|
||||
raise RuntimeError("Persisted token owner has no primary key")
|
||||
for index, raw_token in enumerate(login_tokens, start=1):
|
||||
await db.auth.import_api_token(
|
||||
owner.id,
|
||||
raw_token,
|
||||
name=f"Imported API token {index}",
|
||||
scope="api",
|
||||
)
|
||||
for index, raw_token in enumerate(mcp_tokens, start=1):
|
||||
await db.auth.import_api_token(
|
||||
owner.id,
|
||||
raw_token,
|
||||
name=f"Imported MCP token {index}",
|
||||
scope="mcp",
|
||||
)
|
||||
|
||||
runtime_settings.security.login_tokens = []
|
||||
runtime_settings.security.mcp_tokens = []
|
||||
await asyncio.to_thread(runtime_settings.save)
|
||||
imported = len(login_tokens) + len(mcp_tokens)
|
||||
logger.info("[Auth] Imported %s legacy bearer tokens", imported)
|
||||
return imported
|
||||
@@ -1,286 +1,125 @@
|
||||
"""Tests for Auth API endpoints."""
|
||||
"""Tests for database-backed Auth API endpoints."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from module.api import v1
|
||||
from module.models import ResponseModel
|
||||
from module.security.api import SessionStore, active_user, get_current_user
|
||||
from module.security.jwt import create_access_token
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _store(*usernames: str) -> SessionStore:
|
||||
"""Build a SessionStore pre-populated with the given active usernames."""
|
||||
store = SessionStore()
|
||||
for username in usernames:
|
||||
store.add(username)
|
||||
return store
|
||||
from module.application.auth import AuthenticationError
|
||||
from module.models.user import User
|
||||
from module.security.api import get_auth_service, get_current_user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create a FastAPI app with v1 routes for testing."""
|
||||
def service():
|
||||
service = MagicMock()
|
||||
service.login = AsyncMock()
|
||||
service.refresh_session = AsyncMock()
|
||||
service.exchange_legacy_jwt = AsyncMock()
|
||||
service.logout = AsyncMock(return_value=True)
|
||||
service.get_user = AsyncMock()
|
||||
service.update_current_user = AsyncMock()
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(service):
|
||||
app = FastAPI()
|
||||
app.include_router(v1, prefix="/api")
|
||||
app.dependency_overrides[get_auth_service] = lambda: service
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authed_client(app):
|
||||
"""TestClient with auth dependency overridden."""
|
||||
|
||||
async def mock_user():
|
||||
return "testuser"
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_user
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
app.dependency_overrides.clear()
|
||||
def client(app):
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unauthed_client(app):
|
||||
"""TestClient without auth (no override)."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth requirement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthRequired:
|
||||
@patch("module.security.api.DEV_AUTH_BYPASS", False)
|
||||
def test_refresh_token_unauthorized(self, unauthed_client):
|
||||
"""GET /auth/refresh_token without auth returns 401."""
|
||||
response = unauthed_client.get("/api/v1/auth/refresh_token")
|
||||
assert response.status_code == 401
|
||||
|
||||
@patch("module.security.api.DEV_AUTH_BYPASS", False)
|
||||
def test_logout_unauthorized(self, unauthed_client):
|
||||
"""POST /auth/logout without auth returns 401."""
|
||||
response = unauthed_client.post("/api/v1/auth/logout")
|
||||
assert response.status_code == 401
|
||||
|
||||
@patch("module.security.api.DEV_AUTH_BYPASS", False)
|
||||
def test_update_unauthorized(self, unauthed_client):
|
||||
"""POST /auth/update without auth returns 401."""
|
||||
response = unauthed_client.post(
|
||||
"/api/v1/auth/update",
|
||||
json={"old_password": "test", "new_password": "newtest"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /auth/login
|
||||
# ---------------------------------------------------------------------------
|
||||
def persisted_user(username: str = "testuser") -> User:
|
||||
return User(id=1, username=username, password="hashed-password", enabled=True)
|
||||
|
||||
|
||||
class TestLogin:
|
||||
def test_login_success(self, unauthed_client):
|
||||
"""POST /auth/login with valid credentials returns token."""
|
||||
mock_response = ResponseModel(
|
||||
status=True, status_code=200, msg_en="OK", msg_zh="成功"
|
||||
def test_login_success_sets_persisted_session_cookie(self, client, service):
|
||||
service.login.return_value = (persisted_user(), "persisted-session")
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "testuser", "password": "test-password"},
|
||||
)
|
||||
with patch("module.api.auth.auth_user", return_value=mock_response):
|
||||
response = unauthed_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "adminadmin"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
assert response.json() == {
|
||||
"access_token": "persisted-session",
|
||||
"token_type": "bearer",
|
||||
}
|
||||
assert response.cookies.get("token") == "persisted-session"
|
||||
|
||||
def test_login_failure(self, unauthed_client):
|
||||
"""POST /auth/login with invalid credentials returns error."""
|
||||
mock_response = ResponseModel(
|
||||
status=False, status_code=401, msg_en="Invalid", msg_zh="无效"
|
||||
def test_login_failure_is_unauthorized(self, client, service):
|
||||
service.login.side_effect = AuthenticationError("invalid")
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "testuser", "password": "wrong-password"},
|
||||
)
|
||||
with patch("module.api.auth.auth_user", return_value=mock_response):
|
||||
response = unauthed_client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "wrongpassword"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /auth/refresh_token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRefreshToken:
|
||||
def test_refresh_token_success(self, authed_client):
|
||||
"""GET /auth/refresh_token returns new token."""
|
||||
token = create_access_token(data={"sub": "testuser"})
|
||||
authed_client.cookies.set("token", token)
|
||||
with patch("module.api.auth.active_user", _store("testuser")):
|
||||
response = authed_client.get("/api/v1/auth/refresh_token")
|
||||
|
||||
class TestRefresh:
|
||||
def test_post_refresh_extends_database_session(self, client, service):
|
||||
service.refresh_session.return_value = persisted_user()
|
||||
client.cookies.set("token", "persisted-session")
|
||||
response = client.post("/api/v1/auth/refresh_token")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /auth/logout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLogout:
|
||||
def test_logout_success(self, authed_client):
|
||||
"""POST /auth/logout clears session and returns success."""
|
||||
token = create_access_token(data={"sub": "testuser"})
|
||||
authed_client.cookies.set("token", token)
|
||||
with patch("module.api.auth.active_user", _store("testuser")):
|
||||
response = authed_client.post("/api/v1/auth/logout")
|
||||
assert response.json()["access_token"] == "persisted-session"
|
||||
|
||||
def test_get_refresh_is_deprecated_compatibility_alias(self, client, service):
|
||||
service.refresh_session.return_value = persisted_user()
|
||||
client.cookies.set("token", "persisted-session")
|
||||
response = client.get("/api/v1/auth/refresh_token")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["msg_en"] == "Logout successfully."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /auth/update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateCredentials:
|
||||
def test_update_success(self, authed_client):
|
||||
"""POST /auth/update with valid data updates credentials."""
|
||||
token = create_access_token(data={"sub": "testuser"})
|
||||
authed_client.cookies.set("token", token)
|
||||
with patch("module.api.auth.active_user", _store("testuser")):
|
||||
with patch("module.api.auth.update_user_info", return_value=True):
|
||||
response = authed_client.post(
|
||||
"/api/v1/auth/update",
|
||||
json={"old_password": "oldpass", "new_password": "newpass"},
|
||||
)
|
||||
assert response.headers["deprecation"] == "true"
|
||||
|
||||
def test_refresh_exchanges_legacy_jwt(self, client, service):
|
||||
service.refresh_session.return_value = None
|
||||
service.exchange_legacy_jwt.return_value = (
|
||||
persisted_user(),
|
||||
"new-persisted-session",
|
||||
)
|
||||
client.cookies.set("token", "legacy-jwt")
|
||||
response = client.post("/api/v1/auth/refresh_token")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["message"] == "update success"
|
||||
assert response.json()["access_token"] == "new-persisted-session"
|
||||
|
||||
def test_update_failure(self, authed_client):
|
||||
"""POST /auth/update with invalid old password fails."""
|
||||
token = create_access_token(data={"sub": "testuser"})
|
||||
authed_client.cookies.set("token", token)
|
||||
with patch("module.api.auth.active_user", _store("testuser")):
|
||||
with patch("module.api.auth.update_user_info", return_value=False):
|
||||
# When update_user_info returns False, the endpoint implicitly
|
||||
# returns None which causes an error
|
||||
try:
|
||||
response = authed_client.post(
|
||||
"/api/v1/auth/update",
|
||||
json={"old_password": "wrongpass", "new_password": "newpass"},
|
||||
)
|
||||
# If it doesn't raise, check for error status
|
||||
assert response.status_code in [200, 422, 500]
|
||||
except Exception:
|
||||
# Expected - endpoint doesn't handle failure case properly
|
||||
pass
|
||||
def test_refresh_without_cookie_is_unauthorized(self, client):
|
||||
assert client.post("/api/v1/auth/refresh_token").status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refresh token: cookie-based username resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_logout_is_idempotent_and_revokes_cookie_session(client, service):
|
||||
client.cookies.set("token", "persisted-session")
|
||||
response = client.post("/api/v1/auth/logout")
|
||||
assert response.status_code == 200
|
||||
service.logout.assert_awaited_once_with("persisted-session")
|
||||
assert response.cookies.get("token") is None
|
||||
|
||||
|
||||
class TestRefreshTokenCookieBehavior:
|
||||
def test_refresh_with_no_cookie_raises_401(self, authed_client):
|
||||
"""GET /refresh_token with missing token cookie raises 401."""
|
||||
# Override auth to allow route but provide no cookie token
|
||||
with patch("module.api.auth.decode_token", return_value=None):
|
||||
response = authed_client.get("/api/v1/auth/refresh_token")
|
||||
assert response.status_code == 401
|
||||
def test_update_rotates_all_sessions(client, app, service):
|
||||
async def current_user():
|
||||
return "testuser"
|
||||
|
||||
def test_refresh_with_valid_cookie_updates_active_user(self, authed_client):
|
||||
"""GET /refresh_token updates the active user timestamp."""
|
||||
token = create_access_token(data={"sub": "testuser"})
|
||||
authed_client.cookies.set("token", token)
|
||||
active_users = SessionStore()
|
||||
with patch("module.api.auth.active_user", active_users):
|
||||
response = authed_client.get("/api/v1/auth/refresh_token")
|
||||
assert response.status_code == 200
|
||||
assert "testuser" in active_users
|
||||
|
||||
def test_refresh_returns_new_token(self, authed_client):
|
||||
"""GET /refresh_token issues a valid JWT with bearer type."""
|
||||
token = create_access_token(data={"sub": "testuser"})
|
||||
authed_client.cookies.set("token", token)
|
||||
with patch("module.api.auth.active_user", SessionStore()):
|
||||
response = authed_client.get("/api/v1/auth/refresh_token")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["token_type"] == "bearer"
|
||||
assert isinstance(data["access_token"], str)
|
||||
assert len(data["access_token"]) > 0
|
||||
app.dependency_overrides[get_current_user] = current_user
|
||||
service.update_current_user.return_value = (
|
||||
persisted_user("renamed_user"),
|
||||
"rotated-session",
|
||||
)
|
||||
response = client.post(
|
||||
"/api/v1/auth/update",
|
||||
json={"username": "renamed_user", "password": "new-password"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["access_token"] == "rotated-session"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logout: per-user removal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLogoutCookieBehavior:
|
||||
def test_logout_removes_only_current_user(self, authed_client):
|
||||
"""POST /logout removes the current user from active_user, not others."""
|
||||
token = create_access_token(data={"sub": "testuser"})
|
||||
authed_client.cookies.set("token", token)
|
||||
active_users = _store("testuser", "otheruser")
|
||||
with patch("module.api.auth.active_user", active_users):
|
||||
response = authed_client.post("/api/v1/auth/logout")
|
||||
assert response.status_code == 200
|
||||
assert "testuser" not in active_users
|
||||
assert "otheruser" in active_users
|
||||
|
||||
def test_logout_with_no_cookie_still_succeeds(self, authed_client):
|
||||
"""POST /logout with no cookie clears nothing but returns success."""
|
||||
with patch("module.api.auth.decode_token", return_value=None):
|
||||
with patch("module.api.auth.active_user", SessionStore()):
|
||||
response = authed_client.post("/api/v1/auth/logout")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Update: cookie-based user resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateCookieBehavior:
|
||||
def test_update_with_no_cookie_raises_401(self, authed_client):
|
||||
"""POST /auth/update with no cookie raises 401."""
|
||||
with patch("module.api.auth.decode_token", return_value=None):
|
||||
response = authed_client.post(
|
||||
"/api/v1/auth/update",
|
||||
json={"old_password": "old", "new_password": "new"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_update_with_valid_cookie_succeeds(self, authed_client):
|
||||
"""POST /auth/update resolves username from cookie and issues new token."""
|
||||
token = create_access_token(data={"sub": "testuser"})
|
||||
authed_client.cookies.set("token", token)
|
||||
with patch("module.api.auth.active_user", _store("testuser")):
|
||||
with patch("module.api.auth.update_user_info", return_value=True):
|
||||
response = authed_client.post(
|
||||
"/api/v1/auth/update",
|
||||
json={"old_password": "oldpass", "new_password": "newpass"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["message"] == "update success"
|
||||
@patch("module.security.api.DEV_AUTH_BYPASS", False)
|
||||
def test_me_requires_authentication(client):
|
||||
assert client.get("/api/v1/auth/me").status_code == 401
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi.testclient import TestClient
|
||||
from module.api import v1
|
||||
from module.models import ResponseModel
|
||||
from module.models.passkey import Passkey
|
||||
from module.security.api import SessionStore, get_current_user
|
||||
from module.security.api import get_current_user
|
||||
from test.factories import make_passkey
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -344,7 +344,11 @@ class TestAuthVerify:
|
||||
with patch(
|
||||
"module.api.passkey.PasskeyAuthStrategy", return_value=mock_strategy
|
||||
):
|
||||
with patch("module.api.passkey.active_user", SessionStore()):
|
||||
service = MagicMock()
|
||||
service.issue_session_for_username = AsyncMock(
|
||||
return_value="persisted-session"
|
||||
)
|
||||
with patch("module.security.api.auth_service", service):
|
||||
response = unauthed_client.post(
|
||||
"/api/v1/passkey/auth/verify",
|
||||
json={
|
||||
|
||||
76
backend/src/test/test_api_users.py
Normal file
76
backend/src/test/test_api_users.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Multi-user API and persisted-session integration tests."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from module.database import Database
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_users_and_sessions_survive_client_recreation(app):
|
||||
async with Database() as db:
|
||||
await db.user.create_user("first_user", "first-password")
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
login = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "first_user", "password": "first-password"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
persisted_cookie = client.cookies.get("token")
|
||||
assert persisted_cookie
|
||||
|
||||
created = await client.post(
|
||||
"/api/v1/users",
|
||||
json={"username": "second_user", "password": "second-password"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
users = await client.get("/api/v1/users")
|
||||
assert {user["username"] for user in users.json()} == {
|
||||
"first_user",
|
||||
"second_user",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
client.cookies.set("token", persisted_cookie)
|
||||
me = await client.get("/api/v1/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["username"] == "first_user"
|
||||
assert (await client.post("/api/v1/auth/logout")).status_code == 200
|
||||
assert (await client.get("/api/v1/auth/me")).status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_last_enabled_user_conflict_and_api_token_flow(app):
|
||||
async with Database() as db:
|
||||
only = await db.user.create_user("only_user", "only-password")
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
assert (
|
||||
await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "only_user", "password": "only-password"},
|
||||
)
|
||||
).status_code == 200
|
||||
assert (
|
||||
await client.patch(f"/api/v1/users/{only.id}", json={"enabled": False})
|
||||
).status_code == 409
|
||||
|
||||
created = await client.post(
|
||||
"/api/v1/tokens", json={"name": "automation", "scope": "api"}
|
||||
)
|
||||
assert created.status_code == 201
|
||||
raw_token = created.json()["token"]
|
||||
listed = await client.get("/api/v1/tokens")
|
||||
assert "token" not in listed.json()[0]
|
||||
token_id = listed.json()[0]["id"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {raw_token}"}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
assert (await client.get("/api/v1/auth/me", headers=headers)).status_code == 200
|
||||
assert (
|
||||
await client.delete(f"/api/v1/tokens/{token_id}", headers=headers)
|
||||
).status_code == 204
|
||||
assert (await client.get("/api/v1/auth/me", headers=headers)).status_code == 401
|
||||
132
backend/src/test/test_auth_persistence.py
Normal file
132
backend/src/test/test_auth_persistence.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Persistent multi-user authentication behavior."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
from module.application.auth import AuthenticationService, ConflictError
|
||||
from module.database import Database
|
||||
from module.models.auth import ApiToken, AuthSession
|
||||
from module.models.config import Config
|
||||
from module.models.user import UserCreate, UserUpdate
|
||||
from module.update.auth import migrate_legacy_auth_tokens
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessions_persist_and_are_isolated_between_users(db_engine):
|
||||
now = datetime.now(timezone.utc)
|
||||
async with Database(db_engine) as db:
|
||||
alice = await db.user.create_user("alice_user", "alice-password")
|
||||
bob = await db.user.create_user("bob_user", "bob-password")
|
||||
alice_token = await db.auth.create_session(alice.id, now=now)
|
||||
bob_token = await db.auth.create_session(bob.id, now=now)
|
||||
|
||||
async with Database(db_engine) as reopened:
|
||||
alice_user = await reopened.auth.authenticate_session(alice_token, now=now)
|
||||
bob_user = await reopened.auth.authenticate_session(bob_token, now=now)
|
||||
assert alice_user and alice_user.username == "alice_user"
|
||||
assert bob_user and bob_user.username == "bob_user"
|
||||
|
||||
await reopened.auth.revoke_session(alice_token)
|
||||
assert await reopened.auth.authenticate_session(alice_token, now=now) is None
|
||||
assert await reopened.auth.authenticate_session(bob_token, now=now)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_tokens_are_hashed_and_expire(db_engine):
|
||||
now = datetime.now(timezone.utc)
|
||||
async with Database(db_engine) as db:
|
||||
user = await db.user.create_user("session_user", "session-password")
|
||||
raw_token = await db.auth.create_session(
|
||||
user.id, now=now, ttl=timedelta(minutes=5)
|
||||
)
|
||||
result = await db.session.execute(select(AuthSession))
|
||||
stored = result.scalar_one()
|
||||
assert stored.token_hash != raw_token
|
||||
assert raw_token not in stored.token_hash
|
||||
assert await db.auth.authenticate_session(raw_token, now=now)
|
||||
assert (
|
||||
await db.auth.authenticate_session(
|
||||
raw_token, now=now + timedelta(minutes=6)
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_last_enabled_user_invariants_and_session_revocation(db_engine):
|
||||
service = AuthenticationService(lambda: Database(db_engine))
|
||||
only = await service.create_user(
|
||||
UserCreate(username="only_user", password="only-password")
|
||||
)
|
||||
assert only.id is not None
|
||||
|
||||
with pytest.raises(ConflictError, match="last enabled user"):
|
||||
await service.update_user(only.id, UserUpdate(enabled=False))
|
||||
with pytest.raises(ConflictError, match="last enabled user"):
|
||||
await service.delete_user(only.id)
|
||||
|
||||
second = await service.create_user(
|
||||
UserCreate(username="second_user", password="second-password")
|
||||
)
|
||||
assert second.id is not None
|
||||
_user, token = await service.login("second_user", "second-password")
|
||||
await service.update_user(second.id, UserUpdate(enabled=False))
|
||||
assert await service.authenticate_session(token) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_tokens_are_hashed_scoped_and_revocable(db_engine):
|
||||
now = datetime.now(timezone.utc)
|
||||
async with Database(db_engine) as db:
|
||||
user = await db.user.create_user("token_user", "token-password")
|
||||
stored, raw_token = await db.auth.create_api_token(
|
||||
user.id, name="automation", scope="api", now=now
|
||||
)
|
||||
result = await db.session.execute(select(ApiToken))
|
||||
persisted = result.scalar_one()
|
||||
assert persisted.token_hash != raw_token
|
||||
assert persisted.prefix == raw_token[:12]
|
||||
assert await db.auth.authenticate_api_token(raw_token, scope="api", now=now)
|
||||
assert (
|
||||
await db.auth.authenticate_api_token(raw_token, scope="mcp", now=now)
|
||||
is None
|
||||
)
|
||||
await db.auth.revoke_api_token(stored.id)
|
||||
assert (
|
||||
await db.auth.authenticate_api_token(raw_token, scope="api", now=now)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_config_tokens_are_imported_once(db_engine):
|
||||
class RuntimeSettings(Config):
|
||||
saved: bool = False
|
||||
|
||||
def save(self, config_dict=None):
|
||||
self.saved = True
|
||||
|
||||
runtime_settings = RuntimeSettings()
|
||||
runtime_settings.security.login_tokens = ["legacy-login-token"]
|
||||
runtime_settings.security.mcp_tokens = ["legacy-mcp-token"]
|
||||
async with Database(db_engine) as db:
|
||||
await db.user.create_user("import_user", "import-password")
|
||||
|
||||
imported = await migrate_legacy_auth_tokens(
|
||||
database_factory=lambda: Database(db_engine),
|
||||
runtime_settings=runtime_settings,
|
||||
)
|
||||
|
||||
assert imported == 2
|
||||
assert runtime_settings.saved is True
|
||||
assert runtime_settings.security.login_tokens == []
|
||||
assert runtime_settings.security.mcp_tokens == []
|
||||
async with Database(db_engine) as db:
|
||||
api_user = await db.auth.authenticate_api_token(
|
||||
"legacy-login-token", scope="api"
|
||||
)
|
||||
mcp_user = await db.auth.authenticate_api_token("legacy-mcp-token", scope="mcp")
|
||||
assert api_user and api_user.username == "import_user"
|
||||
assert mcp_user and mcp_user.username == "import_user"
|
||||
@@ -166,6 +166,11 @@ class TestRunMigrations:
|
||||
assert "weekday_locked" in bangumi_cols
|
||||
assert "offset" not in bangumi_cols
|
||||
assert "passkey" in inspector.get_table_names()
|
||||
assert {"enabled", "created_at", "updated_at"} <= _columns(engine, "user")
|
||||
assert "auth_session" in inspector.get_table_names()
|
||||
assert "api_token" in inspector.get_table_names()
|
||||
user_indexes = {ix["name"] for ix in inspector.get_indexes("user")}
|
||||
assert "ix_user_username" in user_indexes
|
||||
|
||||
def test_backfills_indexes_on_upgrading_database(self):
|
||||
"""v13 must create the title_raw/deleted/archived/url/rss_id indexes
|
||||
|
||||
@@ -91,14 +91,16 @@ describe('Auth API Data Structures', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should GET api/v1/auth/refresh_token when refreshing the session', async () => {
|
||||
(axios.get as any).mockResolvedValue({ data: mockLoginSuccess });
|
||||
it('should POST api/v1/auth/refresh_token when refreshing the session', async () => {
|
||||
(axios.post as any).mockResolvedValue({ data: mockLoginSuccess });
|
||||
await apiAuth.refresh();
|
||||
// silent: an expired-session refresh at startup must not toast; the 401
|
||||
// handler still logs out and routes to /login.
|
||||
expect(axios.get).toHaveBeenCalledWith('api/v1/auth/refresh_token', {
|
||||
silent: true,
|
||||
});
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'api/v1/auth/refresh_token',
|
||||
undefined,
|
||||
{ silent: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('should POST api/v1/auth/logout when logging out', async () => {
|
||||
|
||||
52
webui/src/api/access.ts
Normal file
52
webui/src/api/access.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
ApiTokenCreated,
|
||||
ApiTokenPublic,
|
||||
TokenScope,
|
||||
UserCreate,
|
||||
UserPublic,
|
||||
UserUpdate,
|
||||
} from '#/access';
|
||||
import { axios } from '@/utils/axios';
|
||||
|
||||
export const apiUsers = {
|
||||
async list(): Promise<UserPublic[]> {
|
||||
const { data } = await axios.get<UserPublic[]>('api/v1/users');
|
||||
return data;
|
||||
},
|
||||
|
||||
async create(payload: UserCreate): Promise<UserPublic> {
|
||||
const { data } = await axios.post<UserPublic>('api/v1/users', payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async update(userId: number, payload: UserUpdate): Promise<UserPublic> {
|
||||
const { data } = await axios.patch<UserPublic>(
|
||||
`api/v1/users/${userId}`,
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async delete(userId: number): Promise<void> {
|
||||
await axios.delete(`api/v1/users/${userId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const apiTokens = {
|
||||
async list(): Promise<ApiTokenPublic[]> {
|
||||
const { data } = await axios.get<ApiTokenPublic[]>('api/v1/tokens');
|
||||
return data;
|
||||
},
|
||||
|
||||
async create(name: string, scope: TokenScope): Promise<ApiTokenCreated> {
|
||||
const { data } = await axios.post<ApiTokenCreated>('api/v1/tokens', {
|
||||
name,
|
||||
scope,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async revoke(tokenId: number): Promise<void> {
|
||||
await axios.delete(`api/v1/tokens/${tokenId}`);
|
||||
},
|
||||
};
|
||||
@@ -24,8 +24,9 @@ export const apiAuth = {
|
||||
async refresh() {
|
||||
// silent: a startup refresh of an expired session must not flash a scary
|
||||
// error toast — the 401 handler still logs out and routes to /login.
|
||||
const { data } = await axios.get<LoginSuccess>(
|
||||
const { data } = await axios.post<LoginSuccess>(
|
||||
'api/v1/auth/refresh_token',
|
||||
undefined,
|
||||
{ silent: true }
|
||||
);
|
||||
return data;
|
||||
|
||||
352
webui/src/components/setting/config-access.vue
Normal file
352
webui/src/components/setting/config-access.vue
Normal file
@@ -0,0 +1,352 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { TokenScope, UserPublic } from '#/access';
|
||||
import { useAccessStore } from '@/store/access';
|
||||
import { useMessage } from '@/hooks/useMessage';
|
||||
import { useMyI18n } from '@/hooks/useMyI18n';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
|
||||
const store = useAccessStore();
|
||||
const { users, tokens, loading } = storeToRefs(store);
|
||||
const { t } = useMyI18n();
|
||||
const message = useMessage();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const showUserDialog = ref(false);
|
||||
const showTokenDialog = ref(false);
|
||||
const showTokenValue = ref(false);
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const tokenName = ref('');
|
||||
const tokenScope = ref<TokenScope>('api');
|
||||
const issuedToken = ref('');
|
||||
const submitting = ref(false);
|
||||
|
||||
const activeTokens = computed(() =>
|
||||
tokens.value.filter((token) => token.revoked_at === null)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.load().catch(() => undefined);
|
||||
});
|
||||
|
||||
async function handleCreateUser(): Promise<void> {
|
||||
if (!username.value.trim() || password.value.length < 8) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await store.createUser({
|
||||
username: username.value.trim(),
|
||||
password: password.value,
|
||||
});
|
||||
username.value = '';
|
||||
password.value = '';
|
||||
showUserDialog.value = false;
|
||||
message.success(t('access.user_created'));
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUser(user: UserPublic): Promise<void> {
|
||||
await store.updateUser(user.id, { enabled: !user.enabled });
|
||||
}
|
||||
|
||||
async function deleteUser(user: UserPublic): Promise<void> {
|
||||
const confirmed = await confirm({
|
||||
title: t('access.delete_user_confirm', { username: user.username }),
|
||||
confirmText: t('access.delete'),
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await store.deleteUser(user.id);
|
||||
}
|
||||
|
||||
async function handleCreateToken(): Promise<void> {
|
||||
if (!tokenName.value.trim()) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
issuedToken.value = await store.createToken(
|
||||
tokenName.value.trim(),
|
||||
tokenScope.value
|
||||
);
|
||||
tokenName.value = '';
|
||||
showTokenDialog.value = false;
|
||||
showTokenValue.value = true;
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyIssuedToken(): Promise<void> {
|
||||
await navigator.clipboard.writeText(issuedToken.value);
|
||||
message.success(t('access.token_copied'));
|
||||
}
|
||||
|
||||
async function revokeToken(tokenId: number): Promise<void> {
|
||||
const confirmed = await confirm({
|
||||
title: t('access.revoke_token_confirm'),
|
||||
confirmText: t('access.revoke'),
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
await store.revokeToken(tokenId);
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
return value ? new Date(value).toLocaleString() : t('access.never');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-fold-panel :title="$t('access.title')">
|
||||
<div class="access-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h3>{{ $t('access.users') }}</h3>
|
||||
<p>{{ $t('access.users_hint') }}</p>
|
||||
</div>
|
||||
<ab-button size="sm" @click="showUserDialog = true">
|
||||
{{ $t('access.add_user') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="empty-state">{{ $t('access.loading') }}</div>
|
||||
<div v-else class="access-list">
|
||||
<div v-for="user in users" :key="user.id" class="access-row">
|
||||
<div class="row-main">
|
||||
<strong>{{ user.username }}</strong>
|
||||
<span class="status-dot" :class="[{ disabled: !user.enabled }]">
|
||||
{{ user.enabled ? $t('access.enabled') : $t('access.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<ab-switch
|
||||
:model-value="user.enabled"
|
||||
:aria-label="
|
||||
$t('access.toggle_user', { username: user.username })
|
||||
"
|
||||
@update:model-value="toggleUser(user)"
|
||||
/>
|
||||
<ab-button size="sm" variant="danger" @click="deleteUser(user)">
|
||||
{{ $t('access.delete') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="access-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h3>{{ $t('access.tokens') }}</h3>
|
||||
<p>{{ $t('access.tokens_hint') }}</p>
|
||||
</div>
|
||||
<ab-button size="sm" @click="showTokenDialog = true">
|
||||
{{ $t('access.add_token') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTokens.length === 0" class="empty-state">
|
||||
{{ $t('access.no_tokens') }}
|
||||
</div>
|
||||
<div v-else class="access-list">
|
||||
<div v-for="token in activeTokens" :key="token.id" class="access-row">
|
||||
<div class="token-info">
|
||||
<div class="row-main">
|
||||
<strong>{{ token.name }}</strong>
|
||||
<span class="scope-badge">{{ token.scope.toUpperCase() }}</span>
|
||||
</div>
|
||||
<code>{{ token.prefix }}…</code>
|
||||
<small>
|
||||
{{ $t('access.last_used') }}: {{ formatDate(token.last_used_at) }}
|
||||
</small>
|
||||
</div>
|
||||
<ab-button size="sm" variant="danger" @click="revokeToken(token.id)">
|
||||
{{ $t('access.revoke') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ab-modal
|
||||
v-model:show="showUserDialog"
|
||||
size="sm"
|
||||
:title="$t('access.add_user')"
|
||||
>
|
||||
<div class="dialog-form">
|
||||
<ab-field :label="$t('access.username')">
|
||||
<ab-input v-model="username" autocomplete="off" :maxlength="20" />
|
||||
</ab-field>
|
||||
<ab-field :label="$t('access.password')">
|
||||
<ab-input
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</ab-field>
|
||||
</div>
|
||||
<template #footer>
|
||||
<ab-button size="sm" @click="showUserDialog = false">
|
||||
{{ $t('config.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!username.trim() || password.length < 8"
|
||||
@click="handleCreateUser"
|
||||
>
|
||||
{{ $t('access.create') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-modal>
|
||||
|
||||
<ab-modal
|
||||
v-model:show="showTokenDialog"
|
||||
size="sm"
|
||||
:title="$t('access.add_token')"
|
||||
>
|
||||
<div class="dialog-form">
|
||||
<ab-field :label="$t('access.token_name')">
|
||||
<ab-input v-model="tokenName" :maxlength="64" />
|
||||
</ab-field>
|
||||
<ab-field :label="$t('access.token_scope')">
|
||||
<ab-select
|
||||
v-model="tokenScope"
|
||||
:options="[
|
||||
{ label: 'API', value: 'api' },
|
||||
{ label: 'MCP', value: 'mcp' },
|
||||
]"
|
||||
/>
|
||||
</ab-field>
|
||||
</div>
|
||||
<template #footer>
|
||||
<ab-button size="sm" @click="showTokenDialog = false">
|
||||
{{ $t('config.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!tokenName.trim()"
|
||||
@click="handleCreateToken"
|
||||
>
|
||||
{{ $t('access.create') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-modal>
|
||||
|
||||
<ab-modal v-model:show="showTokenValue" :title="$t('access.token_created')">
|
||||
<div class="token-reveal">
|
||||
<p>{{ $t('access.token_once') }}</p>
|
||||
<code>{{ issuedToken }}</code>
|
||||
</div>
|
||||
<template #footer>
|
||||
<ab-button size="sm" variant="primary" @click="copyIssuedToken">
|
||||
{{ $t('access.copy') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-modal>
|
||||
</ab-fold-panel>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.access-section,
|
||||
.dialog-form,
|
||||
.token-reveal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.access-row,
|
||||
.row-main,
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.access-row {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-heading h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.section-heading p,
|
||||
.empty-state,
|
||||
.token-info small {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.access-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.access-row {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.row-main,
|
||||
.row-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot,
|
||||
.scope-badge {
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-dot.disabled {
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.token-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.token-info code,
|
||||
.token-reveal code {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
margin: 16px 0;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.token-reveal p {
|
||||
margin: 0;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.section-heading,
|
||||
.access-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Security } from '#/config';
|
||||
import type { SettingItem } from '#/components';
|
||||
import { useMyI18n } from '@/hooks/useMyI18n';
|
||||
import { useConfigStore } from '@/store/config';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const { getSettingGroup } = useConfigStore();
|
||||
@@ -16,15 +18,6 @@ const items: SettingItem<Security>[] = [
|
||||
placeholder: '192.168.0.0/16',
|
||||
},
|
||||
},
|
||||
{
|
||||
configKey: 'login_tokens',
|
||||
label: () => t('config.security_set.login_tokens'),
|
||||
type: 'dynamic-tags',
|
||||
prop: {
|
||||
placeholder: 'your-api-token',
|
||||
},
|
||||
bottomLine: true,
|
||||
},
|
||||
{
|
||||
configKey: 'mcp_whitelist',
|
||||
label: () => t('config.security_set.mcp_whitelist'),
|
||||
@@ -33,14 +26,6 @@ const items: SettingItem<Security>[] = [
|
||||
placeholder: '127.0.0.0/8',
|
||||
},
|
||||
},
|
||||
{
|
||||
configKey: 'mcp_tokens',
|
||||
label: () => t('config.security_set.mcp_tokens'),
|
||||
type: 'dynamic-tags',
|
||||
prop: {
|
||||
placeholder: 'your-mcp-token',
|
||||
},
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -597,6 +597,36 @@
|
||||
"subscribe": "Subscribe"
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
"title": "Users & Access",
|
||||
"users": "Users",
|
||||
"users_hint": "All enabled users have the same permissions.",
|
||||
"tokens": "API tokens",
|
||||
"tokens_hint": "Tokens are stored as hashes and shown only once.",
|
||||
"add_user": "Add user",
|
||||
"add_token": "Create token",
|
||||
"user_created": "User created",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"toggle_user": "Toggle {username}",
|
||||
"delete": "Delete",
|
||||
"delete_user_confirm": "Delete {username}?",
|
||||
"token_name": "Token name",
|
||||
"token_scope": "Scope",
|
||||
"token_created": "Token created",
|
||||
"token_once": "Copy this token now. It cannot be shown again.",
|
||||
"token_copied": "Token copied",
|
||||
"copy": "Copy",
|
||||
"revoke": "Revoke",
|
||||
"revoke_token_confirm": "Revoke this token?",
|
||||
"last_used": "Last used",
|
||||
"never": "Never",
|
||||
"no_tokens": "No active tokens",
|
||||
"loading": "Loading…",
|
||||
"create": "Create"
|
||||
},
|
||||
"topbar": {
|
||||
"add": {
|
||||
"aggregate": "Aggregate RSS",
|
||||
|
||||
@@ -597,6 +597,36 @@
|
||||
"subscribe": "确认订阅"
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
"title": "用户与访问控制",
|
||||
"users": "用户",
|
||||
"users_hint": "所有启用用户拥有相同权限。",
|
||||
"tokens": "API 令牌",
|
||||
"tokens_hint": "令牌仅以哈希保存,明文只显示一次。",
|
||||
"add_user": "添加用户",
|
||||
"add_token": "创建令牌",
|
||||
"user_created": "用户已创建",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"toggle_user": "切换 {username} 状态",
|
||||
"delete": "删除",
|
||||
"delete_user_confirm": "确认删除 {username}?",
|
||||
"token_name": "令牌名称",
|
||||
"token_scope": "作用域",
|
||||
"token_created": "令牌已创建",
|
||||
"token_once": "请立即复制此令牌,之后无法再次显示。",
|
||||
"token_copied": "令牌已复制",
|
||||
"copy": "复制",
|
||||
"revoke": "撤销",
|
||||
"revoke_token_confirm": "确认撤销此令牌?",
|
||||
"last_used": "上次使用",
|
||||
"never": "从未",
|
||||
"no_tokens": "暂无有效令牌",
|
||||
"loading": "加载中…",
|
||||
"create": "创建"
|
||||
},
|
||||
"topbar": {
|
||||
"add": {
|
||||
"aggregate": "聚合 RSS",
|
||||
|
||||
@@ -14,6 +14,7 @@ import ConfigPlayer from '@/components/setting/config-player.vue';
|
||||
import ConfigLlm from '@/components/setting/config-llm.vue';
|
||||
import ConfigPasskey from '@/components/setting/config-passkey.vue';
|
||||
import ConfigSecurity from '@/components/setting/config-security.vue';
|
||||
import ConfigAccess from '@/components/setting/config-access.vue';
|
||||
import UpdateCard from '@/components/setting/update-card.vue';
|
||||
|
||||
definePage({
|
||||
@@ -142,6 +143,13 @@ const sections: ConfigSection[] = [
|
||||
groups: [],
|
||||
keywords: ['passkey', 'webauthn', 'login'],
|
||||
},
|
||||
{
|
||||
id: 'access',
|
||||
titleKey: 'access.title',
|
||||
component: ConfigAccess,
|
||||
groups: [],
|
||||
keywords: ['user', 'account', 'token', 'api', 'mcp', 'access'],
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
titleKey: 'config.security_set.title',
|
||||
|
||||
63
webui/src/store/access.ts
Normal file
63
webui/src/store/access.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type { TokenScope, UserCreate, UserUpdate } from '#/access';
|
||||
import { apiTokens, apiUsers } from '@/api/access';
|
||||
|
||||
export const useAccessStore = defineStore('access', () => {
|
||||
const users = ref<Awaited<ReturnType<typeof apiUsers.list>>>([]);
|
||||
const tokens = ref<Awaited<ReturnType<typeof apiTokens.list>>>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
[users.value, tokens.value] = await Promise.all([
|
||||
apiUsers.list(),
|
||||
apiTokens.list(),
|
||||
]);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(payload: UserCreate): Promise<void> {
|
||||
await apiUsers.create(payload);
|
||||
users.value = await apiUsers.list();
|
||||
}
|
||||
|
||||
async function updateUser(
|
||||
userId: number,
|
||||
payload: UserUpdate
|
||||
): Promise<void> {
|
||||
await apiUsers.update(userId, payload);
|
||||
users.value = await apiUsers.list();
|
||||
}
|
||||
|
||||
async function deleteUser(userId: number): Promise<void> {
|
||||
await apiUsers.delete(userId);
|
||||
users.value = await apiUsers.list();
|
||||
}
|
||||
|
||||
async function createToken(name: string, scope: TokenScope): Promise<string> {
|
||||
const created = await apiTokens.create(name, scope);
|
||||
tokens.value = await apiTokens.list();
|
||||
return created.token;
|
||||
}
|
||||
|
||||
async function revokeToken(tokenId: number): Promise<void> {
|
||||
await apiTokens.revoke(tokenId);
|
||||
tokens.value = await apiTokens.list();
|
||||
}
|
||||
|
||||
return {
|
||||
users,
|
||||
tokens,
|
||||
loading,
|
||||
load,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
createToken,
|
||||
revokeToken,
|
||||
};
|
||||
});
|
||||
36
webui/types/access.ts
Normal file
36
webui/types/access.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface UserPublic {
|
||||
id: number;
|
||||
username: string;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserCreate {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UserUpdate {
|
||||
username?: string;
|
||||
password?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export type TokenScope = 'api' | 'mcp';
|
||||
|
||||
export interface ApiTokenPublic {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
scope: TokenScope;
|
||||
prefix: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
revoked_at: string | null;
|
||||
}
|
||||
|
||||
export interface ApiTokenCreated extends ApiTokenPublic {
|
||||
token: string;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export type ApiErrorMessage = AuthError | LoginError;
|
||||
* 406 Not Acceptable
|
||||
* 500 Internal Server Error
|
||||
*/
|
||||
export type StatusCode = 401 | 404 | 406 | 500;
|
||||
export type StatusCode = 400 | 401 | 403 | 404 | 406 | 409 | 422 | 500;
|
||||
|
||||
export interface ApiError {
|
||||
status: StatusCode;
|
||||
|
||||
3
webui/types/dts/auto-imports.d.ts
vendored
3
webui/types/dts/auto-imports.d.ts
vendored
@@ -19,7 +19,9 @@ declare global {
|
||||
const apiRSS: typeof import('../../src/api/rss')['apiRSS']
|
||||
const apiSearch: typeof import('../../src/api/search')['apiSearch']
|
||||
const apiSetup: typeof import('../../src/api/setup')['apiSetup']
|
||||
const apiTokens: typeof import('../../src/api/access')['apiTokens']
|
||||
const apiUpdate: typeof import('../../src/api/update')['apiUpdate']
|
||||
const apiUsers: typeof import('../../src/api/access')['apiUsers']
|
||||
const assert: typeof import('vitest')['assert']
|
||||
const axios: typeof import('../../src/utils/axios')['axios']
|
||||
const beforeAll: typeof import('vitest')['beforeAll']
|
||||
@@ -96,6 +98,7 @@ declare global {
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAccessStore: typeof import('../../src/store/access')['useAccessStore']
|
||||
const useAddRss: typeof import('../../src/hooks/useAddRss')['useAddRss']
|
||||
const useApi: typeof import('../../src/hooks/useApi')['useApi']
|
||||
const useAppInfo: typeof import('../../src/hooks/useAppInfo')['useAppInfo']
|
||||
|
||||
1
webui/types/dts/components.d.ts
vendored
1
webui/types/dts/components.d.ts
vendored
@@ -64,6 +64,7 @@ declare module '@vue/runtime-core' {
|
||||
CalendarDayColumn: typeof import('./../../src/components/calendar/calendar-day-column.vue')['default']
|
||||
CalendarMobileList: typeof import('./../../src/components/calendar/calendar-mobile-list.vue')['default']
|
||||
CalendarRuleListPopup: typeof import('./../../src/components/calendar/calendar-rule-list-popup.vue')['default']
|
||||
ConfigAccess: typeof import('./../../src/components/setting/config-access.vue')['default']
|
||||
ConfigDownload: typeof import('./../../src/components/setting/config-download.vue')['default']
|
||||
ConfigLlm: typeof import('./../../src/components/setting/config-llm.vue')['default']
|
||||
ConfigManage: typeof import('./../../src/components/setting/config-manage.vue')['default']
|
||||
|
||||
Reference in New Issue
Block a user