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)
+529
View File
@@ -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
}
@@ -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
}
+30
View File
@@ -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
}
}
}