feat(auth): Self-Service-Admin-Password-Reset via CLI-Token

Operator hat Admin-Passwort vergessen aber SSH-Zugang zur Box →
schneller Reset ohne SMTP/Email-Setup.

Flow:
  1. `sudo edgeguard-ctl reset-password` auf der Box → 32-hex-Token
     + ISO-Expiry werden nach /var/lib/edgeguard/.reset-token (mode
     0600 edgeguard:edgeguard) geschrieben, Token kommt auf stdout.
     TTL: 30 min.
  2. Login-Seite hat „Passwort vergessen?"-Link → /reset-password.
  3. Reset-Page: Token + neues Passwort (min. 12). POST /auth/reset-
     password validiert Token (constant-time compare), prüft Expiry,
     löscht das File (single-use), hash't das Passwort + speichert
     in setup.json.

internal/services/setup/:
  - SetAdminPassword() — bcrypt-hash + save, fehler wenn setup nicht
    completed
  - GenerateResetToken() / ConsumeResetToken() — File-basiert,
    Format: "<token>|<RFC3339-expiry>"

internal/handlers/auth.go: POST /api/v1/auth/reset-password.
cmd/edgeguard-ctl/main.go: `reset-password` command.

UI: /reset-password Page mit Info-Alert für CLI-Snippet
(„sudo edgeguard-ctl reset-password" im dunklen Code-Block); Login-
Seite bekommt den „Passwort vergessen?"-Link.

Verifiziert auf 1.0.76: CLI druckt Token + schreibt File mit 0600
edgeguard:edgeguard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-13 19:04:25 +02:00
parent 27ac7b53fc
commit c79bfe84ec
13 changed files with 323 additions and 8 deletions

View File

@@ -11,6 +11,7 @@ import apiClient, { isEnvelope } from './api/client'
import { useAuthStore, type SessionUser } from './stores/auth'
const LoginPage = lazy(() => import('./pages/Login'))
const ResetPasswordPage = lazy(() => import('./pages/ResetPassword'))
const SetupPage = lazy(() => import('./pages/Setup'))
const DashboardPage = lazy(() => import('./pages/Dashboard'))
const DomainsPage = lazy(() => import('./pages/Domains'))
@@ -98,6 +99,7 @@ export default function App() {
<Routes>
<Route path="/setup" element={<SetupPage onComplete={(u: SessionUser) => useAuthStore.getState().set(u)} />} />
<Route path="/login" element={<LoginPage onLogin={(u: SessionUser) => useAuthStore.getState().set(u)} />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />

View File

@@ -85,7 +85,7 @@ const NAV: NavSection[] = [
},
]
const VERSION = '1.0.75'
const VERSION = '1.0.76'
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent

View File

@@ -172,7 +172,22 @@
"login": "Anmelden",
"logout": "Abmelden",
"loginFailed": "Anmeldung fehlgeschlagen",
"loggedInAs": "Angemeldet als"
"loggedInAs": "Angemeldet als",
"forgotPassword": "Passwort vergessen?"
},
"reset": {
"title": "Admin-Passwort zurücksetzen",
"intro": "Self-Service-Reset über CLI-Token. Du brauchst SSH-Zugang zur Box.",
"cliTitle": "1. Auf der Box ausführen:",
"token": "Reset-Token",
"tokenExtra": "32 hex-Zeichen aus der CLI-Ausgabe. Token ist 30 Minuten gültig.",
"newPassword": "Neues Passwort (min. 12 Zeichen)",
"confirmPassword": "Passwort bestätigen",
"submit": "Passwort setzen",
"backToLogin": "← Zurück zum Login",
"mismatch": "Passwörter stimmen nicht überein",
"success": "Passwort erfolgreich gesetzt. Du wirst weitergeleitet …",
"failed": "Reset fehlgeschlagen"
},
"setup": {
"title": "Erst-Einrichtung",

View File

@@ -172,7 +172,22 @@
"login": "Sign in",
"logout": "Sign out",
"loginFailed": "Sign-in failed",
"loggedInAs": "Signed in as"
"loggedInAs": "Signed in as",
"forgotPassword": "Forgot your password?"
},
"reset": {
"title": "Reset admin password",
"intro": "Self-service reset via CLI token. You need SSH access to the box.",
"cliTitle": "1. Run on the box:",
"token": "Reset token",
"tokenExtra": "32 hex chars from the CLI output. Token is valid for 30 minutes.",
"newPassword": "New password (min. 12 chars)",
"confirmPassword": "Confirm password",
"submit": "Set password",
"backToLogin": "← Back to sign-in",
"mismatch": "Passwords don't match",
"success": "Password set. Redirecting…",
"failed": "Reset failed"
},
"setup": {
"title": "First-time setup",

View File

@@ -1,5 +1,5 @@
import { Button, Card, Form, Input, message, Typography } from 'antd'
import { useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
@@ -65,6 +65,9 @@ export default function LoginPage({ onLogin }: Props) {
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', fontSize: 12 }}>
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
</div>
</Card>
</div>
)

View File

@@ -0,0 +1,92 @@
import { useState } from 'react'
import { Alert, Button, Card, Form, Input, message, Typography } from 'antd'
import { Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import apiClient from '../../api/client'
interface FormValues {
token: string
new_password: string
new_password2: string
}
export default function ResetPasswordPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [form] = Form.useForm<FormValues>()
const onFinish = async (v: FormValues) => {
if (v.new_password !== v.new_password2) {
setError(t('reset.mismatch'))
return
}
setSubmitting(true); setError(null)
try {
await apiClient.post('/auth/reset-password', {
token: v.token.trim(),
new_password: v.new_password,
})
message.success(t('reset.success'))
setTimeout(() => navigate('/login', { replace: true }), 1500)
} catch (e: unknown) {
const err = e as { message?: string }
setError(err.message ?? t('reset.failed'))
} finally {
setSubmitting(false)
}
}
return (
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
<Card style={{ width: 480 }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 8 }}>
{t('reset.title')}
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ textAlign: 'center', fontSize: 12 }}>
{t('reset.intro')}
</Typography.Paragraph>
<Alert
type="info"
showIcon
className="mb-16"
message={t('reset.cliTitle')}
description={
<code style={{ display: 'block', padding: 8, marginTop: 8, background: '#0F172A', color: '#CBD5E1', borderRadius: 4 }}>
sudo edgeguard-ctl reset-password
</code>
}
/>
{error && <Alert type="error" showIcon className="mb-16" message={error} closable
onClose={() => setError(null)} />}
<Form form={form} layout="vertical" onFinish={onFinish}>
<Form.Item label={t('reset.token')} name="token" rules={[{ required: true, len: 32 }]}
extra={t('reset.tokenExtra')}>
<Input autoComplete="off" autoFocus placeholder="32 hex chars" />
</Form.Item>
<Form.Item label={t('reset.newPassword')} name="new_password"
rules={[{ required: true, min: 12 }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item label={t('reset.confirmPassword')} name="new_password2"
rules={[{ required: true, min: 12 }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={submitting}>
{t('reset.submit')}
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', fontSize: 12 }}>
<Link to="/login">{t('reset.backToLogin')}</Link>
</div>
</Card>
</div>
)
}