nsfwapp/backend/auth.go
2026-05-11 19:13:54 +02:00

1013 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// backend/auth.go
package main
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
)
type passkeyInfo struct {
CredentialID string `json:"credentialId"`
Label string `json:"label"`
CreatedAt string `json:"createdAt,omitempty"`
LastUsedAt string `json:"lastUsedAt,omitempty"`
}
type totpInfo struct {
Label string `json:"label,omitempty"`
ConfiguredAt string `json:"configuredAt,omitempty"`
}
type authConfig struct {
Username string `json:"username"`
PasswordHash string `json:"passwordHash"`
TOTPEnabled bool `json:"totpEnabled"`
TOTPSecret string `json:"totpSecret"`
TOTPInfo totpInfo `json:"totpInfo,omitempty"`
PasskeyUserID string `json:"passkeyUserId,omitempty"`
Passkeys []webauthn.Credential `json:"passkeys,omitempty"`
PasskeyInfos []passkeyInfo `json:"passkeyInfos,omitempty"`
}
// Session in memory (Restart = logout für alle)
type session struct {
User string
Authed bool // voll authed (nach 2FA)
Pending2FA bool
ExpiresAt time.Time
CreatedAt time.Time
LastSeenAt time.Time
}
type AuthManager struct {
confMu sync.Mutex
conf authConfig
sessMu sync.Mutex
sess map[string]*session
passkeyMu sync.Mutex
passkeySessions map[string]*webauthn.SessionData
webAuthn *webauthn.WebAuthn
configPath string
}
const (
sessionCookieName = "sid"
sessionTTL = 7 * 24 * time.Hour
)
func NewAuthManager() (*AuthManager, error) {
am := &AuthManager{
sess: make(map[string]*session),
passkeySessions: make(map[string]*webauthn.SessionData),
}
// optional: persist config in file
// (ENV-only geht auch, aber setup/enable kann ENV nicht zurückschreiben)
am.configPath = strings.TrimSpace(os.Getenv("AUTH_CONFIG_FILE"))
if am.configPath == "" {
am.configPath = "data/auth.json"
}
// 1) erst Datei laden, wenn vorhanden
if err := am.loadFromFileIfExists(); err != nil {
return nil, err
}
// 2) ENV überschreibt (wenn gesetzt)
if err := am.loadFromEnvOverride(); err != nil {
return nil, err
}
// 3) sanity
am.confMu.Lock()
if strings.TrimSpace(am.conf.Username) == "" || strings.TrimSpace(am.conf.PasswordHash) == "" {
am.confMu.Unlock()
return nil, errors.New("AUTH_USER/AUTH_PASS_HASH fehlen (oder auth.json unvollständig)")
}
am.confMu.Unlock()
// 4) Passkey User-ID sicherstellen
if err := am.ensurePasskeyUserID(); err != nil {
return nil, err
}
// 5) WebAuthn initialisieren
if err := am.initWebAuthn(); err != nil {
return nil, err
}
return am, nil
}
func (am *AuthManager) loadFromFileIfExists() error {
p := strings.TrimSpace(am.configPath)
if p == "" {
return nil
}
abs, _ := resolvePathRelativeToApp(p)
if abs == "" {
abs = p
}
b, err := os.ReadFile(abs)
if err != nil {
if os.IsNotExist(err) {
// ✅ Datei fehlt: bootstrap erzeugen + Passwort ausgeben
return am.bootstrapAuthFile(abs)
}
return appErrorf("read auth config: %w", err)
}
var c authConfig
if err := json.Unmarshal(b, &c); err != nil {
return appErrorf("parse auth config: %w", err)
}
am.confMu.Lock()
am.conf = c
am.confMu.Unlock()
return nil
}
func (am *AuthManager) loadFromEnvOverride() error {
u := strings.TrimSpace(os.Getenv("AUTH_USER"))
pwh := strings.TrimSpace(os.Getenv("AUTH_PASS_HASH"))
sec := strings.TrimSpace(os.Getenv("AUTH_TOTP_SECRET"))
en := strings.TrimSpace(os.Getenv("AUTH_TOTP_ENABLED"))
am.confMu.Lock()
defer am.confMu.Unlock()
// nur überschreiben wenn gesetzt
if u != "" {
am.conf.Username = u
}
if pwh != "" {
am.conf.PasswordHash = pwh
}
if sec != "" {
am.conf.TOTPSecret = sec
}
if en != "" {
am.conf.TOTPEnabled = (en == "1" || strings.EqualFold(en, "true") || strings.EqualFold(en, "yes"))
}
return nil
}
func (am *AuthManager) persistConfBestEffort() {
p := strings.TrimSpace(am.configPath)
if p == "" {
return
}
abs, _ := resolvePathRelativeToApp(p)
if abs == "" {
abs = p
}
_ = os.MkdirAll(filepath.Dir(abs), 0o755)
am.confMu.Lock()
c := am.conf
am.confMu.Unlock()
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return
}
b = append(b, '\n')
_ = atomicWriteFileCompat(abs, b)
}
func atomicWriteFileCompat(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
func credentialIDString(id []byte) string {
return base64.RawURLEncoding.EncodeToString(id)
}
func shortCredentialID(id string) string {
id = strings.TrimSpace(id)
if len(id) <= 12 {
return id
}
return id[:6] + "…" + id[len(id)-6:]
}
func findPasskeyInfo(infos []passkeyInfo, credentialID string) (passkeyInfo, bool) {
for _, info := range infos {
if strings.TrimSpace(info.CredentialID) == credentialID {
return info, true
}
}
return passkeyInfo{}, false
}
func upsertPasskeyInfo(infos []passkeyInfo, next passkeyInfo) []passkeyInfo {
next.CredentialID = strings.TrimSpace(next.CredentialID)
if next.CredentialID == "" {
return infos
}
for i := range infos {
if strings.TrimSpace(infos[i].CredentialID) == next.CredentialID {
if strings.TrimSpace(next.Label) != "" {
infos[i].Label = next.Label
}
if strings.TrimSpace(next.CreatedAt) != "" {
infos[i].CreatedAt = next.CreatedAt
}
if strings.TrimSpace(next.LastUsedAt) != "" {
infos[i].LastUsedAt = next.LastUsedAt
}
return infos
}
}
return append(infos, next)
}
func generateRandomPassword() (string, error) {
// 24 bytes => 32 chars base64url (ohne =), gut zum Abtippen/Kopieren
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// wird nur aufgerufen, wenn auth.json nicht existiert
func (am *AuthManager) bootstrapAuthFile(abs string) error {
// Username: ENV bevorzugen, sonst "admin"
user := strings.TrimSpace(os.Getenv("AUTH_USER"))
if user == "" {
user = "admin"
}
// Wenn AUTH_PASS_HASH gesetzt ist, können wir KEIN Klartext-Passwort anzeigen.
// Dann schreiben wir die Datei "as-is" und loggen das entsprechend.
passHash := strings.TrimSpace(os.Getenv("AUTH_PASS_HASH"))
var plain string
if passHash == "" {
p, err := generateRandomPassword()
if err != nil {
return appErrorf("generate password: %w", err)
}
plain = p
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
if err != nil {
return appErrorf("bcrypt: %w", err)
}
passHash = string(h)
}
c := authConfig{
Username: user,
PasswordHash: passHash,
TOTPEnabled: false,
TOTPSecret: "",
}
// In-memory setzen
am.confMu.Lock()
am.conf = c
am.confMu.Unlock()
// Datei schreiben
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return appErrorf("mkdir auth dir: %w", err)
}
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return appErrorf("marshal auth config: %w", err)
}
b = append(b, '\n')
if err := atomicWriteFileCompat(abs, b); err != nil {
return appErrorf("write auth config: %w", err)
}
// Passwort in Konsole anzeigen (nur wenn wir es generiert haben)
if plain != "" {
appLogln("🔐 auth.json wurde neu erstellt:")
appLogln(" Datei: ", abs)
appLogln(" Username:", user)
appLogln(" Passwort:", plain)
appLogln(" Hinweis: Bitte sofort sichern/ändern das Passwort wird nur beim Erstellen angezeigt.")
} else {
appLogln("🔐 auth.json wurde neu erstellt (aus ENV AUTH_PASS_HASH):")
appLogln(" Datei: ", abs)
appLogln(" Username:", user)
appLogln(" Hinweis: Klartext-Passwort ist unbekannt (AUTH_PASS_HASH war gesetzt).")
}
return nil
}
func newSessionID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func isSecureRequest(r *http.Request) bool {
if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
return true
}
return r.TLS != nil
}
func setSessionCookie(w http.ResponseWriter, sid string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sid,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
Expires: time.Now().Add(sessionTTL),
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Expires: time.Unix(0, 0),
MaxAge: -1,
SameSite: http.SameSiteLaxMode,
})
}
func (am *AuthManager) getSession(r *http.Request) (*session, string) {
c, err := r.Cookie(sessionCookieName)
if err != nil || strings.TrimSpace(c.Value) == "" {
return nil, ""
}
sid := strings.TrimSpace(c.Value)
am.sessMu.Lock()
defer am.sessMu.Unlock()
s := am.sess[sid]
if s == nil {
return nil, sid
}
if time.Now().After(s.ExpiresAt) {
delete(am.sess, sid)
return nil, sid
}
s.LastSeenAt = time.Now()
s.ExpiresAt = time.Now().Add(sessionTTL) // sliding
return s, sid
}
// ---------------------------------------------------------------------
// Middleware: schützt /api/* (außer /api/auth/*) + optional Frontend (/)
// ---------------------------------------------------------------------
// protectFrontend=true => auch "/" (SPA) schützen.
// allowPaths sind public prefixes wie "/api/auth/" oder "/login".
func requireAuth(am *AuthManager, next http.Handler, protectFrontend bool, allowPaths ...string) http.Handler {
allowed := func(p string) bool {
for _, a := range allowPaths {
if a != "" && strings.HasPrefix(p, a) {
return true
}
}
return false
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
// allowlist (z.B. "/login", "/assets/", "/favicon.ico")
if allowed(p) {
next.ServeHTTP(w, r)
return
}
// /api/auth/* immer frei (Login/Logout/Me/2FA Setup/Enable)
if strings.HasPrefix(p, "/api/auth/") {
next.ServeHTTP(w, r)
return
}
// wenn nicht Frontend-schutz: alles außerhalb /api/ durchlassen
if !protectFrontend && !strings.HasPrefix(p, "/api/") {
next.ServeHTTP(w, r)
return
}
s, _ := am.getSession(r)
if s == nil || !s.Authed {
// API: weiterhin 401 (kein Redirect)
if strings.HasPrefix(p, "/api/") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Frontend: Redirect auf /login?next=...
if protectFrontend {
nextPath := r.URL.RequestURI() // inkl. Query
if nextPath == "" || !strings.HasPrefix(nextPath, "/") || strings.HasPrefix(nextPath, "/login") {
nextPath = "/"
}
http.Redirect(w, r, "/login?next="+url.QueryEscape(nextPath), http.StatusFound)
return
}
// falls protectFrontend=false: durchlassen
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// ---------------------------------------------------------------------
// Handler Factories (passen zu deinem registerRoutes())
// ---------------------------------------------------------------------
func authMeHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, _ := am.getSession(r)
am.confMu.Lock()
secretPresent := strings.TrimSpace(am.conf.TOTPSecret) != ""
totpFlag := am.conf.TOTPEnabled
totpEnabled := totpFlag && secretPresent
totpConfigured := totpEnabled
passkeyCount := len(am.conf.Passkeys)
totpDevice := map[string]any(nil)
if totpConfigured {
label := strings.TrimSpace(am.conf.TOTPInfo.Label)
if label == "" {
label = "Authenticator-App"
}
totpDevice = map[string]any{
"id": "totp",
"label": label,
"type": "Authenticator-App",
"configuredAt": strings.TrimSpace(am.conf.TOTPInfo.ConfiguredAt),
"active": totpEnabled,
}
}
passkeys := make([]map[string]any, 0, len(am.conf.Passkeys))
for i, cred := range am.conf.Passkeys {
credentialID := credentialIDString(cred.ID)
info, ok := findPasskeyInfo(am.conf.PasskeyInfos, credentialID)
label := strings.TrimSpace(info.Label)
if label == "" {
label = "Passkey " + shortCredentialID(credentialID)
}
createdAt := strings.TrimSpace(info.CreatedAt)
lastUsedAt := strings.TrimSpace(info.LastUsedAt)
passkeys = append(passkeys, map[string]any{
"id": credentialID,
"label": label,
"type": "Passkey",
"createdAt": createdAt,
"lastUsedAt": lastUsedAt,
"signCount": cred.Authenticator.SignCount,
"backupState": cred.Flags.BackupState,
"backupEligible": cred.Flags.BackupEligible,
"index": i + 1,
"known": ok,
})
}
am.confMu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"authenticated": s != nil && s.Authed,
"pending2fa": s != nil && s.Pending2FA,
"totpEnabled": totpEnabled,
"totpConfigured": totpConfigured,
"totpFlag": totpFlag,
"totpDevice": totpDevice,
"passkeyConfigured": passkeyCount > 0,
"passkeyCount": passkeyCount,
"passkeys": passkeys,
})
}
}
func authLogoutHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, sid := am.getSession(r)
if sid != "" {
am.sessMu.Lock()
delete(am.sess, sid)
am.sessMu.Unlock()
}
clearSessionCookie(w)
w.WriteHeader(http.StatusNoContent)
}
}
type loginReq struct {
Username string `json:"username"`
Password string `json:"password"`
}
func authLoginHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
var req loginReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
u := strings.TrimSpace(req.Username)
p := req.Password
am.confMu.Lock()
wantU := am.conf.Username
hash := am.conf.PasswordHash
totpRequired := am.conf.TOTPEnabled && strings.TrimSpace(am.conf.TOTPSecret) != ""
am.confMu.Unlock()
if u == "" || p == "" || u != wantU {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(p)); err != nil {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
sid, err := newSessionID()
if err != nil {
http.Error(w, "session error", http.StatusInternalServerError)
return
}
now := time.Now()
s := &session{
User: u,
CreatedAt: now,
LastSeenAt: now,
ExpiresAt: now.Add(sessionTTL),
}
if totpRequired {
s.Pending2FA = true
s.Authed = false
} else {
s.Pending2FA = false
s.Authed = true
}
am.sessMu.Lock()
am.sess[sid] = s
am.sessMu.Unlock()
setSessionCookie(w, sid, isSecureRequest(r))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"totpRequired": totpRequired,
})
}
}
type codeReq struct {
Code string `json:"code"`
}
// /api/auth/2fa/setup -> secret + otpauth (nur wenn bereits authed)
func auth2FASetupHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
s, _ := am.getSession(r)
if s == nil || !s.Authed {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// 1) aktuellen State lesen (ohne lange Lock-Haltezeit)
am.confMu.Lock()
if strings.TrimSpace(am.conf.TOTPSecret) != "" {
am.confMu.Unlock()
http.Error(w, "2fa already configured", http.StatusConflict)
return
}
username := am.conf.Username
am.confMu.Unlock()
// 2) Secret erzeugen
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "Recorder",
AccountName: username,
Period: 30,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
http.Error(w, "totp generate failed", http.StatusInternalServerError)
return
}
// 3) setzen (und nochmal prüfen, falls parallel jemand war)
am.confMu.Lock()
if strings.TrimSpace(am.conf.TOTPSecret) != "" {
am.confMu.Unlock()
http.Error(w, "2fa already configured", http.StatusConflict)
return
}
am.conf.TOTPSecret = key.Secret()
am.conf.TOTPEnabled = false // erst nach Enable aktiv
am.conf.TOTPInfo = totpInfo{
Label: "Authenticator-App",
ConfiguredAt: time.Now().Format(time.RFC3339),
}
am.confMu.Unlock()
// 4) persist ohne Lock
go am.persistConfBestEffort()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"secret": key.Secret(),
"otpauth": key.URL(),
})
}
}
// /api/auth/2fa/enable -> Code prüfen
// - wenn Session pending2fa: verifiziert Login (setzt Authed=true)
// - wenn Session authed und totp noch nicht enabled: aktiviert 2FA global
func auth2FAEnableHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
s, sid := am.getSession(r)
if s == nil || sid == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req codeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
code := strings.TrimSpace(req.Code)
if code == "" {
http.Error(w, "code required", http.StatusBadRequest)
return
}
// Secret holen
am.confMu.Lock()
secret := strings.TrimSpace(am.conf.TOTPSecret)
wasEnabled := am.conf.TOTPEnabled
am.confMu.Unlock()
if secret == "" {
http.Error(w, "2fa not configured", http.StatusBadRequest)
return
}
if !totp.Validate(code, secret) {
http.Error(w, "invalid code", http.StatusUnauthorized)
return
}
now := time.Now()
// 1) Session verifizieren (auch wenn pending2fa)
am.sessMu.Lock()
cur := am.sess[sid]
if cur == nil {
am.sessMu.Unlock()
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// optional harte Regel: nur wenn pending2fa oder bereits authed
if !cur.Authed && !cur.Pending2FA {
am.sessMu.Unlock()
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
cur.Pending2FA = false
cur.Authed = true
cur.LastSeenAt = now
cur.ExpiresAt = now.Add(sessionTTL)
am.sessMu.Unlock()
// 2) 2FA global aktivieren, falls noch nicht enabled
changed := false
if !wasEnabled {
am.confMu.Lock()
if !am.conf.TOTPEnabled {
am.conf.TOTPEnabled = true
changed = true
}
am.confMu.Unlock()
}
if changed {
go am.persistConfBestEffort()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
}
type passwordChangeReq struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
func authPasswordChangeHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
s, sid := am.getSession(r)
if s == nil || !s.Authed || sid == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req passwordChangeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
currentPassword := strings.TrimSpace(req.CurrentPassword)
newPassword := strings.TrimSpace(req.NewPassword)
if currentPassword == "" {
http.Error(w, "current password required", http.StatusBadRequest)
return
}
if len(newPassword) < 8 {
http.Error(w, "new password must be at least 8 characters", http.StatusBadRequest)
return
}
am.confMu.Lock()
oldHash := am.conf.PasswordHash
am.confMu.Unlock()
if err := bcrypt.CompareHashAndPassword([]byte(oldHash), []byte(currentPassword)); err != nil {
http.Error(w, "invalid current password", http.StatusUnauthorized)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "bcrypt failed", http.StatusInternalServerError)
return
}
am.confMu.Lock()
am.conf.PasswordHash = string(hash)
am.confMu.Unlock()
go am.persistConfBestEffort()
// Alle anderen Sessions abmelden, aktuelle Session behalten.
am.sessMu.Lock()
cur := am.sess[sid]
am.sess = map[string]*session{}
if cur != nil {
am.sess[sid] = cur
}
am.sessMu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
}
type disable2FAReq struct {
CurrentPassword string `json:"currentPassword"`
Code string `json:"code"`
}
func auth2FADisableHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
s, _ := am.getSession(r)
if s == nil || !s.Authed {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req disable2FAReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
currentPassword := strings.TrimSpace(req.CurrentPassword)
code := strings.TrimSpace(req.Code)
if currentPassword == "" {
http.Error(w, "current password required", http.StatusBadRequest)
return
}
am.confMu.Lock()
hash := am.conf.PasswordHash
secret := strings.TrimSpace(am.conf.TOTPSecret)
totpEnabled := am.conf.TOTPEnabled && secret != ""
am.confMu.Unlock()
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(currentPassword)); err != nil {
http.Error(w, "invalid current password", http.StatusUnauthorized)
return
}
if totpEnabled {
if code == "" {
http.Error(w, "2fa code required", http.StatusBadRequest)
return
}
if !totp.Validate(code, secret) {
http.Error(w, "invalid code", http.StatusUnauthorized)
return
}
}
am.confMu.Lock()
am.conf.TOTPEnabled = false
am.conf.TOTPSecret = ""
am.conf.TOTPInfo = totpInfo{}
am.confMu.Unlock()
go am.persistConfBestEffort()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
}
type passkeyDeleteReq struct {
CredentialID string `json:"credentialId"`
}
func authPasskeyDeleteHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "DELETE required", http.StatusMethodNotAllowed)
return
}
s, _ := am.getSession(r)
if s == nil || !s.Authed {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req passkeyDeleteReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
credentialID := strings.TrimSpace(req.CredentialID)
if credentialID == "" {
http.Error(w, "credentialId required", http.StatusBadRequest)
return
}
am.confMu.Lock()
nextPasskeys := make([]webauthn.Credential, 0, len(am.conf.Passkeys))
removed := false
for _, cred := range am.conf.Passkeys {
if credentialIDString(cred.ID) == credentialID {
removed = true
continue
}
nextPasskeys = append(nextPasskeys, cred)
}
if !removed {
am.confMu.Unlock()
http.Error(w, "passkey not found", http.StatusNotFound)
return
}
nextInfos := make([]passkeyInfo, 0, len(am.conf.PasskeyInfos))
for _, info := range am.conf.PasskeyInfos {
if strings.TrimSpace(info.CredentialID) == credentialID {
continue
}
nextInfos = append(nextInfos, info)
}
am.conf.Passkeys = nextPasskeys
am.conf.PasskeyInfos = nextInfos
am.confMu.Unlock()
go am.persistConfBestEffort()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
}
func auth2FACancelSetupHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
s, _ := am.getSession(r)
if s == nil || !s.Authed {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
am.confMu.Lock()
// Nur ein unfertiges Setup entfernen.
// Wenn 2FA bereits aktiv ist, darf dieser Endpoint nichts deaktivieren.
if !am.conf.TOTPEnabled {
am.conf.TOTPSecret = ""
am.conf.TOTPInfo = totpInfo{}
}
am.confMu.Unlock()
go am.persistConfBestEffort()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
}