+
+
+
+ if service.Type == "email" {
+
+
+
+ } else if service.Type == "webhook" {
+
+
+
+ } else {
+
+
+
+ }
+
+
+ { service.Name }
+
+
+ { service.Description }
+
- } else if service.Type == "webhook" {
-
-
+
+
+
+
+
+
+
+
+
+
+ if service.IsEnabled {
+ Active
+ } else {
+ Disabled
+ }
+
+
+ { service.Type }
+
+ if len(service.EventTriggers) > 0 && service.Type == "webhook" {
+
+ { fmt.Sprintf("%d triggers", len(service.EventTriggers)) }
+
+ }
+ if service.SuccessCount > 0 || service.FailureCount > 0 {
+
+ { fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount + service.FailureCount) }
+
+ }
+
+
+ if service.Type == "webhook" {
+
+
+ Events:
+
+ if len(service.EventTriggers) == 0 {
+ None
+ } else {
+ for i, trigger := range service.EventTriggers {
+ if i > 0 {
+ ,
+ }
+ { trigger }
+ }
+ }
+
+
+
+ Retry:
+
+ if service.RetryPolicy == "" {
+ Default
+ } else {
+ { service.RetryPolicy }
+ }
+
+
} else {
-
-
+
+
+
+ Last sent:
+ if service.SuccessCount > 0 {
+ "Recently"
+ } else {
+ "Never"
+ }
+
}
-
-
- { service.Name }
-
-
- { service.Description }
-
-
-
-
-
-
-
-
-
-
- if service.IsEnabled {
- Active
- } else {
- Disabled
- }
-
-
- { service.Type }
-
- if len(service.EventTriggers) > 0 && service.Type == "webhook" {
-
- { fmt.Sprintf("%d triggers", len(service.EventTriggers)) }
-
- }
- if service.SuccessCount > 0 || service.FailureCount > 0 {
-
- { fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount + service.FailureCount) }
-
- }
-
+
+
+ }
+
+
+ }
+
+
+
+
+
+
Security Settings
+
+
+
+
Authentication Sources
+
+
+
+
+
+ Configure external authentication providers to allow users to log in using existing accounts.
+
+
+
+
+
+ -
+
+
+
+
+
-
- if service.Type == "webhook" {
-
-
- Events:
-
- if len(service.EventTriggers) == 0 {
- None
- } else {
- for i, trigger := range service.EventTriggers {
- if i > 0 {
- ,
- }
- { trigger }
- }
- }
-
-
-
- Retry:
-
- if service.RetryPolicy == "" {
- Default
- } else {
- { service.RetryPolicy }
- }
-
-
-
- } else {
-
-
-
Last sent:
- if service.SuccessCount > 0 {
- "Recently"
- } else {
- "Never"
- }
-
-
- }
+
+
+ Local Authentication
+
+
+ Default username and password authentication
+
+
+
+
+
+ Active
+
- }
-
-
- }
-
-
-
-
-
-
-
-
}
@toggleNotificationFields()
+ @addAuthSourceModals()
+ @setupTabSwitching()
}
+// Add new templ component for auth source modals
+templ addAuthSourceModals() {
+
+
+
+
+
+
+ Add Authentication Source
+
+
+
+
+
+
+
+
+}
+
+// Script to toggle notification fields based on selection
script toggleNotificationFields() {
document.addEventListener('DOMContentLoaded', function() {
const typeSelector = document.getElementById('notification_type');
@@ -469,3 +682,83 @@ script toggleNotificationFields() {
});
});
}
+
+// Add script to toggle authentication source fields based on type selection
+script toggleAuthSourceFields() {
+ document.addEventListener('DOMContentLoaded', function() {
+ const typeSelector = document.getElementById('auth_type');
+ const authFields = document.querySelectorAll('.auth-type-fields');
+
+ if (typeSelector) {
+ typeSelector.addEventListener('change', function() {
+ // Hide all auth type fields first
+ authFields.forEach(field => field.classList.add('hidden'));
+
+ // Show the selected type's fields
+ const selectedType = this.value;
+ if (selectedType) {
+ const fieldsToShow = document.getElementById(`${selectedType}_fields`);
+ if (fieldsToShow) {
+ fieldsToShow.classList.remove('hidden');
+ }
+ }
+ });
+ }
+ });
+}
+
+// Add script to handle tab switching
+script setupTabSwitching() {
+ document.addEventListener('DOMContentLoaded', function() {
+ const tabButtons = document.querySelectorAll('[role="tab"]');
+ const tabPanels = document.querySelectorAll('[role="tabpanel"]');
+
+ function switchTab(targetId) {
+ // Hide all tab panels
+ tabPanels.forEach(panel => {
+ panel.classList.add('hidden');
+ });
+
+ // Show the target panel
+ const targetPanel = document.querySelector(targetId);
+ if (targetPanel) {
+ targetPanel.classList.remove('hidden');
+ targetPanel.classList.add('block');
+ }
+
+ // Update tab button styles
+ tabButtons.forEach(button => {
+ // Get the target from the button
+ const buttonTarget = button.getAttribute('data-tabs-target');
+
+ if (buttonTarget === targetId) {
+ // Active tab
+ button.classList.add('border-blue-600', 'text-blue-600', 'dark:text-blue-500', 'dark:border-blue-500');
+ button.classList.remove('border-transparent', 'hover:border-gray-300', 'hover:text-gray-600', 'dark:hover:text-gray-300');
+ button.setAttribute('aria-selected', 'true');
+ } else {
+ // Inactive tab
+ button.classList.remove('border-blue-600', 'text-blue-600', 'dark:text-blue-500', 'dark:border-blue-500');
+ button.classList.add('border-transparent', 'hover:border-gray-300', 'hover:text-gray-600', 'dark:hover:text-gray-300');
+ button.setAttribute('aria-selected', 'false');
+ }
+ });
+ }
+
+ // Add click event listeners to tab buttons
+ tabButtons.forEach(button => {
+ button.addEventListener('click', function(e) {
+ e.preventDefault();
+ const targetId = this.getAttribute('data-tabs-target');
+ switchTab(targetId);
+ });
+ });
+
+ // Initialize the tabs (select the one with aria-selected="true")
+ const initialTab = document.querySelector('[role="tab"][aria-selected="true"]');
+ if (initialTab) {
+ const targetId = initialTab.getAttribute('data-tabs-target');
+ switchTab(targetId);
+ }
+ });
+}
diff --git a/internal/db/auth_provider.go b/internal/db/auth_provider.go
new file mode 100644
index 0000000..b08c681
--- /dev/null
+++ b/internal/db/auth_provider.go
@@ -0,0 +1,154 @@
+package db
+
+import (
+ "database/sql"
+ "encoding/json"
+ "time"
+)
+
+// ProviderType represents the type of authentication provider
+type ProviderType string
+
+const (
+ // ProviderTypeAuthentik represents an Authentik authentication provider
+ ProviderTypeAuthentik ProviderType = "authentik"
+
+ // ProviderTypeOIDC represents an OpenID Connect authentication provider
+ ProviderTypeOIDC ProviderType = "oidc"
+
+ // ProviderTypeSAML represents a SAML authentication provider
+ ProviderTypeSAML ProviderType = "saml"
+
+ // ProviderTypeOAuth2 represents an OAuth2 authentication provider
+ ProviderTypeOAuth2 ProviderType = "oauth2"
+)
+
+// AuthProvider represents an external authentication provider configuration
+type AuthProvider struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ Name string `gorm:"not null" json:"name"`
+ Type ProviderType `gorm:"not null" json:"type"`
+ Enabled bool `gorm:"default:true" json:"enabled"`
+ Description string `json:"description"`
+ ProviderURL string `json:"provider_url"`
+ ClientID string `json:"client_id"`
+ ClientSecret string `json:"-"` // Not returned in JSON responses
+ RedirectURL string `json:"redirect_url"`
+ Scopes string `json:"scopes"`
+ AttributeMapping string `json:"attribute_mapping"`
+ Config string `json:"-"` // Stores provider-specific configuration
+ IconURL string `json:"icon_url"` // URL to provider icon
+ SuccessfulLogins int `json:"successful_logins"`
+ LastUsed sql.NullTime `json:"last_used"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+
+ // Unmarshalled config
+ configData map[string]interface{} `gorm:"-" json:"-"`
+}
+
+// GetConfig returns the unmarshalled configuration data
+func (p *AuthProvider) GetConfig() (map[string]interface{}, error) {
+ if p.configData == nil && p.Config != "" {
+ err := json.Unmarshal([]byte(p.Config), &p.configData)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if p.configData == nil {
+ p.configData = make(map[string]interface{})
+ }
+
+ return p.configData, nil
+}
+
+// SetConfig sets the configuration data and marshals it to JSON
+func (p *AuthProvider) SetConfig(data map[string]interface{}) error {
+ jsonData, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ p.Config = string(jsonData)
+ p.configData = data
+ return nil
+}
+
+// ExternalUserIdentity represents a user identity from an external authentication provider
+type ExternalUserIdentity struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ UserID uint `gorm:"not null" json:"user_id"`
+ ProviderID uint `gorm:"not null" json:"provider_id"`
+ ProviderType ProviderType `gorm:"not null" json:"provider_type"`
+ ExternalID string `gorm:"not null" json:"external_id"`
+ Email string `gorm:"not null" json:"email"`
+ Username string `json:"username"`
+ DisplayName string `json:"display_name"`
+ Groups string `json:"groups"` // JSON array of groups
+ LastLogin sql.NullTime `json:"last_login"`
+ ProviderData string `json:"-"` // Raw data from provider
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+
+ // Foreign key relationships
+ User User `gorm:"foreignKey:UserID" json:"-"`
+ Provider AuthProvider `gorm:"foreignKey:ProviderID" json:"-"`
+
+ // Unmarshalled provider data
+ providerDataObj map[string]interface{} `gorm:"-" json:"-"`
+}
+
+// GetProviderData returns the unmarshalled provider data
+func (e *ExternalUserIdentity) GetProviderData() (map[string]interface{}, error) {
+ if e.providerDataObj == nil && e.ProviderData != "" {
+ err := json.Unmarshal([]byte(e.ProviderData), &e.providerDataObj)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if e.providerDataObj == nil {
+ e.providerDataObj = make(map[string]interface{})
+ }
+
+ return e.providerDataObj, nil
+}
+
+// SetProviderData sets the provider data and marshals it to JSON
+func (e *ExternalUserIdentity) SetProviderData(data map[string]interface{}) error {
+ jsonData, err := json.Marshal(data)
+ if err != nil {
+ return err
+ }
+
+ e.ProviderData = string(jsonData)
+ e.providerDataObj = data
+ return nil
+}
+
+// GetGroups returns the unmarshalled groups
+func (e *ExternalUserIdentity) GetGroups() ([]string, error) {
+ var groups []string
+ if e.Groups == "" {
+ return groups, nil
+ }
+
+ err := json.Unmarshal([]byte(e.Groups), &groups)
+ if err != nil {
+ return nil, err
+ }
+
+ return groups, nil
+}
+
+// SetGroups sets the groups and marshals them to JSON
+func (e *ExternalUserIdentity) SetGroups(groups []string) error {
+ jsonData, err := json.Marshal(groups)
+ if err != nil {
+ return err
+ }
+
+ e.Groups = string(jsonData)
+ return nil
+}
diff --git a/internal/db/auth_provider_db.go b/internal/db/auth_provider_db.go
new file mode 100644
index 0000000..3188ba6
--- /dev/null
+++ b/internal/db/auth_provider_db.go
@@ -0,0 +1,156 @@
+package db
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "gorm.io/gorm"
+)
+
+// GetAllAuthProviders returns all authentication providers
+func (db *DB) GetAllAuthProviders(ctx context.Context) ([]AuthProvider, error) {
+ var providers []AuthProvider
+ tx := db.WithContext(ctx).Order("name asc").Find(&providers)
+ if tx.Error != nil {
+ return nil, fmt.Errorf("failed to get auth providers: %w", tx.Error)
+ }
+ return providers, nil
+}
+
+// GetAuthProviderByID retrieves an authentication provider by ID
+func (db *DB) GetAuthProviderByID(ctx context.Context, id uint) (*AuthProvider, error) {
+ var provider AuthProvider
+ tx := db.WithContext(ctx).First(&provider, id)
+ if tx.Error != nil {
+ if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
+ return nil, fmt.Errorf("auth provider not found: %d", id)
+ }
+ return nil, fmt.Errorf("failed to get auth provider: %w", tx.Error)
+ }
+ return &provider, nil
+}
+
+// CreateAuthProvider creates a new authentication provider
+func (db *DB) CreateAuthProvider(ctx context.Context, provider *AuthProvider) error {
+ tx := db.WithContext(ctx).Create(provider)
+ if tx.Error != nil {
+ return fmt.Errorf("failed to create auth provider: %w", tx.Error)
+ }
+ return nil
+}
+
+// UpdateAuthProvider updates an existing authentication provider
+func (db *DB) UpdateAuthProvider(ctx context.Context, provider *AuthProvider) error {
+ tx := db.WithContext(ctx).Save(provider)
+ if tx.Error != nil {
+ return fmt.Errorf("failed to update auth provider: %w", tx.Error)
+ }
+ return nil
+}
+
+// DeleteAuthProvider deletes an authentication provider by ID
+func (db *DB) DeleteAuthProvider(ctx context.Context, id uint) error {
+ tx := db.WithContext(ctx).Delete(&AuthProvider{}, id)
+ if tx.Error != nil {
+ return fmt.Errorf("failed to delete auth provider: %w", tx.Error)
+ }
+ if tx.RowsAffected == 0 {
+ return fmt.Errorf("auth provider not found: %d", id)
+ }
+ return nil
+}
+
+// GetEnabledAuthProviders returns all enabled authentication providers
+func (db *DB) GetEnabledAuthProviders(ctx context.Context) ([]AuthProvider, error) {
+ var providers []AuthProvider
+ tx := db.WithContext(ctx).Where("enabled = ?", true).Order("name asc").Find(&providers)
+ if tx.Error != nil {
+ return nil, fmt.Errorf("failed to get enabled auth providers: %w", tx.Error)
+ }
+ return providers, nil
+}
+
+// GetAuthProviderByType returns authentication providers of a specific type
+func (db *DB) GetAuthProviderByType(ctx context.Context, providerType ProviderType) ([]AuthProvider, error) {
+ var providers []AuthProvider
+ tx := db.WithContext(ctx).Where("type = ?", providerType).Order("name asc").Find(&providers)
+ if tx.Error != nil {
+ return nil, fmt.Errorf("failed to get auth providers by type: %w", tx.Error)
+ }
+ return providers, nil
+}
+
+// GetExternalUserIdentitiesByProviderID returns all external user identities for a specific provider
+func (db *DB) GetExternalUserIdentitiesByProviderID(ctx context.Context, providerID uint) ([]ExternalUserIdentity, error) {
+ var identities []ExternalUserIdentity
+ tx := db.WithContext(ctx).Where("provider_id = ?", providerID).Find(&identities)
+ if tx.Error != nil {
+ return nil, fmt.Errorf("failed to get external user identities: %w", tx.Error)
+ }
+ return identities, nil
+}
+
+// CountExternalUserIdentitiesByProviderID counts the number of external user identities for a specific provider
+func (db *DB) CountExternalUserIdentitiesByProviderID(ctx context.Context, providerID uint) (int64, error) {
+ var count int64
+ tx := db.WithContext(ctx).Model(&ExternalUserIdentity{}).Where("provider_id = ?", providerID).Count(&count)
+ if tx.Error != nil {
+ return 0, fmt.Errorf("failed to count external user identities: %w", tx.Error)
+ }
+ return count, nil
+}
+
+// GetExternalUserIdentity gets an external user identity by provider ID and external ID
+func (db *DB) GetExternalUserIdentity(ctx context.Context, providerID uint, externalID string) (*ExternalUserIdentity, error) {
+ var identity ExternalUserIdentity
+ tx := db.WithContext(ctx).Where("provider_id = ? AND external_id = ?", providerID, externalID).First(&identity)
+ if tx.Error != nil {
+ if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
+ return nil, nil // Not found, but not an error
+ }
+ return nil, fmt.Errorf("failed to get external user identity: %w", tx.Error)
+ }
+ return &identity, nil
+}
+
+// CreateExternalUserIdentity creates a new external user identity
+func (db *DB) CreateExternalUserIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
+ tx := db.WithContext(ctx).Create(identity)
+ if tx.Error != nil {
+ return fmt.Errorf("failed to create external user identity: %w", tx.Error)
+ }
+ return nil
+}
+
+// UpdateExternalUserIdentity updates an existing external user identity
+func (db *DB) UpdateExternalUserIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
+ tx := db.WithContext(ctx).Save(identity)
+ if tx.Error != nil {
+ return fmt.Errorf("failed to update external user identity: %w", tx.Error)
+ }
+ return nil
+}
+
+// DeleteExternalUserIdentity deletes an external user identity by ID
+func (db *DB) DeleteExternalUserIdentity(ctx context.Context, id uint) error {
+ tx := db.WithContext(ctx).Delete(&ExternalUserIdentity{}, id)
+ if tx.Error != nil {
+ return fmt.Errorf("failed to delete external user identity: %w", tx.Error)
+ }
+ return nil
+}
+
+// UpdateAuthProviderLastUsed updates the last used timestamp and increments the successful logins counter
+func (db *DB) UpdateAuthProviderLastUsed(ctx context.Context, providerID uint) error {
+ tx := db.WithContext(ctx).Model(&AuthProvider{}).
+ Where("id = ?", providerID).
+ Updates(map[string]interface{}{
+ "last_used": gorm.Expr("NOW()"),
+ "successful_logins": gorm.Expr("successful_logins + 1"),
+ })
+ if tx.Error != nil {
+ return fmt.Errorf("failed to update auth provider last used: %w", tx.Error)
+ }
+ return nil
+}
diff --git a/internal/db/db.go b/internal/db/db.go
index abc92f0..c6dc67a 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -1,6 +1,7 @@
package db
import (
+ "context"
"errors"
"fmt"
"log"
@@ -2079,3 +2080,30 @@ func (db *DB) GetRcloneCommandFlagsMap(commandID uint) (map[uint]RcloneCommandFl
return flagsMap, nil
}
+
+// GetEnabledAuthProviders returns all enabled authentication providers
+// func (db *DB) GetEnabledAuthProviders(ctx context.Context) ([]AuthProvider, error) {
+// var providers []AuthProvider
+// result := db.WithContext(ctx).Where("enabled = ?", true).Find(&providers)
+// return providers, result.Error
+// }
+
+// GetExternalIdentity retrieves an external identity by provider ID and external ID
+func (db *DB) GetExternalIdentity(ctx context.Context, providerID uint, externalID string) (*ExternalUserIdentity, error) {
+ var identity ExternalUserIdentity
+ result := db.WithContext(ctx).Where("provider_id = ? AND external_id = ?", providerID, externalID).First(&identity)
+ if result.Error != nil {
+ return nil, result.Error
+ }
+ return &identity, nil
+}
+
+// CreateExternalIdentity creates a new external user identity
+func (db *DB) CreateExternalIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
+ return db.WithContext(ctx).Create(identity).Error
+}
+
+// UpdateExternalIdentity updates an existing external user identity
+func (db *DB) UpdateExternalIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
+ return db.WithContext(ctx).Save(identity).Error
+}
diff --git a/internal/db/migrations/011_add_auth_providers.go b/internal/db/migrations/011_add_auth_providers.go
new file mode 100644
index 0000000..3c42f9c
--- /dev/null
+++ b/internal/db/migrations/011_add_auth_providers.go
@@ -0,0 +1,134 @@
+package migrations
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/go-gormigrate/gormigrate/v2"
+ "gorm.io/gorm"
+)
+
+// AddAuthProviders adds tables for external authentication providers
+func AddAuthProviders() *gormigrate.Migration {
+ return &gormigrate.Migration{
+ ID: "011_add_auth_providers",
+ Migrate: func(tx *gorm.DB) error {
+ // Check if any tables exist (indicating an existing database)
+ var count int64
+ if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
+ return fmt.Errorf("failed to check for existing tables: %v", err)
+ }
+
+ // If tables exist, create a backup
+ if count > 0 {
+ // Get the database path
+ sqlDB, err := tx.DB()
+ if err != nil {
+ return fmt.Errorf("failed to get underlying database: %v", err)
+ }
+
+ var seq int
+ var name, dbPath string
+ if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
+ return fmt.Errorf("failed to get database path: %v", err)
+ }
+
+ // Get backup directory from environment variable or use default
+ backupDir := os.Getenv("BACKUP_DIR")
+ if backupDir == "" {
+ backupDir = "/app/backups" // Default Docker path
+ // Check if we're not in Docker
+ if _, err := os.Stat(backupDir); os.IsNotExist(err) {
+ backupDir = "backups" // Fallback to local directory
+ }
+ }
+
+ // Create backup directory if it doesn't exist
+ if err := os.MkdirAll(backupDir, 0755); err != nil {
+ return fmt.Errorf("failed to create backup directory: %v", err)
+ }
+
+ // Create backup file with timestamp in the backup directory
+ dbFileName := filepath.Base(dbPath)
+ backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
+ backupFile := filepath.Join(backupDir, backupFileName)
+
+ // Read original database
+ data, err := os.ReadFile(dbPath)
+ if err != nil {
+ return fmt.Errorf("failed to read database for backup: %v", err)
+ }
+
+ // Write backup
+ if err := os.WriteFile(backupFile, data, 0644); err != nil {
+ return fmt.Errorf("failed to write database backup: %v", err)
+ }
+
+ fmt.Printf("Created database backup at %s\n", backupFile)
+ }
+
+ // Create auth_providers table
+ if err := tx.Exec(`CREATE TABLE IF NOT EXISTS auth_providers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name VARCHAR(255) NOT NULL,
+ type VARCHAR(50) NOT NULL,
+ enabled BOOLEAN DEFAULT TRUE,
+ description TEXT,
+ provider_url TEXT,
+ icon_url TEXT,
+ client_id VARCHAR(255),
+ client_secret VARCHAR(255),
+ redirect_url TEXT,
+ scopes TEXT,
+ attribute_mapping TEXT,
+ config TEXT,
+ successful_logins INTEGER DEFAULT 0,
+ last_used DATETIME,
+ created_at DATETIME,
+ updated_at DATETIME
+ )`).Error; err != nil {
+ return err
+ }
+
+ // Create external_user_identities table
+ if err := tx.Exec(`CREATE TABLE IF NOT EXISTS external_user_identities (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ provider_id INTEGER NOT NULL,
+ provider_type VARCHAR(50) NOT NULL,
+ external_id VARCHAR(255) NOT NULL,
+ email VARCHAR(255) NOT NULL,
+ username VARCHAR(255),
+ display_name VARCHAR(255),
+ groups TEXT,
+ last_login DATETIME,
+ provider_data TEXT,
+ created_at DATETIME,
+ updated_at DATETIME,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY (provider_id) REFERENCES auth_providers(id) ON DELETE CASCADE
+ )`).Error; err != nil {
+ return err
+ }
+
+ // Create unique index on provider_id and external_id
+ if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_external_user_identities_provider_external
+ ON external_user_identities(provider_id, external_id)`).Error; err != nil {
+ return err
+ }
+
+ return nil
+ },
+ Rollback: func(tx *gorm.DB) error {
+ if err := tx.Exec("DROP TABLE IF EXISTS external_user_identities").Error; err != nil {
+ return err
+ }
+ if err := tx.Exec("DROP TABLE IF EXISTS auth_providers").Error; err != nil {
+ return err
+ }
+ return nil
+ },
+ }
+}
diff --git a/internal/db/migrations/migrations.go b/internal/db/migrations/migrations.go
index 79980ae..f4389f9 100644
--- a/internal/db/migrations/migrations.go
+++ b/internal/db/migrations/migrations.go
@@ -21,6 +21,7 @@ func GetMigrations(db *gorm.DB) *gormigrate.Gormigrate {
AddUserNotifications(), // 008
AddRcloneTables(), // 009
AddRcloneCommandToConfig(), // 010
+ AddAuthProviders(), // 011
)
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
diff --git a/internal/web/handlers/auth_handlers.go b/internal/web/handlers/auth_handlers.go
index 5805825..d9c9741 100644
--- a/internal/web/handlers/auth_handlers.go
+++ b/internal/web/handlers/auth_handlers.go
@@ -3,10 +3,16 @@ package handlers
import (
"context"
"crypto/rand"
+ "database/sql"
"encoding/base64"
+ "encoding/json"
"fmt"
+ "io"
"log"
"net/http"
+ "net/url"
+ "os"
+ "strconv"
"strings"
"time"
@@ -668,3 +674,526 @@ func generateResetToken(length int) (string, error) {
}
return base64.URLEncoding.EncodeToString(b), nil
}
+
+// GetAuthProviders returns the list of enabled authentication providers for login
+func (h *Handlers) GetAuthProviders(c *gin.Context) {
+ providers, err := h.DB.GetEnabledAuthProviders(c.Request.Context())
+ if err != nil {
+ log.Printf("Error fetching auth providers: %v", err)
+ c.String(http.StatusInternalServerError, "")
+ return
+ }
+
+ components.AuthProviderButtons(providers).Render(c.Request.Context(), c.Writer)
+}
+
+// HandleAuthProviderInit initiates authentication with the selected provider
+func (h *Handlers) HandleAuthProviderInit(c *gin.Context) {
+ // Get provider ID
+ providerID, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil {
+ h.HandleBadRequest(c, "Invalid Provider ID", "The provider ID is not valid")
+ return
+ }
+
+ // Get the auth provider
+ provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
+ if err != nil || !provider.Enabled {
+ h.HandleBadRequest(c, "Provider Not Available", "The authentication provider is not available")
+ return
+ }
+
+ // Generate state parameter for CSRF protection
+ state, err := generateResetToken(32)
+ if err != nil {
+ h.HandleServerError(c, err)
+ return
+ }
+
+ // Store state in session/cookie for validation on callback
+ c.SetCookie("auth_state", state, 3600, "/", "", false, true)
+ c.SetCookie("auth_provider_id", fmt.Sprintf("%d", providerID), 3600, "/", "", false, true)
+
+ // Get base URL for redirect URI
+ baseURL := os.Getenv("BASE_URL")
+ if baseURL == "" {
+ // Try to detect the base URL from the request
+ scheme := "http"
+ if c.Request.TLS != nil {
+ scheme = "https"
+ }
+ baseURL = fmt.Sprintf("%s://%s", scheme, c.Request.Host)
+ }
+
+ // Default redirect URL if not specified in provider
+ redirectURI := provider.RedirectURL
+ if redirectURI == "" {
+ redirectURI = fmt.Sprintf("%s/auth/callback", baseURL)
+ }
+
+ // Handle different provider types
+ switch provider.Type {
+ case db.ProviderTypeOIDC, db.ProviderTypeOAuth2:
+ // Build the authorization URL for OIDC/OAuth2
+ scopes := "openid profile email"
+ if provider.Scopes != "" {
+ scopes = provider.Scopes
+ }
+
+ // Get config for OIDC
+ configData, _ := provider.GetConfig()
+ var authEndpoint string
+
+ if provider.Type == db.ProviderTypeOIDC && configData["discovery_url"] != "" {
+ // Fetch from discovery endpoint
+ discoveryURL, _ := configData["discovery_url"].(string)
+ discoveryData, err := h.fetchOIDCDiscovery(discoveryURL)
+ if err != nil {
+ h.HandleServerError(c, err)
+ return
+ }
+ authEndpoint = discoveryData["authorization_endpoint"].(string)
+ } else {
+ // Use provider URL as base
+ authEndpoint = fmt.Sprintf("%s/oauth2/authorize", provider.ProviderURL)
+ }
+
+ // Build the auth URL
+ authURL := fmt.Sprintf("%s?client_id=%s&redirect_uri=%s&scope=%s&response_type=code&state=%s",
+ authEndpoint,
+ url.QueryEscape(provider.ClientID),
+ url.QueryEscape(redirectURI),
+ url.QueryEscape(scopes),
+ url.QueryEscape(state))
+
+ // Redirect user to the authorization endpoint
+ c.Redirect(http.StatusFound, authURL)
+ return
+
+ case db.ProviderTypeAuthentik:
+ // Build the authorization URL for Authentik
+ configData, _ := provider.GetConfig()
+ tenant := "default"
+ if tenantID, ok := configData["tenant_id"].(string); ok && tenantID != "" {
+ tenant = tenantID
+ }
+
+ // Construct Authentik authorization URL
+ scopes := "openid profile email"
+ if provider.Scopes != "" {
+ scopes = provider.Scopes
+ }
+
+ authURL := fmt.Sprintf("%s/application/o/authorize/?client_id=%s&redirect_uri=%s&scope=%s&response_type=code&state=%s&tenant=%s",
+ strings.TrimSuffix(provider.ProviderURL, "/"),
+ url.QueryEscape(provider.ClientID),
+ url.QueryEscape(redirectURI),
+ url.QueryEscape(scopes),
+ url.QueryEscape(state),
+ url.QueryEscape(tenant))
+
+ // Redirect user to the Authentik authorization endpoint
+ c.Redirect(http.StatusFound, authURL)
+ return
+
+ case db.ProviderTypeSAML:
+ // Note: SAML flows work differently than OAuth2/OIDC
+ // Here you would typically generate a SAML request and redirect the user
+ // This is just a placeholder - actual SAML implementation would need a SAML library
+ h.HandleBadRequest(c, "SAML Not Implemented", "SAML authentication is not yet implemented")
+ return
+
+ default:
+ h.HandleBadRequest(c, "Unsupported Provider", "The authentication provider type is not supported")
+ return
+ }
+}
+
+// Helper function to fetch OIDC discovery document
+func (h *Handlers) fetchOIDCDiscovery(discoveryURL string) (map[string]interface{}, error) {
+ resp, err := http.Get(discoveryURL)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed to fetch discovery document, status: %d", resp.StatusCode)
+ }
+
+ var result map[string]interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// HandleAuthProviderCallback handles the callback from external authentication providers
+func (h *Handlers) HandleAuthProviderCallback(c *gin.Context) {
+ // Get state and code from query params
+ state := c.Query("state")
+ code := c.Query("code")
+
+ if state == "" || code == "" {
+ h.HandleBadRequest(c, "Invalid Authentication Response", "Missing required parameters from authentication provider")
+ return
+ }
+
+ // Verify state to prevent CSRF
+ storedState, err := c.Cookie("auth_state")
+ if err != nil || state != storedState {
+ h.HandleBadRequest(c, "Invalid Authentication State", "The authentication process was corrupted or expired")
+ return
+ }
+
+ // Get provider ID from cookie
+ providerIDStr, err := c.Cookie("auth_provider_id")
+ if err != nil {
+ h.HandleBadRequest(c, "Authentication Error", "Unable to determine authentication provider")
+ return
+ }
+
+ providerID, err := strconv.ParseUint(providerIDStr, 10, 64)
+ if err != nil {
+ h.HandleBadRequest(c, "Invalid Provider ID", "The provider ID is not valid")
+ return
+ }
+
+ // Get the auth provider
+ provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
+ if err != nil || !provider.Enabled {
+ h.HandleBadRequest(c, "Provider Not Available", "The authentication provider is not available")
+ return
+ }
+
+ // Get base URL for redirect URI
+ baseURL := os.Getenv("BASE_URL")
+ if baseURL == "" {
+ // Try to detect the base URL from the request
+ scheme := "http"
+ if c.Request.TLS != nil {
+ scheme = "https"
+ }
+ baseURL = fmt.Sprintf("%s://%s", scheme, c.Request.Host)
+ }
+
+ // Use provider's redirect URL or default
+ redirectURI := provider.RedirectURL
+ if redirectURI == "" {
+ redirectURI = fmt.Sprintf("%s/auth/callback", baseURL)
+ }
+
+ // Exchange code for tokens
+ var userInfo map[string]interface{}
+ var externalID string
+ var email string
+ var username string
+ var displayName string
+
+ switch provider.Type {
+ case db.ProviderTypeOIDC, db.ProviderTypeOAuth2, db.ProviderTypeAuthentik:
+ // Get token endpoint
+ var tokenEndpoint string
+ if provider.Type == db.ProviderTypeOIDC {
+ configData, _ := provider.GetConfig()
+ if discoveryURL, ok := configData["discovery_url"].(string); ok && discoveryURL != "" {
+ discoveryData, err := h.fetchOIDCDiscovery(discoveryURL)
+ if err != nil {
+ h.HandleServerError(c, err)
+ return
+ }
+ tokenEndpoint = discoveryData["token_endpoint"].(string)
+ } else {
+ tokenEndpoint = fmt.Sprintf("%s/oauth2/token", provider.ProviderURL)
+ }
+ } else if provider.Type == db.ProviderTypeAuthentik {
+ tokenEndpoint = fmt.Sprintf("%s/application/o/token/", strings.TrimSuffix(provider.ProviderURL, "/"))
+ } else {
+ tokenEndpoint = fmt.Sprintf("%s/oauth/token", provider.ProviderURL)
+ }
+
+ // Exchange code for token
+ data := url.Values{}
+ data.Set("grant_type", "authorization_code")
+ data.Set("code", code)
+ data.Set("redirect_uri", redirectURI)
+ data.Set("client_id", provider.ClientID)
+ data.Set("client_secret", provider.ClientSecret)
+
+ resp, err := http.PostForm(tokenEndpoint, data)
+ if err != nil {
+ h.HandleServerError(c, err)
+ return
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ log.Printf("Error exchanging code for token: %s", string(body))
+ h.HandleBadRequest(c, "Authentication Failed", "Failed to authenticate with the provider")
+ return
+ }
+
+ var tokenResponse map[string]interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
+ h.HandleServerError(c, err)
+ return
+ }
+
+ // Get access token
+ accessToken, ok := tokenResponse["access_token"].(string)
+ if !ok {
+ h.HandleBadRequest(c, "Authentication Failed", "Invalid token response from provider")
+ return
+ }
+
+ // Get user info
+ userInfo, err = h.fetchUserInfo(provider, accessToken)
+ if err != nil {
+ h.HandleServerError(c, err)
+ return
+ }
+
+ // Extract fields based on attribute mapping
+ var attributeMapping map[string]string
+ if provider.AttributeMapping != "" {
+ if err := json.Unmarshal([]byte(provider.AttributeMapping), &attributeMapping); err != nil {
+ log.Printf("Error parsing attribute mapping: %v", err)
+ // Use defaults if mapping fails
+ attributeMapping = map[string]string{
+ "username": "preferred_username",
+ "email": "email",
+ "name": "name",
+ }
+ }
+ } else {
+ // Default mapping
+ attributeMapping = map[string]string{
+ "username": "preferred_username",
+ "email": "email",
+ "name": "name",
+ }
+ }
+
+ // Extract user info using the attribute mapping
+ if subValue, ok := userInfo["sub"].(string); ok {
+ externalID = subValue
+ } else {
+ // Generate fallback ID if 'sub' is not available
+ externalID = fmt.Sprintf("%s_%d", provider.Type, time.Now().Unix())
+ }
+
+ // Extract email - this is critical for user matching
+ if emailAttr, ok := attributeMapping["email"]; ok && emailAttr != "" {
+ if emailValue, ok := userInfo[emailAttr].(string); ok {
+ email = emailValue
+ }
+ }
+ if email == "" && userInfo["email"] != nil {
+ email = userInfo["email"].(string)
+ }
+
+ // Extract username
+ if usernameAttr, ok := attributeMapping["username"]; ok && usernameAttr != "" {
+ if usernameValue, ok := userInfo[usernameAttr].(string); ok {
+ username = usernameValue
+ }
+ }
+ if username == "" && userInfo["preferred_username"] != nil {
+ username = userInfo["preferred_username"].(string)
+ }
+
+ // Extract display name
+ if nameAttr, ok := attributeMapping["name"]; ok && nameAttr != "" {
+ if nameValue, ok := userInfo[nameAttr].(string); ok {
+ displayName = nameValue
+ }
+ }
+ if displayName == "" && userInfo["name"] != nil {
+ displayName = userInfo["name"].(string)
+ }
+
+ default:
+ h.HandleBadRequest(c, "Unsupported Provider", "The authentication provider type is not supported")
+ return
+ }
+
+ // Require email for user identification
+ if email == "" {
+ h.HandleBadRequest(c, "Authentication Failed", "Unable to retrieve email address from the provider")
+ return
+ }
+
+ // Look up existing user by email
+ existingUser, err := h.DB.GetUserByEmail(email)
+ if err != nil {
+ // If user doesn't exist, check if auto-provisioning is allowed
+ // For now, we'll require existing users
+ h.HandleBadRequest(c, "Authentication Failed", "No account exists with this email address")
+ return
+ }
+
+ // Check for existing identity
+ identity, err := h.DB.GetExternalUserIdentity(c.Request.Context(), provider.ID, externalID)
+ if err != nil || identity == nil {
+ // If identity doesn't exist, create it
+ identity = &db.ExternalUserIdentity{
+ UserID: existingUser.ID,
+ ProviderID: provider.ID,
+ ProviderType: provider.Type,
+ ExternalID: externalID,
+ Email: email,
+ Username: username,
+ DisplayName: displayName,
+ LastLogin: sql.NullTime{Time: time.Now(), Valid: true},
+ }
+
+ // Store provider data
+ if err := identity.SetProviderData(userInfo); err != nil {
+ log.Printf("Error serializing provider data: %v", err)
+ }
+
+ // Extract and store groups if available
+ var attributeMapping map[string]string
+ if provider.AttributeMapping != "" {
+ if err := json.Unmarshal([]byte(provider.AttributeMapping), &attributeMapping); err == nil {
+ if groupsAttr, ok := attributeMapping["groups"]; ok && groupsAttr != "" {
+ if groupsValue, ok := userInfo[groupsAttr]; ok {
+ // Handle different group formats (array or comma-separated string)
+ var groups []string
+ switch v := groupsValue.(type) {
+ case []interface{}:
+ for _, g := range v {
+ if gs, ok := g.(string); ok {
+ groups = append(groups, gs)
+ }
+ }
+ case string:
+ groups = strings.Split(v, ",")
+ }
+
+ if len(groups) > 0 {
+ if err := identity.SetGroups(groups); err != nil {
+ log.Printf("Error serializing groups: %v", err)
+ }
+ }
+ }
+ }
+ } else {
+ log.Printf("Error parsing attribute mapping: %v", err)
+ }
+ }
+
+ // Save the identity
+ if err := h.DB.CreateExternalUserIdentity(c.Request.Context(), identity); err != nil {
+ log.Printf("Error creating external identity: %v", err)
+ h.HandleServerError(c, err)
+ return
+ }
+ } else {
+ // Update existing identity
+ identity.LastLogin = sql.NullTime{Time: time.Now(), Valid: true}
+ identity.Email = email
+ identity.Username = username
+ identity.DisplayName = displayName
+
+ // Update provider data
+ if err := identity.SetProviderData(userInfo); err != nil {
+ log.Printf("Error serializing provider data: %v", err)
+ }
+
+ // Save the updated identity
+ if err := h.DB.UpdateExternalUserIdentity(c.Request.Context(), identity); err != nil {
+ log.Printf("Error updating external identity: %v", err)
+ }
+ }
+
+ // Update provider usage stats
+ provider.SuccessfulLogins++
+ provider.LastUsed = sql.NullTime{Time: time.Now(), Valid: true}
+ if err := h.DB.UpdateAuthProvider(c.Request.Context(), provider); err != nil {
+ log.Printf("Error updating provider stats: %v", err)
+ }
+
+ // Create session for the user
+ isAdmin := false
+ if existingUser.IsAdmin != nil {
+ isAdmin = *existingUser.IsAdmin
+ }
+ token, err := h.GenerateJWT(existingUser.ID, existingUser.Email, isAdmin)
+ if err != nil {
+ h.HandleServerError(c, err)
+ return
+ }
+
+ // Clear auth cookies
+ c.SetCookie("auth_state", "", -1, "/", "", false, true)
+ c.SetCookie("auth_provider_id", "", -1, "/", "", false, true)
+
+ // Set session cookie
+ c.SetCookie("jwt_token", token, 86400, "/", "", false, true)
+
+ // Redirect to dashboard
+ c.Redirect(http.StatusFound, "/dashboard")
+}
+
+// Helper function to fetch user info with access token
+func (h *Handlers) fetchUserInfo(provider *db.AuthProvider, accessToken string) (map[string]interface{}, error) {
+ var userInfoEndpoint string
+
+ // Determine user info endpoint based on provider type
+ switch provider.Type {
+ case db.ProviderTypeOIDC:
+ // For OIDC, check if we have discovery URL
+ configData, _ := provider.GetConfig()
+ if discoveryURL, ok := configData["discovery_url"].(string); ok && discoveryURL != "" {
+ discoveryData, err := h.fetchOIDCDiscovery(discoveryURL)
+ if err != nil {
+ return nil, err
+ }
+ userInfoEndpoint = discoveryData["userinfo_endpoint"].(string)
+ } else {
+ userInfoEndpoint = fmt.Sprintf("%s/oauth2/userinfo", provider.ProviderURL)
+ }
+
+ case db.ProviderTypeAuthentik:
+ userInfoEndpoint = fmt.Sprintf("%s/application/o/userinfo/", strings.TrimSuffix(provider.ProviderURL, "/"))
+
+ case db.ProviderTypeOAuth2:
+ userInfoEndpoint = fmt.Sprintf("%s/oauth/userinfo", provider.ProviderURL)
+
+ default:
+ return nil, fmt.Errorf("unsupported provider type: %s", provider.Type)
+ }
+
+ // Make request to user info endpoint
+ req, err := http.NewRequest("GET", userInfoEndpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ // Add authorization header
+ req.Header.Add("Authorization", "Bearer "+accessToken)
+
+ // Make the request
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("failed to get user info: %s", string(body))
+ }
+
+ // Parse response
+ var userInfo map[string]interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
+ return nil, err
+ }
+
+ return userInfo, nil
+}
diff --git a/internal/web/handlers/auth_provider_handlers.go b/internal/web/handlers/auth_provider_handlers.go
new file mode 100644
index 0000000..c484209
--- /dev/null
+++ b/internal/web/handlers/auth_provider_handlers.go
@@ -0,0 +1,428 @@
+package handlers
+
+// Authentication Provider handlers for the GoMFT application
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ "github.com/starfleetcptn/gomft/components"
+ "github.com/starfleetcptn/gomft/internal/db"
+)
+
+// AuthProvidersPage renders the page listing all authentication providers
+func (h *Handlers) AuthProvidersPage(c *gin.Context) {
+ // Get user from context
+ userID := c.GetUint("userID")
+ if userID == 0 {
+ h.HandleUnauthorized(c)
+ return
+ }
+
+ // Get all auth providers
+ providers, err := h.DB.GetAllAuthProviders(c.Request.Context())
+ if err != nil {
+ h.HandleServerError(c, err)
+ return
+ }
+
+ components.AuthProviders(c.Request.Context(), providers).Render(c, c.Writer)
+
+}
+
+// NewAuthProviderPage renders the form to create a new authentication provider
+func (h *Handlers) NewAuthProviderPage(c *gin.Context) {
+ // Get user from context
+ userID := c.GetUint("userID")
+ if userID == 0 {
+ h.HandleUnauthorized(c)
+ return
+ }
+
+ // Create an empty auth provider for the form
+ provider := &db.AuthProvider{}
+
+ // Render the auth provider form for creating a new provider
+ components.AuthProviderForm(c.Request.Context(), provider, true).Render(c, c.Writer)
+}
+
+// EditAuthProviderPage renders the form to edit an existing authentication provider
+func (h *Handlers) EditAuthProviderPage(c *gin.Context) {
+ // Get user from context
+ userID := c.GetUint("userID")
+ if userID == 0 {
+ h.HandleUnauthorized(c)
+ return
+ }
+
+ providerID, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil {
+ h.HandleBadRequest(c, "Invalid Provider ID", "The provider ID is not valid")
+ return
+ }
+
+ // Get the auth provider
+ provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
+ if err != nil {
+ h.HandleBadRequest(c, "Provider Not Found", "The authentication provider could not be found")
+ return
+ }
+
+ // Render the auth provider form for editing
+ components.AuthProviderForm(c.Request.Context(), provider, false).Render(c, c.Writer)
+}
+
+// HandleCreateAuthProvider handles the form submission to create a new authentication provider
+func (h *Handlers) HandleCreateAuthProvider(c *gin.Context) {
+ // Get user from context
+ userID := c.GetUint("userID")
+ if userID == 0 {
+ h.HandleUnauthorized(c)
+ return
+ }
+
+ if err := c.Request.ParseForm(); err != nil {
+ h.HandleBadRequest(c, "Invalid Form Data", "Could not parse form data")
+ return
+ }
+
+ provider := &db.AuthProvider{
+ Name: c.PostForm("name"),
+ Type: db.ProviderType(c.PostForm("type")),
+ ProviderURL: c.PostForm("provider_url"),
+ ClientID: c.PostForm("client_id"),
+ ClientSecret: c.PostForm("client_secret"),
+ RedirectURL: c.PostForm("redirect_url"),
+ Scopes: c.PostForm("scopes"),
+ Description: c.PostForm("description"),
+ IconURL: c.PostForm("icon_url"),
+ Enabled: c.PostForm("enabled") == "on",
+ }
+
+ // Process config values based on provider type
+ config := make(map[string]interface{})
+ switch provider.Type {
+ case db.ProviderTypeAuthentik:
+ if tenant := c.PostForm("authentik_tenant"); tenant != "" {
+ config["tenant_id"] = tenant
+ }
+ case db.ProviderTypeOIDC:
+ if discoveryURL := c.PostForm("oidc_discovery_url"); discoveryURL != "" {
+ config["discovery_url"] = discoveryURL
+ }
+ case db.ProviderTypeSAML:
+ if metadataURL := c.PostForm("saml_metadata_url"); metadataURL != "" {
+ config["metadata_url"] = metadataURL
+ }
+ }
+
+ // Set the config
+ if len(config) > 0 {
+ configJSON, err := json.Marshal(config)
+ if err != nil {
+ h.HandleBadRequest(c, "Invalid Configuration", "Could not process provider configuration")
+ return
+ }
+ provider.Config = string(configJSON)
+ }
+
+ // Process attribute mappings
+ attrMapping := map[string]string{
+ "username": c.PostForm("attr_username"),
+ "email": c.PostForm("attr_email"),
+ "name": c.PostForm("attr_name"),
+ "groups": c.PostForm("attr_groups"),
+ }
+
+ // Remove empty mappings
+ for k, v := range attrMapping {
+ if v == "" {
+ delete(attrMapping, k)
+ }
+ }
+
+ if len(attrMapping) > 0 {
+ mappingJSON, err := json.Marshal(attrMapping)
+ if err != nil {
+ h.HandleBadRequest(c, "Invalid Attribute Mapping", "Could not process attribute mappings")
+ return
+ }
+ provider.AttributeMapping = string(mappingJSON)
+ }
+
+ // Create the provider
+ if err := h.DB.CreateAuthProvider(c.Request.Context(), provider); err != nil {
+ h.HandleServerError(c, err)
+ return
+ }
+
+ log.Printf("[Audit] User %d created auth provider %d of type %s with name '%s'",
+ userID, provider.ID, provider.Type, provider.Name)
+
+ // Set success flash and redirect
+ c.SetCookie("flash_message", fmt.Sprintf("Authentication provider '%s' created successfully", provider.Name),
+ 3600, "/", "", false, true)
+ c.SetCookie("flash_type", "success", 3600, "/", "", false, true)
+
+ c.Redirect(http.StatusFound, "/admin/settings/auth-providers")
+}
+
+// HandleUpdateAuthProvider handles the form submission to update an existing authentication provider
+func (h *Handlers) HandleUpdateAuthProvider(c *gin.Context) {
+ // Get user from context
+ userID := c.GetUint("userID")
+ if userID == 0 {
+ h.HandleUnauthorized(c)
+ return
+ }
+
+ providerID, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil {
+ h.HandleBadRequest(c, "Invalid Provider ID", "The provider ID is not valid")
+ return
+ }
+
+ // Get the existing provider
+ existingProvider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
+ if err != nil {
+ h.HandleBadRequest(c, "Provider Not Found", "The authentication provider could not be found")
+ return
+ }
+
+ if err := c.Request.ParseForm(); err != nil {
+ h.HandleBadRequest(c, "Invalid Form Data", "Could not parse form data")
+ return
+ }
+
+ // Update the provider with form data
+ existingProvider.Name = c.PostForm("name")
+ existingProvider.ProviderURL = c.PostForm("provider_url")
+ existingProvider.ClientID = c.PostForm("client_id")
+ existingProvider.IconURL = c.PostForm("icon_url")
+
+ // Only update client secret if provided
+ if clientSecret := c.PostForm("client_secret"); clientSecret != "" {
+ existingProvider.ClientSecret = clientSecret
+ }
+
+ existingProvider.RedirectURL = c.PostForm("redirect_url")
+ existingProvider.Scopes = c.PostForm("scopes")
+ existingProvider.Description = c.PostForm("description")
+ existingProvider.Enabled = c.PostForm("enabled") == "on"
+
+ // Update the type if changed
+ if providerType := c.PostForm("type"); providerType != "" {
+ existingProvider.Type = db.ProviderType(providerType)
+ }
+
+ // Process config values based on provider type
+ config := make(map[string]interface{})
+ switch existingProvider.Type {
+ case db.ProviderTypeAuthentik:
+ if tenant := c.PostForm("authentik_tenant"); tenant != "" {
+ config["tenant_id"] = tenant
+ }
+ case db.ProviderTypeOIDC:
+ if discoveryURL := c.PostForm("oidc_discovery_url"); discoveryURL != "" {
+ config["discovery_url"] = discoveryURL
+ }
+ case db.ProviderTypeSAML:
+ if metadataURL := c.PostForm("saml_metadata_url"); metadataURL != "" {
+ config["metadata_url"] = metadataURL
+ }
+ }
+
+ // Set the config
+ if len(config) > 0 {
+ configJSON, err := json.Marshal(config)
+ if err != nil {
+ h.HandleBadRequest(c, "Invalid Configuration", "Could not process provider configuration")
+ return
+ }
+ existingProvider.Config = string(configJSON)
+ }
+
+ // Process attribute mappings
+ attrMapping := map[string]string{
+ "username": c.PostForm("attr_username"),
+ "email": c.PostForm("attr_email"),
+ "name": c.PostForm("attr_name"),
+ "groups": c.PostForm("attr_groups"),
+ }
+
+ // Remove empty mappings
+ for k, v := range attrMapping {
+ if v == "" {
+ delete(attrMapping, k)
+ }
+ }
+
+ if len(attrMapping) > 0 {
+ mappingJSON, err := json.Marshal(attrMapping)
+ if err != nil {
+ h.HandleBadRequest(c, "Invalid Attribute Mapping", "Could not process attribute mappings")
+ return
+ }
+ existingProvider.AttributeMapping = string(mappingJSON)
+ }
+
+ // Update the provider
+ if err := h.DB.UpdateAuthProvider(c.Request.Context(), existingProvider); err != nil {
+ h.HandleServerError(c, err)
+ return
+ }
+
+ log.Printf("[Audit] User %d updated auth provider %d of type %s with name '%s'",
+ userID, existingProvider.ID, existingProvider.Type, existingProvider.Name)
+
+ // Set success flash and redirect
+ c.SetCookie("flash_message", fmt.Sprintf("Authentication provider '%s' updated successfully", existingProvider.Name),
+ 3600, "/", "", false, true)
+ c.SetCookie("flash_type", "success", 3600, "/", "", false, true)
+
+ c.Redirect(http.StatusFound, "/admin/settings/auth-providers")
+}
+
+// HandleDeleteAuthProvider handles the request to delete an authentication provider
+func (h *Handlers) HandleDeleteAuthProvider(c *gin.Context) {
+ // Get user from context
+ userID := c.GetUint("userID")
+ if userID == 0 {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "error": "Unauthorized",
+ })
+ return
+ }
+
+ providerID, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "Invalid provider ID",
+ })
+ return
+ }
+
+ // Get the provider to be deleted
+ provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{
+ "error": "Authentication provider not found",
+ })
+ return
+ }
+
+ // Check if there are any user identities associated with this provider
+ count, err := h.DB.CountExternalUserIdentitiesByProviderID(c.Request.Context(), uint(providerID))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Failed to check if provider is in use",
+ })
+ return
+ }
+
+ if count > 0 {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": fmt.Sprintf("Cannot delete provider '%s' because it has %d associated user identities", provider.Name, count),
+ })
+ return
+ }
+
+ // Delete the provider
+ if err := h.DB.DeleteAuthProvider(c.Request.Context(), uint(providerID)); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Failed to delete authentication provider",
+ })
+ return
+ }
+
+ log.Printf("[Audit] User %d deleted auth provider %d of type %s with name '%s'",
+ userID, provider.ID, provider.Type, provider.Name)
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": fmt.Sprintf("Authentication provider '%s' deleted successfully", provider.Name),
+ })
+}
+
+// HandleTestAuthProviderConnection tests the connection to an authentication provider
+func (h *Handlers) HandleTestAuthProviderConnection(c *gin.Context) {
+ // Get user from context
+ userID := c.GetUint("userID")
+ if userID == 0 {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "error": "Unauthorized",
+ })
+ return
+ }
+
+ providerID, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "Invalid provider ID",
+ })
+ return
+ }
+
+ // Get the provider
+ provider, err := h.DB.GetAuthProviderByID(c.Request.Context(), uint(providerID))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{
+ "error": "Authentication provider not found",
+ })
+ return
+ }
+
+ // Attempt to test the connection based on provider type
+ var testResult error
+ switch provider.Type {
+ case db.ProviderTypeAuthentik:
+ testResult = h.testAuthentikConnection(provider)
+ case db.ProviderTypeOIDC:
+ testResult = h.testOIDCConnection(provider)
+ case db.ProviderTypeSAML:
+ testResult = h.testSAMLConnection(provider)
+ case db.ProviderTypeOAuth2:
+ testResult = h.testOAuth2Connection(provider)
+ default:
+ testResult = fmt.Errorf("unsupported provider type: %s", provider.Type)
+ }
+
+ if testResult != nil {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": fmt.Sprintf("Connection test failed: %s", testResult.Error()),
+ })
+ return
+ }
+
+ log.Printf("[Audit] User %d successfully tested connection to auth provider %d of type %s",
+ userID, provider.ID, provider.Type)
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "Connection test successful",
+ })
+}
+
+// Test methods for different provider types
+func (h *Handlers) testAuthentikConnection(provider *db.AuthProvider) error {
+ // TODO: Implement actual test logic for Authentik
+ return nil
+}
+
+func (h *Handlers) testOIDCConnection(provider *db.AuthProvider) error {
+ // TODO: Implement actual test logic for OIDC
+ return nil
+}
+
+func (h *Handlers) testSAMLConnection(provider *db.AuthProvider) error {
+ // TODO: Implement actual test logic for SAML
+ return nil
+}
+
+func (h *Handlers) testOAuth2Connection(provider *db.AuthProvider) error {
+ // TODO: Implement actual test logic for OAuth2
+ return nil
+}
diff --git a/internal/web/handlers/routes.go b/internal/web/handlers/routes.go
index ad7003d..dae20f0 100644
--- a/internal/web/handlers/routes.go
+++ b/internal/web/handlers/routes.go
@@ -21,6 +21,11 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
router.GET("/reset-password", h.HandleResetPasswordPage)
router.POST("/reset-password", h.HandleResetPassword)
+ // External Authentication Provider routes for login page
+ router.GET("/auth/providers", h.GetAuthProviders)
+ router.GET("/auth/provider/:id", h.HandleAuthProviderInit)
+ router.GET("/auth/callback", h.HandleAuthProviderCallback)
+
// Protected routes
authorized := router.Group("/")
authorized.Use(h.AuthMiddleware())
@@ -145,9 +150,26 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
settingsGroup.Use(h.PermissionMiddleware("system.settings"))
{
settingsGroup.GET("", h.HandleSettings)
+ // settingsGroup.GET("/backups", h.HandleBackupsPage)
+ // settingsGroup.POST("/backups", h.HandleCreateBackup)
+ // settingsGroup.GET("/logs", h.HandleLogsPage)
+ // settingsGroup.POST("/logs/download", h.HandleDownloadLogs)
+ // settingsGroup.DELETE("/logs", h.HandlePurgeLogs)
+
+ // Auth Provider routes
+ authProviderGroup := settingsGroup.Group("/auth-providers")
+ authProviderGroup.GET("", h.AuthProvidersPage)
+ authProviderGroup.GET("/new", h.NewAuthProviderPage)
+ authProviderGroup.POST("", h.HandleCreateAuthProvider)
+ authProviderGroup.GET("/:id/edit", h.EditAuthProviderPage)
+ authProviderGroup.POST("/:id", h.HandleUpdateAuthProvider)
+ authProviderGroup.DELETE("/:id", h.HandleDeleteAuthProvider)
+ authProviderGroup.POST("/:id/test", h.HandleTestAuthProviderConnection)
+
settingsGroup.POST("/notifications", h.HandleCreateNotificationService)
settingsGroup.DELETE("/notifications/:id", h.HandleDeleteNotificationService)
settingsGroup.POST("/notifications/test", h.HandleTestNotification)
+
settingsGroup.POST("/general", h.HandleSettings) // Placeholder for future implementation
settingsGroup.POST("/security", h.HandleSettings) // Placeholder for future implementation
}
@@ -209,6 +231,14 @@ func (h *Handlers) RegisterRoutes(router *gin.Engine) {
apiAdmin.POST("/users", h.HandleAPICreateUser)
apiAdmin.PUT("/users/:id", h.HandleAPIUpdateUser)
apiAdmin.DELETE("/users/:id", h.HandleAPIDeleteUser)
+
+ // Auth providers API routes
+ apiAdmin.GET("/auth-providers", h.HandleAPIUsers) // Placeholder for now
+ apiAdmin.GET("/auth-providers/:id", h.HandleAPIUser) // Placeholder for now
+ apiAdmin.POST("/auth-providers", h.HandleAPICreateUser) // Placeholder for now
+ apiAdmin.PUT("/auth-providers/:id", h.HandleAPIUpdateUser) // Placeholder for now
+ apiAdmin.DELETE("/auth-providers/:id", h.HandleAPIDeleteUser) // Placeholder for now
+ apiAdmin.POST("/auth-providers/:id/test", h.HandleAPIUser) // Placeholder for now
}
}
}
diff --git a/static/img/authelia.svg b/static/img/authelia.svg
new file mode 100644
index 0000000..b18ae0d
--- /dev/null
+++ b/static/img/authelia.svg
@@ -0,0 +1,57 @@
+
+
+
diff --git a/static/img/authentik.svg b/static/img/authentik.svg
new file mode 100644
index 0000000..d26507b
--- /dev/null
+++ b/static/img/authentik.svg
@@ -0,0 +1,23 @@
+
+
+
diff --git a/static/img/keycloak.svg b/static/img/keycloak.svg
new file mode 100644
index 0000000..53acd1a
--- /dev/null
+++ b/static/img/keycloak.svg
@@ -0,0 +1,81 @@
+
+
+
diff --git a/static/img/oauth2.svg b/static/img/oauth2.svg
new file mode 100644
index 0000000..68d7bab
--- /dev/null
+++ b/static/img/oauth2.svg
@@ -0,0 +1,106 @@
+
+
+
\ No newline at end of file
diff --git a/static/img/oidc.svg b/static/img/oidc.svg
new file mode 100644
index 0000000..b9695b3
--- /dev/null
+++ b/static/img/oidc.svg
@@ -0,0 +1,11 @@
+
diff --git a/static/img/pocket-id.svg b/static/img/pocket-id.svg
new file mode 100644
index 0000000..14434f2
--- /dev/null
+++ b/static/img/pocket-id.svg
@@ -0,0 +1,12 @@
+
+
+
diff --git a/static/img/saml.svg b/static/img/saml.svg
new file mode 100644
index 0000000..8150414
--- /dev/null
+++ b/static/img/saml.svg
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file