From 3c365e8206246f7f0b21b8f2c2fa72032fb77065 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Sun, 14 Jun 2026 14:56:39 +0000 Subject: [PATCH] feat(auth): replace bearer token with OIDC session + admin role check, auto-fix roles from groups --- src/core/admin/sync.go | 36 ++++++++++++++++++++++++--- src/core/auth/middleware.go | 49 +++++++++++++++++++++++++++++++++++++ src/main.go | 22 +++++++++++------ 3 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 src/core/auth/middleware.go diff --git a/src/core/admin/sync.go b/src/core/admin/sync.go index 465edad..3187805 100644 --- a/src/core/admin/sync.go +++ b/src/core/admin/sync.go @@ -107,10 +107,16 @@ func (sw *SyncWriter) Bootstrap() (int, error) { groups += g } + // Determine role from groups + role := "user" + if containsGroup(groups, "admins") { + role = "admin" + } + _, err := sw.store.GetDB().Exec(` - INSERT INTO users (username, display_name, email, groups, password_hash, disabled, updated_at) - VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - `, username, entry.DisplayName, entry.Email, groups, entry.Password, entry.Disabled) + INSERT INTO users (username, display_name, email, role, groups, password_hash, disabled, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, username, entry.DisplayName, entry.Email, role, groups, entry.Password, entry.Disabled) if err != nil { return imported, fmt.Errorf("import user %s: %w", username, err) } @@ -119,3 +125,27 @@ func (sw *SyncWriter) Bootstrap() (int, error) { return imported, nil } + +// FixRoles updates existing users' roles based on their groups. +func (sw *SyncWriter) FixRoles() (int, error) { + users, err := sw.store.List() + if err != nil { + return 0, err + } + + fixed := 0 + for _, u := range users { + expectedRole := "user" + if containsGroup(u.Groups, "admins") { + expectedRole = "admin" + } + if u.Role != expectedRole { + _, err := sw.store.GetDB().Exec("UPDATE users SET role = ? WHERE username = ?", expectedRole, u.Username) + if err != nil { + return fixed, err + } + fixed++ + } + } + return fixed, nil +} diff --git a/src/core/auth/middleware.go b/src/core/auth/middleware.go new file mode 100644 index 0000000..aba3ac5 --- /dev/null +++ b/src/core/auth/middleware.go @@ -0,0 +1,49 @@ +package auth + +import ( + "database/sql" + "net/http" +) + +// RoleChecker validates that the session user has the required role. +type RoleChecker struct { + db *sql.DB +} + +// NewRoleChecker creates a role checker backed by the database. +func NewRoleChecker(db *sql.DB) *RoleChecker { + return &RoleChecker{db: db} +} + +// RequireAdmin is middleware that allows only users with the "admin" role. +// Must run after SessionMiddleware has populated the context. +func (rc *RoleChecker) RequireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userID, ok := GetUserID(r) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + isAdmin, err := rc.isAdmin(userID) + if err != nil || !isAdmin { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) +} + +// isAdmin checks if a user has the admin role. +func (rc *RoleChecker) isAdmin(username string) (bool, error) { + var role string + err := rc.db.QueryRow("SELECT role FROM users WHERE username = ?", username).Scan(&role) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return role == "admin", nil +} diff --git a/src/main.go b/src/main.go index 6cc9890..81b0bf5 100644 --- a/src/main.go +++ b/src/main.go @@ -60,11 +60,19 @@ func main() { logger.Info("bootstrapped authelia users", "count", imported) } + // Fix existing user roles based on groups + if fixed, err := syncWriter.FixRoles(); err != nil { + logger.Warn("fix roles", "error", err) + } else if fixed > 0 { + logger.Info("fixed user roles", "count", fixed) + } + // Create admin handler adminHandler := admin.NewHandler(userStore, syncWriter, logger) // Initialize session store and OIDC auth sessionStore := auth.NewSessionStore(database.DB) + roleChecker := auth.NewRoleChecker(database.DB) // OIDC issuer: public-facing URL (via Zoraxy) for browser redirects // Falls back to authelia.host if not configured @@ -122,13 +130,13 @@ func main() { } uiHandler.RegisterRoutes(mux, combinedAuth) - // --- Admin API routes (JSON, protected by bearer token) --- - adminAuth := admin.TokenAuthMiddleware(cfg.Admin.SecretToken) - adminHandler.RegisterRoutes(mux, adminAuth) - - // --- Admin UI routes (Templ-rendered HTML, protected by OIDC session) --- - adminHandler.RegisterUIRoutes(mux, combinedAuth) - adminHandler.RegisterHTMXRoutes(mux, combinedAuth) + // --- Admin routes: session auth + admin role check --- + adminRoleAuth := func(next http.Handler) http.Handler { + return sessionStore.SessionMiddleware(roleChecker.RequireAdmin(next)) + } + adminHandler.RegisterRoutes(mux, adminRoleAuth) + adminHandler.RegisterUIRoutes(mux, adminRoleAuth) + adminHandler.RegisterHTMXRoutes(mux, adminRoleAuth) // --- OIDC config page — shows Authelia status --- mux.HandleFunc("GET /auth/status", func(w http.ResponseWriter, r *http.Request) {