645 lines
16 KiB
Go
645 lines
16 KiB
Go
// 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})
|
||
}
|
||
}
|