feat: Implement authentication provider management components

- Added new templates for managing authentication providers, including forms for creating and editing providers.
- Implemented backend logic to handle retrieval, creation, and deletion of authentication providers.
- Introduced new database migrations to support the storage of authentication provider data.
- Enhanced the user interface to display available authentication providers and their statuses.
- Added routes and handlers for managing authentication provider actions in the web application.
This commit is contained in:
StarFleetCPTN
2025-03-24 14:51:38 -07:00
parent 233f1779d8
commit b91b0e8796
21 changed files with 3524 additions and 360 deletions
+154
View File
@@ -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
}
+156
View File
@@ -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
}
+28
View File
@@ -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
}
@@ -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
},
}
}
+1
View File
@@ -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)