refactor: remove security-related hooks and pages
- Deleted `useSecurity.ts` hook which managed authentication users, providers, and policies. - Removed `Policies.tsx`, `Providers.tsx`, and `Users.tsx` pages that utilized the above hook. - Cleaned up the `index.tsx` file in the Security section to remove references to the deleted pages. - Updated mock data by removing unused properties related to forward authentication.
This commit is contained in:
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 == ""
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<Route path="uptime" element={<Uptime />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
<Route path="import" element={<ImportCaddy />} />
|
||||
<Route path="security" element={<Security />} />
|
||||
|
||||
{/* Settings Routes */}
|
||||
<Route path="settings" element={<Settings />}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ForwardAuthConfig> => {
|
||||
const { data } = await client.get<ForwardAuthConfig>('/security/forward-auth');
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateForwardAuthConfig = async (config: ForwardAuthConfig): Promise<ForwardAuthConfig> => {
|
||||
const { data } = await client.put<ForwardAuthConfig>('/security/forward-auth', config);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getForwardAuthTemplates = async (): Promise<Record<string, ForwardAuthTemplate>> => {
|
||||
const { data } = await client.get<Record<string, ForwardAuthTemplate>>('/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<AuthUser[]> => {
|
||||
const { data } = await client.get<AuthUser[]>('/security/users');
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getAuthUser = async (uuid: string): Promise<AuthUser> => {
|
||||
const { data } = await client.get<AuthUser>(`/security/users/${uuid}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createAuthUser = async (user: CreateAuthUserRequest): Promise<AuthUser> => {
|
||||
const { data } = await client.post<AuthUser>('/security/users', user);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateAuthUser = async (uuid: string, user: UpdateAuthUserRequest): Promise<AuthUser> => {
|
||||
const { data } = await client.put<AuthUser>(`/security/users/${uuid}`, user);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteAuthUser = async (uuid: string): Promise<void> => {
|
||||
await client.delete(`/security/users/${uuid}`);
|
||||
};
|
||||
|
||||
export const getAuthUserStats = async (): Promise<AuthUserStats> => {
|
||||
const { data } = await client.get<AuthUserStats>('/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<AuthProvider[]> => {
|
||||
const { data } = await client.get<AuthProvider[]>('/security/providers');
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getAuthProvider = async (uuid: string): Promise<AuthProvider> => {
|
||||
const { data } = await client.get<AuthProvider>(`/security/providers/${uuid}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createAuthProvider = async (provider: CreateAuthProviderRequest): Promise<AuthProvider> => {
|
||||
const { data } = await client.post<AuthProvider>('/security/providers', provider);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateAuthProvider = async (uuid: string, provider: UpdateAuthProviderRequest): Promise<AuthProvider> => {
|
||||
const { data } = await client.put<AuthProvider>(`/security/providers/${uuid}`, provider);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteAuthProvider = async (uuid: string): Promise<void> => {
|
||||
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<AuthPolicy[]> => {
|
||||
const { data } = await client.get<AuthPolicy[]>('/security/policies');
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getAuthPolicy = async (uuid: string): Promise<AuthPolicy> => {
|
||||
const { data } = await client.get<AuthPolicy>(`/security/policies/${uuid}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createAuthPolicy = async (policy: CreateAuthPolicyRequest): Promise<AuthPolicy> => {
|
||||
const { data } = await client.post<AuthPolicy>('/security/policies', policy);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateAuthPolicy = async (uuid: string, policy: UpdateAuthPolicyRequest): Promise<AuthPolicy> => {
|
||||
const { data } = await client.put<AuthPolicy>(`/security/policies/${uuid}`, policy);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteAuthPolicy = async (uuid: string): Promise<void> => {
|
||||
await client.delete(`/security/policies/${uuid}`);
|
||||
};
|
||||
@@ -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<ForwardAuthConfig>({
|
||||
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 <div className="animate-pulse h-64 bg-gray-100 dark:bg-gray-800 rounded-lg"></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-dark-card rounded-lg shadow-sm border border-gray-200 dark:border-gray-800 p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<Shield className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Forward Authentication</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Configure a global authentication provider (SSO) for your proxy hosts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Provider Template
|
||||
</label>
|
||||
<select
|
||||
value={formData.provider}
|
||||
onChange={(e) => handleTemplateChange(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="authelia">Authelia</option>
|
||||
<option value="authentik">Authentik</option>
|
||||
<option value="pomerium">Pomerium</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Select a template to pre-fill configuration or choose Custom.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Auth Service Address
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
value={formData.address}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
The internal URL where Caddy will send auth subrequests.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="trust_forward_header"
|
||||
checked={formData.trust_forward_header}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<label htmlFor="trust_forward_header" className="flex-1">
|
||||
<span className="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
Trust Forward Headers
|
||||
</span>
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
Send X-Forwarded-Method and X-Forwarded-Uri headers to the auth service. Required for most providers.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span>Changes apply immediately to all hosts using Forward Auth.</span>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={mutation.isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Save Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Access Control (SSO & Forward Auth) */}
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-gray-700 space-y-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<ShieldCheck className="text-blue-400" size={20} />
|
||||
<h3 className="text-lg font-medium text-white">Access Control</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Access Policy (Built-in SSO)
|
||||
</label>
|
||||
<select
|
||||
value={formData.auth_policy_id || ''}
|
||||
onChange={e => {
|
||||
const val = e.target.value ? parseInt(e.target.value) : null;
|
||||
setFormData({
|
||||
...formData,
|
||||
auth_policy_id: val,
|
||||
// If a policy is selected, disable legacy forward auth to avoid conflicts
|
||||
forward_auth_enabled: val ? false : formData.forward_auth_enabled
|
||||
});
|
||||
}}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Public (No Authentication)</option>
|
||||
{authPolicies.map(policy => (
|
||||
<option key={policy.id} value={policy.id}>
|
||||
{policy.name} {policy.description ? `(${policy.description})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Select a policy to protect this service with the built-in SSO.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Legacy Forward Auth - Only show if no policy is selected */}
|
||||
{!formData.auth_policy_id && (
|
||||
<div className="pt-4 border-t border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.forward_auth_enabled}
|
||||
onChange={e => setFormData({ ...formData, forward_auth_enabled: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-300">Enable External Forward Auth</span>
|
||||
</label>
|
||||
<div title="Protects this service using your configured global authentication provider (e.g. Authelia, Authentik)." className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.forward_auth_enabled && (
|
||||
<div className="mt-3">
|
||||
<label htmlFor="forward-auth-bypass" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Bypass Paths (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="forward-auth-bypass"
|
||||
value={formData.forward_auth_bypass}
|
||||
onChange={e => setFormData({ ...formData, forward_auth_bypass: e.target.value })}
|
||||
placeholder="/api/webhook, /public/*"
|
||||
rows={2}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Comma-separated list of paths to exclude from authentication.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Config */}
|
||||
<div>
|
||||
<label htmlFor="advanced-config" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
|
||||
@@ -66,7 +66,6 @@ describe('Layout', () => {
|
||||
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Certificates')).toBeInTheDocument()
|
||||
expect(screen.getByText('Import Caddyfile')).toBeInTheDocument()
|
||||
expect(screen.getByText('Security')).toBeInTheDocument()
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -234,25 +234,4 @@ describe('ProxyHostForm', () => {
|
||||
|
||||
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('my-app.existing.com')
|
||||
})
|
||||
|
||||
it('toggles forward auth fields', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// The Forward Auth toggle now uses "Enable External Forward Auth" label
|
||||
// and only appears when no Access Policy is selected (default is no policy)
|
||||
const toggle = screen.getByLabelText('Enable External Forward Auth')
|
||||
expect(toggle).not.toBeChecked()
|
||||
|
||||
// Bypass field should not be visible initially
|
||||
expect(screen.queryByLabelText('Bypass Paths (Optional)')).not.toBeInTheDocument()
|
||||
|
||||
// Enable it
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toBeChecked()
|
||||
|
||||
// Bypass field should now be visible
|
||||
expect(screen.getByLabelText('Bypass Paths (Optional)')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,8 +26,6 @@ const createMockHost = (overrides: Partial<api.ProxyHost> = {}): api.ProxyHost =
|
||||
hsts_subdomains: false,
|
||||
block_exploits: false,
|
||||
websocket_support: false,
|
||||
forward_auth_enabled: false,
|
||||
forward_auth_bypass: '',
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as api from '../api/security';
|
||||
|
||||
// Users Hooks
|
||||
export const useAuthUsers = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: users = [], isLoading, error } = useQuery({
|
||||
queryKey: ['auth-users'],
|
||||
queryFn: api.getAuthUsers,
|
||||
});
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ['auth-users-stats'],
|
||||
queryFn: api.getAuthUserStats,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: api.createAuthUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-users-stats'] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ uuid, data }: { uuid: string; data: api.UpdateAuthUserRequest }) =>
|
||||
api.updateAuthUser(uuid, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-users-stats'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: api.deleteAuthUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-users'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-users-stats'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
users,
|
||||
stats,
|
||||
isLoading: isLoading || statsLoading,
|
||||
error,
|
||||
createUser: createMutation.mutateAsync,
|
||||
updateUser: updateMutation.mutateAsync,
|
||||
deleteUser: deleteMutation.mutateAsync,
|
||||
};
|
||||
};
|
||||
|
||||
// Providers Hooks
|
||||
export const useAuthProviders = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: providers = [], isLoading, error } = useQuery({
|
||||
queryKey: ['auth-providers'],
|
||||
queryFn: api.getAuthProviders,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: api.createAuthProvider,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-providers'] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ uuid, data }: { uuid: string; data: api.UpdateAuthProviderRequest }) =>
|
||||
api.updateAuthProvider(uuid, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-providers'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: api.deleteAuthProvider,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-providers'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
providers,
|
||||
isLoading,
|
||||
error,
|
||||
createProvider: createMutation.mutateAsync,
|
||||
updateProvider: updateMutation.mutateAsync,
|
||||
deleteProvider: deleteMutation.mutateAsync,
|
||||
};
|
||||
};
|
||||
|
||||
// Policies Hooks
|
||||
export const useAuthPolicies = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: policies = [], isLoading, error } = useQuery({
|
||||
queryKey: ['auth-policies'],
|
||||
queryFn: api.getAuthPolicies,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: api.createAuthPolicy,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-policies'] });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ uuid, data }: { uuid: string; data: api.UpdateAuthPolicyRequest }) =>
|
||||
api.updateAuthPolicy(uuid, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-policies'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: api.deleteAuthPolicy,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-policies'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
policies,
|
||||
isLoading,
|
||||
error,
|
||||
createPolicy: createMutation.mutateAsync,
|
||||
updatePolicy: updateMutation.mutateAsync,
|
||||
deletePolicy: deleteMutation.mutateAsync,
|
||||
};
|
||||
};
|
||||
@@ -1,272 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuthPolicies } from '../../hooks/useSecurity';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { Plus, Edit, Trash2, ShieldCheck, Users, Globe } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { AuthPolicy, CreateAuthPolicyRequest, UpdateAuthPolicyRequest } from '../../api/security';
|
||||
|
||||
interface PolicyFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
allowed_roles: string;
|
||||
allowed_users: string;
|
||||
allowed_domains: string;
|
||||
require_mfa: boolean;
|
||||
session_timeout: number;
|
||||
}
|
||||
|
||||
export default function Policies() {
|
||||
const { policies, createPolicy, updatePolicy, deletePolicy, isLoading } = useAuthPolicies();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingPolicy, setEditingPolicy] = useState<AuthPolicy | null>(null);
|
||||
const [formData, setFormData] = useState<PolicyFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
allowed_roles: '',
|
||||
allowed_users: '',
|
||||
allowed_domains: '',
|
||||
require_mfa: false,
|
||||
session_timeout: 0,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingPolicy) {
|
||||
const updateData: UpdateAuthPolicyRequest = {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
allowed_roles: formData.allowed_roles,
|
||||
allowed_users: formData.allowed_users,
|
||||
allowed_domains: formData.allowed_domains,
|
||||
require_mfa: formData.require_mfa,
|
||||
session_timeout: formData.session_timeout,
|
||||
};
|
||||
await updatePolicy({ uuid: editingPolicy.uuid, data: updateData });
|
||||
toast.success('Policy updated successfully');
|
||||
} else {
|
||||
const createData: CreateAuthPolicyRequest = {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
allowed_roles: formData.allowed_roles,
|
||||
allowed_users: formData.allowed_users,
|
||||
allowed_domains: formData.allowed_domains,
|
||||
require_mfa: formData.require_mfa,
|
||||
session_timeout: formData.session_timeout,
|
||||
};
|
||||
await createPolicy(createData);
|
||||
toast.success('Policy created successfully');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
resetForm();
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error?: string } } };
|
||||
toast.error(err.response?.data?.error || 'Failed to save policy');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (confirm('Are you sure you want to delete this policy?')) {
|
||||
try {
|
||||
await deletePolicy(uuid);
|
||||
toast.success('Policy deleted successfully');
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error?: string } } };
|
||||
toast.error(err.response?.data?.error || 'Failed to delete policy');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
allowed_roles: '',
|
||||
allowed_users: '',
|
||||
allowed_domains: '',
|
||||
require_mfa: false,
|
||||
session_timeout: 0,
|
||||
});
|
||||
setEditingPolicy(null);
|
||||
};
|
||||
|
||||
const openEditModal = (policy: AuthPolicy) => {
|
||||
setEditingPolicy(policy);
|
||||
setFormData({
|
||||
name: policy.name,
|
||||
description: policy.description || '',
|
||||
allowed_roles: policy.allowed_roles || '',
|
||||
allowed_users: policy.allowed_users || '',
|
||||
allowed_domains: policy.allowed_domains || '',
|
||||
require_mfa: policy.require_mfa || false,
|
||||
session_timeout: policy.session_timeout || 0,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-white">Access Policies</h2>
|
||||
<Button onClick={() => { resetForm(); setIsModalOpen(true); }}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Policy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{policies.map((policy) => {
|
||||
return (
|
||||
<div key={policy.uuid} className="bg-dark-card rounded-lg border border-gray-800 p-6 flex flex-col md:flex-row justify-between gap-6">
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-medium text-white text-lg">{policy.name}</h3>
|
||||
{policy.require_mfa && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-blue-900/30 text-blue-400 border border-blue-900/50 flex items-center gap-1">
|
||||
<ShieldCheck size={12} /> MFA Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{policy.description || 'No description provided.'}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-4">
|
||||
{policy.allowed_roles && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<ShieldCheck size={16} className="text-gray-500" />
|
||||
<span>Roles: <span className="text-gray-300">{policy.allowed_roles}</span></span>
|
||||
</div>
|
||||
)}
|
||||
{policy.allowed_users && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Users size={16} className="text-gray-500" />
|
||||
<span>Users: <span className="text-gray-300">{policy.allowed_users}</span></span>
|
||||
</div>
|
||||
)}
|
||||
{policy.allowed_domains && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Globe size={16} className="text-gray-500" />
|
||||
<span>Domains: <span className="text-gray-300">{policy.allowed_domains}</span></span>
|
||||
</div>
|
||||
)}
|
||||
{!policy.allowed_roles && !policy.allowed_users && !policy.allowed_domains && (
|
||||
<span className="text-sm text-yellow-500">Public Access (No restrictions)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<button
|
||||
onClick={() => openEditModal(policy)}
|
||||
className="p-2 rounded-md hover:bg-gray-700 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Edit size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(policy.uuid)}
|
||||
className="p-2 rounded-md hover:bg-red-900/30 text-gray-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{policies.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 bg-dark-card rounded-lg border border-gray-800 border-dashed">
|
||||
No access policies defined. Create one to protect your services.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Policy Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-bold text-white mb-4">
|
||||
{editingPolicy ? 'Edit Policy' : 'Add Policy'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Policy Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Admins Only"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Allowed Roles</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.allowed_roles}
|
||||
onChange={e => setFormData({ ...formData, allowed_roles: e.target.value })}
|
||||
placeholder="admin, editor"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Comma-separated list of roles</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Allowed Users</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.allowed_users}
|
||||
onChange={e => setFormData({ ...formData, allowed_users: e.target.value })}
|
||||
placeholder="john, jane@example.com"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Comma-separated usernames/emails</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Allowed Domains</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.allowed_domains}
|
||||
onChange={e => setFormData({ ...formData, allowed_domains: e.target.value })}
|
||||
placeholder="example.com, corp.net"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Restrict access to users with these email domains</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="require_mfa"
|
||||
checked={formData.require_mfa}
|
||||
onChange={e => setFormData({ ...formData, require_mfa: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="require_mfa" className="text-sm text-gray-400">Require MFA</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="ghost" onClick={() => setIsModalOpen(false)}>Cancel</Button>
|
||||
<Button type="submit">{editingPolicy ? 'Save Changes' : 'Create Policy'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useAuthProviders } from '../../hooks/useSecurity';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { Plus, Edit, Trash2, Globe } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { AuthProvider, CreateAuthProviderRequest, UpdateAuthProviderRequest } from '../../api/security';
|
||||
|
||||
interface ProviderFormData {
|
||||
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;
|
||||
display_name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface HelpTooltipProps {
|
||||
content: React.ReactNode;
|
||||
position?: 'left' | 'right';
|
||||
}
|
||||
|
||||
const HelpTooltip = ({ content, position = 'left' }: HelpTooltipProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-4 h-4 rounded-full bg-gray-700 text-gray-400 hover:bg-gray-600 flex items-center justify-center text-xs cursor-help"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`absolute bottom-6 ${position === 'left' ? 'left-0' : 'right-0'} bg-gray-800 text-white text-xs rounded-lg px-3 py-2 w-72 z-10 shadow-lg border border-gray-700`}
|
||||
>
|
||||
{content}
|
||||
<div className={`absolute top-full ${position === 'left' ? 'left-1' : 'right-1'} border-4 border-transparent border-t-gray-800`}></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Providers() {
|
||||
const { providers, createProvider, updateProvider, deleteProvider, isLoading } = useAuthProviders();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<AuthProvider | null>(null);
|
||||
const [formData, setFormData] = useState<ProviderFormData>({
|
||||
name: '',
|
||||
type: 'oidc',
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
issuer_url: '',
|
||||
auth_url: '',
|
||||
token_url: '',
|
||||
user_info_url: '',
|
||||
scopes: 'openid,profile,email',
|
||||
display_name: '',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingProvider) {
|
||||
const updateData: UpdateAuthProviderRequest = {
|
||||
name: formData.name,
|
||||
client_id: formData.client_id,
|
||||
issuer_url: formData.issuer_url,
|
||||
auth_url: formData.auth_url,
|
||||
token_url: formData.token_url,
|
||||
user_info_url: formData.user_info_url,
|
||||
scopes: formData.scopes,
|
||||
display_name: formData.display_name,
|
||||
enabled: formData.enabled,
|
||||
};
|
||||
if (formData.client_secret) {
|
||||
updateData.client_secret = formData.client_secret;
|
||||
}
|
||||
await updateProvider({ uuid: editingProvider.uuid, data: updateData });
|
||||
toast.success('Provider updated successfully');
|
||||
} else {
|
||||
const createData: CreateAuthProviderRequest = {
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
client_id: formData.client_id,
|
||||
client_secret: formData.client_secret,
|
||||
issuer_url: formData.issuer_url,
|
||||
auth_url: formData.auth_url,
|
||||
token_url: formData.token_url,
|
||||
user_info_url: formData.user_info_url,
|
||||
scopes: formData.scopes,
|
||||
display_name: formData.display_name,
|
||||
};
|
||||
await createProvider(createData);
|
||||
toast.success('Provider created successfully');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
resetForm();
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error?: string } } };
|
||||
toast.error(err.response?.data?.error || 'Failed to save provider');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (confirm('Are you sure you want to delete this provider?')) {
|
||||
try {
|
||||
await deleteProvider(uuid);
|
||||
toast.success('Provider deleted successfully');
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error?: string } } };
|
||||
toast.error(err.response?.data?.error || 'Failed to delete provider');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
type: 'oidc',
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
issuer_url: '',
|
||||
auth_url: '',
|
||||
token_url: '',
|
||||
user_info_url: '',
|
||||
scopes: 'openid,profile,email',
|
||||
display_name: '',
|
||||
enabled: true,
|
||||
});
|
||||
setEditingProvider(null);
|
||||
};
|
||||
|
||||
const openEditModal = (provider: AuthProvider) => {
|
||||
setEditingProvider(provider);
|
||||
setFormData({
|
||||
name: provider.name,
|
||||
type: provider.type,
|
||||
client_id: provider.client_id,
|
||||
client_secret: '', // Don't populate secret
|
||||
issuer_url: provider.issuer_url || '',
|
||||
auth_url: provider.auth_url || '',
|
||||
token_url: provider.token_url || '',
|
||||
user_info_url: provider.user_info_url || '',
|
||||
scopes: provider.scopes || 'openid,profile,email',
|
||||
display_name: provider.display_name || '',
|
||||
enabled: provider.enabled,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-white">Identity Providers</h2>
|
||||
<Button onClick={() => { resetForm(); setIsModalOpen(true); }}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Provider
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{providers.map((provider) => (
|
||||
<div key={provider.uuid} className="bg-dark-card rounded-lg border border-gray-800 p-6 space-y-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-900/30 flex items-center justify-center text-purple-400">
|
||||
<Globe size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white">{provider.name}</h3>
|
||||
<p className="text-xs text-gray-500 uppercase">{provider.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => openEditModal(provider)}
|
||||
className="p-1.5 rounded-md hover:bg-gray-700 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(provider.uuid)}
|
||||
className="p-1.5 rounded-md hover:bg-red-900/30 text-gray-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm text-gray-400">
|
||||
<div className="flex justify-between">
|
||||
<span>Client ID:</span>
|
||||
<span className="font-mono text-gray-300">{provider.client_id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Status:</span>
|
||||
{provider.enabled ? (
|
||||
<span className="text-green-400">Active</span>
|
||||
) : (
|
||||
<span className="text-red-400">Disabled</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{providers.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 text-gray-500 bg-dark-card rounded-lg border border-gray-800 border-dashed">
|
||||
No identity providers configured. Add one to enable external authentication.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-3xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-bold text-white mb-4">
|
||||
{editingProvider ? 'Edit Provider' : 'Add Provider'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Google"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Type</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={e => setFormData({ ...formData, type: e.target.value as 'google' | 'github' | 'oidc' })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="oidc">Generic OIDC</option>
|
||||
<option value="google">Google</option>
|
||||
<option value="github">GitHub</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-400">Client ID</label>
|
||||
<HelpTooltip
|
||||
position="left"
|
||||
content={
|
||||
<>
|
||||
<div className="mb-2">The public identifier for your OAuth application.</div>
|
||||
<div className="space-y-1">
|
||||
<div><strong>Google:</strong> <a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener" className="text-blue-300 hover:text-blue-200 underline">Google Cloud Console</a></div>
|
||||
<div><strong>GitHub:</strong> <a href="https://github.com/settings/developers" target="_blank" rel="noopener" className="text-blue-300 hover:text-blue-200 underline">Developer Settings</a></div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.client_id}
|
||||
onChange={e => setFormData({ ...formData, client_id: e.target.value })}
|
||||
placeholder="e.g., 123456789.apps.googleusercontent.com"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-400">
|
||||
{editingProvider ? 'Client Secret (leave blank to keep)' : 'Client Secret'}
|
||||
</label>
|
||||
<HelpTooltip
|
||||
position="right"
|
||||
content={
|
||||
<>
|
||||
<div className="mb-2">The private key for your OAuth application. Keep this secret and secure!</div>
|
||||
<div className="space-y-1">
|
||||
<div><strong>Google:</strong> <a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener" className="text-blue-300 hover:text-blue-200 underline">Google Cloud Console</a></div>
|
||||
<div><strong>GitHub:</strong> <a href="https://github.com/settings/developers" target="_blank" rel="noopener" className="text-blue-300 hover:text-blue-200 underline">Developer Settings</a></div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
required={!editingProvider}
|
||||
value={formData.client_secret}
|
||||
onChange={e => setFormData({ ...formData, client_secret: e.target.value })}
|
||||
placeholder="Enter your client secret"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.type === 'oidc' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Issuer URL (Discovery)</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.issuer_url}
|
||||
onChange={e => setFormData({ ...formData, issuer_url: e.target.value })}
|
||||
placeholder="https://accounts.google.com"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Scopes</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.scopes}
|
||||
onChange={e => setFormData({ ...formData, scopes: e.target.value })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.display_name}
|
||||
onChange={e => setFormData({ ...formData, display_name: e.target.value })}
|
||||
placeholder="Sign in with Google"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
checked={formData.enabled}
|
||||
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="enabled" className="text-sm text-gray-400">Enabled</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="ghost" onClick={() => setIsModalOpen(false)}>Cancel</Button>
|
||||
<Button type="submit">{editingProvider ? 'Save Changes' : 'Create Provider'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuthUsers } from '../../hooks/useSecurity';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { Plus, Edit, Trash2, Shield, User } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { AuthUser, CreateAuthUserRequest, UpdateAuthUserRequest } from '../../api/security';
|
||||
|
||||
export default function Users() {
|
||||
const { users, createUser, updateUser, deleteUser, isLoading } = useAuthUsers();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<AuthUser | null>(null);
|
||||
const [formData, setFormData] = useState<CreateAuthUserRequest>({
|
||||
username: '',
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
roles: '',
|
||||
mfa_enabled: false,
|
||||
additional_emails: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingUser) {
|
||||
const updateData: UpdateAuthUserRequest = {
|
||||
email: formData.email,
|
||||
name: formData.name,
|
||||
roles: formData.roles,
|
||||
mfa_enabled: formData.mfa_enabled,
|
||||
additional_emails: formData.additional_emails,
|
||||
};
|
||||
if (formData.password) {
|
||||
updateData.password = formData.password;
|
||||
}
|
||||
await updateUser({ uuid: editingUser.uuid, data: updateData });
|
||||
toast.success('User updated successfully');
|
||||
} else {
|
||||
await createUser(formData);
|
||||
toast.success('User created successfully');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
resetForm();
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error?: string } } };
|
||||
toast.error(err.response?.data?.error || 'Failed to save user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (confirm('Are you sure you want to delete this user?')) {
|
||||
try {
|
||||
await deleteUser(uuid);
|
||||
toast.success('User deleted successfully');
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error?: string } } };
|
||||
toast.error(err.response?.data?.error || 'Failed to delete user');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
username: '',
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
roles: '',
|
||||
mfa_enabled: false,
|
||||
additional_emails: '',
|
||||
});
|
||||
setEditingUser(null);
|
||||
};
|
||||
|
||||
const openEditModal = (user: AuthUser) => {
|
||||
setEditingUser(user);
|
||||
setFormData({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
password: '', // Don't populate password
|
||||
roles: user.roles,
|
||||
mfa_enabled: user.mfa_enabled,
|
||||
additional_emails: user.additional_emails || '',
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-white">Local Users</h2>
|
||||
<Button onClick={() => { resetForm(); setIsModalOpen(true); }}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
<table className="w-full text-left text-sm text-gray-400">
|
||||
<thead className="bg-gray-900 text-gray-200 uppercase font-medium">
|
||||
<tr>
|
||||
<th className="px-6 py-3">User</th>
|
||||
<th className="px-6 py-3">Name</th>
|
||||
<th className="px-6 py-3">Roles</th>
|
||||
<th className="px-6 py-3">Created</th>
|
||||
<th className="px-6 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{users.map((user) => (
|
||||
<tr key={user.uuid} className="hover:bg-gray-800/50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-900/30 flex items-center justify-center text-blue-400">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-white">{user.username}</div>
|
||||
<div className="text-xs text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{user.name}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{user.roles ? (
|
||||
<span className="text-blue-400 flex items-center gap-1">
|
||||
<Shield size={14} /> {user.roles}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-600">User</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => openEditModal(user)}
|
||||
className="p-1.5 rounded-md hover:bg-gray-700 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.uuid)}
|
||||
className="p-1.5 rounded-md hover:bg-red-900/30 text-gray-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-8 text-center text-gray-500">
|
||||
No users found. Create one to get started.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* User Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-md w-full p-6">
|
||||
<h3 className="text-lg font-bold text-white mb-4">
|
||||
{editingUser ? 'Edit User' : 'Add User'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={e => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
disabled={!!editingUser}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
{editingUser ? 'New Password (leave blank to keep)' : 'Password'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required={!editingUser}
|
||||
value={formData.password}
|
||||
onChange={e => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Roles (comma separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.roles}
|
||||
onChange={e => setFormData({ ...formData, roles: e.target.value })}
|
||||
placeholder="admin, editor"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Additional Emails (comma separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.additional_emails || ''}
|
||||
onChange={e => setFormData({ ...formData, additional_emails: e.target.value })}
|
||||
placeholder="email2@example.com, email3@example.com"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Used for linking multiple OAuth identities to this user.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="mfa_enabled"
|
||||
checked={formData.mfa_enabled}
|
||||
onChange={e => setFormData({ ...formData, mfa_enabled: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="mfa_enabled" className="text-sm text-gray-400">MFA Enabled</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="ghost" onClick={() => setIsModalOpen(false)}>Cancel</Button>
|
||||
<Button type="submit">{editingUser ? 'Save Changes' : 'Create User'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Users, Globe, Lock } from 'lucide-react';
|
||||
import UsersPage from './Users';
|
||||
import ProvidersPage from './Providers';
|
||||
import PoliciesPage from './Policies';
|
||||
|
||||
export default function Security() {
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'providers' | 'policies'>('users');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Security & Access Control</h1>
|
||||
<p className="text-gray-400">Manage users, identity providers, and access policies for your services.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-gray-800">
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
|
||||
activeTab === 'users'
|
||||
? 'border-blue-500 text-blue-500'
|
||||
: 'border-transparent text-gray-400 hover:text-gray-300 hover:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Users size={16} />
|
||||
Users
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('providers')}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
|
||||
activeTab === 'providers'
|
||||
? 'border-blue-500 text-blue-500'
|
||||
: 'border-transparent text-gray-400 hover:text-gray-300 hover:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Globe size={16} />
|
||||
Identity Providers
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('policies')}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${
|
||||
activeTab === 'policies'
|
||||
? 'border-blue-500 text-blue-500'
|
||||
: 'border-transparent text-gray-400 hover:text-gray-300 hover:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Lock size={16} />
|
||||
Access Policies
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
{activeTab === 'users' && <UsersPage />}
|
||||
{activeTab === 'providers' && <ProvidersPage />}
|
||||
{activeTab === 'policies' && <PoliciesPage />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,6 @@ export const mockProxyHosts: ProxyHost[] = [
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: true,
|
||||
forward_auth_enabled: false,
|
||||
forward_auth_bypass: '',
|
||||
locations: [],
|
||||
advanced_config: undefined,
|
||||
enabled: true,
|
||||
@@ -36,8 +34,6 @@ export const mockProxyHosts: ProxyHost[] = [
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: false,
|
||||
forward_auth_enabled: false,
|
||||
forward_auth_bypass: '',
|
||||
locations: [],
|
||||
advanced_config: undefined,
|
||||
enabled: true,
|
||||
|
||||
Reference in New Issue
Block a user