diff --git a/backend/src/module/api/__init__.py b/backend/src/module/api/__init__.py index aac70938..77d73d21 100644 --- a/backend/src/module/api/__init__.py +++ b/backend/src/module/api/__init__.py @@ -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) diff --git a/backend/src/module/api/auth.py b/backend/src/module/api/auth.py index f445b4dc..6ad27a76 100644 --- a/backend/src/module/api/auth.py +++ b/backend/src/module/api/auth.py @@ -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"} diff --git a/backend/src/module/api/passkey.py b/backend/src/module/api/passkey.py index 050001d5..d8690bac 100644 --- a/backend/src/module/api/passkey.py +++ b/backend/src/module/api/passkey.py @@ -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) diff --git a/backend/src/module/api/tokens.py b/backend/src/module/api/tokens.py new file mode 100644 index 00000000..f4036c9f --- /dev/null +++ b/backend/src/module/api/tokens.py @@ -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) diff --git a/backend/src/module/api/users.py b/backend/src/module/api/users.py new file mode 100644 index 00000000..8632ebac --- /dev/null +++ b/backend/src/module/api/users.py @@ -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 diff --git a/backend/src/module/application/__init__.py b/backend/src/module/application/__init__.py new file mode 100644 index 00000000..05fd24ea --- /dev/null +++ b/backend/src/module/application/__init__.py @@ -0,0 +1,15 @@ +"""Application use cases shared by transport adapters.""" + +from .auth import ( + AuthenticationError, + AuthenticationService, + ConflictError, + NotFoundError, +) + +__all__ = [ + "AuthenticationError", + "AuthenticationService", + "ConflictError", + "NotFoundError", +] diff --git a/backend/src/module/application/auth.py b/backend/src/module/application/auth.py new file mode 100644 index 00000000..8aea8036 --- /dev/null +++ b/backend/src/module/application/auth.py @@ -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") diff --git a/backend/src/module/composition.py b/backend/src/module/composition.py new file mode 100644 index 00000000..ea9956b5 --- /dev/null +++ b/backend/src/module/composition.py @@ -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) diff --git a/backend/src/module/core/context.py b/backend/src/module/core/context.py index 26d37ff7..d2af4eb4 100644 --- a/backend/src/module/core/context.py +++ b/backend/src/module/core/context.py @@ -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() diff --git a/backend/src/module/database/auth.py b/backend/src/module/database/auth.py new file mode 100644 index 00000000..91b67422 --- /dev/null +++ b/backend/src/module/database/auth.py @@ -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() diff --git a/backend/src/module/database/combine.py b/backend/src/module/database/combine.py index 8640288f..751a6608 100644 --- a/backend/src/module/database/combine.py +++ b/backend/src/module/database/combine.py @@ -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) diff --git a/backend/src/module/database/migrations.py b/backend/src/module/database/migrations.py index 2e425493..a8bbf6a4 100644 --- a/backend/src/module/database/migrations.py +++ b/backend/src/module/database/migrations.py @@ -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")), + ), ) # 由迁移列表派生,新增迁移时无需手动同步 diff --git a/backend/src/module/database/user.py b/backend/src/module/database/user.py index ff88ccff..9fba1e82 100644 --- a/backend/src/module/database/user.py +++ b/backend/src/module/database/user.py @@ -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) diff --git a/backend/src/module/mcp/security.py b/backend/src/module/mcp/security.py index 78a50c7f..0276822c 100644 --- a/backend/src/module/mcp/security.py +++ b/backend/src/module/mcp/security.py @@ -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 diff --git a/backend/src/module/models/__init__.py b/backend/src/module/models/__init__.py index 441b3e0d..5468094c 100644 --- a/backend/src/module/models/__init__.py +++ b/backend/src/module/models/__init__.py @@ -1,3 +1,4 @@ +from .auth import ApiToken, AuthSession from .bangumi import Bangumi, BangumiUpdate, Episode, Notification from .config import Config from .inbox import InboxMessage diff --git a/backend/src/module/models/auth.py b/backend/src/module/models/auth.py new file mode 100644 index 00000000..289a34e6 --- /dev/null +++ b/backend/src/module/models/auth.py @@ -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 diff --git a/backend/src/module/models/user.py b/backend/src/module/models/user.py index cf02598c..13939dbb 100644 --- a/backend/src/module/models/user.py +++ b/backend/src/module/models/user.py @@ -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): diff --git a/backend/src/module/ports/__init__.py b/backend/src/module/ports/__init__.py new file mode 100644 index 00000000..7848be7c --- /dev/null +++ b/backend/src/module/ports/__init__.py @@ -0,0 +1,5 @@ +"""Dependency-inversion contracts used by application services.""" + +from .auth import AuthUnitOfWork + +__all__ = ["AuthUnitOfWork"] diff --git a/backend/src/module/ports/auth.py b/backend/src/module/ports/auth.py new file mode 100644 index 00000000..38b97c22 --- /dev/null +++ b/backend/src/module/ports/auth.py @@ -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: ... diff --git a/backend/src/module/security/api.py b/backend/src/module/security/api.py index c7ff9438..6acc10e5 100644 --- a/backend/src/module/security/api.py +++ b/backend/src/module/security/api.py @@ -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 `` 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: diff --git a/backend/src/module/security/auth_strategy.py b/backend/src/module/security/auth_strategy.py index 64a719c2..45baefb6 100644 --- a/backend/src/module/security/auth_strategy.py +++ b/backend/src/module/security/auth_strategy.py @@ -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, diff --git a/backend/src/module/update/__init__.py b/backend/src/module/update/__init__.py index 6731e4c5..75f8818f 100644 --- a/backend/src/module/update/__init__.py +++ b/backend/src/module/update/__init__.py @@ -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 diff --git a/backend/src/module/update/auth.py b/backend/src/module/update/auth.py new file mode 100644 index 00000000..da2e493c --- /dev/null +++ b/backend/src/module/update/auth.py @@ -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 diff --git a/backend/src/test/test_api_auth.py b/backend/src/test/test_api_auth.py index b6d52733..7f4794cf 100644 --- a/backend/src/test/test_api_auth.py +++ b/backend/src/test/test_api_auth.py @@ -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 diff --git a/backend/src/test/test_api_passkey.py b/backend/src/test/test_api_passkey.py index 62e199a5..591bc019 100644 --- a/backend/src/test/test_api_passkey.py +++ b/backend/src/test/test_api_passkey.py @@ -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={ diff --git a/backend/src/test/test_api_users.py b/backend/src/test/test_api_users.py new file mode 100644 index 00000000..667810e1 --- /dev/null +++ b/backend/src/test/test_api_users.py @@ -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 diff --git a/backend/src/test/test_auth_persistence.py b/backend/src/test/test_auth_persistence.py new file mode 100644 index 00000000..04ad930f --- /dev/null +++ b/backend/src/test/test_auth_persistence.py @@ -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" diff --git a/backend/src/test/test_migrations_module.py b/backend/src/test/test_migrations_module.py index 85335a09..1261a3a0 100644 --- a/backend/src/test/test_migrations_module.py +++ b/backend/src/test/test_migrations_module.py @@ -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 diff --git a/webui/src/api/__tests__/auth.test.ts b/webui/src/api/__tests__/auth.test.ts index 14c74645..6ccd9287 100644 --- a/webui/src/api/__tests__/auth.test.ts +++ b/webui/src/api/__tests__/auth.test.ts @@ -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 () => { diff --git a/webui/src/api/access.ts b/webui/src/api/access.ts new file mode 100644 index 00000000..61fedf79 --- /dev/null +++ b/webui/src/api/access.ts @@ -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 { + const { data } = await axios.get('api/v1/users'); + return data; + }, + + async create(payload: UserCreate): Promise { + const { data } = await axios.post('api/v1/users', payload); + return data; + }, + + async update(userId: number, payload: UserUpdate): Promise { + const { data } = await axios.patch( + `api/v1/users/${userId}`, + payload + ); + return data; + }, + + async delete(userId: number): Promise { + await axios.delete(`api/v1/users/${userId}`); + }, +}; + +export const apiTokens = { + async list(): Promise { + const { data } = await axios.get('api/v1/tokens'); + return data; + }, + + async create(name: string, scope: TokenScope): Promise { + const { data } = await axios.post('api/v1/tokens', { + name, + scope, + }); + return data; + }, + + async revoke(tokenId: number): Promise { + await axios.delete(`api/v1/tokens/${tokenId}`); + }, +}; diff --git a/webui/src/api/auth.ts b/webui/src/api/auth.ts index 58a5c382..25f9956e 100644 --- a/webui/src/api/auth.ts +++ b/webui/src/api/auth.ts @@ -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( + const { data } = await axios.post( 'api/v1/auth/refresh_token', + undefined, { silent: true } ); return data; diff --git a/webui/src/components/setting/config-access.vue b/webui/src/components/setting/config-access.vue new file mode 100644 index 00000000..2063a7cb --- /dev/null +++ b/webui/src/components/setting/config-access.vue @@ -0,0 +1,352 @@ + + + + + diff --git a/webui/src/components/setting/config-security.vue b/webui/src/components/setting/config-security.vue index c02515d3..3fdf3efb 100644 --- a/webui/src/components/setting/config-security.vue +++ b/webui/src/components/setting/config-security.vue @@ -1,6 +1,8 @@ diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json index 3aa2a8de..98558340 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -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", diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json index edf16870..34804229 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -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", diff --git a/webui/src/pages/index/config.vue b/webui/src/pages/index/config.vue index d183562d..01712b24 100644 --- a/webui/src/pages/index/config.vue +++ b/webui/src/pages/index/config.vue @@ -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', diff --git a/webui/src/store/access.ts b/webui/src/store/access.ts new file mode 100644 index 00000000..2378adb5 --- /dev/null +++ b/webui/src/store/access.ts @@ -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>>([]); + const tokens = ref>>([]); + const loading = ref(false); + + async function load(): Promise { + 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 { + await apiUsers.create(payload); + users.value = await apiUsers.list(); + } + + async function updateUser( + userId: number, + payload: UserUpdate + ): Promise { + await apiUsers.update(userId, payload); + users.value = await apiUsers.list(); + } + + async function deleteUser(userId: number): Promise { + await apiUsers.delete(userId); + users.value = await apiUsers.list(); + } + + async function createToken(name: string, scope: TokenScope): Promise { + const created = await apiTokens.create(name, scope); + tokens.value = await apiTokens.list(); + return created.token; + } + + async function revokeToken(tokenId: number): Promise { + await apiTokens.revoke(tokenId); + tokens.value = await apiTokens.list(); + } + + return { + users, + tokens, + loading, + load, + createUser, + updateUser, + deleteUser, + createToken, + revokeToken, + }; +}); diff --git a/webui/types/access.ts b/webui/types/access.ts new file mode 100644 index 00000000..36daeb1a --- /dev/null +++ b/webui/types/access.ts @@ -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; +} diff --git a/webui/types/api.ts b/webui/types/api.ts index f7efd0be..f5363d87 100644 --- a/webui/types/api.ts +++ b/webui/types/api.ts @@ -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; diff --git a/webui/types/dts/auto-imports.d.ts b/webui/types/dts/auto-imports.d.ts index 471668f1..d7a054fd 100644 --- a/webui/types/dts/auto-imports.d.ts +++ b/webui/types/dts/auto-imports.d.ts @@ -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'] diff --git a/webui/types/dts/components.d.ts b/webui/types/dts/components.d.ts index 84581e3c..79851b9b 100644 --- a/webui/types/dts/components.d.ts +++ b/webui/types/dts/components.d.ts @@ -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']