chore: add Go source code to src/ directory

- Source code for authelia-api (Go 1.22)
- cmd/server/main.go - entry point
- internal/api, auth, bootstrap, config, database, smtp, sync packages
- migrations/001_initial.sql - database schema
- go.mod with dependencies (x/crypto, yaml.v3, modernc/sqlite)
This commit is contained in:
Claus Lohmar 2026-07-15 14:27:33 +00:00 committed by root
parent 49a5ab0cb8
commit 38a69937d5
13 changed files with 2518 additions and 0 deletions

278
src/README.md Normal file
View file

@ -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 <repository> 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 <token>" 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.

69
src/backup.sh Normal file
View file

@ -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."

233
src/cmd/server/main.go Normal file
View file

@ -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)
}

25
src/go.mod Normal file
View file

@ -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
)

57
src/go.sum Normal file
View file

@ -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=

475
src/internal/api/users.go Normal file
View file

@ -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))
}

View file

@ -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 <token>")
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))
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

323
src/internal/smtp/client.go Normal file
View file

@ -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(`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #f8f9fa; padding: 20px; text-align: center; border-radius: 5px; }
.content { padding: 20px; background: white; border-radius: 5px; margin-top: 20px; }
.info-box { background: #e9ecef; padding: 15px; border-radius: 5px; margin: 15px 0; font-family: monospace; }
.button { display: inline-block; background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; margin: 10px 0; }
.footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #dee2e6; font-size: 12px; color: #6c757d; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to Authelia</h1>
</div>
<div class="content">
<p>Hello <strong>%s</strong>,</p>
<p>Your account has been created in our authentication system. To get started, you need to set your password.</p>
<h3>Next Steps:</h3>
<ol>
<li>Visit the Authelia portal at: <strong>https://auth.sechpoint.app</strong></li>
<li>Click on "Reset Password"</li>
<li>Enter your username: <strong>%s</strong></li>
<li>You will receive a password reset email</li>
<li>Follow the instructions in that email to set your password</li>
</ol>
<div class="info-box">
<strong>Important:</strong> Do not use the placeholder password below for login.<br>
It is only shown for verification purposes.
</div>
<p>If you have any issues, please contact your system administrator.</p>
<p>Best regards,<br>
The Authelia Team</p>
</div>
<div class="footer">
<p>This is an automated message. Please do not reply to this email.</p>
<p>If you did not request this account, please contact your administrator immediately.</p>
</div>
</div>
</body>
</html>`, 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
}

259
src/internal/sync/engine.go Normal file
View file

@ -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
}

View file

@ -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;