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:
parent
49a5ab0cb8
commit
38a69937d5
13 changed files with 2518 additions and 0 deletions
278
src/README.md
Normal file
278
src/README.md
Normal 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
69
src/backup.sh
Normal 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
233
src/cmd/server/main.go
Normal 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
25
src/go.mod
Normal 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
57
src/go.sum
Normal 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
475
src/internal/api/users.go
Normal 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))
|
||||
}
|
||||
95
src/internal/auth/middleware.go
Normal file
95
src/internal/auth/middleware.go
Normal 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))
|
||||
}
|
||||
181
src/internal/bootstrap/bootstrap.go
Normal file
181
src/internal/bootstrap/bootstrap.go
Normal 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
|
||||
}
|
||||
219
src/internal/config/config.go
Normal file
219
src/internal/config/config.go
Normal 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
|
||||
}
|
||||
225
src/internal/database/database.go
Normal file
225
src/internal/database/database.go
Normal 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
323
src/internal/smtp/client.go
Normal 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
259
src/internal/sync/engine.go
Normal 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
|
||||
}
|
||||
79
src/migrations/001_initial.sql
Normal file
79
src/migrations/001_initial.sql
Normal 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;
|
||||
Loading…
Reference in a new issue