Skip to content

Commit

Permalink
Merge pull request #1586 from rommapp/kiosk-mode
Browse files Browse the repository at this point in the history
Read-only kiosk mode for viewers
  • Loading branch information
gantoine authored Feb 7, 2025
2 parents c445c89 + 2cd3c95 commit c27eca2
Show file tree
Hide file tree
Showing 37 changed files with 311 additions and 151 deletions.
1 change: 1 addition & 0 deletions backend/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 4 additions & 2 deletions backend/decorators/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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,
},
)
Expand Down
4 changes: 2 additions & 2 deletions backend/endpoints/tests/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down
20 changes: 12 additions & 8 deletions backend/handler/auth/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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())
68 changes: 37 additions & 31 deletions backend/handler/auth/hybrid_auth.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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
6 changes: 3 additions & 3 deletions backend/handler/auth/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)


Expand Down
6 changes: 3 additions & 3 deletions backend/models/tests/test_user.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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
31 changes: 28 additions & 3 deletions backend/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions env.template
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/components/Details/ActionBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -128,7 +130,14 @@ async function copyDownloadLink(rom: DetailedRom) {
>
<v-icon :icon="qrCodeIcon" />
</v-btn>
<v-menu location="bottom">
<v-menu
v-if="
auth.scopes.includes('roms.write') ||
auth.scopes.includes('roms.user.write') ||
auth.scopes.includes('collections.write')
"
location="bottom"
>
<template #activator="{ props: menuProps }">
<v-btn class="flex-grow-1" v-bind="menuProps">
<v-icon icon="mdi-dots-vertical" size="large" />
Expand Down
7 changes: 2 additions & 5 deletions frontend/src/components/Details/Info/GameInfo.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
<script setup lang="ts">
import { type FilterType } from "@/stores/galleryFilter";
import storeGalleryView from "@/stores/galleryView";
import RAvatar from "@/components/common/Collection/RAvatar.vue";
import type { DetailedRom } from "@/stores/roms";
import { storeToRefs } from "pinia";
import { ROUTES } from "@/plugins/router";
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useDisplay, useTheme } from "vuetify";
Expand All @@ -24,8 +23,6 @@ const filters = [
{ value: "collections", name: t("rom.collections") },
{ value: "companies", name: t("rom.companies") },
] as const;
const galleryViewStore = storeGalleryView();
const { defaultAspectRatioScreenshot } = storeToRefs(galleryViewStore);
// Functions
function onFilterClick(filter: FilterType, value: string) {
Expand Down Expand Up @@ -53,7 +50,7 @@ function onFilterClick(filter: FilterType, value: string) {
<v-col cols="12" v-for="collection in rom.user_collections">
<v-chip
:to="{
name: 'collection',
name: ROUTES.COLLECTION,
params: { collection: collection.id },
}"
size="large"
Expand Down
Loading

0 comments on commit c27eca2

Please sign in to comment.