// backend/auth.go package main import ( "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "net/url" "os" "path/filepath" "strings" "sync" "time" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" "golang.org/x/crypto/bcrypt" ) type authConfig struct { Username string `json:"username"` PasswordHash string `json:"passwordHash"` // bcrypt hash TOTPEnabled bool `json:"totpEnabled"` TOTPSecret string `json:"totpSecret"` // base32 } // 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 configPath string } const ( sessionCookieName = "sid" sessionTTL = 7 * 24 * time.Hour ) func NewAuthManager() (*AuthManager, error) { am := &AuthManager{ sess: make(map[string]*session), } // 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() defer am.confMu.Unlock() if strings.TrimSpace(am.conf.Username) == "" || strings.TrimSpace(am.conf.PasswordHash) == "" { return nil, errors.New("AUTH_USER/AUTH_PASS_HASH fehlen (oder auth.json unvollständig)") } 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 fmt.Errorf("read auth config: %w", err) } var c authConfig if err := json.Unmarshal(b, &c); err != nil { return fmt.Errorf("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 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 fmt.Errorf("generate password: %w", err) } plain = p h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("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 fmt.Errorf("mkdir auth dir: %w", err) } b, err := json.MarshalIndent(c, "", " ") if err != nil { return fmt.Errorf("marshal auth config: %w", err) } b = append(b, '\n') if err := atomicWriteFileCompat(abs, b); err != nil { return fmt.Errorf("write auth config: %w", err) } // Passwort in Konsole anzeigen (nur wenn wir es generiert haben) if plain != "" { fmt.Println("🔐 auth.json wurde neu erstellt:") fmt.Println(" Datei: ", abs) fmt.Println(" Username:", user) fmt.Println(" Passwort:", plain) fmt.Println(" Hinweis: Bitte sofort sichern/ändern – das Passwort wird nur beim Erstellen angezeigt.") } else { fmt.Println("🔐 auth.json wurde neu erstellt (aus ENV AUTH_PASS_HASH):") fmt.Println(" Datei: ", abs) fmt.Println(" Username:", user) fmt.Println(" 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() totpEnabled := am.conf.TOTPEnabled && strings.TrimSpace(am.conf.TOTPSecret) != "" am.confMu.Unlock() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "authenticated": s != nil && s.Authed, "pending2fa": s != nil && s.Pending2FA, "totpEnabled": totpEnabled, }) } } 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) { 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.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}) } }