mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-08-01 01:26:28 -04:00
feat: add auth.registration_enabled flag and admin ui user creation (#543)
Hey! @maxdorninger, could you please take a look on the other one ;) ? Closes #145 ; seems like a must have thing - Add `auth.registration_enabled` flag (default `false` — sounds safer default in current setup) - Password registration: `/auth/register` route will return 403 if disabled - OIDC registration: `UserManager.oauth_callback` is overridden to reject user without match; so no auto-provision - `/auth/metadata` exposes the flag to the frontend - Frontend: signup link hidden on the login card; direct navigation to `/login/signup` redirects to `/login` via a `+page.ts` load guard <img width="463" height="461" alt="Screenshot 2026-05-16 at 20 52 11" src="https://github.com/user-attachments/assets/1def5142-e930-4aa6-8771-cbff54250c1f" /> <img width="450" height="391" alt="Screenshot 2026-05-16 at 20 52 22" src="https://github.com/user-attachments/assets/b4013964-cace-4aeb-a848-48ced86fcc5f" /> Also this means uses need to be created somehow -- so.. - Add POST `/users` - admin-protected endpoint to create a new users (created user can be used with OIDC) - Fronted: Add new user button and modal dialog <img width="800" height="379" alt="Screenshot 2026-05-17 at 11 32 06" src="https://github.com/user-attachments/assets/048e6c43-a1c1-42ce-a19c-fd9d916a47d9" /> <img width="537" height="439" alt="Screenshot 2026-05-17 at 11 32 12" src="https://github.com/user-attachments/assets/3f83c2c1-027b-4279-b6a1-bbc8da77efed" /> --- <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Add config toggle to enable/disable user registration; when disabled sign-up endpoints return 403 and OIDC won’t auto-create unknown users. Added admin API to create users. * **Frontend** * Login UI hides signup link when registration is disabled; signup page redirects to login. Admin users list gains “Add User” modal to create users. * **Documentation** * Authentication docs and config examples updated to document the new option. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/maxdorninger/MediaManager/pull/543?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
committed by
GitHub
parent
e8f032f93a
commit
bdb886d076
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
72
web/src/lib/api/api.d.ts
vendored
72
web/src/lib/api/api.d.ts
vendored
@@ -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<string, never>;
|
||||
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;
|
||||
|
||||
@@ -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}</Button
|
||||
>
|
||||
{/each}
|
||||
<div class="mt-4 text-center text-sm">
|
||||
<Button href={resolve('/login/signup/', {})} variant="link"
|
||||
>Don't have an account? Sign up</Button
|
||||
>
|
||||
</div>
|
||||
{#if registrationEnabled}
|
||||
<div class="mt-4 text-center text-sm">
|
||||
<Button href={resolve('/login/signup/', {})} variant="link"
|
||||
>Don't have an account? Sign up</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-4 flex justify-end">
|
||||
<Button onclick={() => (createDialogOpen = true)}>Add User</Button>
|
||||
</div>
|
||||
<Table.Root>
|
||||
<Table.Caption>A list of all users.</Table.Caption>
|
||||
<Table.Header>
|
||||
@@ -248,3 +287,53 @@
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
<Dialog.Root
|
||||
open={createDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
createDialogOpen = open;
|
||||
if (!open) resetCreateForm();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-full max-w-[500px] rounded-lg p-6 shadow-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="mb-1 text-xl font-semibold">Add user</Dialog.Title>
|
||||
<Dialog.Description class="mb-4 text-sm">
|
||||
Create a new user account. Leave password blank to auto-generate one (useful when the user
|
||||
will sign in via OIDC).
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm font-medium" for="create-email">Email</Label>
|
||||
<Input
|
||||
bind:value={createEmail}
|
||||
class="w-full"
|
||||
id="create-email"
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm font-medium" for="create-password">Password</Label>
|
||||
<Input
|
||||
bind:value={createPassword}
|
||||
class="w-full"
|
||||
id="create-password"
|
||||
placeholder="Leave blank for OIDC users"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox bind:checked={createIsSuperuser} id="create-superuser" />
|
||||
<Label class="text-sm" for="create-superuser">Administrator</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 flex justify-end gap-2">
|
||||
<Button onclick={() => (createDialogOpen = false)} variant="outline">Cancel</Button>
|
||||
<Button onclick={() => createUser()} disabled={!createEmail || isCreating}>
|
||||
{isCreating ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -14,5 +14,8 @@
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<LoginCard oauthProviderNames={loginMetaData.oauth_providers} />
|
||||
<LoginCard
|
||||
oauthProviderNames={loginMetaData.oauth_providers}
|
||||
registrationEnabled={loginMetaData.registration_enabled}
|
||||
/>
|
||||
</main>
|
||||
|
||||
10
web/src/routes/login/signup/+page.ts
Normal file
10
web/src/routes/login/signup/+page.ts
Normal file
@@ -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', {}));
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user