Add Certificates page and Traefik API frontend client
This commit is contained in:
parent
9e4c612dcb
commit
7fc90524b5
40 changed files with 6585 additions and 359 deletions
425
backend/internal/api/handlers/handlers_rbac_test.go
Normal file
425
backend/internal/api/handlers/handlers_rbac_test.go
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/traefik/traefik-gui/backend/internal/api/middleware"
|
||||
"github.com/traefik/traefik-gui/backend/internal/auth"
|
||||
"github.com/traefik/traefik-gui/backend/internal/config/file"
|
||||
"github.com/traefik/traefik-gui/backend/internal/database/repositories"
|
||||
"github.com/traefik/traefik-gui/backend/internal/models"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on")
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
// create tables
|
||||
for _, q := range []string{
|
||||
`CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_login DATETIME)`,
|
||||
`CREATE TABLE sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, csrf_token TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL, CHECK (expires_at > created_at))`,
|
||||
`CREATE TABLE backups (id TEXT PRIMARY KEY, filename TEXT NOT NULL, content TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by TEXT NOT NULL, reason TEXT NOT NULL)`,
|
||||
`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||
} {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func createUser(t *testing.T, db *sql.DB, id, username, role, password string) *models.User {
|
||||
t.Helper()
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
_, err = db.Exec(`INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`, id, username, username+"@test.local", hash, role)
|
||||
if err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
return &models.User{ID: id, Username: username, Email: username + "@test.local", Role: role, PasswordHash: hash}
|
||||
}
|
||||
|
||||
func createSession(t *testing.T, db *sql.DB, userID string) *models.Session {
|
||||
t.Helper()
|
||||
sd, err := auth.NewSessionData(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("new session: %v", err)
|
||||
}
|
||||
id, err := auth.GenerateSessionID()
|
||||
if err != nil {
|
||||
t.Fatalf("gen id: %v", err)
|
||||
}
|
||||
sess := &models.Session{ID: id, UserID: userID, CSRFToken: sd.CSRFToken, CreatedAt: sd.CreatedAt, ExpiresAt: sd.ExpiresAt}
|
||||
repo := repositories.NewSessionRepository(db)
|
||||
if err := repo.Create(sess); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T, db *sql.DB) (*gin.Engine, *file.Service) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
dir, err := os.MkdirTemp("", "cfg-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tmp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
// also ensure backups dir is created by service; we use filepath join
|
||||
svc, err := file.NewService(filepath.Join(dir, "dynamic"), db)
|
||||
if err != nil {
|
||||
t.Fatalf("service: %v", err)
|
||||
}
|
||||
userRepo := repositories.NewUserRepository(db)
|
||||
sessionRepo := repositories.NewSessionRepository(db)
|
||||
authMw := middleware.NewAuthMiddleware(sessionRepo, userRepo)
|
||||
authHandler := NewAuthHandler(userRepo, sessionRepo, "test-secret-32-chars-minimum-length", "", false)
|
||||
fileHandler := NewFileConfigHandler(svc)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.CORSMiddleware("http://localhost:5173"))
|
||||
// auth routes
|
||||
authGroup := r.Group("/api/auth")
|
||||
{
|
||||
authGroup.POST("/login", authHandler.Login)
|
||||
authGroup.POST("/logout", authMw.RequireAuth(), authMw.RequireCSRF(), authHandler.Logout)
|
||||
authGroup.GET("/me", authMw.RequireAuth(), authHandler.Me)
|
||||
authGroup.GET("/csrf", authMw.RequireAuth(), authHandler.GetCSRF)
|
||||
}
|
||||
apiGroup := r.Group("/api")
|
||||
apiGroup.Use(authMw.RequireAuth())
|
||||
apiGroup.Use(authMw.RequireCSRF())
|
||||
{
|
||||
cfg := apiGroup.Group("/config")
|
||||
{
|
||||
cfg.GET("/files", fileHandler.ListFiles)
|
||||
cfg.GET("/files/:name", fileHandler.GetFile)
|
||||
cfg.GET("/history", fileHandler.History)
|
||||
cfg.POST("/preview", fileHandler.Preview)
|
||||
cfg.POST("/validate", fileHandler.Validate)
|
||||
cfg.POST("/apply", fileHandler.Apply)
|
||||
cfg.POST("/rollback", fileHandler.Rollback)
|
||||
}
|
||||
}
|
||||
return r, svc
|
||||
}
|
||||
|
||||
func doRequest(r *gin.Engine, method, path string, body interface{}, cookies []*http.Cookie, csrf string) *httptest.ResponseRecorder {
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if csrf != "" {
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
}
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
// Set origin for CORS
|
||||
req.Header.Set("Origin", "http://localhost:5173")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
const validYAML = `http:
|
||||
routers:
|
||||
r1:
|
||||
rule: "Host(` + "`a.com`" + `)"
|
||||
service: s1
|
||||
services:
|
||||
s1:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8080"
|
||||
`
|
||||
|
||||
func TestRBAC_ViewerCannotPreviewValidateApplyRollback(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
viewer := createUser(t, db, "viewer-id", "viewer", "viewer", "viewerpass123")
|
||||
sess := createSession(t, db, viewer.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
body interface{}
|
||||
}{
|
||||
{"/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}},
|
||||
{"/api/config/validate", map[string]interface{}{"filename": "app.yml", "content": validYAML}},
|
||||
{"/api/config/apply", map[string]interface{}{"filename": "app.yml", "content": validYAML, "confirm": true}},
|
||||
{"/api/config/rollback", map[string]interface{}{"filename": "app.yml"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
w := doRequest(r, "POST", tc.path, tc.body, []*http.Cookie{cookie}, sess.CSRFToken)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("viewer %s expected 403, got %d body %s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
// Rotate happened even on forbidden? Should not rotate on forbidden, check not needed
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBAC_OperatorCanPreviewValidateApplyButNotRollback(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
op := createUser(t, db, "op-id", "operator", "operator", "operatorpass123")
|
||||
sess := createSession(t, db, op.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
|
||||
// preview should succeed (valid)
|
||||
w := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, sess.CSRFToken)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("operator preview expected 200, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// need to fetch rotated token from header for next request
|
||||
newCSRF := w.Header().Get("X-CSRF-Token")
|
||||
if newCSRF == "" {
|
||||
newCSRF = sess.CSRFToken // fallback if not rotated (but should be)
|
||||
}
|
||||
// update sess token for subsequent requests - fetch from DB
|
||||
repo := repositories.NewSessionRepository(db)
|
||||
updated, _ := repo.GetByID(sess.ID)
|
||||
if updated != nil {
|
||||
newCSRF = updated.CSRFToken
|
||||
}
|
||||
|
||||
w = doRequest(r, "POST", "/api/config/validate", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, newCSRF)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("operator validate expected 200, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
updated, _ = repo.GetByID(sess.ID)
|
||||
newCSRF = updated.CSRFToken
|
||||
|
||||
w = doRequest(r, "POST", "/api/config/apply", map[string]interface{}{"filename": "app.yml", "content": validYAML, "confirm": true}, []*http.Cookie{cookie}, newCSRF)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("operator apply expected 200, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
updated, _ = repo.GetByID(sess.ID)
|
||||
newCSRF = updated.CSRFToken
|
||||
|
||||
w = doRequest(r, "POST", "/api/config/rollback", map[string]interface{}{"filename": "app.yml"}, []*http.Cookie{cookie}, newCSRF)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("operator rollback expected 403, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBAC_AdminCanAll(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
admin := createUser(t, db, "admin-id", "admin", "admin", "adminpass12345")
|
||||
sess := createSession(t, db, admin.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
repo := repositories.NewSessionRepository(db)
|
||||
|
||||
csrf := sess.CSRFToken
|
||||
for _, tc := range []struct{ path string; body interface{} }{
|
||||
{"/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}},
|
||||
{"/api/config/validate", map[string]interface{}{"filename": "app.yml", "content": validYAML}},
|
||||
} {
|
||||
w := doRequest(r, "POST", tc.path, tc.body, []*http.Cookie{cookie}, csrf)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("admin %s expected 200, got %d %s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
updated, _ := repo.GetByID(sess.ID)
|
||||
csrf = updated.CSRFToken
|
||||
}
|
||||
// apply
|
||||
w := doRequest(r, "POST", "/api/config/apply", map[string]interface{}{"filename": "app.yml", "content": validYAML, "confirm": true}, []*http.Cookie{cookie}, csrf)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("admin apply 200 got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
updated, _ := repo.GetByID(sess.ID)
|
||||
csrf = updated.CSRFToken
|
||||
w = doRequest(r, "POST", "/api/config/rollback", map[string]interface{}{"filename": "app.yml"}, []*http.Cookie{cookie}, csrf)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("admin rollback 200 got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRF_MissingOrInvalidFails(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
admin := createUser(t, db, "admin2", "admin2", "admin", "adminpass12345")
|
||||
sess := createSession(t, db, admin.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
|
||||
// missing token
|
||||
w := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, "")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("missing CSRF should be 403, got %d", w.Code)
|
||||
}
|
||||
// invalid token
|
||||
w = doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, "bad-token")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("invalid CSRF should be 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRF_Rotation(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
admin := createUser(t, db, "admin3", "admin3", "admin", "adminpass12345")
|
||||
sess := createSession(t, db, admin.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
oldToken := sess.CSRFToken
|
||||
|
||||
w := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{cookie}, oldToken)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("preview failed %d", w.Code)
|
||||
}
|
||||
newToken := w.Header().Get("X-CSRF-Token")
|
||||
if newToken == "" {
|
||||
t.Fatalf("expected rotated token in header")
|
||||
}
|
||||
if newToken == oldToken {
|
||||
t.Fatalf("token should rotate")
|
||||
}
|
||||
// old token should now fail
|
||||
w2 := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app2.yml", "content": validYAML}, []*http.Cookie{cookie}, oldToken)
|
||||
if w2.Code != http.StatusForbidden {
|
||||
t.Fatalf("old token should be invalid after rotation, got %d", w2.Code)
|
||||
}
|
||||
// new token should succeed
|
||||
w3 := doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app2.yml", "content": validYAML}, []*http.Cookie{cookie}, newToken)
|
||||
if w3.Code != 200 {
|
||||
t.Fatalf("new token should succeed, got %d", w3.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutRequiresCSRF(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
admin := createUser(t, db, "admin4", "admin4", "admin", "adminpass12345")
|
||||
sess := createSession(t, db, admin.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
|
||||
// without CSRF
|
||||
w := doRequest(r, "POST", "/api/auth/logout", nil, []*http.Cookie{cookie}, "")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("logout without CSRF should be 403, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// with invalid
|
||||
w = doRequest(r, "POST", "/api/auth/logout", nil, []*http.Cookie{cookie}, "bad")
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("logout invalid CSRF 403, got %d", w.Code)
|
||||
}
|
||||
// with valid should succeed
|
||||
w = doRequest(r, "POST", "/api/auth/logout", nil, []*http.Cookie{cookie}, sess.CSRFToken)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("logout valid should be 200, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMe_ReturnsCSRF(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
admin := createUser(t, db, "admin5", "admin5", "admin", "adminpass12345")
|
||||
sess := createSession(t, db, admin.ID)
|
||||
cookie := &http.Cookie{Name: "traefik_gui_session", Value: sess.ID, Path: "/"}
|
||||
|
||||
// GET /me should return csrf_token
|
||||
w := doRequest(r, "GET", "/api/auth/me", nil, []*http.Cookie{cookie}, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("me expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode me: %v", err)
|
||||
}
|
||||
if resp["csrf_token"] == nil || resp["csrf_token"] == "" {
|
||||
t.Fatalf("expected csrf_token in me response, got %v", resp)
|
||||
}
|
||||
if resp["csrf_token"] != sess.CSRFToken {
|
||||
t.Fatalf("csrf_token mismatch expected %q got %q", sess.CSRFToken, resp["csrf_token"])
|
||||
}
|
||||
// Also test /csrf endpoint
|
||||
w = doRequest(r, "GET", "/api/auth/csrf", nil, []*http.Cookie{cookie}, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("csrf endpoint 200 got %d", w.Code)
|
||||
}
|
||||
var csrfResp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&csrfResp)
|
||||
if csrfResp["csrf_token"] != sess.CSRFToken {
|
||||
t.Fatalf("csrf endpoint mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAndPostWithCSRF(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
r, _ := newTestRouter(t, db)
|
||||
_ = createUser(t, db, "login-user", "loginuser", "admin", "securepass12345")
|
||||
// perform login
|
||||
w := doRequest(r, "POST", "/api/auth/login", map[string]string{"username": "loginuser", "password": "securepass12345"}, nil, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("login failed %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var loginResp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&loginResp)
|
||||
csrf, _ := loginResp["csrf_token"].(string)
|
||||
if csrf == "" {
|
||||
t.Fatalf("login should return csrf_token")
|
||||
}
|
||||
// extract cookie
|
||||
var sessCookie *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == "traefik_gui_session" {
|
||||
sessCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessCookie == nil {
|
||||
t.Fatalf("no session cookie")
|
||||
}
|
||||
// POST preview with csrf from login
|
||||
w = doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app.yml", "content": validYAML}, []*http.Cookie{sessCookie}, csrf)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("post after login should succeed, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Simulate reload: GET /me to get csrf
|
||||
w = doRequest(r, "GET", "/api/auth/me", nil, []*http.Cookie{sessCookie}, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("reload me failed %d", w.Code)
|
||||
}
|
||||
var me map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&me)
|
||||
csrf2, _ := me["csrf_token"].(string)
|
||||
if csrf2 == "" {
|
||||
t.Fatalf("me should return csrf")
|
||||
}
|
||||
// Use csrf2 for next POST (might be same as rotated? Need to fetch updated token after previous POST rotation)
|
||||
// The preview POST rotated token, so csrf2 should be the rotated one
|
||||
// Actually we already fetched the rotated token via header, but /me should return current
|
||||
// Get repository current token
|
||||
repo := repositories.NewSessionRepository(db)
|
||||
sess, _ := repo.GetByID(sessCookie.Value)
|
||||
if sess.CSRFToken != csrf2 {
|
||||
t.Fatalf("me csrf should match DB %q vs %q", sess.CSRFToken, csrf2)
|
||||
}
|
||||
w = doRequest(r, "POST", "/api/config/preview", map[string]interface{}{"filename": "app2.yml", "content": validYAML}, []*http.Cookie{sessCookie}, csrf2)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("post after reload should succeed, got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue