package api import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "time" "github.com/cynfo/rmm-backend/db" "github.com/cynfo/rmm-backend/models" "github.com/golang-jwt/jwt/v5" "github.com/pquerna/otp/totp" "golang.org/x/crypto/bcrypt" ) type AuthHandler struct { db *db.Database secret []byte } type JWTClaims struct { UserID int `json:"user_id"` Username string `json:"username"` Role string `json:"role"` TOTPPending bool `json:"totp_pending,omitempty"` // temp token während 2FA-Schritt jwt.RegisteredClaims } func NewAuthHandler(database *db.Database, jwtSecret string) *AuthHandler { secret := jwtSecret if secret == "" { b := make([]byte, 32) rand.Read(b) secret = hex.EncodeToString(b) log.Println("JWT Secret automatisch generiert (nicht persistent)") } return &AuthHandler{db: database, secret: []byte(secret)} } func (a *AuthHandler) EnsureDefaultAdmin() { count, err := a.db.UserCount() if err != nil { log.Printf("User-Count Fehler: %v", err) return } if count == 0 { _, err := a.db.CreateUser("admin", "admin", "Administrator", "admin") if err != nil { log.Printf("Default-Admin anlegen fehlgeschlagen: %v", err) } else { log.Println("Default-Admin angelegt (admin/admin) - bitte Passwort aendern!") } } } func (a *AuthHandler) GenerateToken(user *models.User) (string, time.Time, error) { expiresAt := time.Now().Add(8 * time.Hour) claims := JWTClaims{ UserID: user.ID, Username: user.Username, Role: user.Role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(expiresAt), IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := token.SignedString(a.secret) return signed, expiresAt, err } // Kurzlebiger Token nur für den TOTP-Schritt (5 Minuten, kein echter API-Zugriff) func (a *AuthHandler) generateTOTPPendingToken(user *models.User) (string, error) { claims := JWTClaims{ UserID: user.ID, Username: user.Username, Role: user.Role, TOTPPending: true, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(a.secret) } func (a *AuthHandler) ValidateToken(tokenStr string) (*JWTClaims, error) { token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return a.secret, nil }) if err != nil { return nil, err } claims, ok := token.Claims.(*JWTClaims) if !ok || !token.Valid { return nil, fmt.Errorf("invalid token") } // Echte API-Calls mit Pending-Token ablehnen if claims.TOTPPending { return nil, fmt.Errorf("totp pending token not valid for api access") } return claims, nil } // POST /api/v1/auth/login func (a *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { var req models.LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungueltige Anfrage") return } user, err := a.db.GetUserByUsername(req.Username) if err != nil || user == nil { writeError(w, http.StatusUnauthorized, "Ungueltige Anmeldedaten") return } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { writeError(w, http.StatusUnauthorized, "Ungueltige Anmeldedaten") return } // 2FA aktiv → Pending-Token zurückgeben if user.TOTPEnabled { pendingToken, err := a.generateTOTPPendingToken(user) if err != nil { writeError(w, http.StatusInternalServerError, "Token-Generierung fehlgeschlagen") return } go a.db.AddAuditLog(user.Username, "login_2fa_pending", "user", "", user.Username, "", r.RemoteAddr) writeJSON(w, http.StatusOK, models.LoginResponse{ TOTPRequired: true, TOTPToken: pendingToken, }) return } // Kein 2FA → direkt JWT token, expiresAt, err := a.GenerateToken(user) if err != nil { writeError(w, http.StatusInternalServerError, "Token-Generierung fehlgeschlagen") return } a.db.UpdateUserLastLogin(user.ID) go a.db.AddAuditLog(user.Username, "login", "user", "", user.Username, "", r.RemoteAddr) writeJSON(w, http.StatusOK, models.LoginResponse{ Token: token, ExpiresAt: expiresAt.Format(time.RFC3339), User: user, }) } // POST /api/v1/auth/2fa/validate — zweiter Schritt beim Login func (a *AuthHandler) TOTPValidate(w http.ResponseWriter, r *http.Request) { var req models.TOTPValidateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungueltige Anfrage") return } // Pending-Token parsen (ohne ValidateToken, der lehnt Pending-Tokens ab) jwtToken, err := jwt.ParseWithClaims(req.TOTPToken, &JWTClaims{}, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method") } return a.secret, nil }) if err != nil { writeError(w, http.StatusUnauthorized, "Ungueltige Session") return } claims, ok := jwtToken.Claims.(*JWTClaims) if !ok || !jwtToken.Valid || !claims.TOTPPending { writeError(w, http.StatusUnauthorized, "Ungueltige Session") return } user, err := a.db.GetUserByID(claims.UserID) if err != nil || user == nil || !user.TOTPEnabled { writeError(w, http.StatusUnauthorized, "Ungueltige Session") return } if !totp.Validate(req.Code, user.TOTPSecret) { writeError(w, http.StatusUnauthorized, "Ungültiger 2FA-Code") return } token, expiresAt, err := a.GenerateToken(user) if err != nil { writeError(w, http.StatusInternalServerError, "Token-Generierung fehlgeschlagen") return } a.db.UpdateUserLastLogin(user.ID) go a.db.AddAuditLog(user.Username, "login_2fa_success", "user", "", user.Username, "", r.RemoteAddr) writeJSON(w, http.StatusOK, models.LoginResponse{ Token: token, ExpiresAt: expiresAt.Format(time.RFC3339), User: user, }) } // POST /api/v1/auth/2fa/setup — Secret generieren, QR-URI zurückgeben func (a *AuthHandler) TOTPSetup(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(UserContextKey).(*JWTClaims) if !ok { writeError(w, http.StatusUnauthorized, "Nicht authentifiziert") return } user, err := a.db.GetUserByID(claims.UserID) if err != nil || user == nil { writeError(w, http.StatusNotFound, "User nicht gefunden") return } key, err := totp.Generate(totp.GenerateOpts{ Issuer: "cynfo RMM", AccountName: user.Username, Period: 30, Digits: 6, }) if err != nil { writeError(w, http.StatusInternalServerError, "TOTP-Generierung fehlgeschlagen") return } if err := a.db.SetTOTPSecret(user.ID, key.Secret()); err != nil { writeError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen") return } writeJSON(w, http.StatusOK, models.TOTPSetupResponse{ Secret: key.Secret(), URI: key.URL(), }) } // POST /api/v1/auth/2fa/confirm — ersten Code prüfen und 2FA aktivieren func (a *AuthHandler) TOTPConfirm(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(UserContextKey).(*JWTClaims) if !ok { writeError(w, http.StatusUnauthorized, "Nicht authentifiziert") return } var req models.TOTPConfirmRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungueltige Anfrage") return } user, err := a.db.GetUserByID(claims.UserID) if err != nil || user == nil || user.TOTPSecret == "" { writeError(w, http.StatusBadRequest, "Kein Setup gefunden — bitte zuerst /2fa/setup aufrufen") return } if !totp.Validate(req.Code, user.TOTPSecret) { writeError(w, http.StatusUnauthorized, "Ungültiger Code") return } if err := a.db.EnableTOTP(user.ID); err != nil { writeError(w, http.StatusInternalServerError, "Aktivierung fehlgeschlagen") return } go a.db.AddAuditLog(claims.Username, "2fa_enabled", "user", "", claims.Username, "", r.RemoteAddr) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // POST /api/v1/auth/2fa/disable — 2FA deaktivieren (Passwort + aktueller Code nötig) func (a *AuthHandler) TOTPDisable(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(UserContextKey).(*JWTClaims) if !ok { writeError(w, http.StatusUnauthorized, "Nicht authentifiziert") return } var req models.TOTPDisableRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungueltige Anfrage") return } user, err := a.db.GetUserByID(claims.UserID) if err != nil || user == nil { writeError(w, http.StatusNotFound, "User nicht gefunden") return } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { writeError(w, http.StatusUnauthorized, "Falsches Passwort") return } if user.TOTPEnabled { if !totp.Validate(req.Code, user.TOTPSecret) { writeError(w, http.StatusUnauthorized, "Ungültiger 2FA-Code") return } } if err := a.db.DisableTOTP(user.ID); err != nil { writeError(w, http.StatusInternalServerError, "Deaktivierung fehlgeschlagen") return } go a.db.AddAuditLog(claims.Username, "2fa_disabled", "user", "", claims.Username, "", r.RemoteAddr) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // GET /api/v1/auth/me func (a *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(UserContextKey).(*JWTClaims) if !ok { writeError(w, http.StatusUnauthorized, "Nicht authentifiziert") return } user, err := a.db.GetUserByUsername(claims.Username) if err != nil || user == nil { writeError(w, http.StatusNotFound, "User nicht gefunden") return } writeJSON(w, http.StatusOK, user) } // SetupAuthRoutes registriert Auth-Routes (ohne Auth-Middleware) func (a *AuthHandler) SetupAuthRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/v1/auth/login", a.Login) mux.HandleFunc("POST /api/v1/auth/2fa/validate", a.TOTPValidate) }