nsfwapp/backend/auth.go
2026-02-09 12:29:19 +01:00

645 lines
16 KiB
Go
Raw Permalink 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"
"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)
// ✅ Konfiguration atomar lesen
am.confMu.Lock()
secretPresent := strings.TrimSpace(am.conf.TOTPSecret) != ""
totpFlag := am.conf.TOTPEnabled
totpEnabled := totpFlag && secretPresent
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,
// ✅ „wirklich aktiv“ (Flag + Secret vorhanden)
"totpEnabled": totpEnabled,
// ✅ Zusatzinfos für UI/Debug:
// Secret existiert schon (Setup gemacht), aber evtl. noch nicht enabled
"totpConfigured": secretPresent,
// reiner Flag-Status (kann true sein, obwohl Secret fehlt)
"totpFlag": totpFlag,
})
}
}
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.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})
}
}