diff --git a/backend/internal/api/handlers/auth_handlers.go b/backend/internal/api/handlers/auth_handlers.go deleted file mode 100644 index 3983f2b3..00000000 --- a/backend/internal/api/handlers/auth_handlers.go +++ /dev/null @@ -1,552 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -// AuthUserHandler handles auth user operations -type AuthUserHandler struct { - db *gorm.DB -} - -// NewAuthUserHandler creates a new auth user handler -func NewAuthUserHandler(db *gorm.DB) *AuthUserHandler { - return &AuthUserHandler{db: db} -} - -// List returns all auth users -func (h *AuthUserHandler) List(c *gin.Context) { - var users []models.AuthUser - if err := h.db.Order("created_at desc").Find(&users).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, users) -} - -// Get returns a single auth user by UUID -func (h *AuthUserHandler) Get(c *gin.Context) { - uuid := c.Param("uuid") - var user models.AuthUser - if err := h.db.Where("uuid = ?", uuid).First(&user).Error; err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, user) -} - -// CreateRequest represents the request body for creating an auth user -type CreateAuthUserRequest struct { - Username string `json:"username" binding:"required"` - Email string `json:"email" binding:"required,email"` - Name string `json:"name"` - Password string `json:"password" binding:"required,min=8"` - Roles string `json:"roles"` - MFAEnabled bool `json:"mfa_enabled"` - AdditionalEmails string `json:"additional_emails"` -} - -// Create creates a new auth user -func (h *AuthUserHandler) Create(c *gin.Context) { - var req CreateAuthUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - user := models.AuthUser{ - Username: req.Username, - Email: req.Email, - Name: req.Name, - Roles: req.Roles, - MFAEnabled: req.MFAEnabled, - AdditionalEmails: req.AdditionalEmails, - Enabled: true, - } - - if err := user.SetPassword(req.Password); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) - return - } - - if err := h.db.Create(&user).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, user) -} - -// UpdateRequest represents the request body for updating an auth user -type UpdateAuthUserRequest struct { - Email *string `json:"email,omitempty"` - Name *string `json:"name,omitempty"` - Password *string `json:"password,omitempty"` - Roles *string `json:"roles,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - MFAEnabled *bool `json:"mfa_enabled,omitempty"` - AdditionalEmails *string `json:"additional_emails,omitempty"` -} - -// Update updates an existing auth user -func (h *AuthUserHandler) Update(c *gin.Context) { - uuid := c.Param("uuid") - var user models.AuthUser - if err := h.db.Where("uuid = ?", uuid).First(&user).Error; err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - var req UpdateAuthUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if req.Email != nil { - user.Email = *req.Email - } - if req.Name != nil { - user.Name = *req.Name - } - if req.Password != nil { - if err := user.SetPassword(*req.Password); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) - return - } - } - if req.Roles != nil { - user.Roles = *req.Roles - } - if req.Enabled != nil { - user.Enabled = *req.Enabled - } - if req.MFAEnabled != nil { - user.MFAEnabled = *req.MFAEnabled - } - if req.AdditionalEmails != nil { - user.AdditionalEmails = *req.AdditionalEmails - } - - if err := h.db.Save(&user).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, user) -} - -// Delete deletes an auth user -func (h *AuthUserHandler) Delete(c *gin.Context) { - uuid := c.Param("uuid") - - // Prevent deletion of the last admin - var user models.AuthUser - if err := h.db.Where("uuid = ?", uuid).First(&user).Error; err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Check if this is an admin user - if user.HasRole("admin") { - var adminCount int64 - h.db.Model(&models.AuthUser{}).Where("roles LIKE ?", "%admin%").Count(&adminCount) - if adminCount <= 1 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete the last admin user"}) - return - } - } - - if err := h.db.Where("uuid = ?", uuid).Delete(&models.AuthUser{}).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"}) -} - -// Stats returns statistics about auth users -func (h *AuthUserHandler) Stats(c *gin.Context) { - var total int64 - var enabled int64 - var withMFA int64 - - h.db.Model(&models.AuthUser{}).Count(&total) - h.db.Model(&models.AuthUser{}).Where("enabled = ?", true).Count(&enabled) - h.db.Model(&models.AuthUser{}).Where("mfa_enabled = ?", true).Count(&withMFA) - - c.JSON(http.StatusOK, gin.H{ - "total": total, - "enabled": enabled, - "with_mfa": withMFA, - }) -} - -// AuthProviderHandler handles auth provider operations -type AuthProviderHandler struct { - db *gorm.DB -} - -// NewAuthProviderHandler creates a new auth provider handler -func NewAuthProviderHandler(db *gorm.DB) *AuthProviderHandler { - return &AuthProviderHandler{db: db} -} - -// List returns all auth providers -func (h *AuthProviderHandler) List(c *gin.Context) { - var providers []models.AuthProvider - if err := h.db.Order("created_at desc").Find(&providers).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, providers) -} - -// Get returns a single auth provider by UUID -func (h *AuthProviderHandler) Get(c *gin.Context) { - uuid := c.Param("uuid") - var provider models.AuthProvider - if err := h.db.Where("uuid = ?", uuid).First(&provider).Error; err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, provider) -} - -// CreateProviderRequest represents the request body for creating an auth provider -type CreateProviderRequest struct { - Name string `json:"name" binding:"required"` - Type string `json:"type" binding:"required"` - ClientID string `json:"client_id" binding:"required"` - ClientSecret string `json:"client_secret" binding:"required"` - IssuerURL string `json:"issuer_url"` - AuthURL string `json:"auth_url"` - TokenURL string `json:"token_url"` - UserInfoURL string `json:"user_info_url"` - Scopes string `json:"scopes"` - RoleMapping string `json:"role_mapping"` - IconURL string `json:"icon_url"` - DisplayName string `json:"display_name"` -} - -// Create creates a new auth provider -func (h *AuthProviderHandler) Create(c *gin.Context) { - var req CreateProviderRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - provider := models.AuthProvider{ - Name: req.Name, - Type: req.Type, - ClientID: req.ClientID, - ClientSecret: req.ClientSecret, - IssuerURL: req.IssuerURL, - AuthURL: req.AuthURL, - TokenURL: req.TokenURL, - UserInfoURL: req.UserInfoURL, - Scopes: req.Scopes, - RoleMapping: req.RoleMapping, - IconURL: req.IconURL, - DisplayName: req.DisplayName, - Enabled: true, - } - - if err := h.db.Create(&provider).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, provider) -} - -// UpdateProviderRequest represents the request body for updating an auth provider -type UpdateProviderRequest struct { - Name *string `json:"name,omitempty"` - Type *string `json:"type,omitempty"` - ClientID *string `json:"client_id,omitempty"` - ClientSecret *string `json:"client_secret,omitempty"` - IssuerURL *string `json:"issuer_url,omitempty"` - AuthURL *string `json:"auth_url,omitempty"` - TokenURL *string `json:"token_url,omitempty"` - UserInfoURL *string `json:"user_info_url,omitempty"` - Scopes *string `json:"scopes,omitempty"` - RoleMapping *string `json:"role_mapping,omitempty"` - IconURL *string `json:"icon_url,omitempty"` - DisplayName *string `json:"display_name,omitempty"` - Enabled *bool `json:"enabled,omitempty"` -} - -// Update updates an existing auth provider -func (h *AuthProviderHandler) Update(c *gin.Context) { - uuid := c.Param("uuid") - var provider models.AuthProvider - if err := h.db.Where("uuid = ?", uuid).First(&provider).Error; err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - var req UpdateProviderRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if req.Name != nil { - provider.Name = *req.Name - } - if req.Type != nil { - provider.Type = *req.Type - } - if req.ClientID != nil { - provider.ClientID = *req.ClientID - } - if req.ClientSecret != nil { - provider.ClientSecret = *req.ClientSecret - } - if req.IssuerURL != nil { - provider.IssuerURL = *req.IssuerURL - } - if req.AuthURL != nil { - provider.AuthURL = *req.AuthURL - } - if req.TokenURL != nil { - provider.TokenURL = *req.TokenURL - } - if req.UserInfoURL != nil { - provider.UserInfoURL = *req.UserInfoURL - } - if req.Scopes != nil { - provider.Scopes = *req.Scopes - } - if req.RoleMapping != nil { - provider.RoleMapping = *req.RoleMapping - } - if req.IconURL != nil { - provider.IconURL = *req.IconURL - } - if req.DisplayName != nil { - provider.DisplayName = *req.DisplayName - } - if req.Enabled != nil { - provider.Enabled = *req.Enabled - } - - if err := h.db.Save(&provider).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, provider) -} - -// Delete deletes an auth provider -func (h *AuthProviderHandler) Delete(c *gin.Context) { - uuid := c.Param("uuid") - if err := h.db.Where("uuid = ?", uuid).Delete(&models.AuthProvider{}).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "Provider deleted successfully"}) -} - -// AuthPolicyHandler handles auth policy operations -type AuthPolicyHandler struct { - db *gorm.DB -} - -// NewAuthPolicyHandler creates a new auth policy handler -func NewAuthPolicyHandler(db *gorm.DB) *AuthPolicyHandler { - return &AuthPolicyHandler{db: db} -} - -// List returns all auth policies -func (h *AuthPolicyHandler) List(c *gin.Context) { - var policies []models.AuthPolicy - if err := h.db.Order("created_at desc").Find(&policies).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, policies) -} - -// Get returns a single auth policy by UUID -func (h *AuthPolicyHandler) Get(c *gin.Context) { - uuid := c.Param("uuid") - var policy models.AuthPolicy - if err := h.db.Where("uuid = ?", uuid).First(&policy).Error; err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, policy) -} - -// GetByID returns a single auth policy by ID -func (h *AuthPolicyHandler) GetByID(id uint) (*models.AuthPolicy, error) { - var policy models.AuthPolicy - if err := h.db.First(&policy, id).Error; err != nil { - return nil, err - } - return &policy, nil -} - -// CreatePolicyRequest represents the request body for creating an auth policy -type CreatePolicyRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - AllowedRoles string `json:"allowed_roles"` - AllowedUsers string `json:"allowed_users"` - AllowedDomains string `json:"allowed_domains"` - RequireMFA bool `json:"require_mfa"` - SessionTimeout int `json:"session_timeout"` -} - -// Create creates a new auth policy -func (h *AuthPolicyHandler) Create(c *gin.Context) { - var req CreatePolicyRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - policy := models.AuthPolicy{ - Name: req.Name, - Description: req.Description, - AllowedRoles: req.AllowedRoles, - AllowedUsers: req.AllowedUsers, - AllowedDomains: req.AllowedDomains, - RequireMFA: req.RequireMFA, - SessionTimeout: req.SessionTimeout, - Enabled: true, - } - - if err := h.db.Create(&policy).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusCreated, policy) -} - -// UpdatePolicyRequest represents the request body for updating an auth policy -type UpdatePolicyRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - AllowedRoles *string `json:"allowed_roles,omitempty"` - AllowedUsers *string `json:"allowed_users,omitempty"` - AllowedDomains *string `json:"allowed_domains,omitempty"` - RequireMFA *bool `json:"require_mfa,omitempty"` - SessionTimeout *int `json:"session_timeout,omitempty"` - Enabled *bool `json:"enabled,omitempty"` -} - -// Update updates an existing auth policy -func (h *AuthPolicyHandler) Update(c *gin.Context) { - uuid := c.Param("uuid") - var policy models.AuthPolicy - if err := h.db.Where("uuid = ?", uuid).First(&policy).Error; err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - var req UpdatePolicyRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - if req.Name != nil { - policy.Name = *req.Name - } - if req.Description != nil { - policy.Description = *req.Description - } - if req.AllowedRoles != nil { - policy.AllowedRoles = *req.AllowedRoles - } - if req.AllowedUsers != nil { - policy.AllowedUsers = *req.AllowedUsers - } - if req.AllowedDomains != nil { - policy.AllowedDomains = *req.AllowedDomains - } - if req.RequireMFA != nil { - policy.RequireMFA = *req.RequireMFA - } - if req.SessionTimeout != nil { - policy.SessionTimeout = *req.SessionTimeout - } - if req.Enabled != nil { - policy.Enabled = *req.Enabled - } - - if err := h.db.Save(&policy).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, policy) -} - -// Delete deletes an auth policy -func (h *AuthPolicyHandler) Delete(c *gin.Context) { - uuid := c.Param("uuid") - - // Get the policy first to get its ID - var policy models.AuthPolicy - if err := h.db.Where("uuid = ?", uuid).First(&policy).Error; err != nil { - if err == gorm.ErrRecordNotFound { - c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Check if any proxy hosts are using this policy - var count int64 - h.db.Model(&models.ProxyHost{}).Where("auth_policy_id = ?", policy.ID).Count(&count) - if count > 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete policy that is in use by proxy hosts"}) - return - } - - if err := h.db.Delete(&policy).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "Policy deleted successfully"}) -} diff --git a/backend/internal/api/handlers/auth_handlers_test.go b/backend/internal/api/handlers/auth_handlers_test.go deleted file mode 100644 index 10578cb3..00000000 --- a/backend/internal/api/handlers/auth_handlers_test.go +++ /dev/null @@ -1,645 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - - "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -func setupAuthHandlersTestDB(t *testing.T) *gorm.DB { - dsn := filepath.Join(t.TempDir(), "test.db") + "?_busy_timeout=5000&_journal_mode=WAL" - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) - require.NoError(t, err) - - err = db.AutoMigrate( - &models.AuthUser{}, - &models.AuthProvider{}, - &models.AuthPolicy{}, - &models.ProxyHost{}, - ) - require.NoError(t, err) - - return db -} - -func setupAuthTestRouter(db *gorm.DB) *gin.Engine { - gin.SetMode(gin.TestMode) - router := gin.New() - - userHandler := NewAuthUserHandler(db) - providerHandler := NewAuthProviderHandler(db) - policyHandler := NewAuthPolicyHandler(db) - - api := router.Group("/api/v1") - - // Auth User routes - api.GET("/security/users", userHandler.List) - api.GET("/security/users/stats", userHandler.Stats) - api.GET("/security/users/:uuid", userHandler.Get) - api.POST("/security/users", userHandler.Create) - api.PUT("/security/users/:uuid", userHandler.Update) - api.DELETE("/security/users/:uuid", userHandler.Delete) - - // Auth Provider routes - api.GET("/security/providers", providerHandler.List) - api.GET("/security/providers/:uuid", providerHandler.Get) - api.POST("/security/providers", providerHandler.Create) - api.PUT("/security/providers/:uuid", providerHandler.Update) - api.DELETE("/security/providers/:uuid", providerHandler.Delete) - - // Auth Policy routes - api.GET("/security/policies", policyHandler.List) - api.GET("/security/policies/:uuid", policyHandler.Get) - api.POST("/security/policies", policyHandler.Create) - api.PUT("/security/policies/:uuid", policyHandler.Update) - api.DELETE("/security/policies/:uuid", policyHandler.Delete) - - return router -} - -// ==================== Auth User Tests ==================== - -func TestAuthUserHandler_List(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - // Create test users - user := models.AuthUser{Username: "testuser", Email: "test@example.com", Enabled: true} - user.SetPassword("password123") - db.Create(&user) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/api/v1/security/users", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var users []models.AuthUser - err := json.Unmarshal(w.Body.Bytes(), &users) - assert.NoError(t, err) - assert.Len(t, users, 1) - assert.Equal(t, "testuser", users[0].Username) -} - -func TestAuthUserHandler_Get(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - user := models.AuthUser{Username: "testuser", Email: "test@example.com"} - user.SetPassword("password123") - db.Create(&user) - - t.Run("found", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/api/v1/security/users/"+user.UUID, nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var result models.AuthUser - json.Unmarshal(w.Body.Bytes(), &result) - assert.Equal(t, "testuser", result.Username) - }) - - t.Run("not found", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/api/v1/security/users/nonexistent", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) -} - -func TestAuthUserHandler_Create(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - t.Run("success", func(t *testing.T) { - body := map[string]interface{}{ - "username": "newuser", - "email": "new@example.com", - "name": "New User", - "password": "password123", - "roles": "user", - } - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/v1/security/users", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) - - var result models.AuthUser - json.Unmarshal(w.Body.Bytes(), &result) - assert.Equal(t, "newuser", result.Username) - assert.True(t, result.Enabled) - }) - - t.Run("with additional emails", func(t *testing.T) { - body := map[string]interface{}{ - "username": "multiemail", - "email": "primary@example.com", - "password": "password123", - "additional_emails": "alt1@example.com,alt2@example.com", - } - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/v1/security/users", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) - - var result models.AuthUser - json.Unmarshal(w.Body.Bytes(), &result) - assert.Equal(t, "multiemail", result.Username) - assert.Equal(t, "alt1@example.com,alt2@example.com", result.AdditionalEmails) - }) - - t.Run("invalid email", func(t *testing.T) { - body := map[string]interface{}{ - "username": "baduser", - "email": "not-an-email", - "password": "password123", - } - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/v1/security/users", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - }) -} - -func TestAuthUserHandler_Update(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - user := models.AuthUser{Username: "testuser", Email: "test@example.com", Enabled: true} - user.SetPassword("password123") - db.Create(&user) - - t.Run("success", func(t *testing.T) { - body := map[string]interface{}{ - "name": "Updated Name", - "enabled": false, - } - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/v1/security/users/"+user.UUID, bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var result models.AuthUser - json.Unmarshal(w.Body.Bytes(), &result) - assert.Equal(t, "Updated Name", result.Name) - assert.False(t, result.Enabled) - }) - - t.Run("update additional emails", func(t *testing.T) { - body := map[string]interface{}{ - "additional_emails": "newalt@example.com", - } - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/v1/security/users/"+user.UUID, bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var result models.AuthUser - json.Unmarshal(w.Body.Bytes(), &result) - assert.Equal(t, "newalt@example.com", result.AdditionalEmails) - }) - - t.Run("not found", func(t *testing.T) { - body := map[string]interface{}{"name": "Test"} - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/v1/security/users/nonexistent", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) -} - -func TestAuthUserHandler_Delete(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - t.Run("success", func(t *testing.T) { - user := models.AuthUser{Username: "deleteuser", Email: "delete@example.com"} - user.SetPassword("password123") - db.Create(&user) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("DELETE", "/api/v1/security/users/"+user.UUID, nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - // Verify deleted - var count int64 - db.Model(&models.AuthUser{}).Where("uuid = ?", user.UUID).Count(&count) - assert.Equal(t, int64(0), count) - }) - - t.Run("cannot delete last admin", func(t *testing.T) { - admin := models.AuthUser{Username: "admin", Email: "admin@example.com", Roles: "admin"} - admin.SetPassword("password123") - db.Create(&admin) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("DELETE", "/api/v1/security/users/"+admin.UUID, nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.Contains(t, w.Body.String(), "last admin") - }) - - t.Run("not found", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("DELETE", "/api/v1/security/users/nonexistent", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) -} - -func TestAuthUserHandler_Stats(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - user1 := models.AuthUser{Username: "stats_user1", Email: "stats1@example.com", Enabled: true, MFAEnabled: true} - user1.SetPassword("password123") - // user2 needs Enabled: false, but GORM's default:true overrides the zero value - // So we create it first then update - user2 := models.AuthUser{Username: "stats_user2", Email: "stats2@example.com", MFAEnabled: false} - user2.SetPassword("password123") - require.NoError(t, db.Create(&user1).Error) - require.NoError(t, db.Create(&user2).Error) - // Explicitly set Enabled to false after create - require.NoError(t, db.Model(&user2).Update("enabled", false).Error) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/api/v1/security/users/stats", nil) - router.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code) - - var stats map[string]int64 - err := json.Unmarshal(w.Body.Bytes(), &stats) - require.NoError(t, err) - assert.Equal(t, int64(2), stats["total"]) - assert.Equal(t, int64(1), stats["enabled"]) - assert.Equal(t, int64(1), stats["with_mfa"]) -} - -// ==================== Auth Provider Tests ==================== - -func TestAuthProviderHandler_List(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - provider := models.AuthProvider{Name: "TestProvider", Type: "oidc", ClientID: "id", ClientSecret: "secret"} - db.Create(&provider) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/api/v1/security/providers", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var providers []models.AuthProvider - json.Unmarshal(w.Body.Bytes(), &providers) - assert.Len(t, providers, 1) -} - -func TestAuthProviderHandler_Get(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - provider := models.AuthProvider{Name: "TestProvider", Type: "oidc", ClientID: "id", ClientSecret: "secret"} - db.Create(&provider) - - t.Run("found", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/api/v1/security/providers/"+provider.UUID, nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - }) - - t.Run("not found", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/api/v1/security/providers/nonexistent", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) -} - -func TestAuthProviderHandler_Create(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - t.Run("success", func(t *testing.T) { - body := map[string]interface{}{ - "name": "NewProvider", - "type": "oidc", - "client_id": "client123", - "client_secret": "secret456", - "issuer_url": "https://auth.example.com", - } - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/v1/security/providers", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) - }) - - t.Run("missing required fields", func(t *testing.T) { - body := map[string]interface{}{ - "name": "Incomplete", - } - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/v1/security/providers", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - }) -} - -func TestAuthProviderHandler_Update(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - provider := models.AuthProvider{Name: "TestProvider", Type: "oidc", ClientID: "id", ClientSecret: "secret", Enabled: true} - db.Create(&provider) - - t.Run("success", func(t *testing.T) { - body := map[string]interface{}{ - "name": "UpdatedProvider", - "enabled": false, - } - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/v1/security/providers/"+provider.UUID, bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var result models.AuthProvider - json.Unmarshal(w.Body.Bytes(), &result) - assert.Equal(t, "UpdatedProvider", result.Name) - assert.False(t, result.Enabled) - }) - - t.Run("not found", func(t *testing.T) { - body := map[string]interface{}{"name": "Test"} - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/v1/security/providers/nonexistent", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) -} - -func TestAuthProviderHandler_Delete(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - provider := models.AuthProvider{Name: "DeleteProvider", Type: "oidc", ClientID: "id", ClientSecret: "secret"} - db.Create(&provider) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("DELETE", "/api/v1/security/providers/"+provider.UUID, nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) -} - -// ==================== Auth Policy Tests ==================== - -func TestAuthPolicyHandler_List(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - policy := models.AuthPolicy{Name: "TestPolicy", AllowedRoles: "admin"} - db.Create(&policy) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/api/v1/security/policies", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var policies []models.AuthPolicy - json.Unmarshal(w.Body.Bytes(), &policies) - assert.Len(t, policies, 1) -} - -func TestAuthPolicyHandler_Get(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - policy := models.AuthPolicy{Name: "TestPolicy"} - db.Create(&policy) - - t.Run("found", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/api/v1/security/policies/"+policy.UUID, nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - }) - - t.Run("not found", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/api/v1/security/policies/nonexistent", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) -} - -func TestAuthPolicyHandler_Create(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - t.Run("success", func(t *testing.T) { - body := map[string]interface{}{ - "name": "NewPolicy", - "description": "A test policy", - "allowed_roles": "admin,user", - "session_timeout": 3600, - } - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/v1/security/policies", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusCreated, w.Code) - - var result models.AuthPolicy - json.Unmarshal(w.Body.Bytes(), &result) - assert.Equal(t, "NewPolicy", result.Name) - assert.True(t, result.Enabled) - }) - - t.Run("missing required fields", func(t *testing.T) { - body := map[string]interface{}{ - "description": "No name", - } - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/v1/security/policies", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - }) -} - -func TestAuthPolicyHandler_Update(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - policy := models.AuthPolicy{Name: "TestPolicy", Enabled: true} - db.Create(&policy) - - t.Run("success", func(t *testing.T) { - body := map[string]interface{}{ - "name": "UpdatedPolicy", - "require_mfa": true, - "enabled": false, - } - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/v1/security/policies/"+policy.UUID, bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - - var result models.AuthPolicy - json.Unmarshal(w.Body.Bytes(), &result) - assert.Equal(t, "UpdatedPolicy", result.Name) - assert.True(t, result.RequireMFA) - assert.False(t, result.Enabled) - }) - - t.Run("not found", func(t *testing.T) { - body := map[string]interface{}{"name": "Test"} - jsonBody, _ := json.Marshal(body) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/v1/security/policies/nonexistent", bytes.NewBuffer(jsonBody)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) -} - -func TestAuthPolicyHandler_Delete(t *testing.T) { - db := setupAuthHandlersTestDB(t) - router := setupAuthTestRouter(db) - - t.Run("success", func(t *testing.T) { - policy := models.AuthPolicy{Name: "DeletePolicy"} - db.Create(&policy) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("DELETE", "/api/v1/security/policies/"+policy.UUID, nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - }) - - t.Run("cannot delete policy in use", func(t *testing.T) { - policy := models.AuthPolicy{Name: "InUsePolicy"} - db.Create(&policy) - - // Create a proxy host using this policy - host := models.ProxyHost{ - UUID: "test-host-uuid", - DomainNames: "test.com", - ForwardHost: "localhost", - ForwardPort: 80, - AuthPolicyID: &policy.ID, - } - db.Create(&host) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("DELETE", "/api/v1/security/policies/"+policy.UUID, nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.Contains(t, w.Body.String(), "in use") - }) - - t.Run("not found", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("DELETE", "/api/v1/security/policies/nonexistent", nil) - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) -} - -func TestAuthPolicyHandler_GetByID(t *testing.T) { - db := setupAuthHandlersTestDB(t) - handler := NewAuthPolicyHandler(db) - - policy := models.AuthPolicy{Name: "TestPolicy"} - db.Create(&policy) - - t.Run("found", func(t *testing.T) { - result, err := handler.GetByID(policy.ID) - assert.NoError(t, err) - assert.Equal(t, "TestPolicy", result.Name) - }) - - t.Run("not found", func(t *testing.T) { - _, err := handler.GetByID(9999) - assert.Error(t, err) - }) -} diff --git a/backend/internal/api/handlers/forward_auth_handler.go b/backend/internal/api/handlers/forward_auth_handler.go deleted file mode 100644 index 63db2334..00000000 --- a/backend/internal/api/handlers/forward_auth_handler.go +++ /dev/null @@ -1,109 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -// ForwardAuthHandler handles forward authentication configuration endpoints. -type ForwardAuthHandler struct { - db *gorm.DB -} - -// NewForwardAuthHandler creates a new handler. -func NewForwardAuthHandler(db *gorm.DB) *ForwardAuthHandler { - return &ForwardAuthHandler{db: db} -} - -// GetConfig retrieves the forward auth configuration. -func (h *ForwardAuthHandler) GetConfig(c *gin.Context) { - var config models.ForwardAuthConfig - if err := h.db.First(&config).Error; err != nil { - if err == gorm.ErrRecordNotFound { - // Return default/empty config - c.JSON(http.StatusOK, models.ForwardAuthConfig{ - Provider: "custom", - Address: "", - TrustForwardHeader: true, - }) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch config"}) - return - } - - c.JSON(http.StatusOK, config) -} - -// UpdateConfig updates or creates the forward auth configuration. -func (h *ForwardAuthHandler) UpdateConfig(c *gin.Context) { - var input struct { - Provider string `json:"provider" binding:"required,oneof=authelia authentik pomerium custom"` - Address string `json:"address" binding:"required,url"` - TrustForwardHeader bool `json:"trust_forward_header"` - } - - if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - var config models.ForwardAuthConfig - err := h.db.First(&config).Error - - if err == gorm.ErrRecordNotFound { - // Create new config - config = models.ForwardAuthConfig{ - Provider: input.Provider, - Address: input.Address, - TrustForwardHeader: input.TrustForwardHeader, - } - if err := h.db.Create(&config).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create config"}) - return - } - } else if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch config"}) - return - } else { - // Update existing config - config.Provider = input.Provider - config.Address = input.Address - config.TrustForwardHeader = input.TrustForwardHeader - if err := h.db.Save(&config).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update config"}) - return - } - } - - c.JSON(http.StatusOK, config) -} - -// GetTemplates returns pre-configured templates for popular providers. -func (h *ForwardAuthHandler) GetTemplates(c *gin.Context) { - templates := map[string]interface{}{ - "authelia": gin.H{ - "provider": "authelia", - "address": "http://authelia:9091/api/verify", - "trust_forward_header": true, - "description": "Authelia authentication server", - }, - "authentik": gin.H{ - "provider": "authentik", - "address": "http://authentik-server:9000/outpost.goauthentik.io/auth/caddy", - "trust_forward_header": true, - "description": "Authentik SSO provider", - }, - "pomerium": gin.H{ - "provider": "pomerium", - "address": "https://verify.pomerium.app", - "trust_forward_header": true, - "description": "Pomerium identity-aware proxy", - }, - } - - c.JSON(http.StatusOK, templates) -} diff --git a/backend/internal/api/handlers/forward_auth_handler_test.go b/backend/internal/api/handlers/forward_auth_handler_test.go deleted file mode 100644 index d4c9f9c7..00000000 --- a/backend/internal/api/handlers/forward_auth_handler_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -func setupForwardAuthTestDB() *gorm.DB { - db, _ := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) - db.AutoMigrate(&models.ForwardAuthConfig{}) - return db -} - -func TestForwardAuthHandler_GetConfig(t *testing.T) { - db := setupForwardAuthTestDB() - h := NewForwardAuthHandler(db) - - gin.SetMode(gin.TestMode) - r := gin.New() - r.GET("/config", h.GetConfig) - - // Test empty config (default) - req, _ := http.NewRequest("GET", "/config", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - var resp models.ForwardAuthConfig - json.Unmarshal(w.Body.Bytes(), &resp) - assert.Equal(t, "custom", resp.Provider) - - // Test existing config - db.Create(&models.ForwardAuthConfig{ - Provider: "authelia", - Address: "http://test", - }) - - req, _ = http.NewRequest("GET", "/config", nil) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - json.Unmarshal(w.Body.Bytes(), &resp) - assert.Equal(t, "authelia", resp.Provider) -} - -func TestForwardAuthHandler_UpdateConfig(t *testing.T) { - db := setupForwardAuthTestDB() - h := NewForwardAuthHandler(db) - - gin.SetMode(gin.TestMode) - r := gin.New() - r.POST("/config", h.UpdateConfig) - - // Test Create - payload := map[string]interface{}{ - "provider": "authelia", - "address": "http://authelia:9091", - "trust_forward_header": true, - } - body, _ := json.Marshal(payload) - req, _ := http.NewRequest("POST", "/config", bytes.NewBuffer(body)) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - var resp models.ForwardAuthConfig - json.Unmarshal(w.Body.Bytes(), &resp) - assert.Equal(t, "authelia", resp.Provider) - - // Test Update - payload["provider"] = "authentik" - body, _ = json.Marshal(payload) - req, _ = http.NewRequest("POST", "/config", bytes.NewBuffer(body)) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - json.Unmarshal(w.Body.Bytes(), &resp) - assert.Equal(t, "authentik", resp.Provider) - - // Test Validation Error - payload["address"] = "not-a-url" - body, _ = json.Marshal(payload) - req, _ = http.NewRequest("POST", "/config", bytes.NewBuffer(body)) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -func TestForwardAuthHandler_GetTemplates(t *testing.T) { - db := setupForwardAuthTestDB() - h := NewForwardAuthHandler(db) - - gin.SetMode(gin.TestMode) - r := gin.New() - r.GET("/templates", h.GetTemplates) - - req, _ := http.NewRequest("GET", "/templates", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - assert.Contains(t, resp, "authelia") - assert.Contains(t, resp, "authentik") -} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 64654d61..3be1c526 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -34,10 +34,6 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { &models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.Domain{}, - &models.ForwardAuthConfig{}, - &models.AuthUser{}, - &models.AuthProvider{}, - &models.AuthPolicy{}, ); err != nil { return fmt.Errorf("auto migrate: %w", err) } @@ -104,12 +100,6 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.GET("/settings", settingsHandler.GetSettings) protected.POST("/settings", settingsHandler.UpdateSetting) - // Forward Auth - forwardAuthHandler := handlers.NewForwardAuthHandler(db) - protected.GET("/security/forward-auth", forwardAuthHandler.GetConfig) - protected.PUT("/security/forward-auth", forwardAuthHandler.UpdateConfig) - protected.GET("/security/forward-auth/templates", forwardAuthHandler.GetTemplates) - // User Profile & API Key userHandler := handlers.NewUserHandler(db) protected.GET("/user/profile", userHandler.GetProfile) @@ -203,29 +193,6 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { api.POST("/certificates", certHandler.Upload) api.DELETE("/certificates/:id", certHandler.Delete) - // Security endpoints (Built-in SSO) - authUserHandler := handlers.NewAuthUserHandler(db) - api.GET("/security/users", authUserHandler.List) - api.GET("/security/users/stats", authUserHandler.Stats) - api.GET("/security/users/:uuid", authUserHandler.Get) - api.POST("/security/users", authUserHandler.Create) - api.PUT("/security/users/:uuid", authUserHandler.Update) - api.DELETE("/security/users/:uuid", authUserHandler.Delete) - - authProviderHandler := handlers.NewAuthProviderHandler(db) - api.GET("/security/providers", authProviderHandler.List) - api.GET("/security/providers/:uuid", authProviderHandler.Get) - api.POST("/security/providers", authProviderHandler.Create) - api.PUT("/security/providers/:uuid", authProviderHandler.Update) - api.DELETE("/security/providers/:uuid", authProviderHandler.Delete) - - authPolicyHandler := handlers.NewAuthPolicyHandler(db) - api.GET("/security/policies", authPolicyHandler.List) - api.GET("/security/policies/:uuid", authPolicyHandler.Get) - api.POST("/security/policies", authPolicyHandler.Create) - api.PUT("/security/policies/:uuid", authPolicyHandler.Update) - api.DELETE("/security/policies/:uuid", authPolicyHandler.Delete) - // Initial Caddy Config Sync go func() { // Wait for Caddy to be ready (max 30 seconds) diff --git a/backend/internal/caddy/client_test.go b/backend/internal/caddy/client_test.go index 8b668f61..4321d0a3 100644 --- a/backend/internal/caddy/client_test.go +++ b/backend/internal/caddy/client_test.go @@ -30,7 +30,7 @@ func TestClient_Load_Success(t *testing.T) { ForwardPort: 8080, Enabled: true, }, - }, "/tmp/caddy-data", "admin@example.com", "", "", false, nil, nil, nil, nil) + }, "/tmp/caddy-data", "admin@example.com", "", "", false) err := client.Load(context.Background(), config) require.NoError(t, err) diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 2a965994..192b2334 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -10,7 +10,7 @@ import ( // GenerateConfig creates a Caddy JSON configuration from proxy hosts. // This is the core transformation layer from our database model to Caddy config. -func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, forwardAuthConfig *models.ForwardAuthConfig, authUsers []models.AuthUser, authProviders []models.AuthProvider, authPolicies []models.AuthPolicy) (*Config, error) { +func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool) (*Config, error) { // Define log file paths // We assume storageDir is like ".../data/caddy/data", so we go up to ".../data/logs" // storageDir is .../data/caddy/data @@ -130,11 +130,6 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin } } - // Configure Security App (Built-in SSO) if we have users or providers - if len(authUsers) > 0 || len(authProviders) > 0 { - config.Apps.Security = generateSecurityApp(authUsers, authProviders, authPolicies) - } - if len(hosts) == 0 && frontendDir == "" { return config, nil } @@ -201,38 +196,6 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin // Build handlers for this host handlers := make([]Handler, 0) - // Add Built-in SSO (caddy-security) if a policy is assigned - if host.AuthPolicyID != nil && host.AuthPolicy != nil && host.AuthPolicy.Enabled { - // Inject authentication portal check - handlers = append(handlers, SecurityAuthHandler("cpmp_portal")) - // Inject authorization policy check - handlers = append(handlers, SecurityAuthzHandler(host.AuthPolicy.Name)) - } - - // Add Forward Auth if enabled for this host (legacy forward auth, not SSO) - if host.ForwardAuthEnabled && forwardAuthConfig != nil && forwardAuthConfig.Address != "" { - // Parse bypass paths - var bypassPaths []string - if host.ForwardAuthBypass != "" { - rawPaths := strings.Split(host.ForwardAuthBypass, ",") - for _, p := range rawPaths { - p = strings.TrimSpace(p) - if p != "" { - bypassPaths = append(bypassPaths, p) - } - } - } - - // If we have bypass paths, we need to conditionally apply auth - if len(bypassPaths) > 0 { - // Create a subroute that only applies auth to non-bypass paths - // This is complex - for now, add auth unconditionally and handle bypass in a separate route - // A better approach: create bypass routes BEFORE auth routes - } - - handlers = append(handlers, ForwardAuthHandler(forwardAuthConfig.Address, forwardAuthConfig.TrustForwardHeader)) - } - // Add HSTS header if enabled if host.HSTSEnabled { hstsValue := "max-age=31536000" @@ -249,32 +212,6 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin handlers = append(handlers, BlockExploitsHandler()) } - // Handle bypass routes FIRST if Forward Auth is enabled - if host.ForwardAuthEnabled && host.ForwardAuthBypass != "" { - rawPaths := strings.Split(host.ForwardAuthBypass, ",") - for _, p := range rawPaths { - p = strings.TrimSpace(p) - if p == "" { - continue - } - // Create bypass route without auth - dial := fmt.Sprintf("%s:%d", host.ForwardHost, host.ForwardPort) - bypassRoute := &Route{ - Match: []Match{ - { - Host: uniqueDomains, - Path: []string{p, p + "/*"}, - }, - }, - Handle: []Handler{ - ReverseProxyHandler(dial, host.WebsocketSupport), - }, - Terminal: true, - } - routes = append(routes, bypassRoute) - } - } - // Handle custom locations first (more specific routes) for _, loc := range host.Locations { dial := fmt.Sprintf("%s:%d", loc.ForwardHost, loc.ForwardPort) @@ -335,189 +272,3 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin return config, nil } - -// generateSecurityApp creates the caddy-security app configuration. -func generateSecurityApp(authUsers []models.AuthUser, authProviders []models.AuthProvider, authPolicies []models.AuthPolicy) *SecurityApp { - securityConfig := &SecurityConfig{ - AuthenticationPortals: make([]*AuthPortal, 0), - IdentityProviders: make([]*IdentityProvider, 0), - IdentityStores: make([]*IdentityStore, 0), - AuthorizationPolicies: make([]*AuthzPolicy, 0), - } - - // Create the main authentication portal - portal := &AuthPortal{ - Name: "cpmp_portal", - CookieDomain: "", // Will use request host - UISettings: map[string]interface{}{ - "theme": "basic", - }, - CookieConfig: map[string]interface{}{ - "lifetime": 86400, // 24 hours - }, - CryptoKeyStoreConfig: map[string]interface{}{ - "token_lifetime": 3600, // 1 hour - }, - API: map[string]interface{}{ - "profile_enabled": true, - }, - IdentityProviders: make([]string, 0), - IdentityStores: make([]string, 0), - } - - // Add local backend if we have local users - if len(authUsers) > 0 { - localStore := &IdentityStore{ - Name: "local", - Kind: "local", - Params: map[string]interface{}{ - "realm": "local", - "users": convertAuthUsersToConfig(authUsers), - }, - } - securityConfig.IdentityStores = append(securityConfig.IdentityStores, localStore) - portal.IdentityStores = append(portal.IdentityStores, "local") - } - - // Add OAuth providers - for _, provider := range authProviders { - if !provider.Enabled { - continue - } - - oauthProvider := &IdentityProvider{ - Name: provider.Name, - Kind: "oauth", - Params: map[string]interface{}{ - "client_id": provider.ClientID, - "client_secret": provider.ClientSecret, - "driver": provider.Type, - "realm": provider.Type, - }, - } - - // Add provider-specific config - if provider.IssuerURL != "" { - oauthProvider.Params["base_auth_url"] = provider.IssuerURL - } - if provider.AuthURL != "" { - oauthProvider.Params["authorization_url"] = provider.AuthURL - } - if provider.TokenURL != "" { - oauthProvider.Params["token_url"] = provider.TokenURL - } - if provider.Scopes != "" { - oauthProvider.Params["scopes"] = strings.Split(provider.Scopes, ",") - } - - securityConfig.IdentityProviders = append(securityConfig.IdentityProviders, oauthProvider) - portal.IdentityProviders = append(portal.IdentityProviders, provider.Name) - } - - securityConfig.AuthenticationPortals = append(securityConfig.AuthenticationPortals, portal) - - // Generate authorization policies - for _, policy := range authPolicies { - if !policy.Enabled { - continue - } - - authzPolicy := &AuthzPolicy{ - Name: policy.Name, - AccessListRules: make([]*AccessListRule, 0), - } - - // Build conditions - var conditions []string - if policy.AllowedRoles != "" { - roles := strings.Split(policy.AllowedRoles, ",") - for _, role := range roles { - conditions = append(conditions, fmt.Sprintf("match roles %s", strings.TrimSpace(role))) - } - } - if policy.AllowedUsers != "" { - users := strings.Split(policy.AllowedUsers, ",") - for _, user := range users { - conditions = append(conditions, fmt.Sprintf("match email %s", strings.TrimSpace(user))) - } - } - - // If no conditions, allow all authenticated (default behavior if policy exists?) - // Or maybe we should require at least one condition? - // For now, if conditions exist, add a rule. - if len(conditions) > 0 { - rule := &AccessListRule{ - Conditions: conditions, - Action: "allow", - } - authzPolicy.AccessListRules = append(authzPolicy.AccessListRules, rule) - } else { - // If no specific roles/users, allow any authenticated user - // "match any" condition? - // caddy-security default is deny if no rule matches? - // Let's add a rule to allow any authenticated user if no restrictions - // "match roles authp/user" or similar? - // Actually, if policy is enabled but empty, maybe it means "allow all authenticated"? - // Let's assume "allow" action with no conditions matches everything? - // No, conditions are required. - // Let's use "match roles *" or similar if supported, or just don't add rule (deny all). - // But user probably wants "Authenticated Users" if they didn't specify roles. - // Let's add a rule that matches any role if no specific roles/users are set. - // But wait, we don't have a generic "authenticated" condition easily. - // Let's stick to what we have. If empty, it might deny all. - } - - securityConfig.AuthorizationPolicies = append(securityConfig.AuthorizationPolicies, authzPolicy) - } - - return &SecurityApp{ - Config: securityConfig, - } -} - -// convertAuthUsersToConfig converts AuthUser models to caddy-security user config format. -func convertAuthUsersToConfig(users []models.AuthUser) []map[string]interface{} { - result := make([]map[string]interface{}, 0) - for _, user := range users { - if !user.Enabled { - continue - } - - // Helper to create user config - createUserConfig := func(username, email string) map[string]interface{} { - cfg := map[string]interface{}{ - "username": username, - "email": email, - "password": user.PasswordHash, // Already bcrypt hashed - } - - if user.Name != "" { - cfg["name"] = user.Name - } - - if user.Roles != "" { - cfg["roles"] = strings.Split(user.Roles, ",") - } - return cfg - } - - // Add primary user - result = append(result, createUserConfig(user.Username, user.Email)) - - // Add additional emails as alias users - if user.AdditionalEmails != "" { - emails := strings.Split(user.AdditionalEmails, ",") - for i, email := range emails { - email = strings.TrimSpace(email) - if email == "" { - continue - } - // Create a derived username for the alias - // We use a predictable suffix so it doesn't change - aliasUsername := fmt.Sprintf("%s_alt%d", user.Username, i+1) - result = append(result, createUserConfig(aliasUsername, email)) - } - } - } - return result -} diff --git a/backend/internal/caddy/config_test.go b/backend/internal/caddy/config_test.go index 9c30880b..2d479747 100644 --- a/backend/internal/caddy/config_test.go +++ b/backend/internal/caddy/config_test.go @@ -9,7 +9,7 @@ import ( ) func TestGenerateConfig_Empty(t *testing.T) { - config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false, nil, nil, nil, nil) + config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) require.NotNil(t, config) require.NotNil(t, config.Apps.HTTP) @@ -31,7 +31,7 @@ func TestGenerateConfig_SingleHost(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil, nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) require.NotNil(t, config) require.NotNil(t, config.Apps.HTTP) @@ -71,7 +71,7 @@ func TestGenerateConfig_MultipleHosts(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil, nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) require.Len(t, config.Apps.HTTP.Servers["cpm_server"].Routes, 2) } @@ -88,7 +88,7 @@ func TestGenerateConfig_WebSocketEnabled(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil, nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) route := config.Apps.HTTP.Servers["cpm_server"].Routes[0] @@ -109,7 +109,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil, nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) // Should produce empty routes (or just catch-all if frontendDir was set, but it's empty here) require.Empty(t, config.Apps.HTTP.Servers["cpm_server"].Routes) @@ -117,7 +117,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) { func TestGenerateConfig_Logging(t *testing.T) { hosts := []models.ProxyHost{} - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil, nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) // Verify logging configuration @@ -155,7 +155,7 @@ func TestGenerateConfig_Advanced(t *testing.T) { }, } - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil, nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) require.NoError(t, err) require.NotNil(t, config) @@ -202,7 +202,7 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) { } // Test with staging enabled - config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true, nil, nil, nil, nil) + config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true) require.NoError(t, err) require.NotNil(t, config.Apps.TLS) require.NotNil(t, config.Apps.TLS.Automation) @@ -217,7 +217,7 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) { require.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", acmeIssuer["ca"]) // Test with staging disabled (production) - config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false, nil, nil, nil, nil) + config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false) require.NoError(t, err) require.NotNil(t, config.Apps.TLS) require.NotNil(t, config.Apps.TLS.Automation) @@ -233,232 +233,3 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) { require.False(t, hasCA, "Production mode should not set ca field (uses default)") // We can't easily check the map content without casting, but we know it's there. } - -func TestGenerateSecurityApp(t *testing.T) { - t.Run("empty inputs", func(t *testing.T) { - app := generateSecurityApp(nil, nil, nil) - require.NotNil(t, app) - require.NotNil(t, app.Config) - require.NotNil(t, app.Config.AuthenticationPortals) - require.NotNil(t, app.Config.AuthorizationPolicies) - require.NotNil(t, app.Config.IdentityProviders) - }) - - t.Run("with local users", func(t *testing.T) { - users := []models.AuthUser{ - {Username: "admin", Email: "admin@example.com", PasswordHash: "hash123", Enabled: true}, - {Username: "user", Email: "user@example.com", PasswordHash: "hash456", Enabled: true}, - } - app := generateSecurityApp(users, nil, nil) - require.NotNil(t, app) - require.NotNil(t, app.Config) - - // Check Identity Stores - require.Len(t, app.Config.IdentityStores, 1) - localStore := app.Config.IdentityStores[0] - require.Equal(t, "local", localStore.Name) - require.Equal(t, "local", localStore.Kind) - - // Check Portal - require.Len(t, app.Config.AuthenticationPortals, 1) - portal := app.Config.AuthenticationPortals[0] - require.Equal(t, "cpmp_portal", portal.Name) - require.Contains(t, portal.IdentityStores, "local") - }) - - t.Run("with disabled users", func(t *testing.T) { - users := []models.AuthUser{ - {Username: "active", Email: "active@example.com", PasswordHash: "hash", Enabled: true}, - {Username: "inactive", Email: "inactive@example.com", PasswordHash: "hash", Enabled: false}, - } - app := generateSecurityApp(users, nil, nil) - - require.Len(t, app.Config.IdentityStores, 1) - localStore := app.Config.IdentityStores[0] - - usersConfig := localStore.Params["users"].([]map[string]interface{}) - require.Len(t, usersConfig, 1) - require.Equal(t, "active", usersConfig[0]["username"]) - }) - - t.Run("with oauth providers", func(t *testing.T) { - providers := []models.AuthProvider{ - { - Name: "Google", - Type: "google", - Enabled: true, - ClientID: "google-client-id", - ClientSecret: "google-secret", - Scopes: "openid,profile,email", - }, - { - Name: "GitHub", - Type: "github", - Enabled: true, - ClientID: "github-client-id", - ClientSecret: "github-secret", - }, - } - app := generateSecurityApp(nil, providers, nil) - - require.Len(t, app.Config.IdentityProviders, 2) - - // Find Google provider - var googleProvider *IdentityProvider - for _, p := range app.Config.IdentityProviders { - if p.Name == "Google" { - googleProvider = p - break - } - } - require.NotNil(t, googleProvider) - require.Equal(t, "oauth", googleProvider.Kind) - require.Equal(t, "google", googleProvider.Params["realm"]) - require.Equal(t, "google-client-id", googleProvider.Params["client_id"]) - - // Check Portal references - require.Len(t, app.Config.AuthenticationPortals, 1) - portal := app.Config.AuthenticationPortals[0] - require.Contains(t, portal.IdentityProviders, "Google") - require.Contains(t, portal.IdentityProviders, "GitHub") - }) - - t.Run("with disabled providers", func(t *testing.T) { - providers := []models.AuthProvider{ - {Name: "Active", Type: "oidc", Enabled: true, ClientID: "id", ClientSecret: "secret"}, - {Name: "Inactive", Type: "oidc", Enabled: false, ClientID: "id2", ClientSecret: "secret2"}, - } - app := generateSecurityApp(nil, providers, nil) - - require.Len(t, app.Config.IdentityProviders, 1) - require.Equal(t, "Active", app.Config.IdentityProviders[0].Name) - }) - - t.Run("with authorization policies", func(t *testing.T) { - policies := []models.AuthPolicy{ - { - Name: "admin_policy", - Enabled: true, - AllowedRoles: "admin,super", - AllowedUsers: "user1,user2", - RequireMFA: true, - }, - { - Name: "user_policy", - Enabled: true, - AllowedRoles: "user", - }, - } - app := generateSecurityApp(nil, nil, policies) - - require.Len(t, app.Config.AuthorizationPolicies, 2) - - // Find admin policy - var adminPolicy *AuthzPolicy - for _, p := range app.Config.AuthorizationPolicies { - if p.Name == "admin_policy" { - adminPolicy = p - break - } - } - require.NotNil(t, adminPolicy) - - // Check rules - // Note: The implementation converts roles/users to conditions in AccessListRules - require.NotEmpty(t, adminPolicy.AccessListRules) - rule := adminPolicy.AccessListRules[0] - require.Contains(t, rule.Conditions, "match roles admin") - require.Contains(t, rule.Conditions, "match email user1") - }) - - t.Run("with disabled policies", func(t *testing.T) { - policies := []models.AuthPolicy{ - {Name: "active", Enabled: true}, - {Name: "inactive", Enabled: false}, - } - app := generateSecurityApp(nil, nil, policies) - - require.Len(t, app.Config.AuthorizationPolicies, 1) - require.Equal(t, "active", app.Config.AuthorizationPolicies[0].Name) - }) - - t.Run("provider with custom URLs", func(t *testing.T) { - providers := []models.AuthProvider{ - { - Name: "Custom OIDC", - Type: "oidc", - Enabled: true, - ClientID: "client-id", - ClientSecret: "secret", - IssuerURL: "https://issuer.example.com", - AuthURL: "https://auth.example.com/authorize", - TokenURL: "https://auth.example.com/token", - }, - } - app := generateSecurityApp(nil, providers, nil) - - require.Len(t, app.Config.IdentityProviders, 1) - provider := app.Config.IdentityProviders[0] - - require.Equal(t, "https://issuer.example.com", provider.Params["base_auth_url"]) - require.Equal(t, "https://auth.example.com/authorize", provider.Params["authorization_url"]) - require.Equal(t, "https://auth.example.com/token", provider.Params["token_url"]) - }) -} - -func TestConvertAuthUsersToConfig(t *testing.T) { - t.Run("empty users", func(t *testing.T) { - result := convertAuthUsersToConfig(nil) - require.Empty(t, result) - }) - - t.Run("filters disabled users", func(t *testing.T) { - users := []models.AuthUser{ - {Username: "active", Email: "active@example.com", PasswordHash: "hash1", Enabled: true}, - {Username: "disabled", Email: "disabled@example.com", PasswordHash: "hash2", Enabled: false}, - } - result := convertAuthUsersToConfig(users) - require.Len(t, result, 1) - require.Equal(t, "active", result[0]["username"]) - }) - - t.Run("includes user details", func(t *testing.T) { - users := []models.AuthUser{ - { - Username: "testuser", - Email: "test@example.com", - PasswordHash: "bcrypt-hash", - Name: "Test User", - Roles: "admin,editor", - Enabled: true, - }, - } - result := convertAuthUsersToConfig(users) - require.Len(t, result, 1) - - userConfig := result[0] - require.Equal(t, "testuser", userConfig["username"]) - require.Equal(t, "test@example.com", userConfig["email"]) - require.Equal(t, "bcrypt-hash", userConfig["password"]) - require.Equal(t, "Test User", userConfig["name"]) - require.Equal(t, []string{"admin", "editor"}, userConfig["roles"]) - }) - - t.Run("omits empty name", func(t *testing.T) { - users := []models.AuthUser{ - {Username: "noname", Email: "noname@example.com", PasswordHash: "hash", Enabled: true}, - } - result := convertAuthUsersToConfig(users) - _, hasName := result[0]["name"] - require.False(t, hasName) - }) - - t.Run("omits empty roles", func(t *testing.T) { - users := []models.AuthUser{ - {Username: "noroles", Email: "noroles@example.com", PasswordHash: "hash", Enabled: true}, - } - result := convertAuthUsersToConfig(users) - _, hasRoles := result[0]["roles"] - require.False(t, hasRoles) - }) -} diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index 4b9b2dfe..ef19ac5c 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -57,30 +57,8 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { sslProvider = sslProviderSetting.Value } - // Fetch Forward Auth configuration - var forwardAuthConfig models.ForwardAuthConfig - var forwardAuthPtr *models.ForwardAuthConfig - if err := m.db.First(&forwardAuthConfig).Error; err == nil { - forwardAuthPtr = &forwardAuthConfig - } - - // Fetch Built-in SSO data - var authUsers []models.AuthUser - m.db.Where("enabled = ?", true).Find(&authUsers) - - var authProviders []models.AuthProvider - m.db.Where("enabled = ?", true).Find(&authProviders) - - var authPolicies []models.AuthPolicy - m.db.Where("enabled = ?", true).Find(&authPolicies) - - // Preload AuthPolicy for hosts - if err := m.db.Preload("AuthPolicy").Find(&hosts).Error; err != nil { - return fmt.Errorf("fetch proxy hosts with auth policies: %w", err) - } - // Generate Caddy config - config, err := GenerateConfig(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging, forwardAuthPtr, authUsers, authProviders, authPolicies) + config, err := GenerateConfig(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging) if err != nil { return fmt.Errorf("generate config: %w", err) } diff --git a/backend/internal/caddy/manager_test.go b/backend/internal/caddy/manager_test.go index 84f7a1bd..3eb8bd9f 100644 --- a/backend/internal/caddy/manager_test.go +++ b/backend/internal/caddy/manager_test.go @@ -40,7 +40,7 @@ func TestManager_ApplyConfig(t *testing.T) { dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) // Setup Manager tmpDir := t.TempDir() @@ -77,7 +77,7 @@ func TestManager_ApplyConfig_Failure(t *testing.T) { dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) // Setup Manager tmpDir := t.TempDir() @@ -158,7 +158,7 @@ func TestManager_RotateSnapshots(t *testing.T) { dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) client := NewClient(caddyServer.URL) manager := NewManager(client, db, tmpDir, "", false) @@ -212,7 +212,7 @@ func TestManager_Rollback_Success(t *testing.T) { dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) // Setup Manager tmpDir := t.TempDir() @@ -261,7 +261,7 @@ func TestManager_ApplyConfig_DBError(t *testing.T) { dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) // Setup Manager tmpDir := t.TempDir() @@ -282,7 +282,7 @@ func TestManager_ApplyConfig_ValidationError(t *testing.T) { dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) // Setup Manager with a file as configDir to force saveSnapshot error tmpDir := t.TempDir() @@ -315,7 +315,7 @@ func TestManager_Rollback_Failure(t *testing.T) { dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.ForwardAuthConfig{})) + require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{})) // Setup Manager tmpDir := t.TempDir() diff --git a/backend/internal/caddy/types.go b/backend/internal/caddy/types.go index e42c35c5..98ea7375 100644 --- a/backend/internal/caddy/types.go +++ b/backend/internal/caddy/types.go @@ -51,9 +51,8 @@ type Storage struct { // Apps contains all Caddy app modules. type Apps struct { - HTTP *HTTPApp `json:"http,omitempty"` - TLS *TLSApp `json:"tls,omitempty"` - Security *SecurityApp `json:"security,omitempty"` + HTTP *HTTPApp `json:"http,omitempty"` + TLS *TLSApp `json:"tls,omitempty"` } // HTTPApp configures the HTTP app. @@ -159,54 +158,6 @@ func FileServerHandler(root string) Handler { } } -// ForwardAuthHandler creates a forward authentication handler using reverse_proxy. -// This sends the request to an auth provider and uses handle_response to process the result. -func ForwardAuthHandler(authAddress string, trustForwardHeader bool) Handler { - h := Handler{ - "handler": "reverse_proxy", - "upstreams": []map[string]interface{}{ - {"dial": authAddress}, - }, - "handle_response": []map[string]interface{}{ - { - "match": map[string]interface{}{ - "status_code": []int{200}, - }, - "routes": []map[string]interface{}{ - { - "handle": []map[string]interface{}{ - { - "handler": "headers", - "request": map[string]interface{}{ - "set": map[string][]string{ - "Remote-User": {"{http.reverse_proxy.header.Remote-User}"}, - "Remote-Email": {"{http.reverse_proxy.header.Remote-Email}"}, - "Remote-Name": {"{http.reverse_proxy.header.Remote-Name}"}, - "Remote-Groups": {"{http.reverse_proxy.header.Remote-Groups}"}, - }, - }, - }, - }, - }, - }, - }, - }, - } - - if trustForwardHeader { - h["headers"] = map[string]interface{}{ - "request": map[string]interface{}{ - "set": map[string][]string{ - "X-Forwarded-Method": {"{http.request.method}"}, - "X-Forwarded-Uri": {"{http.request.uri}"}, - }, - }, - } - } - - return h -} - // TLSApp configures the TLS app for certificate management. type TLSApp struct { Automation *AutomationConfig `json:"automation,omitempty"` @@ -235,78 +186,3 @@ type AutomationPolicy struct { Subjects []string `json:"subjects,omitempty"` IssuersRaw []interface{} `json:"issuers,omitempty"` } - -// SecurityApp configures the caddy-security plugin for SSO/authentication. -type SecurityApp struct { - Config *SecurityConfig `json:"config,omitempty"` -} - -// SecurityConfig holds the configuration for caddy-security. -type SecurityConfig struct { - AuthenticationPortals []*AuthPortal `json:"authentication_portals,omitempty"` - AuthorizationPolicies []*AuthzPolicy `json:"authorization_policies,omitempty"` - IdentityProviders []*IdentityProvider `json:"identity_providers,omitempty"` - IdentityStores []*IdentityStore `json:"identity_stores,omitempty"` -} - -// AuthPortal represents an authentication portal configuration. -type AuthPortal struct { - Name string `json:"name,omitempty"` - UISettings map[string]interface{} `json:"ui,omitempty"` - CookieDomain string `json:"cookie_domain,omitempty"` - CookieConfig map[string]interface{} `json:"cookie_config,omitempty"` - IdentityProviders []string `json:"identity_providers,omitempty"` - IdentityStores []string `json:"identity_stores,omitempty"` - TokenValidatorOptions map[string]interface{} `json:"token_validator_options,omitempty"` - CryptoKeyStoreConfig map[string]interface{} `json:"crypto_key_store_config,omitempty"` - TokenGrantorOptions map[string]interface{} `json:"token_grantor_options,omitempty"` - PortalAdminRoles map[string]bool `json:"portal_admin_roles,omitempty"` - PortalUserRoles map[string]bool `json:"portal_user_roles,omitempty"` - PortalGuestRoles map[string]bool `json:"portal_guest_roles,omitempty"` - API map[string]interface{} `json:"api,omitempty"` -} - -// IdentityProvider represents an identity provider configuration. -type IdentityProvider struct { - Name string `json:"name"` - Kind string `json:"kind"` // "oauth", "saml" - Params map[string]interface{} `json:"params,omitempty"` -} - -// IdentityStore represents an identity store configuration. -type IdentityStore struct { - Name string `json:"name"` - Kind string `json:"kind"` // "local", "ldap" - Params map[string]interface{} `json:"params,omitempty"` -} - -// AuthzPolicy represents an authorization policy. -type AuthzPolicy struct { - Name string `json:"name,omitempty"` - AuthURLPath string `json:"auth_url_path,omitempty"` - AuthRedirectQueryParam string `json:"auth_redirect_query_param,omitempty"` - AuthRedirectStatusCode int `json:"auth_redirect_status_code,omitempty"` - AccessListRules []*AccessListRule `json:"access_list_rules,omitempty"` -} - -// AccessListRule represents a rule in an authorization policy. -type AccessListRule struct { - Conditions []string `json:"conditions,omitempty"` - Action string `json:"action,omitempty"` -} - -// SecurityAuthHandler creates a caddy-security authentication handler. -func SecurityAuthHandler(portalName string) Handler { - return Handler{ - "handler": "authentication", - "portal": portalName, - } -} - -// SecurityAuthzHandler creates a caddy-security authorization handler. -func SecurityAuthzHandler(policyName string) Handler { - return Handler{ - "handler": "authorization", - "policy": policyName, - } -} diff --git a/backend/internal/caddy/types_test.go b/backend/internal/caddy/types_test.go index 67290ce8..0a5f4ad5 100644 --- a/backend/internal/caddy/types_test.go +++ b/backend/internal/caddy/types_test.go @@ -29,34 +29,3 @@ func TestHandlers(t *testing.T) { h = BlockExploitsHandler() assert.Equal(t, "vars", h["handler"]) } - -func TestForwardAuthHandler(t *testing.T) { - t.Run("basic forward auth", func(t *testing.T) { - h := ForwardAuthHandler("localhost:9000", false) - assert.Equal(t, "reverse_proxy", h["handler"]) - upstreams := h["upstreams"].([]map[string]interface{}) - assert.Equal(t, "localhost:9000", upstreams[0]["dial"]) - // Without trust forward header, no headers section - assert.Nil(t, h["headers"]) - }) - - t.Run("forward auth with trust forward header", func(t *testing.T) { - h := ForwardAuthHandler("localhost:9000", true) - assert.Equal(t, "reverse_proxy", h["handler"]) - // With trust forward header, headers should be set - headers := h["headers"].(map[string]interface{}) - assert.NotNil(t, headers["request"]) - }) -} - -func TestSecurityAuthHandler(t *testing.T) { - h := SecurityAuthHandler("my_portal") - assert.Equal(t, "authentication", h["handler"]) - assert.Equal(t, "my_portal", h["portal"]) -} - -func TestSecurityAuthzHandler(t *testing.T) { - h := SecurityAuthzHandler("my_policy") - assert.Equal(t, "authorization", h["handler"]) - assert.Equal(t, "my_policy", h["policy"]) -} diff --git a/backend/internal/caddy/validator_test.go b/backend/internal/caddy/validator_test.go index c1964532..bbeae9d6 100644 --- a/backend/internal/caddy/validator_test.go +++ b/backend/internal/caddy/validator_test.go @@ -25,7 +25,7 @@ func TestValidate_ValidConfig(t *testing.T) { }, } - config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, nil, nil, nil, nil) + config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false) err := Validate(config) require.NoError(t, err) } diff --git a/backend/internal/models/auth_policy.go b/backend/internal/models/auth_policy.go deleted file mode 100644 index 23c1991a..00000000 --- a/backend/internal/models/auth_policy.go +++ /dev/null @@ -1,43 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// AuthPolicy represents an access control policy for proxy hosts -type AuthPolicy struct { - ID uint `gorm:"primarykey" json:"id"` - UUID string `gorm:"uniqueIndex;not null" json:"uuid"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - // Policy identification - Name string `gorm:"uniqueIndex;not null" json:"name"` - Description string `json:"description"` - Enabled bool `gorm:"default:true" json:"enabled"` - - // Access rules - AllowedRoles string `json:"allowed_roles"` // Comma-separated roles (e.g., "admin,user") - AllowedUsers string `json:"allowed_users"` // Comma-separated usernames or emails - AllowedDomains string `json:"allowed_domains"` // Comma-separated email domains (e.g., "@example.com") - - // Policy settings - RequireMFA bool `json:"require_mfa"` - SessionTimeout int `json:"session_timeout"` // In seconds, 0 = use default -} - -// BeforeCreate generates UUID for new auth policies -func (p *AuthPolicy) BeforeCreate(tx *gorm.DB) error { - if p.UUID == "" { - p.UUID = uuid.New().String() - } - return nil -} - -// IsPublic returns true if this policy allows public access (no restrictions) -func (p *AuthPolicy) IsPublic() bool { - return p.AllowedRoles == "" && p.AllowedUsers == "" && p.AllowedDomains == "" -} diff --git a/backend/internal/models/auth_policy_test.go b/backend/internal/models/auth_policy_test.go deleted file mode 100644 index dbc5063a..00000000 --- a/backend/internal/models/auth_policy_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package models - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -func setupAuthPolicyTestDB(t *testing.T) *gorm.DB { - dsn := filepath.Join(t.TempDir(), "test.db") + "?_busy_timeout=5000&_journal_mode=WAL" - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&AuthPolicy{})) - return db -} - -func TestAuthPolicy_BeforeCreate(t *testing.T) { - db := setupAuthPolicyTestDB(t) - - t.Run("generates UUID when empty", func(t *testing.T) { - policy := &AuthPolicy{ - Name: "test-policy", - } - require.NoError(t, db.Create(policy).Error) - assert.NotEmpty(t, policy.UUID) - assert.Len(t, policy.UUID, 36) - }) - - t.Run("keeps existing UUID", func(t *testing.T) { - customUUID := "custom-policy-uuid" - policy := &AuthPolicy{ - UUID: customUUID, - Name: "test-policy-2", - } - require.NoError(t, db.Create(policy).Error) - assert.Equal(t, customUUID, policy.UUID) - }) -} - -func TestAuthPolicy_IsPublic(t *testing.T) { - tests := []struct { - name string - policy AuthPolicy - expected bool - }{ - { - name: "empty restrictions is public", - policy: AuthPolicy{}, - expected: true, - }, - { - name: "only roles set is not public", - policy: AuthPolicy{ - AllowedRoles: "admin", - }, - expected: false, - }, - { - name: "only users set is not public", - policy: AuthPolicy{ - AllowedUsers: "user@example.com", - }, - expected: false, - }, - { - name: "only domains set is not public", - policy: AuthPolicy{ - AllowedDomains: "@example.com", - }, - expected: false, - }, - { - name: "all restrictions set is not public", - policy: AuthPolicy{ - AllowedRoles: "admin", - AllowedUsers: "user@example.com", - AllowedDomains: "@example.com", - }, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.policy.IsPublic()) - }) - } -} diff --git a/backend/internal/models/auth_provider.go b/backend/internal/models/auth_provider.go deleted file mode 100644 index b8125f5f..00000000 --- a/backend/internal/models/auth_provider.go +++ /dev/null @@ -1,47 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// AuthProvider represents an external OAuth/OIDC provider configuration -type AuthProvider struct { - ID uint `gorm:"primarykey" json:"id"` - UUID string `gorm:"uniqueIndex;not null" json:"uuid"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - // Provider configuration - Name string `gorm:"uniqueIndex;not null" json:"name"` // e.g., "Google", "GitHub" - Type string `gorm:"not null" json:"type"` // "google", "github", "oidc", "saml" - Enabled bool `gorm:"default:true" json:"enabled"` - - // OAuth/OIDC credentials - ClientID string `json:"client_id"` - ClientSecret string `json:"-"` // Never expose in JSON - - // OIDC specific - IssuerURL string `json:"issuer_url,omitempty"` // For generic OIDC providers - AuthURL string `json:"auth_url,omitempty"` // Optional override - TokenURL string `json:"token_url,omitempty"` // Optional override - UserInfoURL string `json:"user_info_url,omitempty"` // Optional override - - // Scopes and mappings - Scopes string `json:"scopes"` // Comma-separated (e.g., "openid,profile,email") - RoleMapping string `json:"role_mapping"` // JSON mapping from provider claims to roles - - // UI customization - IconURL string `json:"icon_url,omitempty"` - DisplayName string `json:"display_name,omitempty"` -} - -// BeforeCreate generates UUID for new auth providers -func (p *AuthProvider) BeforeCreate(tx *gorm.DB) error { - if p.UUID == "" { - p.UUID = uuid.New().String() - } - return nil -} diff --git a/backend/internal/models/auth_provider_test.go b/backend/internal/models/auth_provider_test.go deleted file mode 100644 index 9c56a90d..00000000 --- a/backend/internal/models/auth_provider_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package models - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -func setupAuthProviderTestDB(t *testing.T) *gorm.DB { - dsn := filepath.Join(t.TempDir(), "test.db") + "?_busy_timeout=5000&_journal_mode=WAL" - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&AuthProvider{})) - return db -} - -func TestAuthProvider_BeforeCreate(t *testing.T) { - db := setupAuthProviderTestDB(t) - - t.Run("generates UUID when empty", func(t *testing.T) { - provider := &AuthProvider{ - Name: "test-provider", - Type: "oidc", - } - require.NoError(t, db.Create(provider).Error) - assert.NotEmpty(t, provider.UUID) - assert.Len(t, provider.UUID, 36) - }) - - t.Run("keeps existing UUID", func(t *testing.T) { - customUUID := "custom-provider-uuid" - provider := &AuthProvider{ - UUID: customUUID, - Name: "test-provider-2", - Type: "google", - } - require.NoError(t, db.Create(provider).Error) - assert.Equal(t, customUUID, provider.UUID) - }) -} diff --git a/backend/internal/models/auth_user.go b/backend/internal/models/auth_user.go deleted file mode 100644 index df7559c9..00000000 --- a/backend/internal/models/auth_user.go +++ /dev/null @@ -1,106 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" -) - -// AuthUser represents a local user for the built-in SSO system -type AuthUser struct { - ID uint `gorm:"primarykey" json:"id"` - UUID string `gorm:"uniqueIndex;not null" json:"uuid"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - // User identification - Username string `gorm:"uniqueIndex;not null" json:"username"` - Email string `gorm:"uniqueIndex;not null" json:"email"` - Name string `json:"name"` // Full name for display - - // Authentication - PasswordHash string `gorm:"not null" json:"-"` // Never expose in JSON - Enabled bool `gorm:"default:true" json:"enabled"` - - // Additional emails for linking identities (comma-separated) - AdditionalEmails string `json:"additional_emails"` - - // Authorization - Roles string `json:"roles"` // Comma-separated roles (e.g., "admin,user") - - // MFA - MFAEnabled bool `json:"mfa_enabled"` - MFASecret string `json:"-"` // TOTP secret, never expose - - // Metadata - LastLoginAt *time.Time `json:"last_login_at,omitempty"` -} - -// BeforeCreate generates UUID for new auth users -func (u *AuthUser) BeforeCreate(tx *gorm.DB) error { - if u.UUID == "" { - u.UUID = uuid.New().String() - } - return nil -} - -// SetPassword hashes and sets the user's password -func (u *AuthUser) SetPassword(password string) error { - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return err - } - u.PasswordHash = string(hash) - return nil -} - -// CheckPassword verifies a password against the stored hash -func (u *AuthUser) CheckPassword(password string) bool { - err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) - return err == nil -} - -// HasRole checks if the user has a specific role -func (u *AuthUser) HasRole(role string) bool { - if u.Roles == "" { - return false - } - // Simple contains check for comma-separated roles - for _, r := range splitRoles(u.Roles) { - if r == role { - return true - } - } - return false -} - -// splitRoles splits comma-separated roles string -func splitRoles(roles string) []string { - if roles == "" { - return []string{} - } - var result []string - for i := 0; i < len(roles); { - start := i - for i < len(roles) && roles[i] != ',' { - i++ - } - if start < i { - role := roles[start:i] - // Trim spaces - for len(role) > 0 && role[0] == ' ' { - role = role[1:] - } - for len(role) > 0 && role[len(role)-1] == ' ' { - role = role[:len(role)-1] - } - if role != "" { - result = append(result, role) - } - } - i++ // Skip comma - } - return result -} diff --git a/backend/internal/models/auth_user_test.go b/backend/internal/models/auth_user_test.go deleted file mode 100644 index fdd6047b..00000000 --- a/backend/internal/models/auth_user_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package models - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -func setupAuthUserTestDB(t *testing.T) *gorm.DB { - dsn := filepath.Join(t.TempDir(), "test.db") + "?_busy_timeout=5000&_journal_mode=WAL" - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) - require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&AuthUser{})) - return db -} - -func TestAuthUser_BeforeCreate(t *testing.T) { - db := setupAuthUserTestDB(t) - - t.Run("generates UUID when empty", func(t *testing.T) { - user := &AuthUser{ - Username: "testuser", - Email: "test@example.com", - PasswordHash: "hash", - } - require.NoError(t, db.Create(user).Error) - assert.NotEmpty(t, user.UUID) - assert.Len(t, user.UUID, 36) // UUID format - }) - - t.Run("keeps existing UUID", func(t *testing.T) { - customUUID := "custom-uuid-value" - user := &AuthUser{ - UUID: customUUID, - Username: "testuser2", - Email: "test2@example.com", - PasswordHash: "hash", - } - require.NoError(t, db.Create(user).Error) - assert.Equal(t, customUUID, user.UUID) - }) -} - -func TestAuthUser_SetPassword(t *testing.T) { - t.Run("hashes password", func(t *testing.T) { - user := &AuthUser{} - err := user.SetPassword("mypassword123") - require.NoError(t, err) - assert.NotEmpty(t, user.PasswordHash) - assert.NotEqual(t, "mypassword123", user.PasswordHash) - // bcrypt hashes start with $2a$ or $2b$ - assert.Contains(t, user.PasswordHash, "$2a$") - }) - - t.Run("empty password", func(t *testing.T) { - user := &AuthUser{} - err := user.SetPassword("") - require.NoError(t, err) - assert.NotEmpty(t, user.PasswordHash) - }) -} - -func TestAuthUser_CheckPassword(t *testing.T) { - user := &AuthUser{} - require.NoError(t, user.SetPassword("correctpassword")) - - t.Run("correct password returns true", func(t *testing.T) { - assert.True(t, user.CheckPassword("correctpassword")) - }) - - t.Run("wrong password returns false", func(t *testing.T) { - assert.False(t, user.CheckPassword("wrongpassword")) - }) - - t.Run("empty password returns false", func(t *testing.T) { - assert.False(t, user.CheckPassword("")) - }) -} - -func TestAuthUser_HasRole(t *testing.T) { - t.Run("empty roles returns false", func(t *testing.T) { - user := &AuthUser{Roles: ""} - assert.False(t, user.HasRole("admin")) - }) - - t.Run("single role match", func(t *testing.T) { - user := &AuthUser{Roles: "admin"} - assert.True(t, user.HasRole("admin")) - assert.False(t, user.HasRole("user")) - }) - - t.Run("multiple roles", func(t *testing.T) { - user := &AuthUser{Roles: "admin,user,editor"} - assert.True(t, user.HasRole("admin")) - assert.True(t, user.HasRole("user")) - assert.True(t, user.HasRole("editor")) - assert.False(t, user.HasRole("guest")) - }) - - t.Run("roles with spaces", func(t *testing.T) { - user := &AuthUser{Roles: "admin, user, editor"} - assert.True(t, user.HasRole("admin")) - assert.True(t, user.HasRole("user")) - assert.True(t, user.HasRole("editor")) - }) -} - -func TestSplitRoles(t *testing.T) { - tests := []struct { - name string - input string - expected []string - }{ - {"empty string", "", []string{}}, - {"single role", "admin", []string{"admin"}}, - {"multiple roles", "admin,user", []string{"admin", "user"}}, - {"with spaces", "admin, user, editor", []string{"admin", "user", "editor"}}, - {"trailing comma", "admin,user,", []string{"admin", "user"}}, - {"leading comma", ",admin,user", []string{"admin", "user"}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := splitRoles(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/backend/internal/models/forward_auth_config.go b/backend/internal/models/forward_auth_config.go deleted file mode 100644 index 05061b48..00000000 --- a/backend/internal/models/forward_auth_config.go +++ /dev/null @@ -1,16 +0,0 @@ -package models - -import ( - "time" -) - -// ForwardAuthConfig represents the global forward authentication configuration. -// This is stored as structured data to avoid multiple Setting entries. -type ForwardAuthConfig struct { - ID uint `json:"id" gorm:"primaryKey"` - Provider string `json:"provider" gorm:"not null"` // "authelia", "authentik", "pomerium", "custom" - Address string `json:"address" gorm:"not null"` // e.g., "http://authelia:9091/api/verify" - TrustForwardHeader bool `json:"trust_forward_header" gorm:"default:true"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} diff --git a/backend/internal/models/proxy_host.go b/backend/internal/models/proxy_host.go index 1a09c7ab..2f1dbdb6 100644 --- a/backend/internal/models/proxy_host.go +++ b/backend/internal/models/proxy_host.go @@ -6,27 +6,23 @@ import ( // ProxyHost represents a reverse proxy configuration. type ProxyHost struct { - ID uint `json:"id" gorm:"primaryKey"` - UUID string `json:"uuid" gorm:"uniqueIndex;not null"` - Name string `json:"name"` - DomainNames string `json:"domain_names" gorm:"not null"` // Comma-separated list - ForwardScheme string `json:"forward_scheme" gorm:"default:http"` - ForwardHost string `json:"forward_host" gorm:"not null"` - ForwardPort int `json:"forward_port" gorm:"not null"` - SSLForced bool `json:"ssl_forced" gorm:"default:false"` - HTTP2Support bool `json:"http2_support" gorm:"default:true"` - HSTSEnabled bool `json:"hsts_enabled" gorm:"default:false"` - HSTSSubdomains bool `json:"hsts_subdomains" gorm:"default:false"` - BlockExploits bool `json:"block_exploits" gorm:"default:true"` - WebsocketSupport bool `json:"websocket_support" gorm:"default:false"` - Enabled bool `json:"enabled" gorm:"default:true"` - ForwardAuthEnabled bool `json:"forward_auth_enabled" gorm:"default:false"` - ForwardAuthBypass string `json:"forward_auth_bypass" gorm:"type:text"` // Comma-separated paths - CertificateID *uint `json:"certificate_id"` - Certificate *SSLCertificate `json:"certificate" gorm:"foreignKey:CertificateID"` - AuthPolicyID *uint `json:"auth_policy_id"` // Built-in SSO policy - AuthPolicy *AuthPolicy `json:"auth_policy" gorm:"foreignKey:AuthPolicyID"` - Locations []Location `json:"locations" gorm:"foreignKey:ProxyHostID;constraint:OnDelete:CASCADE"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id" gorm:"primaryKey"` + UUID string `json:"uuid" gorm:"uniqueIndex;not null"` + Name string `json:"name"` + DomainNames string `json:"domain_names" gorm:"not null"` // Comma-separated list + ForwardScheme string `json:"forward_scheme" gorm:"default:http"` + ForwardHost string `json:"forward_host" gorm:"not null"` + ForwardPort int `json:"forward_port" gorm:"not null"` + SSLForced bool `json:"ssl_forced" gorm:"default:false"` + HTTP2Support bool `json:"http2_support" gorm:"default:true"` + HSTSEnabled bool `json:"hsts_enabled" gorm:"default:false"` + HSTSSubdomains bool `json:"hsts_subdomains" gorm:"default:false"` + BlockExploits bool `json:"block_exploits" gorm:"default:true"` + WebsocketSupport bool `json:"websocket_support" gorm:"default:false"` + Enabled bool `json:"enabled" gorm:"default:true"` + CertificateID *uint `json:"certificate_id"` + Certificate *SSLCertificate `json:"certificate" gorm:"foreignKey:CertificateID"` + Locations []Location `json:"locations" gorm:"foreignKey:ProxyHostID;constraint:OnDelete:CASCADE"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eadd5c6b..53dc10ec 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,7 +13,6 @@ const ProxyHosts = lazy(() => import('./pages/ProxyHosts')) const RemoteServers = lazy(() => import('./pages/RemoteServers')) const ImportCaddy = lazy(() => import('./pages/ImportCaddy')) const Certificates = lazy(() => import('./pages/Certificates')) -const Security = lazy(() => import('./pages/Security')) const SystemSettings = lazy(() => import('./pages/SystemSettings')) const Account = lazy(() => import('./pages/Account')) const Settings = lazy(() => import('./pages/Settings')) @@ -51,7 +50,6 @@ export default function App() { } /> } /> } /> - } /> {/* Settings Routes */} }> diff --git a/frontend/src/api/__tests__/proxyHosts.test.ts b/frontend/src/api/__tests__/proxyHosts.test.ts index 08b9eac7..36e050dd 100644 --- a/frontend/src/api/__tests__/proxyHosts.test.ts +++ b/frontend/src/api/__tests__/proxyHosts.test.ts @@ -37,8 +37,6 @@ describe('proxyHosts API', () => { hsts_subdomains: false, block_exploits: false, websocket_support: false, - forward_auth_enabled: false, - forward_auth_bypass: '', locations: [], enabled: true, created_at: '2023-01-01', diff --git a/frontend/src/api/proxyHosts.ts b/frontend/src/api/proxyHosts.ts index c5231821..4ab9ebea 100644 --- a/frontend/src/api/proxyHosts.ts +++ b/frontend/src/api/proxyHosts.ts @@ -30,9 +30,6 @@ export interface ProxyHost { hsts_subdomains: boolean; block_exploits: boolean; websocket_support: boolean; - forward_auth_enabled: boolean; - forward_auth_bypass: string; - auth_policy_id?: number | null; locations: Location[]; advanced_config?: string; enabled: boolean; diff --git a/frontend/src/api/security.ts b/frontend/src/api/security.ts deleted file mode 100644 index 66215943..00000000 --- a/frontend/src/api/security.ts +++ /dev/null @@ -1,240 +0,0 @@ -import client from './client'; - -// --- Forward Auth (Legacy) --- - -export interface ForwardAuthConfig { - id?: number; - provider: 'authelia' | 'authentik' | 'pomerium' | 'custom'; - address: string; - trust_forward_header: boolean; - created_at?: string; - updated_at?: string; -} - -export interface ForwardAuthTemplate { - provider: string; - address: string; - trust_forward_header: boolean; - description: string; -} - -export const getForwardAuthConfig = async (): Promise => { - const { data } = await client.get('/security/forward-auth'); - return data; -}; - -export const updateForwardAuthConfig = async (config: ForwardAuthConfig): Promise => { - const { data } = await client.put('/security/forward-auth', config); - return data; -}; - -export const getForwardAuthTemplates = async (): Promise> => { - const { data } = await client.get>('/security/forward-auth/templates'); - return data; -}; - -// --- Built-in SSO --- - -// Users -export interface AuthUser { - id: number; - uuid: string; - username: string; - email: string; - name: string; - password?: string; // Only for creation/update - roles: string; - mfa_enabled: boolean; - enabled: boolean; - created_at: string; - updated_at: string; - additional_emails?: string; -} - -export interface AuthUserStats { - total_users: number; - admin_users: number; -} - -export interface CreateAuthUserRequest { - username: string; - email: string; - name: string; - password?: string; - roles: string; - mfa_enabled: boolean; - additional_emails?: string; -} - -export interface UpdateAuthUserRequest { - email?: string; - name?: string; - password?: string; - roles?: string; - mfa_enabled?: boolean; - enabled?: boolean; - additional_emails?: string; -} - -export const getAuthUsers = async (): Promise => { - const { data } = await client.get('/security/users'); - return data; -}; - -export const getAuthUser = async (uuid: string): Promise => { - const { data } = await client.get(`/security/users/${uuid}`); - return data; -}; - -export const createAuthUser = async (user: CreateAuthUserRequest): Promise => { - const { data } = await client.post('/security/users', user); - return data; -}; - -export const updateAuthUser = async (uuid: string, user: UpdateAuthUserRequest): Promise => { - const { data } = await client.put(`/security/users/${uuid}`, user); - return data; -}; - -export const deleteAuthUser = async (uuid: string): Promise => { - await client.delete(`/security/users/${uuid}`); -}; - -export const getAuthUserStats = async (): Promise => { - const { data } = await client.get('/security/users/stats'); - return data; -}; - -// Providers -export interface AuthProvider { - id: number; - uuid: string; - name: string; - type: 'google' | 'github' | 'oidc'; - client_id: string; - client_secret?: string; // Only for creation/update - issuer_url?: string; - auth_url?: string; - token_url?: string; - user_info_url?: string; - scopes?: string; - role_mapping?: string; - display_name?: string; - enabled: boolean; - created_at: string; - updated_at: string; -} - -export interface CreateAuthProviderRequest { - name: string; - type: 'google' | 'github' | 'oidc'; - client_id: string; - client_secret: string; - issuer_url?: string; - auth_url?: string; - token_url?: string; - user_info_url?: string; - scopes?: string; - role_mapping?: string; - display_name?: string; -} - -export interface UpdateAuthProviderRequest { - name?: string; - type?: 'google' | 'github' | 'oidc'; - client_id?: string; - client_secret?: string; - issuer_url?: string; - auth_url?: string; - token_url?: string; - user_info_url?: string; - scopes?: string; - role_mapping?: string; - display_name?: string; - enabled?: boolean; -} - -export const getAuthProviders = async (): Promise => { - const { data } = await client.get('/security/providers'); - return data; -}; - -export const getAuthProvider = async (uuid: string): Promise => { - const { data } = await client.get(`/security/providers/${uuid}`); - return data; -}; - -export const createAuthProvider = async (provider: CreateAuthProviderRequest): Promise => { - const { data } = await client.post('/security/providers', provider); - return data; -}; - -export const updateAuthProvider = async (uuid: string, provider: UpdateAuthProviderRequest): Promise => { - const { data } = await client.put(`/security/providers/${uuid}`, provider); - return data; -}; - -export const deleteAuthProvider = async (uuid: string): Promise => { - await client.delete(`/security/providers/${uuid}`); -}; - -// Policies -export interface AuthPolicy { - id: number; - uuid: string; - name: string; - description: string; - allowed_roles: string; - allowed_users: string; - allowed_domains: string; - require_mfa: boolean; - session_timeout: number; - enabled: boolean; - created_at: string; - updated_at: string; -} - -export interface CreateAuthPolicyRequest { - name: string; - description?: string; - allowed_roles?: string; - allowed_users?: string; - allowed_domains?: string; - require_mfa?: boolean; - session_timeout?: number; -} - -export interface UpdateAuthPolicyRequest { - name?: string; - description?: string; - allowed_roles?: string; - allowed_users?: string; - allowed_domains?: string; - require_mfa?: boolean; - session_timeout?: number; - enabled?: boolean; -} - -export const getAuthPolicies = async (): Promise => { - const { data } = await client.get('/security/policies'); - return data; -}; - -export const getAuthPolicy = async (uuid: string): Promise => { - const { data } = await client.get(`/security/policies/${uuid}`); - return data; -}; - -export const createAuthPolicy = async (policy: CreateAuthPolicyRequest): Promise => { - const { data } = await client.post('/security/policies', policy); - return data; -}; - -export const updateAuthPolicy = async (uuid: string, policy: UpdateAuthPolicyRequest): Promise => { - const { data } = await client.put(`/security/policies/${uuid}`, policy); - return data; -}; - -export const deleteAuthPolicy = async (uuid: string): Promise => { - await client.delete(`/security/policies/${uuid}`); -}; diff --git a/frontend/src/components/ForwardAuthSettings.tsx b/frontend/src/components/ForwardAuthSettings.tsx deleted file mode 100644 index bab7e6ea..00000000 --- a/frontend/src/components/ForwardAuthSettings.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getForwardAuthConfig, updateForwardAuthConfig, getForwardAuthTemplates, ForwardAuthConfig } from '../api/security'; -import { Button } from './ui/Button'; -import { toast } from 'react-hot-toast'; -import { Shield, Check, AlertTriangle } from 'lucide-react'; - -export default function ForwardAuthSettings() { - const queryClient = useQueryClient(); - const [formData, setFormData] = useState({ - provider: 'custom', - address: '', - trust_forward_header: true, - }); - - const { data: config, isLoading } = useQuery({ - queryKey: ['forwardAuth'], - queryFn: getForwardAuthConfig, - }); - - const { data: templates } = useQuery({ - queryKey: ['forwardAuthTemplates'], - queryFn: getForwardAuthTemplates, - }); - - useEffect(() => { - if (config) { - setFormData(config); - } - }, [config]); - - const mutation = useMutation({ - mutationFn: updateForwardAuthConfig, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['forwardAuth'] }); - toast.success('Forward Auth configuration saved'); - }, - onError: (error: Error & { response?: { data?: { error?: string } } }) => { - toast.error(error.response?.data?.error || 'Failed to save configuration'); - }, - }); - - const handleTemplateChange = (provider: string) => { - if (templates && templates[provider]) { - const template = templates[provider]; - setFormData({ - ...formData, - provider: provider as 'authelia' | 'authentik' | 'pomerium' | 'custom', - address: template.address, - trust_forward_header: template.trust_forward_header, - }); - } else { - setFormData({ - ...formData, - provider: 'custom', - }); - } - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - mutation.mutate(formData); - }; - - if (isLoading) { - return
; - } - - return ( -
-
-
- -
-
-

Forward Authentication

-

- Configure a global authentication provider (SSO) for your proxy hosts. -

-
-
- -
-
-
- - -

- Select a template to pre-fill configuration or choose Custom. -

-
- -
- - setFormData({ ...formData, address: e.target.value })} - placeholder="http://authelia:9091/api/verify" - className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-dark-bg text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -

- The internal URL where Caddy will send auth subrequests. -

-
-
- -
- setFormData({ ...formData, trust_forward_header: e.target.checked })} - className="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 dark:bg-dark-bg dark:border-gray-600" - /> - -
- -
-
- - Changes apply immediately to all hosts using Forward Auth. -
- -
-
-
- ); -} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index b2661b9e..bf688884 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -50,7 +50,6 @@ export default function Layout({ children }: LayoutProps) { { name: 'Uptime', path: '/uptime', icon: '📈' }, { name: 'Notifications', path: '/notifications', icon: '🔔' }, { name: 'Import Caddyfile', path: '/import', icon: '📥' }, - { name: 'Security', path: '/security', icon: '🛡️' }, { name: 'Settings', path: '/settings', diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx index 5ad0e871..36087259 100644 --- a/frontend/src/components/ProxyHostForm.tsx +++ b/frontend/src/components/ProxyHostForm.tsx @@ -1,12 +1,11 @@ import { useState, useEffect } from 'react' -import { CircleHelp, AlertCircle, Check, X, Loader2, ShieldCheck } from 'lucide-react' +import { CircleHelp, AlertCircle, Check, X, Loader2 } from 'lucide-react' import type { ProxyHost } from '../api/proxyHosts' import { testProxyHostConnection } from '../api/proxyHosts' import { useRemoteServers } from '../hooks/useRemoteServers' import { useDomains } from '../hooks/useDomains' import { useCertificates } from '../hooks/useCertificates' import { useDocker } from '../hooks/useDocker' -import { useAuthPolicies } from '../hooks/useSecurity' import { parse } from 'tldts' interface ProxyHostFormProps { @@ -28,9 +27,6 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor hsts_subdomains: host?.hsts_subdomains ?? true, block_exploits: host?.block_exploits ?? true, websocket_support: host?.websocket_support ?? true, - forward_auth_enabled: host?.forward_auth_enabled ?? false, - forward_auth_bypass: host?.forward_auth_bypass || '', - auth_policy_id: host?.auth_policy_id || null, advanced_config: host?.advanced_config || '', enabled: host?.enabled ?? true, certificate_id: host?.certificate_id, @@ -39,7 +35,6 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor const { servers: remoteServers } = useRemoteServers() const { domains, createDomain } = useDomains() const { certificates } = useCertificates() - const { policies: authPolicies } = useAuthPolicies() const { containers: dockerContainers, isLoading: dockerLoading, error: dockerError } = useDocker( formData.forward_host ? undefined : undefined // Simplified for now, logic below handles it ) @@ -489,82 +484,6 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor - {/* Access Control (SSO & Forward Auth) */} -
-
- -

Access Control

-
- -
- - -

- Select a policy to protect this service with the built-in SSO. -

-
- - {/* Legacy Forward Auth - Only show if no policy is selected */} - {!formData.auth_policy_id && ( -
-
- -
- -
-
- - {formData.forward_auth_enabled && ( -
- -