diff --git a/config.example.toml b/config.example.toml index 85d65ee..f6a74d9 100644 --- a/config.example.toml +++ b/config.example.toml @@ -38,6 +38,7 @@ dbname = "MediaManager" [auth] email_password_resets = false # if true, you also need to set up SMTP (notifications.smtp_config) +registration_enabled = false # if false, the sign-up page is disabled and OIDC will not auto-create new users token_secret = "CHANGE_ME_GENERATE_RANDOM_STRING" # generate a random string with "openssl rand -hex 32", e.g. here https://www.cryptool.org/en/cto/openssl/ session_lifetime = 86400 # this is how long you will be logged in after loggin in, in seconds diff --git a/docs/configuration/authentication.md b/docs/configuration/authentication.md index aba5656..89cabbf 100644 --- a/docs/configuration/authentication.md +++ b/docs/configuration/authentication.md @@ -19,10 +19,18 @@ All authentication settings are configured in the `[auth]` section of your `conf A list of email addresses for administrator accounts. This is required. * `email_password_resets`\ Enables password resets via email. Default is `false`. +* `registration_enabled`\ + Allows new users to sign up for an account. Default is `false`. !!! info To use email password resets, you must also configure SMTP settings in the `[notifications.smtp_config]` section. +!!! info + When `registration_enabled` is `false`, the sign-up page is hidden, `/auth/register` returns 403, and OIDC rejects unknown users (existing users keep working). The bootstrap admin (see `admin_emails`) is unaffected. + +!!! tip "Adding users when registration is disabled" + Administrators can add users at any time from the **Settings → Users** page via the "Add User" button. For OIDC-only users, leave the password field blank — a random password is generated and the user is matched to their OIDC account by email on first sign-in. + !!! info When setting up MediaManager for the first time, you should add your email to `admin_emails` in the `[auth]` config section. MediaManager will then use this email instead of the default admin email. Your account will automatically be created as an admin account, allowing you to manage other users, media and settings. @@ -70,6 +78,7 @@ token_secret = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6" session_lifetime = 604800 # 1 week admin_emails = ["admin@example.com", "manager@example.com"] email_password_resets = true +registration_enabled = false [auth.openid_connect] enabled = true diff --git a/media_manager/auth/config.py b/media_manager/auth/config.py index 6ad4c87..3baa9f6 100644 --- a/media_manager/auth/config.py +++ b/media_manager/auth/config.py @@ -19,4 +19,5 @@ class AuthConfig(BaseSettings): session_lifetime: int = 60 * 60 * 24 admin_emails: list[str] = [] email_password_resets: bool = False + registration_enabled: bool = False openid_connect: OpenIdConfig = OpenIdConfig() diff --git a/media_manager/auth/router.py b/media_manager/auth/router.py index 267776e..320a51a 100644 --- a/media_manager/auth/router.py +++ b/media_manager/auth/router.py @@ -1,18 +1,27 @@ +import uuid from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from typing import Annotated -from fastapi import APIRouter, Depends, FastAPI, status +from fastapi import APIRouter, Depends, FastAPI, HTTPException, status +from fastapi_users import BaseUserManager, exceptions from fastapi_users.router import get_oauth_router from httpx_oauth.oauth2 import OAuth2 from sqlalchemy import select from media_manager.auth.db import User -from media_manager.auth.schemas import AuthMetadata, UserRead +from media_manager.auth.schemas import ( + AdminUserCreate, + AuthMetadata, + UserCreate, + UserRead, +) from media_manager.auth.users import ( SECRET, create_default_admin_user, current_superuser, fastapi_users, + get_user_manager, openid_client, openid_cookie_auth_backend, ) @@ -59,7 +68,8 @@ def get_openid_router() -> APIRouter: ) -openid_config = MediaManagerConfig().auth.openid_connect +auth_config = MediaManagerConfig().auth +openid_config = auth_config.openid_connect @users_router.get( @@ -73,8 +83,45 @@ async def get_all_users(db: DbSessionDependency) -> list[UserRead]: return [UserRead.model_validate(user) for user in result] +@users_router.post( + "/users/", + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(current_superuser)], +) +async def admin_create_user( + payload: AdminUserCreate, + user_manager: Annotated[ + BaseUserManager[User, uuid.UUID], Depends(get_user_manager) + ], +) -> UserRead: + password = payload.password or user_manager.password_helper.generate() + try: + user = await user_manager.create( + UserCreate( + email=payload.email, + password=password, + is_superuser=payload.is_superuser, + is_verified=payload.is_verified, + ), + safe=False, + ) + except exceptions.UserAlreadyExists as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A user with this email already exists.", + ) from exc + except exceptions.InvalidPasswordException as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid password: {exc.reason}", + ) from exc + return UserRead.model_validate(user) + + @auth_metadata_router.get("/auth/metadata", status_code=status.HTTP_200_OK) def get_auth_metadata() -> AuthMetadata: - if openid_config.enabled: - return AuthMetadata(oauth_providers=[openid_config.name]) - return AuthMetadata(oauth_providers=[]) + providers = [openid_config.name] if openid_config.enabled else [] + return AuthMetadata( + oauth_providers=providers, + registration_enabled=auth_config.registration_enabled, + ) diff --git a/media_manager/auth/schemas.py b/media_manager/auth/schemas.py index 6350282..fe5d8e2 100644 --- a/media_manager/auth/schemas.py +++ b/media_manager/auth/schemas.py @@ -1,7 +1,7 @@ import uuid from fastapi_users import schemas -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr class UserRead(schemas.BaseUser[uuid.UUID]): @@ -16,5 +16,13 @@ class UserUpdate(schemas.BaseUserUpdate): pass +class AdminUserCreate(BaseModel): + email: EmailStr + password: str | None = None + is_superuser: bool = False + is_verified: bool = True + + class AuthMetadata(BaseModel): oauth_providers: list[str] + registration_enabled: bool diff --git a/media_manager/auth/users.py b/media_manager/auth/users.py index c820fd2..2dcd0f5 100644 --- a/media_manager/auth/users.py +++ b/media_manager/auth/users.py @@ -4,9 +4,9 @@ import uuid from collections.abc import AsyncGenerator from typing import Any, override -from fastapi import Depends, Request +from fastapi import Depends, HTTPException, Request from fastapi.responses import RedirectResponse, Response -from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models +from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, exceptions, models from fastapi_users.authentication import ( AuthenticationBackend, BearerTransport, @@ -59,6 +59,43 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): updated_user = UserUpdate(is_verified=True) await self.update(user=user, user_update=updated_user) + @override + async def oauth_callback( + self, + oauth_name: str, + access_token: str, + account_id: str, + account_email: str, + expires_at: int | None = None, + refresh_token: str | None = None, + request: Request | None = None, + *, + associate_by_email: bool = False, + is_verified_by_default: bool = False, + ) -> User: + if not config.registration_enabled: + try: + await self.get_by_oauth_account(oauth_name, account_id) + except exceptions.UserNotExists: + try: + await self.get_by_email(account_email) + except exceptions.UserNotExists: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User registration is disabled.", + ) from None + return await super().oauth_callback( + oauth_name, + access_token, + account_id, + account_email, + expires_at, + refresh_token, + request, + associate_by_email=associate_by_email, + is_verified_by_default=is_verified_by_default, + ) + @override async def on_after_register( self, user: User, request: Request | None = None diff --git a/media_manager/main.py b/media_manager/main.py index bff0211..a36b4be 100644 --- a/media_manager/main.py +++ b/media_manager/main.py @@ -6,7 +6,15 @@ from contextlib import asynccontextmanager import uvicorn from asgi_correlation_id import CorrelationIdMiddleware -from fastapi import APIRouter, FastAPI, Request, Response +from fastapi import ( + APIRouter, + Depends, + FastAPI, + HTTPException, + Request, + Response, + status, +) from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from psycopg.errors import UniqueViolation @@ -153,10 +161,21 @@ api_app.include_router( prefix="/auth/cookie", tags=["auth"], ) + + +def reject_if_registration_disabled() -> None: + if not config.auth.registration_enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User registration is disabled.", + ) + + api_app.include_router( fastapi_users.get_register_router(UserRead, UserCreate), prefix="/auth", tags=["auth"], + dependencies=[Depends(reject_if_registration_disabled)], ) api_app.include_router( fastapi_users.get_reset_password_router(), prefix="/auth", tags=["auth"] diff --git a/web/src/lib/api/api.d.ts b/web/src/lib/api/api.d.ts index 1c02f80..08e74f2 100644 --- a/web/src/lib/api/api.d.ts +++ b/web/src/lib/api/api.d.ts @@ -191,6 +191,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/users/': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Admin Create User */ + post: operations['admin_create_user_api_v1_users__post']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v1/users/me': { parameters: { query?: never; @@ -1075,10 +1092,32 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** AdminUserCreate */ + AdminUserCreate: { + /** + * Email + * Format: email + */ + email: string; + /** Password */ + password?: string | null; + /** + * Is Superuser + * @default false + */ + is_superuser?: boolean; + /** + * Is Verified + * @default true + */ + is_verified?: boolean; + }; /** AuthMetadata */ AuthMetadata: { /** Oauth Providers */ oauth_providers: string[]; + /** Registration Enabled */ + registration_enabled: boolean; }; /** BearerResponse */ BearerResponse: { @@ -2155,6 +2194,39 @@ export interface operations { }; }; }; + admin_create_user_api_v1_users__post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['AdminUserCreate']; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UserRead']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; users_current_user_api_v1_users_me_get: { parameters: { query?: never; diff --git a/web/src/lib/components/auth/login-card.svelte b/web/src/lib/components/auth/login-card.svelte index 77b8577..d08828c 100644 --- a/web/src/lib/components/auth/login-card.svelte +++ b/web/src/lib/components/auth/login-card.svelte @@ -13,9 +13,11 @@ import { resolve } from '$app/paths'; let { - oauthProviderNames + oauthProviderNames, + registrationEnabled }: { oauthProviderNames: string[]; + registrationEnabled: boolean; } = $props(); let email = $state(''); @@ -125,10 +127,12 @@ >Login with {name} {/each} -
- -
+ {#if registrationEnabled} +
+ +
+ {/if} diff --git a/web/src/lib/components/user-data-table.svelte b/web/src/lib/components/user-data-table.svelte index 9e6d0fc..a0a786f 100644 --- a/web/src/lib/components/user-data-table.svelte +++ b/web/src/lib/components/user-data-table.svelte @@ -7,6 +7,7 @@ import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js'; import { Label } from '$lib/components/ui/label/index.js'; import * as RadioGroup from '$lib/components/ui/radio-group/index.js'; + import { Checkbox } from '$lib/components/ui/checkbox/index.js'; import { Input } from '$lib/components/ui/input/index.js'; import { invalidateAll } from '$app/navigation'; import client from '$lib/api'; @@ -20,6 +21,41 @@ let newEmail: string = $state(''); let dialogOpen = $state(false); let deleteDialogOpen = $state(false); + let createDialogOpen = $state(false); + let createEmail: string = $state(''); + let createPassword: string = $state(''); + let createIsSuperuser: boolean = $state(false); + let isCreating: boolean = $state(false); + + function resetCreateForm() { + createEmail = ''; + createPassword = ''; + createIsSuperuser = false; + } + + async function createUser() { + if (isCreating) return; + isCreating = true; + try { + const { error } = await client.POST('/api/v1/users/', { + body: { + email: createEmail, + password: createPassword || null, + is_superuser: createIsSuperuser, + is_verified: true + } + }); + if (error) { + toast.error(`Failed to create user: ${error.detail ?? error}`); + return; + } + toast.success(`User ${createEmail} created successfully.`); + createDialogOpen = false; + await invalidateAll(); + } finally { + isCreating = false; + } + } async function saveUser() { if (!selectedUser) return; @@ -73,6 +109,9 @@ } +
+ +
A list of all users. @@ -248,3 +287,53 @@ + { + createDialogOpen = open; + if (!open) resetCreateForm(); + }} +> + + + Add user + + Create a new user account. Leave password blank to auto-generate one (useful when the user + will sign in via OIDC). + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte index 71cce25..e1be91a 100644 --- a/web/src/routes/login/+page.svelte +++ b/web/src/routes/login/+page.svelte @@ -14,5 +14,8 @@
- +
diff --git a/web/src/routes/login/signup/+page.ts b/web/src/routes/login/signup/+page.ts new file mode 100644 index 0000000..942b668 --- /dev/null +++ b/web/src/routes/login/signup/+page.ts @@ -0,0 +1,10 @@ +import { redirect } from '@sveltejs/kit'; +import { resolve } from '$app/paths'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ parent }) => { + const { loginData } = await parent(); + if (!loginData?.registration_enabled) { + redirect(307, resolve('/login', {})); + } +};