Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Read-only kiosk mode for viewers #1586

Merged
merged 5 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading
Loading