feat: persist multi-user authentication

This commit is contained in:
Estrella Pan
2026-07-10 00:52:39 +02:00
parent 6a8eafc9c9
commit 42ccb7611b
41 changed files with 1988 additions and 372 deletions

View File

@@ -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)

View File

@@ -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"}

View File

@@ -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)

View 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)

View 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

View File

@@ -0,0 +1,15 @@
"""Application use cases shared by transport adapters."""
from .auth import (
AuthenticationError,
AuthenticationService,
ConflictError,
NotFoundError,
)
__all__ = [
"AuthenticationError",
"AuthenticationService",
"ConflictError",
"NotFoundError",
]

View 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")

View 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)

View File

@@ -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()

View 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()

View File

@@ -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)

View File

@@ -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")),
),
)
# 由迁移列表派生,新增迁移时无需手动同步

View File

@@ -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)

View File

@@ -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

View File

@@ -1,3 +1,4 @@
from .auth import ApiToken, AuthSession
from .bangumi import Bangumi, BangumiUpdate, Episode, Notification
from .config import Config
from .inbox import InboxMessage

View 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

View File

@@ -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):

View File

@@ -0,0 +1,5 @@
"""Dependency-inversion contracts used by application services."""
from .auth import AuthUnitOfWork
__all__ = ["AuthUnitOfWork"]

View 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: ...

View File

@@ -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:

View File

@@ -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,

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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={

View 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

View 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"

View File

@@ -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

View File

@@ -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
View 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}`);
},
};

View File

@@ -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;

View 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>

View File

@@ -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>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
View 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
View 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;
}

View File

@@ -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;

View File

@@ -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']

View File

@@ -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']