mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
First-run admin setup wizard, password persistence, forced setup gate
- Admin enabled by default (was opt-in via env var) - New /admin/status (public) and /admin/setup (first-run only) endpoints - Setup endpoint locked after first use (returns 403) - Admin password persisted to SQLite config table (survives restarts) - Change password now persists to DB (was in-memory only) - Frontend router guard forces /admin redirect until setup is complete - AdminSetup.vue wizard: username + password + confirm - Public config exposes admin_enabled/admin_setup_complete for frontend - TLS warning only fires when password is actually configured
This commit is contained in:
parent
b86366116a
commit
1592407658
12 changed files with 375 additions and 8 deletions
|
|
@ -82,7 +82,7 @@ class UIConfig(BaseModel):
|
|||
class AdminConfig(BaseModel):
|
||||
"""Admin panel settings."""
|
||||
|
||||
enabled: bool = False
|
||||
enabled: bool = True
|
||||
username: str = "admin"
|
||||
password_hash: str = ""
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ async def lifespan(app: FastAPI):
|
|||
logger.info("Config loaded from defaults + env vars (no YAML file)")
|
||||
|
||||
# --- TLS warning ---
|
||||
if config.admin.enabled:
|
||||
if config.admin.enabled and config.admin.password_hash:
|
||||
logger.warning(
|
||||
"Admin panel is enabled. Ensure HTTPS is configured via a reverse proxy "
|
||||
"(Caddy, Traefik, nginx) to protect admin credentials in transit."
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
"""Admin API endpoints — protected by require_admin dependency.
|
||||
|
||||
Settings are persisted to SQLite and survive container restarts.
|
||||
Admin setup (first-run password creation) is unauthenticated but only
|
||||
available when no password has been configured yet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import bcrypt
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
|
@ -17,6 +20,83 @@ logger = logging.getLogger("mediarip.admin")
|
|||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public endpoints (no auth) — admin status + first-run setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def admin_status(request: Request) -> dict:
|
||||
"""Public endpoint: is admin enabled, and has initial setup been done?"""
|
||||
config = request.app.state.config
|
||||
return {
|
||||
"enabled": config.admin.enabled,
|
||||
"setup_complete": bool(config.admin.password_hash),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/setup")
|
||||
async def admin_setup(request: Request) -> dict:
|
||||
"""First-run setup: create admin credentials.
|
||||
|
||||
Only works when admin is enabled AND no password has been set yet.
|
||||
After setup, this endpoint returns 403 — use /admin/password to change.
|
||||
"""
|
||||
config = request.app.state.config
|
||||
|
||||
if not config.admin.enabled:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": "Admin panel is not enabled"},
|
||||
)
|
||||
|
||||
if config.admin.password_hash:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "Admin is already configured. Use the change password flow."},
|
||||
)
|
||||
|
||||
body = await request.json()
|
||||
username = body.get("username", "").strip()
|
||||
password = body.get("password", "")
|
||||
|
||||
if not username:
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"detail": "Username is required"},
|
||||
)
|
||||
|
||||
if len(password) < 4:
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"detail": "Password must be at least 4 characters"},
|
||||
)
|
||||
|
||||
# Hash and persist
|
||||
password_hash = bcrypt.hashpw(
|
||||
password.encode("utf-8"), bcrypt.gensalt()
|
||||
).decode("utf-8")
|
||||
|
||||
config.admin.username = username
|
||||
config.admin.password_hash = password_hash
|
||||
|
||||
# Persist to DB so it survives restarts
|
||||
from app.services.settings import save_settings
|
||||
db = request.app.state.db
|
||||
await save_settings(db, {
|
||||
"admin_username": username,
|
||||
"admin_password_hash": password_hash,
|
||||
})
|
||||
|
||||
logger.info("Admin setup complete — user '%s' created", username)
|
||||
return {"status": "ok", "username": username}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authenticated endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def list_sessions(
|
||||
request: Request,
|
||||
|
|
@ -351,9 +431,7 @@ async def change_password(
|
|||
request: Request,
|
||||
_admin: str = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""Change admin password. Persisted in-memory only (set via env var for persistence)."""
|
||||
import bcrypt
|
||||
|
||||
"""Change admin password. Persisted to SQLite for durability."""
|
||||
body = await request.json()
|
||||
current = body.get("current_password", "")
|
||||
new_pw = body.get("new_password", "")
|
||||
|
|
@ -387,8 +465,13 @@ async def change_password(
|
|||
|
||||
new_hash = bcrypt.hashpw(new_pw.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
config.admin.password_hash = new_hash
|
||||
logger.info("Admin password changed by user '%s'", _admin)
|
||||
|
||||
# Persist to DB
|
||||
from app.services.settings import save_settings
|
||||
db = request.app.state.db
|
||||
await save_settings(db, {"admin_password_hash": new_hash})
|
||||
|
||||
logger.info("Admin password changed by user '%s'", _admin)
|
||||
return {"status": "ok", "message": "Password changed successfully"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -29,4 +29,6 @@ async def public_config(request: Request) -> dict:
|
|||
"default_audio_format": getattr(request.app.state, "_default_audio_format", "auto"),
|
||||
"privacy_mode": config.purge.privacy_mode,
|
||||
"privacy_retention_hours": config.purge.privacy_retention_hours,
|
||||
"admin_enabled": config.admin.enabled,
|
||||
"admin_setup_complete": bool(config.admin.password_hash),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ ADMIN_WRITABLE_KEYS = {
|
|||
"session_mode",
|
||||
"session_timeout_hours",
|
||||
"admin_username",
|
||||
"admin_password_hash",
|
||||
"purge_enabled",
|
||||
"purge_max_age_hours",
|
||||
}
|
||||
|
|
@ -99,6 +100,8 @@ def apply_persisted_to_config(config, settings: dict) -> None:
|
|||
config.session.timeout_hours = settings["session_timeout_hours"]
|
||||
if "admin_username" in settings:
|
||||
config.admin.username = settings["admin_username"]
|
||||
if "admin_password_hash" in settings:
|
||||
config.admin.password_hash = settings["admin_password_hash"]
|
||||
if "purge_enabled" in settings:
|
||||
config.purge.enabled = settings["purge_enabled"]
|
||||
if "purge_max_age_hours" in settings:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ class TestZeroConfig:
|
|||
|
||||
def test_admin_defaults(self):
|
||||
config = AppConfig()
|
||||
assert config.admin.enabled is False
|
||||
assert config.admin.enabled is True
|
||||
assert config.admin.username == "admin"
|
||||
assert config.admin.password_hash == ""
|
||||
|
||||
def test_source_templates_default_entries(self):
|
||||
config = AppConfig()
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ export interface PublicConfig {
|
|||
default_audio_format: string
|
||||
privacy_mode: boolean
|
||||
privacy_retention_hours: number
|
||||
admin_enabled: boolean
|
||||
admin_setup_complete: boolean
|
||||
}
|
||||
|
||||
export interface HealthStatus {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useAdminStore } from '@/stores/admin'
|
|||
import { useConfigStore } from '@/stores/config'
|
||||
import { api } from '@/api/client'
|
||||
import AdminLogin from './AdminLogin.vue'
|
||||
import AdminSetup from './AdminSetup.vue'
|
||||
|
||||
const store = useAdminStore()
|
||||
const configStore = useConfigStore()
|
||||
|
|
@ -194,7 +195,16 @@ function formatFilesize(bytes: number | null): string {
|
|||
|
||||
<template>
|
||||
<div class="admin-panel">
|
||||
<AdminLogin v-if="!store.isAuthenticated" />
|
||||
<!-- First-run setup: no password configured yet -->
|
||||
<AdminSetup v-if="configStore.config?.admin_enabled && !configStore.config?.admin_setup_complete" />
|
||||
|
||||
<!-- Admin disabled -->
|
||||
<div v-else-if="!configStore.config?.admin_enabled" class="admin-disabled">
|
||||
<p>Admin panel is disabled. Set <code>admin.enabled: true</code> in your config to enable it.</p>
|
||||
</div>
|
||||
|
||||
<!-- Normal login/panel flow -->
|
||||
<AdminLogin v-else-if="!store.isAuthenticated" />
|
||||
|
||||
<template v-else>
|
||||
<div class="admin-header">
|
||||
|
|
@ -1112,4 +1122,22 @@ h3 {
|
|||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-disabled {
|
||||
max-width: 400px;
|
||||
margin: var(--space-xl) auto;
|
||||
padding: var(--space-xl);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-disabled code {
|
||||
background: var(--color-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
186
frontend/src/components/AdminSetup.vue
Normal file
186
frontend/src/components/AdminSetup.vue
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { clearAdminStatusCache } from '@/router'
|
||||
|
||||
const store = useAdminStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const username = ref('admin')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const passwordMismatch = computed(() =>
|
||||
confirmPassword.value.length > 0 && password.value !== confirmPassword.value
|
||||
)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
username.value.trim().length > 0 &&
|
||||
password.value.length >= 4 &&
|
||||
password.value === confirmPassword.value &&
|
||||
!isSubmitting.value
|
||||
)
|
||||
|
||||
async function handleSetup() {
|
||||
if (!canSubmit.value) return
|
||||
isSubmitting.value = true
|
||||
const ok = await store.setup(username.value.trim(), password.value)
|
||||
if (ok) {
|
||||
// Clear cached status so router guard knows setup is done
|
||||
clearAdminStatusCache()
|
||||
// Reload public config so admin_setup_complete updates everywhere
|
||||
await configStore.loadConfig()
|
||||
}
|
||||
isSubmitting.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-setup">
|
||||
<div class="setup-card">
|
||||
<h2>Set Up Admin</h2>
|
||||
<p class="setup-hint">
|
||||
Create your admin account. You'll use these credentials to access
|
||||
the admin panel for managing sessions, settings, and downloads.
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="handleSetup" class="setup-form">
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="At least 4 characters"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Confirm Password</span>
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
autocomplete="new-password"
|
||||
:class="{ 'input-error': passwordMismatch }"
|
||||
/>
|
||||
<span v-if="passwordMismatch" class="field-error">Passwords don't match</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" :disabled="!canSubmit">
|
||||
{{ isSubmitting ? 'Creating…' : 'Create Admin Account' }}
|
||||
</button>
|
||||
|
||||
<p v-if="store.authError" class="error">{{ store.authError }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-setup {
|
||||
max-width: 440px;
|
||||
margin: var(--space-xl) auto;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.setup-hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.setup-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
label > span {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
input.input-error {
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -16,4 +16,41 @@ const router = createRouter({
|
|||
],
|
||||
})
|
||||
|
||||
// Cache the admin status check — fetched once per page load
|
||||
let adminStatusCache: { enabled: boolean; setup_complete: boolean } | null = null
|
||||
|
||||
async function fetchAdminStatus() {
|
||||
if (adminStatusCache) return adminStatusCache
|
||||
try {
|
||||
const res = await fetch('/api/admin/status')
|
||||
if (res.ok) {
|
||||
adminStatusCache = await res.json()
|
||||
return adminStatusCache
|
||||
}
|
||||
} catch {
|
||||
// Network error — let navigation proceed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Clear the cached admin status — call after setup completes. */
|
||||
export function clearAdminStatusCache() {
|
||||
adminStatusCache = null
|
||||
}
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
// Skip if already heading to admin
|
||||
if (to.name === 'admin') return true
|
||||
|
||||
const status = await fetchAdminStatus()
|
||||
if (!status) return true // Can't determine — let it through
|
||||
|
||||
// Force setup if admin is enabled but no password set
|
||||
if (status.enabled && !status.setup_complete) {
|
||||
return { name: 'admin' }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
|
|||
|
|
@ -72,6 +72,27 @@ export const useAdminStore = defineStore('admin', () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function setup(user: string, pass: string): Promise<boolean> {
|
||||
authError.value = null
|
||||
try {
|
||||
const res = await fetch('/api/admin/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user, password: pass }),
|
||||
})
|
||||
if (res.ok) {
|
||||
// Auto-login after setup
|
||||
return await login(user, pass)
|
||||
}
|
||||
const data = await res.json()
|
||||
authError.value = data.detail || `Setup failed: ${res.status}`
|
||||
return false
|
||||
} catch (err: any) {
|
||||
authError.value = err.message || 'Network error'
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function logout(): void {
|
||||
username.value = ''
|
||||
password.value = ''
|
||||
|
|
@ -176,6 +197,7 @@ export const useAdminStore = defineStore('admin', () => {
|
|||
isLoading,
|
||||
errorLog,
|
||||
login,
|
||||
setup,
|
||||
logout,
|
||||
loadSessions,
|
||||
loadStorage,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ describe('config store', () => {
|
|||
default_audio_format: 'auto',
|
||||
privacy_mode: false,
|
||||
privacy_retention_hours: 24,
|
||||
admin_enabled: true,
|
||||
admin_setup_complete: false,
|
||||
}
|
||||
vi.mocked(api.getPublicConfig).mockResolvedValue(mockConfig)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue