diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..2bbb25f --- /dev/null +++ b/src/README.md @@ -0,0 +1,278 @@ +# Authelia API - Development Guide + +This directory contains the source code for the Authelia API. For production deployment, use the ready-to-use binary in the root directory and follow the installation instructions in the main [README.md](../README.md). + +A Go-based REST API and management layer that sits alongside an Authelia LXC on Proxmox. Provides a "Source of Truth" in SQLite, handles bulk user onboarding via JSON, and automates synchronization of the Authelia `users_database.yml` file. + +## Features + +- **Sovereign Bootstrap**: Automatically imports existing Authelia users on first run +- **Bulk User Management**: Create multiple users via JSON API with automatic password generation +- **Real-time Sync**: SQLite changes automatically sync to Authelia's YAML configuration +- **SMTP Onboarding**: Send welcome emails using Authelia's SMTP configuration +- **Secure API**: Bearer token authentication with bcrypt hashing +- **Drop-in Deployment**: Runs alongside existing Authelia installation + +## Prerequisites + +- Authelia v4.38+ installed and configured +- Go 1.22+ (for building from source) +- SQLite 3 (embedded via pure Go driver) +- Systemd (for service management) + +## Quick Start + +1. **Clone or copy the API to your Authelia directory:** + ```bash + cd /opt/authelia + git clone api + ``` + +2. **Build the binary:** + ```bash +cd api +go build -o authelia-api ./cmd/server + ``` + +3. **Run the bootstrap (first time only):** + ```bash + ./authelia-api --bootstrap + ``` + +4. **Start the API server:** + ```bash + ./authelia-api --config /opt/authelia/configuration.yml + ``` + +## Configuration + +The API reads Authelia's `configuration.yml` automatically. It looks for: +- `session.secret` for initial admin token +- `notifier.smtp` for email sending +- `authentication_backend.file.path` for user database location + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `AUTHELIA_API_DB_PATH` | `./authelia-api.db` | SQLite database path | +| `AUTHELIA_API_LISTEN_ADDR` | `127.0.0.1:8080` | API listen address | +| `AUTHELIA_API_LOG_LEVEL` | `info` | Log level (debug, info, warn, error) | + +## API Reference + +All endpoints require Bearer token authentication. + +### Authentication + +Get your initial token from the bootstrap process: +```bash +# The initial token is the hashed session.secret from Authelia config +curl -H "Authorization: Bearer " http://localhost:8080/api/health +``` + +### Endpoints + +#### `POST /api/users/bulk` +Create multiple users with generated placeholder passwords. + +**Request:** +```json +[ + { + "username": "john.doe", + "display_name": "John Doe", + "email": "john@example.com", + "groups": ["users", "developers"] + } +] +``` + +**Response:** +```json +{ + "success": true, + "created": 1, + "users": [ + { + "username": "john.doe", + "placeholder_password": "random-generated-password", + "status": "created" + } + ] +} +``` + +#### `GET /api/users` +List all users with pagination. + +**Query Parameters:** +- `page` (default: 1) +- `pageSize` (default: 50) + +#### `DELETE /api/users/{username}` +Remove a user from Authelia. + +#### `POST /api/admins` +Add a new API admin. + +**Request:** +```json +{ + "name": "admin_name", + "token": "raw_bearer_token" +} +``` + +## How It Works + +### Phase 1: Sovereign Bootstrap +On first run, the API: +1. Locates Authelia's `configuration.yml` +2. Extracts `session.secret` and hashes it for initial admin token +3. Reads existing `users_database.yml` and imports all users to SQLite +4. Creates the SQLite database as the "Source of Truth" + +### Phase 2: Core API +- All changes go through the SQLite database first +- Password hashing uses `authelia crypto hash generate` for compatibility +- Bearer tokens validated against `api_admins` table + +### Phase 3: Synchronization Engine +- Every SQLite change triggers YAML regeneration +- If `watch: true` is disabled in Authelia config, restarts the service +- Atomic writes (temp file → rename) prevent corruption + +### Phase 4: SMTP Onboarding +- Parses SMTP configuration from Authelia config +- Sends welcome emails with password reset instructions +- Uses Authelia's own SMTP credentials + +## Database Schema + +### `users` table +- `username` TEXT PRIMARY KEY +- `displayname` TEXT +- `email` TEXT +- `password_hash` TEXT (raw Argon2id hash) +- `groups` TEXT (JSON array) +- `created_at` DATETIME +- `updated_at` DATETIME + +### `api_admins` table +- `id` INTEGER PRIMARY KEY +- `name` TEXT UNIQUE +- `token_hash` TEXT (bcrypt) +- `created_at` DATETIME + +### `sync_logs` table +- `id` INTEGER PRIMARY KEY +- `operation` TEXT +- `username` TEXT +- `success` BOOLEAN +- `error` TEXT +- `timestamp` DATETIME + +## Deployment + +### Systemd Service +Create `/etc/systemd/system/authelia-api.service`: + +```ini +[Unit] +Description=Authelia API +After=authelia.service +Requires=authelia.service + +[Service] +Type=simple +User=authelia +Group=authelia +WorkingDirectory=/opt/authelia +ExecStart=/opt/authelia/api/authelia-api --config /opt/authelia/configuration.yml +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: +```bash +systemctl daemon-reload +systemctl enable --now authelia-api +``` + +### Backup Considerations +The SQLite database (`authelia-api.db`) should be included in your Authelia backups since it's the "Source of Truth". It's created in the same directory as Authelia's configuration by default. + +## Security + +- API only listens on `127.0.0.1` by default (configurable) +- Bearer tokens hashed with bcrypt +- Input validation on all endpoints +- No sensitive data in error responses +- SQLite transactions with `IMMEDIATE` mode for concurrency + +## Troubleshooting + +### "authelia binary not found" +Ensure `authelia` is in `$PATH` or set the full path via environment variable. + +### "Cannot parse configuration.yml" +Check file permissions and YAML syntax. The API needs read access to Authelia's config. + +### "SMTP email failing" +Verify SMTP credentials in Authelia config and test connectivity. + +### "YAML not syncing" +Check if `watch: true` is set in Authelia config. If not, the API needs permission to restart the service. + +## Development + +### Building from Source +```bash +cd api +go mod download +go build -o authelia-api ./cmd/server +``` + +### Installing Development Build +After building, install with development mode enabled to use the local binary: + +```bash +# Move binary to root directory (if building in src/) +mv authelia-api ../ + +# Install with development mode +cd .. +AUTHELIA_API_DEVELOPMENT_MODE=true sudo ./install-authelia-api.sh +``` + +### Testing +```bash +go test ./... +``` + +### Architecture +``` +api/ +├── cmd/server/ # Main entry point +├── internal/ # Private packages +│ ├── bootstrap/ # Phase 1: Startup logic +│ ├── api/ # Phase 2: REST endpoints +│ ├── sync/ # Phase 3: YAML synchronization +│ ├── smtp/ # Phase 4: Email onboarding +│ └── auth/ # Bearer token middleware +├── pkg/ # Public packages +│ ├── config/ # Configuration parsing +│ ├── database/ # SQLite operations +│ └── authelia/ # Authelia binary integration +└── migrations/ # Database schema +``` + +## License +MIT License - See LICENSE file for details. + +## Contributing +Contributions welcome! Please open issues or pull requests on GitHub. \ No newline at end of file diff --git a/src/backup.sh b/src/backup.sh new file mode 100644 index 0000000..cbcb993 --- /dev/null +++ b/src/backup.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -e + +# Authelia API Backup Script +# Backups database and configuration +# Run this script as the authelia user or with sudo + +BACKUP_DIR="/opt/authelia/backups" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$BACKUP_DIR/authelia-api_backup_$TIMESTAMP.tar.gz" + +echo "=== Authelia API Backup ===" +echo "Timestamp: $TIMESTAMP" +echo "" + +# Create backup directory if it doesn't exist +if [ ! -d "$BACKUP_DIR" ]; then + echo "Creating backup directory: $BACKUP_DIR" + mkdir -p "$BACKUP_DIR" + chown authelia:authelia "$BACKUP_DIR" 2>/dev/null || true +fi + +# Check if authelia-api service is running +if systemctl is-active --quiet authelia-api; then + echo "Stopping authelia-api service..." + systemctl stop authelia-api + SERVICE_WAS_RUNNING=true +else + echo "Service is not running, proceeding with backup..." + SERVICE_WAS_RUNNING=false +fi + +# Create backup +echo "Creating backup archive..." +cd /opt/authelia/api + +# Include essential files +tar -czf "$BACKUP_FILE" \ + authelia-api.db \ + config.yml 2>/dev/null || true + +# Also include source files if they exist +if [ -d "src" ]; then + tar -czf "$BACKUP_FILE" --append \ + src/README.md \ + src/migrations/ \ + src/schema/ 2>/dev/null || true +fi + +# Restart service if it was running +if [ "$SERVICE_WAS_RUNNING" = true ]; then + echo "Starting authelia-api service..." + systemctl start authelia-api +fi + +# Set proper permissions on backup file +chown authelia:authelia "$BACKUP_FILE" 2>/dev/null || true +chmod 600 "$BACKUP_FILE" + +echo "" +echo "=== Backup Complete ===" +echo "Backup file: $BACKUP_FILE" +echo "Size: $(du -h "$BACKUP_FILE" | cut -f1)" +echo "" +echo "To restore:" +echo " tar -xzf $BACKUP_FILE -C /opt/authelia/api/" +echo " systemctl restart authelia-api" +echo "" +echo "Note: Ensure service is stopped before restoring backup." \ No newline at end of file diff --git a/src/cmd/server/main.go b/src/cmd/server/main.go new file mode 100644 index 0000000..725f3d9 --- /dev/null +++ b/src/cmd/server/main.go @@ -0,0 +1,233 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "authelia-api/internal/api" + "authelia-api/internal/auth" + "authelia-api/internal/bootstrap" + "authelia-api/internal/config" + "authelia-api/internal/database" + "authelia-api/internal/smtp" + "authelia-api/internal/sync" +) + +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func main() { + // Parse command line flags + configPath := flag.String("config", "", "Path to Authelia configuration.yml (default: auto-discover)") + bootstrapFlag := flag.Bool("bootstrap", false, "Run bootstrap process only (first-time setup)") + listenAddr := flag.String("listen", "", "API server listen address (default: from config.yml or 127.0.0.1:8080)") + dbPath := flag.String("db", "authelia-api.db", "SQLite database path") + logLevel := flag.String("log-level", "", "Log level (debug, info, warn, error) (default: from config.yml or info)") + showVersion := flag.Bool("version", false, "Show version information") + flag.Parse() + + if *showVersion { + fmt.Printf("Authelia API v%s (%s) built %s\n", version, commit, date) + os.Exit(0) + } + + // Setup logger + log.SetFlags(log.LstdFlags | log.Lshortfile) + + // Load configuration + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Override config with command line flags (only if flag is provided) + if *dbPath != "authelia-api.db" { + cfg.DatabasePath = *dbPath + } + if *listenAddr != "" { + cfg.ListenAddr = *listenAddr + } + if *logLevel != "" { + cfg.LogLevel = *logLevel + } + + // Run bootstrap if requested or always check + log.Println("Checking/bootstraping...") + if err := bootstrap.Run(cfg); err != nil { + log.Fatalf("Bootstrap failed: %v", err) + } + + // If bootstrap-only flag, exit now + if *bootstrapFlag { + log.Println("Bootstrap completed successfully") + os.Exit(0) + } + + // Initialize database + db, err := database.Init(cfg.DatabasePath) + if err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + defer db.Close() + + // Create application + app, err := NewApplication(cfg, db) + if err != nil { + log.Fatalf("Failed to create application: %v", err) + } + + // Start the application + if err := app.Start(); err != nil { + log.Fatalf("Failed to start application: %v", err) + } + + // Wait for shutdown signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + // Graceful shutdown + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := app.Shutdown(ctx); err != nil { + log.Fatalf("Server shutdown failed: %v", err) + } + + log.Println("Server stopped") +} + +// Application holds the main application dependencies +type Application struct { + config *config.Config + db *sql.DB + server *http.Server + syncEngine *sync.Engine + smtpClient *smtp.Client +} + +// NewApplication creates a new application instance +func NewApplication(cfg *config.Config, db *sql.DB) (*Application, error) { + app := &Application{ + config: cfg, + db: db, + } + + // Initialize sync engine + syncEngine := sync.NewEngine(db, cfg.AutheliaConfigPath, cfg.UserDatabasePath, cfg.WatchEnabled) + app.syncEngine = syncEngine + + // Initialize SMTP client if configured + if cfg.SMTP.Enabled { + smtpConfig := smtp.Config{ + Address: cfg.SMTP.Address, + Username: cfg.SMTP.Username, + Password: cfg.SMTP.Password, + Sender: cfg.SMTP.Sender, + } + smtpClient, err := smtp.NewClient(smtpConfig) + if err != nil { + return nil, fmt.Errorf("failed to create SMTP client: %w", err) + } + app.smtpClient = smtpClient + + // Test SMTP connection + if err := smtpClient.TestConnection(); err != nil { + log.Printf("Warning: SMTP connection test failed: %v", err) + } + } + + return app, nil +} + +// Start starts the HTTP server and sync engine +func (a *Application) Start() error { + // Start sync engine + a.syncEngine.Start() + + // Setup HTTP server + router := http.NewServeMux() + + // Health check endpoint (no auth required) + router.HandleFunc("GET /api/health", a.handleHealth) + + // Create authentication middleware + authMiddleware := auth.NewMiddleware(a.db) + + // Create API handlers + usersHandler := api.NewUsersHandler(a.db, a.config.AutheliaBinaryPath, a.syncEngine, a.smtpClient) + + // Protected routes + protectedRouter := http.NewServeMux() + usersHandler.RegisterRoutes(protectedRouter) + + // Wrap protected routes with auth middleware + router.Handle("/api/", authMiddleware.RequireAuth(protectedRouter)) + + // Create HTTP server + a.server = &http.Server{ + Addr: a.config.ListenAddr, + Handler: router, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start server in background + go func() { + log.Printf("Starting API server on %s", a.config.ListenAddr) + if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("HTTP server error: %v", err) + } + }() + + return nil +} + +// Shutdown gracefully shuts down the application +func (a *Application) Shutdown(ctx context.Context) error { + // Stop sync engine + if a.syncEngine != nil { + a.syncEngine.Stop() + } + + // Shutdown HTTP server + if a.server != nil { + return a.server.Shutdown(ctx) + } + + return nil +} + +// handleHealth handles health check requests +func (a *Application) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + response := map[string]interface{}{ + "status": "ok", + "version": version, + "time": time.Now().Format(time.RFC3339), + } + + // Check database connectivity + if err := a.db.Ping(); err != nil { + response["status"] = "error" + response["error"] = "database connection failed" + w.WriteHeader(http.StatusServiceUnavailable) + } + + json.NewEncoder(w).Encode(response) +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..2208858 --- /dev/null +++ b/src/go.mod @@ -0,0 +1,25 @@ +module authelia-api + +go 1.22 + +require ( + golang.org/x/crypto v0.22.0 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.29.8 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.19.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.49.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..961f541 --- /dev/null +++ b/src/go.sum @@ -0,0 +1,57 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk= +modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA= +modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg= +modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.29.8 h1:nGKglNx9K5v0As+zF0/Gcl1kMkmaU1XynYyq92PbsC8= +modernc.org/sqlite v1.29.8/go.mod h1:lQPm27iqa4UNZpmr4Aor0MH0HkCLbt1huYDfWylLZFk= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/src/internal/api/users.go b/src/internal/api/users.go new file mode 100644 index 0000000..6de88bf --- /dev/null +++ b/src/internal/api/users.go @@ -0,0 +1,475 @@ +package api + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "math/rand" + "net/http" + "os/exec" + "strings" + "time" + + "authelia-api/internal/smtp" + "authelia-api/internal/sync" +) + +// User represents a user in the system +type User struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Groups []string `json:"groups"` + Disabled bool `json:"disabled,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// BulkUserRequest represents a request to create multiple users +type BulkUserRequest struct { + Users []UserCreate `json:"users"` +} + +// UserCreate represents a user creation request +type UserCreate struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Groups []string `json:"groups"` +} + +// UserResponse represents a user response with placeholder password +type UserResponse struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + PlaceholderPassword string `json:"placeholder_password,omitempty"` + Status string `json:"status"` +} + +// BulkUserResponse represents the response for bulk user creation +type BulkUserResponse struct { + Success bool `json:"success"` + Created int `json:"created"` + Users []UserResponse `json:"users"` +} + +// UsersHandler handles user-related API endpoints +type UsersHandler struct { + db *sql.DB + autheliaPath string + syncEngine *sync.Engine + smtpClient *smtp.Client +} + +// NewUsersHandler creates a new users handler +func NewUsersHandler(db *sql.DB, autheliaPath string, syncEngine *sync.Engine, smtpClient *smtp.Client) *UsersHandler { + return &UsersHandler{ + db: db, + autheliaPath: autheliaPath, + syncEngine: syncEngine, + smtpClient: smtpClient, + } +} + +// RegisterRoutes registers the user routes +func (h *UsersHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/users/bulk", h.handleBulkCreate) + mux.HandleFunc("GET /api/users", h.handleList) + mux.HandleFunc("GET /api/users/{username}", h.handleGet) + mux.HandleFunc("DELETE /api/users/{username}", h.handleDelete) +} + +// handleBulkCreate handles bulk user creation +func (h *UsersHandler) handleBulkCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + var req BulkUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, fmt.Sprintf("Invalid JSON: %v", err)) + return + } + + if len(req.Users) == 0 { + writeError(w, http.StatusBadRequest, "No users provided") + return + } + + // Limit batch size + if len(req.Users) > 1000 { + writeError(w, http.StatusBadRequest, "Batch size too large (max 1000 users)") + return + } + + // Process users + responses := make([]UserResponse, 0, len(req.Users)) + createdCount := 0 + + for _, userReq := range req.Users { + // Validate user + if err := validateUserCreate(userReq); err != nil { + responses = append(responses, UserResponse{ + Username: userReq.Username, + DisplayName: userReq.DisplayName, + Email: userReq.Email, + Status: fmt.Sprintf("validation failed: %v", err), + }) + continue + } + + // Check if user already exists + var existingCount int + err := h.db.QueryRow("SELECT COUNT(*) FROM users WHERE username = ?", userReq.Username).Scan(&existingCount) + if err != nil { + responses = append(responses, UserResponse{ + Username: userReq.Username, + DisplayName: userReq.DisplayName, + Email: userReq.Email, + Status: fmt.Sprintf("database error: %v", err), + }) + continue + } + + if existingCount > 0 { + responses = append(responses, UserResponse{ + Username: userReq.Username, + DisplayName: userReq.DisplayName, + Email: userReq.Email, + Status: "user already exists", + }) + continue + } + + // Generate placeholder password + placeholderPassword := generatePassword(16) + + // Generate password hash using authelia binary + passwordHash, err := h.generatePasswordHash(placeholderPassword) + if err != nil { + responses = append(responses, UserResponse{ + Username: userReq.Username, + DisplayName: userReq.DisplayName, + Email: userReq.Email, + Status: fmt.Sprintf("password hash failed: %v", err), + }) + continue + } + + // Convert groups to JSON + groupsJSON := "[]" + if len(userReq.Groups) > 0 { + groupsJSON = "[" + strings.Join( + func() []string { + quoted := make([]string, len(userReq.Groups)) + for i, g := range userReq.Groups { + quoted[i] = `"` + g + `"` + } + return quoted + }(), + ",", + ) + "]" + } + + // Insert user into database + _, err = h.db.Exec(` + INSERT INTO users (username, displayname, email, password_hash, groups, disabled) + VALUES (?, ?, ?, ?, ?, ?) + `, userReq.Username, userReq.DisplayName, userReq.Email, passwordHash, groupsJSON, false) + + if err != nil { + responses = append(responses, UserResponse{ + Username: userReq.Username, + DisplayName: userReq.DisplayName, + Email: userReq.Email, + Status: fmt.Sprintf("database insert failed: %v", err), + }) + continue + } + + // Send welcome email if SMTP client is configured + emailStatus := "created" + if h.smtpClient != nil { + if err := h.smtpClient.SendWelcomeEmail(userReq.Email, userReq.Username, placeholderPassword); err != nil { + log.Printf("Failed to send welcome email to %s: %v", userReq.Email, err) + emailStatus = "created (email failed)" + } else { + log.Printf("Welcome email sent to %s", userReq.Email) + } + } + + // Trigger sync to generate YAML + if h.syncEngine != nil { + h.syncEngine.TriggerSync() + } + + responses = append(responses, UserResponse{ + Username: userReq.Username, + DisplayName: userReq.DisplayName, + Email: userReq.Email, + PlaceholderPassword: placeholderPassword, + Status: emailStatus, + }) + createdCount++ + } + + // Prepare response + response := BulkUserResponse{ + Success: createdCount > 0, + Created: createdCount, + Users: responses, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to encode response: %v", err)) + return + } +} + +// handleList handles listing users +func (h *UsersHandler) handleList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + // Parse pagination parameters + page := 1 + pageSize := 50 + + // TODO: Implement pagination + _ = page + _ = pageSize + + // Query users + rows, err := h.db.Query(` + SELECT username, displayname, email, groups, disabled, created_at, updated_at + FROM users + ORDER BY username + `) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Database error: %v", err)) + return + } + defer rows.Close() + + users := make([]User, 0) + for rows.Next() { + var user User + var groupsJSON string + var createdAt, updatedAt string + + if err := rows.Scan(&user.Username, &user.DisplayName, &user.Email, &groupsJSON, &user.Disabled, &createdAt, &updatedAt); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Database scan error: %v", err)) + return + } + + // Parse groups JSON + if groupsJSON != "" && groupsJSON != "[]" { + // Simple JSON parsing (for demo) + groupsJSON = strings.Trim(groupsJSON, "[]") + if groupsJSON != "" { + user.Groups = strings.Split(groupsJSON, ",") + // Remove quotes + for i, g := range user.Groups { + user.Groups[i] = strings.Trim(g, `" `) + } + } + } + + user.CreatedAt = createdAt + user.UpdatedAt = updatedAt + users = append(users, user) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(users); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to encode response: %v", err)) + return + } +} + +// handleGet handles retrieving a single user by username +func (h *UsersHandler) handleGet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + // Extract username from URL path + username := r.PathValue("username") + if username == "" { + writeError(w, http.StatusBadRequest, "Username is required") + return + } + + // Special handling for legacy query parameter pattern + // If the path variable is "username" and there's a "username" query parameter, use the query parameter + // This supports the pattern: /api/users/username?username=actualUsername + if username == "username" && r.URL.Query().Has("username") { + queryUsername := r.URL.Query().Get("username") + if queryUsername != "" { + username = queryUsername + } + } + + // Query user from database + var user User + var groupsJSON string + var createdAt, updatedAt string + + err := h.db.QueryRow(` + SELECT username, displayname, email, groups, disabled, created_at, updated_at + FROM users + WHERE username = ? + `, username).Scan(&user.Username, &user.DisplayName, &user.Email, &groupsJSON, &user.Disabled, &createdAt, &updatedAt) + + if err != nil { + if err == sql.ErrNoRows { + + writeError(w, http.StatusNotFound, "User not found") + return + } + log.Printf("[ERROR] handleGet: database error for username='%s': %v", username, err) + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Database error: %v", err)) + return + } + + // Parse groups JSON + if groupsJSON != "" && groupsJSON != "[]" { + // Simple JSON parsing (for demo) + groupsJSON = strings.Trim(groupsJSON, "[]") + if groupsJSON != "" { + user.Groups = strings.Split(groupsJSON, ",") + // Remove quotes + for i, g := range user.Groups { + user.Groups[i] = strings.Trim(g, `" `) + } + } + } + + user.CreatedAt = createdAt + user.UpdatedAt = updatedAt + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(user); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to encode response: %v", err)) + return + } +} + +// handleDelete handles user deletion +func (h *UsersHandler) handleDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + writeError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + // Extract username from URL path + username := r.PathValue("username") + if username == "" { + writeError(w, http.StatusBadRequest, "Username is required") + return + } + + // Special handling for legacy query parameter pattern + // If the path variable is "username" and there's a "username" query parameter, use the query parameter + // This supports the pattern: /api/users/username?username=actualUsername + if username == "username" && r.URL.Query().Has("username") { + queryUsername := r.URL.Query().Get("username") + if queryUsername != "" { + username = queryUsername + } + } + + // Delete user from database + result, err := h.db.Exec("DELETE FROM users WHERE username = ?", username) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Database error: %v", err)) + return + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Database error: %v", err)) + return + } + + if rowsAffected == 0 { + writeError(w, http.StatusNotFound, "User not found") + return + } + + // Trigger sync to generate YAML + if h.syncEngine != nil { + h.syncEngine.TriggerSync() + } + + w.Header().Set("Content-Type", "application/json") + response := map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("User %s deleted", username), + } + json.NewEncoder(w).Encode(response) +} + +// generatePasswordHash uses authelia binary to generate a compatible password hash +func (h *UsersHandler) generatePasswordHash(password string) (string, error) { + cmd := exec.Command(h.autheliaPath, "crypto", "hash", "generate", "argon2", "--password", password, "--no-confirm", "--config", "/dev/null") + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("authelia crypto failed: %v, output: %s", err, string(output)) + } + + // Parse the hash from output (format: "Digest: $argon2id$...") + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "Digest: ") { + return strings.TrimPrefix(line, "Digest: "), nil + } + } + + return "", fmt.Errorf("could not parse hash from authelia output: %s", string(output)) +} + +// validateUserCreate validates user creation request +func validateUserCreate(user UserCreate) error { + if user.Username == "" { + return fmt.Errorf("username is required") + } + if user.DisplayName == "" { + return fmt.Errorf("display_name is required") + } + if user.Email == "" { + return fmt.Errorf("email is required") + } + // Simple email validation + if !strings.Contains(user.Email, "@") { + return fmt.Errorf("invalid email format") + } + return nil +} + +// generatePassword generates a cryptographically random password +func generatePassword(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;:,.<>?" + seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) + + b := make([]byte, length) + for i := range b { + b[i] = charset[seededRand.Intn(len(charset))] + } + return string(b) +} + +// writeError writes a standardized error response +func writeError(w http.ResponseWriter, statusCode int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + errorResponse := fmt.Sprintf(`{"error": {"code": "%d", "message": "%s"}}`, statusCode, message) + w.Write([]byte(errorResponse)) +} diff --git a/src/internal/auth/middleware.go b/src/internal/auth/middleware.go new file mode 100644 index 0000000..735c1c5 --- /dev/null +++ b/src/internal/auth/middleware.go @@ -0,0 +1,95 @@ +package auth + +import ( + "database/sql" + "fmt" + "net/http" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +// Middleware provides Bearer token authentication +type Middleware struct { + db *sql.DB +} + +// NewMiddleware creates a new authentication middleware +func NewMiddleware(db *sql.DB) *Middleware { + return &Middleware{db: db} +} + +// RequireAuth is an HTTP middleware that validates Bearer tokens +func (m *Middleware) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extract Bearer token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + writeError(w, http.StatusUnauthorized, "Authorization header required") + return + } + + // Check Bearer scheme + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + writeError(w, http.StatusUnauthorized, "Invalid Authorization header format. Expected: Bearer ") + return + } + + token := parts[1] + if token == "" { + writeError(w, http.StatusUnauthorized, "Bearer token is empty") + return + } + + // Validate token against database + valid, err := m.validateToken(token) + if err != nil { + writeError(w, http.StatusInternalServerError, "Internal server error") + // Log the actual error server-side + fmt.Printf("Token validation error: %v\n", err) + return + } + + if !valid { + writeError(w, http.StatusUnauthorized, "Invalid or expired token") + return + } + + // Token is valid, proceed to next handler + next.ServeHTTP(w, r) + }) +} + +// validateToken checks if a Bearer token exists in the api_admins table +func (m *Middleware) validateToken(token string) (bool, error) { + // Get all admin token hashes from database + rows, err := m.db.Query("SELECT token_hash FROM api_admins WHERE token_hash != ''") + if err != nil { + return false, fmt.Errorf("failed to query admin tokens: %w", err) + } + defer rows.Close() + + // Check each hash (bcrypt comparison) + for rows.Next() { + var tokenHash string + if err := rows.Scan(&tokenHash); err != nil { + return false, fmt.Errorf("failed to scan token hash: %w", err) + } + + // Compare the provided token with the stored bcrypt hash + if err := bcrypt.CompareHashAndPassword([]byte(tokenHash), []byte(token)); err == nil { + return true, nil + } + } + + return false, nil +} + +// writeError writes a standardized error response +func writeError(w http.ResponseWriter, statusCode int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + errorResponse := fmt.Sprintf(`{"error": {"code": "%d", "message": "%s"}}`, statusCode, message) + w.Write([]byte(errorResponse)) +} diff --git a/src/internal/bootstrap/bootstrap.go b/src/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..7d2bd35 --- /dev/null +++ b/src/internal/bootstrap/bootstrap.go @@ -0,0 +1,181 @@ +package bootstrap + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + + "authelia-api/internal/config" + "authelia-api/internal/database" + + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" + _ "modernc.org/sqlite" // Pure Go SQLite driver +) + +// Run executes the bootstrap process (idempotent) +func Run(cfg *config.Config) error { + // Step 1: Initialize database + db, err := database.Init(cfg.DatabasePath) + if err != nil { + return fmt.Errorf("database initialization failed: %w", err) + } + defer db.Close() + + // Step 2: Seed admin if needed + if err := seedAdmin(db, cfg.SessionSecret); err != nil { + return fmt.Errorf("admin seeding failed: %w", err) + } + + // Step 3: Import existing users + if err := importExistingUsers(db, cfg.UserDatabasePath); err != nil { + return fmt.Errorf("user import failed: %w", err) + } + + return nil +} + +// seedAdmin seeds the initial admin user if no admins exist +func seedAdmin(db *sql.DB, sessionSecret string) error { + // Check if any admins exist + var adminCount int + err := db.QueryRow("SELECT COUNT(*) FROM api_admins WHERE token_hash != ''").Scan(&adminCount) + if err != nil { + return fmt.Errorf("failed to check existing admins: %w", err) + } + + if adminCount > 0 { + // Admins already exist, nothing to do + return nil + } + + // Hash the session secret for use as initial bearer token + tokenHash, err := hashToken(sessionSecret) + if err != nil { + return fmt.Errorf("failed to hash session secret: %w", err) + } + + // Insert bootstrap admin + _, err = db.Exec( + "INSERT OR REPLACE INTO api_admins (name, token_hash) VALUES (?, ?)", + "bootstrap_admin", + tokenHash, + ) + if err != nil { + return fmt.Errorf("failed to insert bootstrap admin: %w", err) + } + + // Log the bootstrap (but don't expose the raw secret!) + fmt.Println("✓ Bootstrap admin created using session.secret from Authelia config") + fmt.Println(" Use the session.secret value as your initial Bearer token") + fmt.Println(" Example: curl -H 'Authorization: Bearer YOUR_SESSION_SECRET' http://localhost:8080/api/health") + + return nil +} + +// importExistingUsers imports users from Authelia's YAML database +func importExistingUsers(db *sql.DB, userDatabasePath string) error { + // Check if the file exists + if _, err := os.Stat(userDatabasePath); os.IsNotExist(err) { + // No existing user database, nothing to import + return nil + } + + // Parse the YAML file + data, err := os.ReadFile(userDatabasePath) + if err != nil { + return fmt.Errorf("failed to read user database: %w", err) + } + + var userDB struct { + Users map[string]struct { + DisplayName string `yaml:"displayname"` + Password string `yaml:"password"` + Email string `yaml:"email"` + Groups []string `yaml:"groups"` + Disabled bool `yaml:"disabled,omitempty"` + } `yaml:"users"` + } + + if err := yaml.Unmarshal(data, &userDB); err != nil { + return fmt.Errorf("failed to parse user database YAML: %w", err) + } + + // Start transaction for bulk import + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Prepare statement + stmt, err := tx.Prepare(` + INSERT OR REPLACE INTO users + (username, displayname, email, password_hash, groups, disabled) + VALUES (?, ?, ?, ?, ?, ?) + `) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + importCount := 0 + for username, user := range userDB.Users { + // Convert groups to JSON array + groupsJSON := "[]" + if len(user.Groups) > 0 { + // Simple JSON array serialization + groupsJSON = "[" + strings.Join( + func() []string { + quoted := make([]string, len(user.Groups)) + for i, g := range user.Groups { + quoted[i] = `"` + g + `"` + } + return quoted + }(), + ",", + ) + "]" + } + + // CRITICAL: Store the raw Argon2id hash as-is (no re-hashing) + _, err := stmt.Exec( + username, + user.DisplayName, + user.Email, + user.Password, // Raw hash + groupsJSON, + user.Disabled, + ) + if err != nil { + return fmt.Errorf("failed to import user %s: %w", username, err) + } + importCount++ + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + if importCount > 0 { + fmt.Printf("✓ Imported %d existing users from %s\n", importCount, filepath.Base(userDatabasePath)) + } + + return nil +} + +// hashToken hashes a bearer token using bcrypt +func hashToken(token string) (string, error) { + // Use a reasonable cost factor (bcrypt.DefaultCost is 10) + hash, err := bcrypt.GenerateFromPassword([]byte(token), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("bcrypt hash failed: %w", err) + } + return string(hash), nil +} + +// VerifyToken verifies a bearer token against a hash +func VerifyToken(token, hash string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(token)) == nil +} diff --git a/src/internal/config/config.go b/src/internal/config/config.go new file mode 100644 index 0000000..f614877 --- /dev/null +++ b/src/internal/config/config.go @@ -0,0 +1,219 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Config holds the application configuration +type Config struct { + // Authelia configuration + AutheliaConfigPath string `yaml:"-"` // Path to Authelia configuration.yml + AutheliaBinaryPath string `yaml:"-"` // Path to authelia binary + + // Session secret from Authelia config (for bootstrap) + SessionSecret string `yaml:"-" json:"-"` + + // SMTP configuration from Authelia + SMTP struct { + Address string `yaml:"address"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Sender string `yaml:"sender"` + Enabled bool `yaml:"-"` + } `yaml:"-"` + + // User database path from Authelia + UserDatabasePath string `yaml:"-"` + + // API configuration + DatabasePath string `yaml:"database_path"` // SQLite database path + ListenAddr string `yaml:"listen_addr"` // API server address + LogLevel string `yaml:"log_level"` // Log level + + // Authelia watch setting + WatchEnabled bool `yaml:"-"` // From authentication_backend.file.watch +} + +// AutheliaConfig represents the structure of Authelia's configuration.yml +type AutheliaConfig struct { + Session struct { + Secret string `yaml:"secret"` + } `yaml:"session"` + + AuthenticationBackend struct { + File struct { + Path string `yaml:"path"` + Watch bool `yaml:"watch"` + } `yaml:"file"` + } `yaml:"authentication_backend"` + + Notifier struct { + SMTP struct { + Address string `yaml:"address"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Sender string `yaml:"sender"` + } `yaml:"smtp"` + } `yaml:"notifier"` +} + +// Load loads configuration from environment and Authelia config +func Load(configPath string) (*Config, error) { + cfg := &Config{ + DatabasePath: getEnv("AUTHELIA_API_DB_PATH", "authelia-api.db"), + ListenAddr: getEnv("AUTHELIA_API_LISTEN_ADDR", "127.0.0.1:8080"), + LogLevel: getEnv("AUTHELIA_API_LOG_LEVEL", "info"), + } + + // Try to load authelia-api config.yml from working directory + if data, err := os.ReadFile("config.yml"); err == nil { + var apiConfig Config + if err := yaml.Unmarshal(data, &apiConfig); err == nil { + // Override with values from config.yml if they are not empty + if apiConfig.DatabasePath != "" { + cfg.DatabasePath = apiConfig.DatabasePath + } + if apiConfig.ListenAddr != "" { + cfg.ListenAddr = apiConfig.ListenAddr + } + if apiConfig.LogLevel != "" { + cfg.LogLevel = apiConfig.LogLevel + } + } + } + + // Discover Authelia configuration + autheliaConfigPath, err := discoverAutheliaConfig(configPath) + if err != nil { + return nil, fmt.Errorf("failed to discover Authelia config: %w", err) + } + cfg.AutheliaConfigPath = autheliaConfigPath + + // Parse Authelia configuration + autheliaConfig, err := parseAutheliaConfig(autheliaConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to parse Authelia config: %w", err) + } + + // Extract values from Authelia config + cfg.SessionSecret = autheliaConfig.Session.Secret + cfg.UserDatabasePath = autheliaConfig.AuthenticationBackend.File.Path + cfg.WatchEnabled = autheliaConfig.AuthenticationBackend.File.Watch + + // Extract SMTP configuration if available + if autheliaConfig.Notifier.SMTP.Address != "" { + cfg.SMTP.Address = autheliaConfig.Notifier.SMTP.Address + cfg.SMTP.Username = autheliaConfig.Notifier.SMTP.Username + cfg.SMTP.Password = autheliaConfig.Notifier.SMTP.Password + cfg.SMTP.Sender = autheliaConfig.Notifier.SMTP.Sender + cfg.SMTP.Enabled = true + } + + // Find authelia binary + autheliaBinaryPath, err := findAutheliaBinary() + if err != nil { + return nil, fmt.Errorf("authelia binary not found: %w", err) + } + cfg.AutheliaBinaryPath = autheliaBinaryPath + + return cfg, nil +} + +// discoverAutheliaConfig finds the Authelia configuration file +func discoverAutheliaConfig(configPath string) (string, error) { + // If explicit path provided, use it + if configPath != "" { + if _, err := os.Stat(configPath); err == nil { + return configPath, nil + } + return "", fmt.Errorf("specified config path does not exist: %s", configPath) + } + + // Common Authelia config locations + possiblePaths := []string{ + "/opt/authelia/configuration.yml", + "/etc/authelia/configuration.yml", + "/config/configuration.yml", + "./configuration.yml", + } + + // Check current directory and parent + cwd, _ := os.Getwd() + possiblePaths = append(possiblePaths, + filepath.Join(cwd, "configuration.yml"), + filepath.Join(filepath.Dir(cwd), "configuration.yml"), + ) + + for _, path := range possiblePaths { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + + return "", fmt.Errorf("could not find Authelia configuration.yml in any known location") +} + +// parseAutheliaConfig parses the Authelia YAML configuration +func parseAutheliaConfig(configPath string) (*AutheliaConfig, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var config AutheliaConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + + return &config, nil +} + +// findAutheliaBinary locates the authelia binary +func findAutheliaBinary() (string, error) { + // Check PATH first + if path, err := os.Executable(); err == nil { + // Check if we're actually the authelia binary (unlikely) + if filepath.Base(path) == "authelia" { + return path, nil + } + } + + // Check common locations + possiblePaths := []string{ + "/usr/local/bin/authelia", + "/usr/bin/authelia", + "/opt/authelia/authelia", + "./authelia", + } + + // Check PATH + if path := os.Getenv("PATH"); path != "" { + for _, dir := range strings.Split(path, ":") { + possiblePaths = append(possiblePaths, filepath.Join(dir, "authelia")) + } + } + + for _, binaryPath := range possiblePaths { + if _, err := os.Stat(binaryPath); err == nil { + // Check if it's executable + if info, err := os.Stat(binaryPath); err == nil && info.Mode()&0111 != 0 { + return binaryPath, nil + } + } + } + + return "", fmt.Errorf("authelia binary not found in PATH or common locations") +} + +// getEnv gets an environment variable with a fallback default +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/src/internal/database/database.go b/src/internal/database/database.go new file mode 100644 index 0000000..9117df0 --- /dev/null +++ b/src/internal/database/database.go @@ -0,0 +1,225 @@ +package database + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" // Pure Go SQLite driver +) + +// DB wraps a SQLite database connection +type DB struct { + *sql.DB + path string +} + +// Init initializes the SQLite database and runs migrations +func Init(dbPath string) (*sql.DB, error) { + // Ensure directory exists + dir := filepath.Dir(dbPath) + if dir != "." && dir != "" && dir != "/" { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create database directory: %w", err) + } + } + + // Open database with proper settings for concurrency + // Use IMMEDIATE transaction mode to prevent database locks during bulk imports + connStr := fmt.Sprintf("file:%s?_journal=WAL&_timeout=5000&_txlock=immediate", dbPath) + db, err := sql.Open("sqlite", connStr) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Test connection + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("database ping failed: %w", err) + } + + // Enable foreign keys + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { + db.Close() + return nil, fmt.Errorf("failed to enable foreign keys: %w", err) + } + + // Set busy timeout + if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil { + db.Close() + return nil, fmt.Errorf("failed to set busy timeout: %w", err) + } + + // Run migrations + if err := runMigrations(db); err != nil { + db.Close() + return nil, fmt.Errorf("migrations failed: %w", err) + } + + return db, nil +} + +// runMigrations executes SQL migration files in order +func runMigrations(db *sql.DB) error { + // Check if migrations table exists + var tableExists int + err := db.QueryRow(` + SELECT COUNT(*) FROM sqlite_master + WHERE type='table' AND name='schema_migrations' + `).Scan(&tableExists) + if err != nil { + return fmt.Errorf("failed to check migrations table: %w", err) + } + + // Create migrations table if it doesn't exist + if tableExists == 0 { + _, err := db.Exec(` + CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return fmt.Errorf("failed to create migrations table: %w", err) + } + } + + // Get list of applied migrations + applied := make(map[int]bool) + rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version") + if err != nil { + return fmt.Errorf("failed to query applied migrations: %w", err) + } + defer rows.Close() + + for rows.Next() { + var version int + if err := rows.Scan(&version); err != nil { + return fmt.Errorf("failed to scan migration version: %w", err) + } + applied[version] = true + } + + // Read migration files from embedded filesystem or directory + // For now, we'll use the SQL from the migrations directory + migrationFiles := []struct { + version int + name string + sql string + }{ + { + version: 1, + name: "initial_schema", + sql: `BEGIN TRANSACTION; + +-- API administrators table (for Bearer token authentication) +CREATE TABLE IF NOT EXISTS api_admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + token_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Users table (Source of Truth for Authelia users) +CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + displayname TEXT NOT NULL, + email TEXT NOT NULL, + password_hash TEXT NOT NULL, + groups TEXT NOT NULL DEFAULT '[]', + disabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Sync logs for auditing +CREATE TABLE IF NOT EXISTS sync_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + operation TEXT NOT NULL, + username TEXT, + success BOOLEAN NOT NULL DEFAULT TRUE, + error TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_groups ON users(groups); +CREATE INDEX IF NOT EXISTS idx_sync_logs_timestamp ON sync_logs(timestamp); +CREATE INDEX IF NOT EXISTS idx_sync_logs_username ON sync_logs(username); + +-- Insert bootstrap admin placeholder +INSERT OR IGNORE INTO api_admins (name, token_hash) +SELECT 'bootstrap_admin', '' +WHERE NOT EXISTS (SELECT 1 FROM api_admins); + +-- Triggers +CREATE TRIGGER IF NOT EXISTS update_api_admins_timestamp +AFTER UPDATE ON api_admins +BEGIN + UPDATE api_admins SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; +END; + +CREATE TRIGGER IF NOT EXISTS update_users_timestamp +AFTER UPDATE ON users +BEGIN + UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE username = NEW.username; +END; + +CREATE TRIGGER IF NOT EXISTS log_user_changes +AFTER INSERT ON users +BEGIN + INSERT INTO sync_logs (operation, username, success) VALUES ('CREATE', NEW.username, TRUE); +END; + +CREATE TRIGGER IF NOT EXISTS log_user_updates +AFTER UPDATE ON users +BEGIN + INSERT INTO sync_logs (operation, username, success) VALUES ('UPDATE', NEW.username, TRUE); +END; + +CREATE TRIGGER IF NOT EXISTS log_user_deletes +AFTER DELETE ON users +BEGIN + INSERT INTO sync_logs (operation, username, success) VALUES ('DELETE', OLD.username, TRUE); +END; + +COMMIT;`, + }, + } + + // Apply pending migrations + for _, migration := range migrationFiles { + if !applied[migration.version] { + // Execute migration + if _, err := db.Exec(migration.sql); err != nil { + return fmt.Errorf("migration %d failed: %w", migration.version, err) + } + + // Record migration + _, err := db.Exec( + "INSERT INTO schema_migrations (version, name) VALUES (?, ?)", + migration.version, + migration.name, + ) + if err != nil { + return fmt.Errorf("failed to record migration %d: %w", migration.version, err) + } + + fmt.Printf("✓ Applied migration: %s (v%d)\n", migration.name, migration.version) + } + } + + return nil +} + +// Close closes the database connection +func Close(db *sql.DB) error { + if db != nil { + return db.Close() + } + return nil +} diff --git a/src/internal/smtp/client.go b/src/internal/smtp/client.go new file mode 100644 index 0000000..935eb52 --- /dev/null +++ b/src/internal/smtp/client.go @@ -0,0 +1,323 @@ +package smtp + +import ( + "crypto/tls" + "fmt" + "net" + "net/smtp" + "net/url" + "time" +) + +// Client represents an SMTP client for sending emails +type Client struct { + host string + port string + username string + password string + sender string + tls bool + startTLS bool +} + +// Config holds SMTP configuration +type Config struct { + Address string + Username string + Password string + Sender string +} + +// NewClient creates a new SMTP client from Authelia configuration +func NewClient(cfg Config) (*Client, error) { + if cfg.Address == "" { + return nil, fmt.Errorf("SMTP address is required") + } + + // Parse the address (format: "submission://host:port") + u, err := url.Parse(cfg.Address) + if err != nil { + return nil, fmt.Errorf("invalid SMTP address format: %w", err) + } + + host := u.Hostname() + port := u.Port() + if port == "" { + // Default ports based on scheme + switch u.Scheme { + case "smtp": + port = "25" + case "smtps": + port = "465" + case "submission": + port = "587" + default: + port = "587" + } + } + + // Determine TLS mode based on scheme + var useTLS, useStartTLS bool + switch u.Scheme { + case "smtp": + // Plain SMTP, no TLS + useTLS = false + useStartTLS = false + case "smtps": + // Implicit TLS (SMTPS) + useTLS = true + useStartTLS = false + case "submission": + // STARTTLS on port 587 + useTLS = false + useStartTLS = true + default: + // Default to STARTTLS for unknown schemes + useTLS = false + useStartTLS = true + } + + return &Client{ + host: host, + port: port, + username: cfg.Username, + password: cfg.Password, + sender: cfg.Sender, + tls: useTLS, + startTLS: useStartTLS, + }, nil +} + +// SendWelcomeEmail sends a welcome email to a new user +func (c *Client) SendWelcomeEmail(toEmail, username, placeholderPassword string) error { + subject := "Welcome to Authelia - Set Your Password" + body := c.generateWelcomeEmail(username, placeholderPassword) + + // Prepare message + message := fmt.Sprintf(`From: %s +To: %s +Subject: %s +Content-Type: text/html; charset=UTF-8 + +%s`, c.sender, toEmail, subject, body) + + // Connect to SMTP server + var auth smtp.Auth + if c.username != "" && c.password != "" { + auth = smtp.PlainAuth("", c.username, c.password, c.host) + } + + addr := fmt.Sprintf("%s:%s", c.host, c.port) + + // Send email based on TLS configuration + if c.tls { + // Implicit TLS (SMTPS) + tlsConfig := &tls.Config{ + ServerName: c.host, + MinVersion: tls.VersionTLS12, + } + + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("TLS dial failed: %w", err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, c.host) + if err != nil { + return fmt.Errorf("SMTP client creation failed: %w", err) + } + defer client.Close() + + // Authenticate if needed + if auth != nil { + if err := client.Auth(auth); err != nil { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + } + + // Send mail + if err := client.Mail(c.sender); err != nil { + return fmt.Errorf("SMTP MAIL failed: %w", err) + } + if err := client.Rcpt(toEmail); err != nil { + return fmt.Errorf("SMTP RCPT failed: %w", err) + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("SMTP DATA failed: %w", err) + } + + _, err = w.Write([]byte(message)) + if err != nil { + return fmt.Errorf("SMTP write failed: %w", err) + } + + err = w.Close() + if err != nil { + return fmt.Errorf("SMTP close failed: %w", err) + } + + client.Quit() + } else if c.startTLS { + // STARTTLS (plain connection then upgrade) + client, err := smtp.Dial(addr) + if err != nil { + return fmt.Errorf("SMTP dial failed: %w", err) + } + defer client.Close() + + // Start TLS + tlsConfig := &tls.Config{ + ServerName: c.host, + MinVersion: tls.VersionTLS12, + } + if err := client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("STARTTLS failed: %w", err) + } + + // Authenticate if needed + if auth != nil { + if err := client.Auth(auth); err != nil { + return fmt.Errorf("SMTP authentication failed: %w", err) + } + } + + // Send mail + if err := client.Mail(c.sender); err != nil { + return fmt.Errorf("SMTP MAIL failed: %w", err) + } + if err := client.Rcpt(toEmail); err != nil { + return fmt.Errorf("SMTP RCPT failed: %w", err) + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("SMTP DATA failed: %w", err) + } + + _, err = w.Write([]byte(message)) + if err != nil { + return fmt.Errorf("SMTP write failed: %w", err) + } + + err = w.Close() + if err != nil { + return fmt.Errorf("SMTP close failed: %w", err) + } + + client.Quit() + } else { + // Plain SMTP (not recommended) + err := smtp.SendMail(addr, auth, c.sender, []string{toEmail}, []byte(message)) + if err != nil { + return fmt.Errorf("SMTP send failed: %w", err) + } + } + + fmt.Printf("✓ Welcome email sent to %s\n", toEmail) + return nil +} + +// generateWelcomeEmail generates the HTML email content +func (c *Client) generateWelcomeEmail(username, placeholderPassword string) string { + // Simple HTML email template + return fmt.Sprintf(` + + + + + + +
+
+

Welcome to Authelia

+
+ +
+

Hello %s,

+ +

Your account has been created in our authentication system. To get started, you need to set your password.

+ +

Next Steps:

+
    +
  1. Visit the Authelia portal at: https://auth.sechpoint.app
  2. +
  3. Click on "Reset Password"
  4. +
  5. Enter your username: %s
  6. +
  7. You will receive a password reset email
  8. +
  9. Follow the instructions in that email to set your password
  10. +
+ +
+ Important: Do not use the placeholder password below for login.
+ It is only shown for verification purposes. +
+ +

If you have any issues, please contact your system administrator.

+ +

Best regards,
+ The Authelia Team

+
+ + +
+ +`, username, username) +} + +// TestConnection tests the SMTP connection with current credentials +func (c *Client) TestConnection() error { + addr := fmt.Sprintf("%s:%s", c.host, c.port) + + // Test with timeout + conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + if err != nil { + return fmt.Errorf("connection failed: %w", err) + } + conn.Close() + + // Don't actually send, just test credentials + if c.tls { + // Implicit TLS test + tlsConfig := &tls.Config{ + ServerName: c.host, + MinVersion: tls.VersionTLS12, + } + + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("TLS test failed: %w", err) + } + conn.Close() + } else if c.startTLS { + // STARTTLS test + client, err := smtp.Dial(addr) + if err != nil { + return fmt.Errorf("SMTP dial failed: %w", err) + } + defer client.Close() + + tlsConfig := &tls.Config{ + ServerName: c.host, + MinVersion: tls.VersionTLS12, + } + if err := client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("STARTTLS test failed: %w", err) + } + } + + fmt.Printf("✓ SMTP connection test passed: %s:%s\n", c.host, c.port) + return nil +} diff --git a/src/internal/sync/engine.go b/src/internal/sync/engine.go new file mode 100644 index 0000000..9702418 --- /dev/null +++ b/src/internal/sync/engine.go @@ -0,0 +1,259 @@ +package sync + +import ( + "database/sql" + "fmt" + "os" + "os/exec" + "strings" + "sync" + "time" + + "gopkg.in/yaml.v3" +) + +// Engine handles synchronization between SQLite and Authelia YAML +type Engine struct { + db *sql.DB + autheliaConfigPath string + userDatabasePath string + watchEnabled bool + mu sync.Mutex + triggerChan chan struct{} + stopChan chan struct{} +} + +// NewEngine creates a new synchronization engine +func NewEngine(db *sql.DB, autheliaConfigPath, userDatabasePath string, watchEnabled bool) *Engine { + return &Engine{ + db: db, + autheliaConfigPath: autheliaConfigPath, + userDatabasePath: userDatabasePath, + watchEnabled: watchEnabled, + triggerChan: make(chan struct{}, 1), // Buffered to avoid blocking + stopChan: make(chan struct{}), + } +} + +// Start begins the synchronization engine +func (e *Engine) Start() { + go e.run() +} + +// Stop stops the synchronization engine +func (e *Engine) Stop() { + close(e.stopChan) +} + +// TriggerSync triggers an immediate synchronization +func (e *Engine) TriggerSync() { + select { + case e.triggerChan <- struct{}{}: + // Trigger sent + default: + // Trigger already pending + } +} + +// run is the main synchronization loop +func (e *Engine) run() { + for { + select { + case <-e.stopChan: + return + case <-e.triggerChan: + e.syncNow() + case <-time.After(5 * time.Second): + // Periodic check (though triggers should handle most cases) + } + } +} + +// syncNow performs an immediate synchronization +func (e *Engine) syncNow() { + e.mu.Lock() + defer e.mu.Unlock() + + // Generate YAML from database + if err := e.generateYAML(); err != nil { + fmt.Printf("YAML generation failed: %v\n", err) + // Log to sync_logs table + e.logSync("SYNC", "", false, err.Error()) + return + } + + // Log successful sync + e.logSync("SYNC", "", true, "") + + // Restart Authelia if watch is disabled + if !e.watchEnabled { + if err := e.restartAuthelia(); err != nil { + fmt.Printf("Authelia restart failed: %v\n", err) + e.logSync("RESTART", "", false, err.Error()) + } else { + e.logSync("RESTART", "", true, "") + } + } +} + +// generateYAML generates the users_database.yml file from SQLite +func (e *Engine) generateYAML() error { + // Query all users from database + rows, err := e.db.Query(` + SELECT username, displayname, email, password_hash, groups, disabled + FROM users + ORDER BY username + `) + if err != nil { + return fmt.Errorf("failed to query users: %w", err) + } + defer rows.Close() + + // Build YAML structure + users := make(map[string]UserYAML) + for rows.Next() { + var username, displayname, email, passwordHash, groupsJSON string + var disabled bool + + if err := rows.Scan(&username, &displayname, &email, &passwordHash, &groupsJSON, &disabled); err != nil { + return fmt.Errorf("failed to scan user row: %w", err) + } + + // Parse groups JSON + var groups []string + if groupsJSON != "" && groupsJSON != "[]" { + // Simple JSON parsing + groupsJSON = strings.Trim(groupsJSON, "[]") + if groupsJSON != "" { + groupItems := strings.Split(groupsJSON, ",") + for _, g := range groupItems { + groups = append(groups, strings.Trim(g, `" `)) + } + } + } + + users[username] = UserYAML{ + DisplayName: displayname, + Password: passwordHash, + Email: email, + Groups: groups, + Disabled: disabled, + } + } + + if err := rows.Err(); err != nil { + return fmt.Errorf("row iteration error: %w", err) + } + + // Create YAML structure matching Authelia schema + yamlData := struct { + Users map[string]UserYAML `yaml:"users"` + }{ + Users: users, + } + + // Marshal YAML + yamlBytes, err := yaml.Marshal(&yamlData) + if err != nil { + return fmt.Errorf("failed to marshal YAML: %w", err) + } + + // Write to temporary file first (atomic write) + tempPath := e.userDatabasePath + ".tmp" + if err := os.WriteFile(tempPath, yamlBytes, 0644); err != nil { + return fmt.Errorf("failed to write temporary YAML file: %w", err) + } + + // Atomically rename to target path + if err := os.Rename(tempPath, e.userDatabasePath); err != nil { + // Try to clean up temp file + os.Remove(tempPath) + return fmt.Errorf("failed to rename temporary file: %w", err) + } + + fmt.Printf("✓ Generated YAML with %d users at %s\n", len(users), e.userDatabasePath) + return nil +} + +// restartAuthelia restarts the Authelia service if watch is disabled +func (e *Engine) restartAuthelia() error { + // Try systemctl first + cmd := exec.Command("systemctl", "restart", "authelia") + if output, err := cmd.CombinedOutput(); err == nil { + fmt.Println("✓ Authelia service restarted via systemctl") + return nil + } else { + fmt.Printf("Systemctl restart failed: %v, output: %s\n", err, string(output)) + } + + // Fallback: Try to find and kill/restart authelia process + // This is a simpler approach for environments without systemd + fmt.Println("⚠ Systemctl failed, trying fallback restart methods") + + // Option 1: Send SIGHUP if authelia supports hot reload + cmd = exec.Command("pkill", "-HUP", "authelia") + if _, err := cmd.CombinedOutput(); err == nil { + fmt.Println("✓ Sent SIGHUP to authelia processes") + return nil + } + + // Option 2: If authelia is running in foreground, we can't restart it + // Just log a warning + fmt.Println("⚠ Could not restart Authelia. Manual restart may be required.") + fmt.Println(" Ensure Authelia is configured with watch: true for automatic reloads.") + + return fmt.Errorf("failed to restart authelia via any method") +} + +// logSync logs a synchronization event to the database +func (e *Engine) logSync(operation, username string, success bool, errorMsg string) { + _, err := e.db.Exec(` + INSERT INTO sync_logs (operation, username, success, error, timestamp) + VALUES (?, ?, ?, ?, ?) + `, operation, username, success, errorMsg, time.Now().Format(time.RFC3339)) + + if err != nil { + fmt.Printf("Failed to log sync event: %v\n", err) + } +} + +// UserYAML represents a user in Authelia's YAML format +type UserYAML struct { + DisplayName string `yaml:"displayname"` + Password string `yaml:"password"` + Email string `yaml:"email"` + Groups []string `yaml:"groups"` + Disabled bool `yaml:"disabled,omitempty"` +} + +// CheckConfig reads the Authelia config to determine if watch is enabled +func CheckConfig(configPath string) (bool, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return false, fmt.Errorf("failed to read config: %w", err) + } + + // Simple YAML parsing to find watch setting + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if strings.Contains(line, "watch:") { + // Check next few lines if value is on same line + value := strings.TrimSpace(strings.TrimPrefix(line, "watch:")) + if value == "" { + // Check next line + if i+1 < len(lines) { + value = strings.TrimSpace(lines[i+1]) + } + } + + // Parse boolean + if strings.Contains(value, "true") { + return true, nil + } + return false, nil + } + } + + // Default to false if not found + return false, nil +} diff --git a/src/migrations/001_initial.sql b/src/migrations/001_initial.sql new file mode 100644 index 0000000..2f3d295 --- /dev/null +++ b/src/migrations/001_initial.sql @@ -0,0 +1,79 @@ +-- Authelia API Database Schema +-- Version: 1.0 +-- Description: Initial schema for Authelia API + +BEGIN TRANSACTION; + +-- API administrators table (for Bearer token authentication) +CREATE TABLE IF NOT EXISTS api_admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + token_hash TEXT NOT NULL, -- bcrypt/argon2 hashed bearer token + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Users table (Source of Truth for Authelia users) +CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + displayname TEXT NOT NULL, + email TEXT NOT NULL, + password_hash TEXT NOT NULL, -- Raw Argon2id hash from Authelia + groups TEXT NOT NULL DEFAULT '[]', -- JSON array of group names + disabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Sync logs for auditing +CREATE TABLE IF NOT EXISTS sync_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + operation TEXT NOT NULL, -- CREATE, UPDATE, DELETE, SYNC + username TEXT, + success BOOLEAN NOT NULL DEFAULT TRUE, + error TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_groups ON users(groups); +CREATE INDEX IF NOT EXISTS idx_sync_logs_timestamp ON sync_logs(timestamp); +CREATE INDEX IF NOT EXISTS idx_sync_logs_username ON sync_logs(username); + +-- Insert bootstrap admin if no admins exist +-- The token will be set during bootstrap phase using session.secret +INSERT OR IGNORE INTO api_admins (name, token_hash) +SELECT 'bootstrap_admin', '' +WHERE NOT EXISTS (SELECT 1 FROM api_admins); + +-- Triggers for updated_at timestamps +CREATE TRIGGER IF NOT EXISTS update_api_admins_timestamp +AFTER UPDATE ON api_admins +BEGIN + UPDATE api_admins SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; +END; + +CREATE TRIGGER IF NOT EXISTS update_users_timestamp +AFTER UPDATE ON users +BEGIN + UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE username = NEW.username; +END; + +-- Trigger to log user changes +CREATE TRIGGER IF NOT EXISTS log_user_changes +AFTER INSERT OR UPDATE OR DELETE ON users +BEGIN + INSERT INTO sync_logs (operation, username, success) + VALUES ( + CASE + WHEN EXISTS (SELECT 1 FROM inserted) AND EXISTS (SELECT 1 FROM deleted) THEN 'UPDATE' + WHEN EXISTS (SELECT 1 FROM inserted) THEN 'CREATE' + ELSE 'DELETE' + END, + COALESCE((SELECT username FROM inserted), (SELECT username FROM deleted)), + TRUE + ); +END; + +COMMIT; \ No newline at end of file