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:
@@ -54,7 +54,7 @@ import (
|
||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||
)
|
||||
|
||||
var version = "1.0.75"
|
||||
var version = "1.0.76"
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||
|
||||
@@ -7,9 +7,11 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
var version = "1.0.75"
|
||||
var version = "1.0.76"
|
||||
|
||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||
|
||||
@@ -25,6 +27,7 @@ Commands:
|
||||
initdb Create PostgreSQL role + database (idempotent)
|
||||
render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=)
|
||||
wg-import [--path <dir>] Import existing /etc/wireguard/*.conf files into the DB
|
||||
reset-password Generate a one-time token for the /reset-password UI flow
|
||||
cluster-join Join an existing cluster (Phase 3, not yet implemented)
|
||||
promote Promote this node's PG to primary (Phase 3, not yet implemented)
|
||||
dump-config Print effective config (Phase 3, not yet implemented)
|
||||
@@ -48,6 +51,8 @@ func main() {
|
||||
os.Exit(cmdRenderConfig(os.Args[2:]))
|
||||
case "wg-import":
|
||||
os.Exit(cmdWGImport(os.Args[2:]))
|
||||
case "reset-password":
|
||||
os.Exit(cmdResetPassword())
|
||||
case "cluster-join", "cluster-leave", "promote", "dump-config":
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
|
||||
os.Exit(1)
|
||||
@@ -57,3 +62,25 @@ func main() {
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
// cmdResetPassword generates a single-use reset token and prints it to
|
||||
// stdout. Operator pastes it into the /reset-password UI within 30 min.
|
||||
// File mode 0600, owned by the edgeguard user — the CLI muss als sudo
|
||||
// edgeguard ausgeführt werden (oder als root); fremde User sehen das
|
||||
// Token nicht.
|
||||
func cmdResetPassword() int {
|
||||
store := setup.NewStore(setup.DefaultDir)
|
||||
token, err := store.GenerateResetToken()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl reset-password: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("Admin-Password-Reset-Token (gültig 30 Minuten):")
|
||||
fmt.Println()
|
||||
fmt.Println(" " + token)
|
||||
fmt.Println()
|
||||
fmt.Println("→ UI öffnen: https://<box-fqdn>/reset-password")
|
||||
fmt.Println("→ Token eingeben, neues Passwort setzen (min. 12 Zeichen)")
|
||||
fmt.Println("→ Token ist single-use und wird beim erfolgreichen Reset gelöscht.")
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
)
|
||||
|
||||
var version = "1.0.75"
|
||||
var version = "1.0.76"
|
||||
|
||||
const (
|
||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||
|
||||
@@ -32,6 +32,7 @@ func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc)
|
||||
g.POST("/login", h.Login)
|
||||
g.POST("/logout", h.Logout)
|
||||
g.GET("/me", requireAuth, h.Me)
|
||||
g.POST("/reset-password", h.ResetPassword)
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -99,6 +100,31 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
type resetPasswordRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=12"`
|
||||
}
|
||||
|
||||
// ResetPassword verifies the operator-generated token from
|
||||
// /var/lib/edgeguard/.reset-token and sets a new admin password. The
|
||||
// token is single-use — ConsumeResetToken löscht das File bei Erfolg.
|
||||
func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||
var req resetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Setup.ConsumeResetToken(req.Token); err != nil {
|
||||
response.Err(c, http.StatusUnauthorized, err)
|
||||
return
|
||||
}
|
||||
if err := h.Setup.SetAdminPassword(req.NewPassword); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func setSessionCookie(c *gin.Context, raw string, expUnix int64) {
|
||||
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
|
||||
if maxAge < 0 {
|
||||
|
||||
114
internal/services/setup/reset_token.go
Normal file
114
internal/services/setup/reset_token.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package setup
|
||||
|
||||
// Admin-Password-Reset-Flow: Operator hat Login-Zugang vergessen,
|
||||
// aber SSH-Zugang zur Box. Auf der Box ruft er
|
||||
//
|
||||
// sudo edgeguard-ctl reset-password
|
||||
//
|
||||
// auf — der CLI schreibt ein Token-File nach
|
||||
// /var/lib/edgeguard/.reset-token (mode 0600 edgeguard:edgeguard) und
|
||||
// druckt das Token. Der Operator gibt es auf der /reset-password-Seite
|
||||
// ein, setzt ein neues Passwort, fertig.
|
||||
//
|
||||
// Token-Eigenschaften:
|
||||
// - 32 zufällige hex-chars (128 bit entropy)
|
||||
// - File-mode 0600 — nur edgeguard liest, nur root + edgeguard schreibt
|
||||
// - Expiry 30 min (im File als ISO-timestamp gespeichert)
|
||||
// - Single-use: bei jedem erfolgreichen Reset wird das File gelöscht
|
||||
//
|
||||
// Sicherheit gegen Brute-Force: nicht nötig — Token hat 128 bit
|
||||
// Entropy, das File darf existieren nur kurzzeitig nach einem expliziten
|
||||
// CLI-Aufruf. Login-Seite hat kein Rate-Limit, aber jeder Versuch
|
||||
// braucht das exakte File-Inhalt — bei 32 hex zu raten dauert 2^127
|
||||
// Versuche.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
resetTokenFile = ".reset-token"
|
||||
resetTokenTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
// GenerateResetToken erzeugt ein neues Token, schreibt das File und
|
||||
// gibt das Token zurück. Existiert bereits ein Token → wird
|
||||
// überschrieben (Operator hat das alte verworfen). Aufrufer: CLI.
|
||||
func (s *Store) GenerateResetToken() (string, error) {
|
||||
prev, err := s.Load()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if prev == nil || !prev.Completed {
|
||||
return "", errors.New("setup not completed — nothing to reset")
|
||||
}
|
||||
var raw [16]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := hex.EncodeToString(raw[:])
|
||||
expiry := time.Now().Add(resetTokenTTL).UTC().Format(time.RFC3339)
|
||||
content := token + "|" + expiry + "\n"
|
||||
|
||||
path := filepath.Join(s.Dir, resetTokenFile)
|
||||
if err := os.MkdirAll(s.Dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ConsumeResetToken prüft Token-Match + Expiry und löscht das File bei
|
||||
// Erfolg. Aufrufer: API-Handler /auth/reset-password.
|
||||
func (s *Store) ConsumeResetToken(token string) error {
|
||||
path := filepath.Join(s.Dir, resetTokenFile)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errors.New("no reset-token available — run `sudo edgeguard-ctl reset-password` on the box first")
|
||||
}
|
||||
return err
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimSpace(string(b)), "|", 2)
|
||||
if len(parts) != 2 {
|
||||
return errors.New("reset-token file malformed")
|
||||
}
|
||||
stored := strings.TrimSpace(parts[0])
|
||||
expiryStr := strings.TrimSpace(parts[1])
|
||||
if !constantTimeEqual(stored, strings.TrimSpace(token)) {
|
||||
return errors.New("reset-token mismatch")
|
||||
}
|
||||
exp, err := time.Parse(time.RFC3339, expiryStr)
|
||||
if err != nil {
|
||||
return errors.New("reset-token expiry malformed")
|
||||
}
|
||||
if time.Now().After(exp) {
|
||||
_ = os.Remove(path)
|
||||
return errors.New("reset-token expired — generate a new one")
|
||||
}
|
||||
// success → delete file (single-use)
|
||||
_ = os.Remove(path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// constantTimeEqual prevents timing-attacks on the token compare.
|
||||
// crypto/subtle hätte's auch getan, aber für hex-strings reicht das
|
||||
// hier — und vermeidet einen weiteren Import.
|
||||
func constantTimeEqual(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
var diff byte
|
||||
for i := range a {
|
||||
diff |= a[i] ^ b[i]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
@@ -136,6 +136,27 @@ func (st *State) VerifyAdminPassword(plaintext string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(plaintext)) == nil
|
||||
}
|
||||
|
||||
// SetAdminPassword hash't ein neues Plaintext-Passwort und persistiert
|
||||
// es. Verwendet vom Self-Service-Reset (CLI-Token-Flow).
|
||||
func (s *Store) SetAdminPassword(plaintext string) error {
|
||||
if len(plaintext) < 12 {
|
||||
return errors.New("admin_password must be at least 12 characters")
|
||||
}
|
||||
prev, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prev == nil || !prev.Completed {
|
||||
return errors.New("setup not completed — cannot reset password before initial setup")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), adminPwCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash admin password: %w", err)
|
||||
}
|
||||
prev.AdminPasswordHash = string(hash)
|
||||
return s.Save(prev)
|
||||
}
|
||||
|
||||
func validate(req Request) error {
|
||||
if _, err := mail.ParseAddress(req.AdminEmail); err != nil {
|
||||
return fmt.Errorf("invalid admin_email: %w", err)
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
92
management-ui/src/pages/ResetPassword/index.tsx
Normal file
92
management-ui/src/pages/ResetPassword/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user