diff --git a/backend/config/__init__.py b/backend/config/__init__.py index e4ba1e0cd..9e8ce4b69 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -115,6 +115,7 @@ def str_to_bool(value: str) -> bool: # FRONTEND UPLOAD_TIMEOUT = int(os.environ.get("UPLOAD_TIMEOUT", 600)) +KIOSK_MODE = str_to_bool(os.environ.get("KIOSK_MODE", "false")) # LOGGING LOGLEVEL: Final = os.environ.get("LOGLEVEL", "INFO").upper() diff --git a/backend/decorators/auth.py b/backend/decorators/auth.py index 5c8830151..2a93f7089 100644 --- a/backend/decorators/auth.py +++ b/backend/decorators/auth.py @@ -16,8 +16,9 @@ from fastapi.security.oauth2 import OAuth2PasswordBearer from fastapi.types import DecoratedCallable from handler.auth.constants import ( - DEFAULT_SCOPES_MAP, + EDIT_SCOPES_MAP, FULL_SCOPES_MAP, + READ_SCOPES_MAP, WRITE_SCOPES_MAP, Scope, ) @@ -29,8 +30,9 @@ tokenUrl="/token", auto_error=False, scopes={ - **DEFAULT_SCOPES_MAP, + **READ_SCOPES_MAP, **WRITE_SCOPES_MAP, + **EDIT_SCOPES_MAP, **FULL_SCOPES_MAP, }, ) diff --git a/backend/endpoints/tests/test_oauth.py b/backend/endpoints/tests/test_oauth.py index 40ae26036..12965acc3 100644 --- a/backend/endpoints/tests/test_oauth.py +++ b/backend/endpoints/tests/test_oauth.py @@ -2,7 +2,7 @@ from endpoints.auth import ACCESS_TOKEN_EXPIRE_MINUTES from fastapi.exceptions import HTTPException from fastapi.testclient import TestClient -from handler.auth.constants import WRITE_SCOPES +from handler.auth.constants import EDIT_SCOPES from main import app @@ -96,7 +96,7 @@ def test_auth_via_upass_with_excess_scopes(client, viewer_user): "grant_type": "password", "username": "test_viewer", "password": "test_viewer_password", - "scopes": WRITE_SCOPES, + "scopes": EDIT_SCOPES, }, ) except HTTPException as e: diff --git a/backend/handler/auth/constants.py b/backend/handler/auth/constants.py index 632d60404..7b4c8e9b9 100644 --- a/backend/handler/auth/constants.py +++ b/backend/handler/auth/constants.py @@ -26,21 +26,24 @@ class Scope(enum.StrEnum): TASKS_RUN = "tasks.run" -DEFAULT_SCOPES_MAP: Final = { +READ_SCOPES_MAP: Final = { Scope.ME_READ: "View your profile", - Scope.ME_WRITE: "Modify your profile", Scope.ROMS_READ: "View ROMs", Scope.PLATFORMS_READ: "View platforms", Scope.ASSETS_READ: "View assets", - Scope.ASSETS_WRITE: "Modify assets", Scope.FIRMWARE_READ: "View firmware", Scope.ROMS_USER_READ: "View user-rom properties", - Scope.ROMS_USER_WRITE: "Modify user-rom properties", Scope.COLLECTIONS_READ: "View collections", - Scope.COLLECTIONS_WRITE: "Modify collections", } WRITE_SCOPES_MAP: Final = { + Scope.ME_WRITE: "Modify your profile", + Scope.ASSETS_WRITE: "Modify assets", + Scope.ROMS_USER_WRITE: "Modify user-rom properties", + Scope.COLLECTIONS_WRITE: "Modify collections", +} + +EDIT_SCOPES_MAP: Final = { Scope.ROMS_WRITE: "Modify ROMs", Scope.PLATFORMS_WRITE: "Modify platforms", Scope.FIRMWARE_WRITE: "Modify firmware", @@ -52,6 +55,7 @@ class Scope(enum.StrEnum): Scope.TASKS_RUN: "Run tasks", } -DEFAULT_SCOPES: Final = list(DEFAULT_SCOPES_MAP.keys()) -WRITE_SCOPES: Final = DEFAULT_SCOPES + list(WRITE_SCOPES_MAP.keys()) -FULL_SCOPES: Final = WRITE_SCOPES + list(FULL_SCOPES_MAP.keys()) +READ_SCOPES: Final = list(READ_SCOPES_MAP.keys()) +WRITE_SCOPES: Final = READ_SCOPES + list(WRITE_SCOPES_MAP.keys()) +EDIT_SCOPES: Final = WRITE_SCOPES + list(EDIT_SCOPES_MAP.keys()) +FULL_SCOPES: Final = EDIT_SCOPES + list(FULL_SCOPES_MAP.keys()) diff --git a/backend/handler/auth/hybrid_auth.py b/backend/handler/auth/hybrid_auth.py index e9a258404..b8e6e6dbc 100644 --- a/backend/handler/auth/hybrid_auth.py +++ b/backend/handler/auth/hybrid_auth.py @@ -1,9 +1,12 @@ +from config import KIOSK_MODE from fastapi.security.http import HTTPBasic from handler.auth import auth_handler, oauth_handler from models.user import User from starlette.authentication import AuthCredentials, AuthenticationBackend from starlette.requests import HTTPConnection +from .constants import READ_SCOPES + class HybridAuthBackend(AuthenticationBackend): async def authenticate( @@ -16,44 +19,47 @@ async def authenticate( return (AuthCredentials(user.oauth_scopes), user) # Check if Authorization header exists - if "Authorization" not in conn.headers: - return None + if "Authorization" in conn.headers: + scheme, token = conn.headers["Authorization"].split() - scheme, token = conn.headers["Authorization"].split() + # Check if basic auth header is valid + if scheme.lower() == "basic": + credentials = await HTTPBasic().__call__(conn) # type: ignore[arg-type] + if not credentials: + return None - # Check if basic auth header is valid - if scheme.lower() == "basic": - credentials = await HTTPBasic().__call__(conn) # type: ignore[arg-type] - if not credentials: - return None + user = auth_handler.authenticate_user( + credentials.username, credentials.password + ) + if user is None: + return None - user = auth_handler.authenticate_user( - credentials.username, credentials.password - ) - if user is None: - return None + user.set_last_active() + return (AuthCredentials(user.oauth_scopes), user) - user.set_last_active() - return (AuthCredentials(user.oauth_scopes), user) + # Check if bearer auth header is valid + if scheme.lower() == "bearer": + ( + user, + claims, + ) = await oauth_handler.get_current_active_user_from_bearer_token(token) + if user is None or claims is None: + return None - # Check if bearer auth header is valid - if scheme.lower() == "bearer": - ( - user, - claims, - ) = await oauth_handler.get_current_active_user_from_bearer_token(token) - if user is None or claims is None: - return None + # Only access tokens can request resources + if claims.get("type") != "access": + return None - # Only access tokens can request resources - if claims.get("type") != "access": - return None + # Only grant access to resources with overlapping scopes + token_scopes = set(list(claims.get("scopes", "").split(" "))) + overlapping_scopes = list(token_scopes & set(user.oauth_scopes)) - # Only grant access to resources with overlapping scopes - token_scopes = set(list(claims.get("scopes", "").split(" "))) - overlapping_scopes = list(token_scopes & set(user.oauth_scopes)) + user.set_last_active() + return (AuthCredentials(overlapping_scopes), user) - user.set_last_active() - return (AuthCredentials(overlapping_scopes), user) + # Check if we're in KIOSK_MODE + if KIOSK_MODE: + user = User.kiosk_mode_user() + return (AuthCredentials(READ_SCOPES), user) return None diff --git a/backend/handler/auth/tests/test_auth.py b/backend/handler/auth/tests/test_auth.py index 05092bdd0..d3924fa3d 100644 --- a/backend/handler/auth/tests/test_auth.py +++ b/backend/handler/auth/tests/test_auth.py @@ -3,7 +3,7 @@ import pytest from fastapi.exceptions import HTTPException from handler.auth import auth_handler, oauth_handler -from handler.auth.constants import WRITE_SCOPES +from handler.auth.constants import EDIT_SCOPES from handler.auth.hybrid_auth import HybridAuthBackend from handler.database import db_user_handler from models.user import User @@ -88,7 +88,7 @@ def __init__(self): creds, user = result assert user.id == editor_user.id assert creds.scopes == editor_user.oauth_scopes - assert creds.scopes == WRITE_SCOPES + assert creds.scopes == EDIT_SCOPES async def test_hybrid_auth_backend_empty_session_and_headers(editor_user: User): @@ -159,7 +159,7 @@ def __init__(self): creds, user = result assert user.id == editor_user.id - assert creds.scopes == WRITE_SCOPES + assert creds.scopes == EDIT_SCOPES assert set(creds.scopes).issubset(editor_user.oauth_scopes) diff --git a/backend/models/tests/test_user.py b/backend/models/tests/test_user.py index 6c69b24d9..3274dbee6 100644 --- a/backend/models/tests/test_user.py +++ b/backend/models/tests/test_user.py @@ -1,4 +1,4 @@ -from handler.auth.constants import DEFAULT_SCOPES, FULL_SCOPES, WRITE_SCOPES +from handler.auth.constants import EDIT_SCOPES, FULL_SCOPES, WRITE_SCOPES from models.user import User @@ -7,8 +7,8 @@ def test_admin(admin_user: User): def test_editor(editor_user: User): - assert editor_user.oauth_scopes == WRITE_SCOPES + assert editor_user.oauth_scopes == EDIT_SCOPES def test_user(viewer_user: User): - assert viewer_user.oauth_scopes == DEFAULT_SCOPES + assert viewer_user.oauth_scopes == WRITE_SCOPES diff --git a/backend/models/user.py b/backend/models/user.py index da1a1784b..c906d0cb1 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -4,7 +4,14 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING -from handler.auth.constants import DEFAULT_SCOPES, FULL_SCOPES, WRITE_SCOPES, Scope +from config import KIOSK_MODE +from handler.auth.constants import ( + EDIT_SCOPES, + FULL_SCOPES, + READ_SCOPES, + WRITE_SCOPES, + Scope, +) from models.base import BaseModel from sqlalchemy import TIMESTAMP, Enum, String from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -45,15 +52,33 @@ class User(BaseModel, SimpleUser): rom_users: Mapped[list[RomUser]] = relationship(back_populates="user") collections: Mapped[list[Collection]] = relationship(back_populates="user") + @classmethod + def kiosk_mode_user(cls) -> User: + now = datetime.now(timezone.utc) + return cls( + id=-1, + username="kiosk", + role=Role.VIEWER, + enabled=True, + avatar_path="", + last_active=now, + last_login=now, + created_at=now, + updated_at=now, + ) + @property def oauth_scopes(self) -> list[Scope]: if self.role == Role.ADMIN: return FULL_SCOPES if self.role == Role.EDITOR: - return WRITE_SCOPES + return EDIT_SCOPES + + if KIOSK_MODE: + return READ_SCOPES - return DEFAULT_SCOPES + return WRITE_SCOPES @property def fs_safe_folder_name(self): diff --git a/env.template b/env.template index 31fe2683d..c6705e26d 100644 --- a/env.template +++ b/env.template @@ -1,5 +1,6 @@ ROMM_BASE_PATH=/path/to/romm_mock DEV_MODE=true +KIOSK_MODE=false # Gunicorn (optional) GUNICORN_WORKERS=4 # (2 × CPU cores) + 1 diff --git a/frontend/src/components/Details/ActionBar.vue b/frontend/src/components/Details/ActionBar.vue index edf51733e..ad2782659 100644 --- a/frontend/src/components/Details/ActionBar.vue +++ b/frontend/src/components/Details/ActionBar.vue @@ -6,6 +6,7 @@ import storeDownload from "@/stores/download"; import storeHeartbeat from "@/stores/heartbeat"; import storeConfig from "@/stores/config"; import type { DetailedRom } from "@/stores/roms"; +import storeAuth from "@/stores/auth"; import type { Events } from "@/types/emitter"; import { getDownloadLink, @@ -26,6 +27,7 @@ const playInfoIcon = ref("mdi-play"); const qrCodeIcon = ref("mdi-qrcode"); const configStore = storeConfig(); const { config } = storeToRefs(configStore); +const auth = storeAuth(); const platformSlug = computed(() => props.rom.platform_slug in config.value.PLATFORMS_VERSIONS @@ -128,7 +130,14 @@ async function copyDownloadLink(rom: DetailedRom) { > - +