Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versi
### Added

- **Scheduled off-host database backups** ([issue #104](https://github.com/X4Applegate/caddyui/issues/104)): under **Settings → Backup**, enable automatic SQLite snapshots (`VACUUM INTO`) written to a directory on an interval, keeping the newest N. Point the directory at a mounted volume, network share, or object-store gateway to keep copies off the host. A **Back up now** button runs one on demand. (MariaDB installs continue to use their platform's own backup tooling.)
- **OIDC / SSO login** ([issue #106](https://github.com/X4Applegate/caddyui/issues/106)): sign in to CaddyUI through an external identity provider (Authelia, Authentik, Keycloak, Google, …) alongside local password + TOTP — configure it under **Settings → Security**. Uses the standard auth-code flow with `state` + `nonce`, and delegates ID-token verification (JWKS, signature, `iss`/`aud`/`exp`, nonce) to the vetted `go-oidc` library. A verified email is required; it matches an existing CaddyUI user by email, or — opt-in — provisions a read-only account on first sign-in. Local login always remains available.

## [2.51.0] - 2026-09-17 - Per-host rate limiting

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ run CaddyUI directly in an LXC, VM, or bare-metal host.

### Analytics, certificates and local services in v2.39 – v2.52

- **OIDC / SSO login** *(v2.52.0)* — sign in through an external identity provider (Authelia, Authentik, Keycloak, Google, …) alongside local password + TOTP; configure under Settings → Security. Standard auth-code flow with state/nonce and `go-oidc` token verification; verified-email matching with opt-in auto-provisioning.
- **Scheduled off-host backups** *(v2.52.0)* — automatic SQLite snapshots to a directory on an interval (keep the newest N), plus a **Back up now** button, under Settings → Backup. Point the directory at a mounted volume or share to keep copies off the host.
- **Per-host rate limiting** *(v2.51.0)* — cap a host at *N requests per M seconds, per client IP* (429 on excess), via the `caddy-ratelimit` module now built into `Dockerfile.caddy`. Rebuild your custom Caddy image to use it.
- **Block an IP from analytics** *(v2.50.0)* — a visitor's drill-down page gains one-click **Block on this host** and **Block everywhere (fleet-wide)** actions; the global blocklist is managed under Settings → Security. Turns "who's probing me?" into a one-click 403.
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ require (
github.com/aws/aws-sdk-go-v2 v1.47.0
github.com/aws/aws-sdk-go-v2/credentials v1.20.5
github.com/aws/aws-sdk-go-v2/service/route53 v1.70.0
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-chi/chi/v5 v5.3.2
github.com/go-sql-driver/mysql v1.10.1
github.com/pquerna/otp v1.5.0
golang.org/x/crypto v0.57.0
golang.org/x/oauth2 v0.37.0
modernc.org/sqlite v1.59.0
)

Expand All @@ -20,6 +22,7 @@ require (
github.com/aws/smithy-go v1.28.1 // indirect
github.com/boombuler/barcode v1.0.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,16 @@ github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqx
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
Expand All @@ -46,6 +50,8 @@ golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
Expand Down
275 changes: 275 additions & 0 deletions internal/server/oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
package server

import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"log"
"net/http"
"strings"
"sync"
"time"

"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"

"github.com/X4Applegate/caddyui/internal/auth"
"github.com/X4Applegate/caddyui/internal/models"
)

// Optional OIDC / SSO login (issue #106). CaddyUI can accept sign-ins from an
// external identity provider (Authelia, Authentik, Keycloak, Google, …) in
// addition to local password + TOTP. ID-token verification (JWKS, signature,
// iss/aud/exp, nonce) is delegated to the vetted go-oidc library rather than
// hand-rolled. Local login always remains available.
const (
settingOIDCEnabled = "oidc_enabled"
settingOIDCIssuer = "oidc_issuer"
settingOIDCClientID = "oidc_client_id"
settingOIDCClientSecret = "oidc_client_secret"
settingOIDCRedirectURL = "oidc_redirect_url"
settingOIDCAutoCreate = "oidc_auto_create"
settingOIDCButtonLabel = "oidc_button_label"

oidcStateCookie = "caddyui_oidc_state"
oidcNonceCookie = "caddyui_oidc_nonce"
oidcDefaultButtonLabel = "Sign in with SSO"
)

type oidcConfig struct {
Enabled bool
Issuer string
ClientID string
ClientSecret string
RedirectURL string
AutoCreate bool
ButtonLabel string
}

func (s *Server) oidcConfig() oidcConfig {
get := func(k string) string { return strings.TrimSpace(mustGetSetting(s.DB, k)) }
label := get(settingOIDCButtonLabel)
if label == "" {
label = oidcDefaultButtonLabel
}
return oidcConfig{
Enabled: mustGetSetting(s.DB, settingOIDCEnabled) == "1",
Issuer: get(settingOIDCIssuer),
ClientID: get(settingOIDCClientID),
ClientSecret: strings.TrimSpace(mustGetSetting(s.DB, settingOIDCClientSecret)),
RedirectURL: get(settingOIDCRedirectURL),
AutoCreate: mustGetSetting(s.DB, settingOIDCAutoCreate) == "1",
ButtonLabel: label,
}
}

// ready reports whether SSO is enabled and has the minimum configuration to run.
func (c oidcConfig) ready() bool {
return c.Enabled && c.Issuer != "" && c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != ""
}

// oidcProviderCache memoises the discovery document per issuer so each login
// doesn't re-fetch /.well-known/openid-configuration.
var oidcProviderCache sync.Map // issuer -> *oidc.Provider

func oidcProviderFor(ctx context.Context, issuer string) (*oidc.Provider, error) {
if v, ok := oidcProviderCache.Load(issuer); ok {
return v.(*oidc.Provider), nil
}
p, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, err
}
oidcProviderCache.Store(issuer, p)
return p, nil
}

func (s *Server) oidcClients(ctx context.Context, cfg oidcConfig) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
provider, err := oidcProviderFor(ctx, cfg.Issuer)
if err != nil {
return nil, nil, err
}
oauth2Cfg := &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "email", "profile"},
}
verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
return oauth2Cfg, verifier, nil
}

func oidcRandom() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}

func oidcSecure(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}

func setOIDCFlowCookie(w http.ResponseWriter, r *http.Request, name, value string) {
http.SetCookie(w, &http.Cookie{
Name: name, Value: value, Path: "/", MaxAge: 600,
HttpOnly: true, Secure: oidcSecure(r), SameSite: http.SameSiteLaxMode,
})
}

func clearOIDCFlowCookie(w http.ResponseWriter, r *http.Request, name string) {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: oidcSecure(r), SameSite: http.SameSiteLaxMode,
})
}

// getOIDCLogin starts the auth-code flow: mint state + nonce, stash them in
// short-lived cookies, and redirect to the provider.
func (s *Server) getOIDCLogin(w http.ResponseWriter, r *http.Request) {
cfg := s.oidcConfig()
if !cfg.ready() {
http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther)
return
}
oauth2Cfg, _, err := s.oidcClients(r.Context(), cfg)
if err != nil {
log.Printf("oidc: provider init: %v", err)
http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther)
return
}
state, nonce := oidcRandom(), oidcRandom()
setOIDCFlowCookie(w, r, oidcStateCookie, state)
setOIDCFlowCookie(w, r, oidcNonceCookie, nonce)
http.Redirect(w, r, oauth2Cfg.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusSeeOther)
}

// getOIDCCallback completes the flow: verify state, exchange the code, verify
// the ID token + nonce, then map the email to a CaddyUI user and sign in.
func (s *Server) getOIDCCallback(w http.ResponseWriter, r *http.Request) {
cfg := s.oidcConfig()
if !cfg.ready() {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// One-time flow cookies — clear them regardless of outcome.
stateCookie, _ := r.Cookie(oidcStateCookie)
nonceCookie, _ := r.Cookie(oidcNonceCookie)
clearOIDCFlowCookie(w, r, oidcStateCookie)
clearOIDCFlowCookie(w, r, oidcNonceCookie)

if errParam := r.URL.Query().Get("error"); errParam != "" {
log.Printf("oidc: provider returned error: %s", errParam)
http.Redirect(w, r, "/login?error=sso_denied", http.StatusSeeOther)
return
}
if stateCookie == nil || stateCookie.Value == "" || r.URL.Query().Get("state") != stateCookie.Value {
http.Redirect(w, r, "/login?error=sso_state", http.StatusSeeOther)
return
}

ctx := r.Context()
oauth2Cfg, verifier, err := s.oidcClients(ctx, cfg)
if err != nil {
log.Printf("oidc: provider init: %v", err)
http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther)
return
}
token, err := oauth2Cfg.Exchange(ctx, r.URL.Query().Get("code"))
if err != nil {
log.Printf("oidc: token exchange: %v", err)
http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther)
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther)
return
}
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
log.Printf("oidc: id token verify: %v", err)
http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther)
return
}
if nonceCookie == nil || nonceCookie.Value == "" || idToken.Nonce != nonceCookie.Value {
http.Redirect(w, r, "/login?error=sso_nonce", http.StatusSeeOther)
return
}

var claims struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil {
http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther)
return
}
email := strings.ToLower(strings.TrimSpace(claims.Email))
if email == "" || !claims.EmailVerified {
// An unverified (or missing) email must not be trusted to match or
// create an account — it would allow impersonation on IdPs that let a
// user set an arbitrary address.
log.Printf("oidc: rejecting sign-in: email empty or unverified (%q verified=%v)", email, claims.EmailVerified)
http.Redirect(w, r, "/login?error=sso_email", http.StatusSeeOther)
return
}

u, err := models.GetUserByEmail(s.DB, email)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
// A real DB failure must not be mistaken for "no such user" (which
// would wrongly deny — or, with auto-create on, try to provision).
log.Printf("oidc: lookup %s: %v", email, err)
http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther)
return
}
if u == nil {
if !cfg.AutoCreate {
log.Printf("oidc: no CaddyUI account for %s and auto-create is off", email)
http.Redirect(w, r, "/login?error=sso_nouser", http.StatusSeeOther)
return
}
name := strings.TrimSpace(claims.Name)
if name == "" {
name = email
}
// New SSO users get the read-only role and an unusable password (a
// random non-bcrypt string), so they can't password-log-in. Promote
// them under Users if they need more.
if _, cerr := models.CreateUser(s.DB, email, oidcRandom(), name, models.RoleView); cerr != nil {
log.Printf("oidc: provision %s: %v", email, cerr)
http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther)
return
}
if u, err = models.GetUserByEmail(s.DB, email); err != nil || u == nil {
http.Redirect(w, r, "/login?error=sso", http.StatusSeeOther)
return
}
}

// Honour a user's local TOTP as a second factor even over SSO — otherwise
// enabling SSO would silently downgrade every TOTP-protected account. Route
// through the same pending-TOTP challenge that local login uses.
if u.TOTPEnabled && u.TOTPSecret != "" {
tok := oidcRandom()
s.pendingTOTP.Store(tok, u.ID)
go func() {
time.Sleep(5 * time.Minute)
s.pendingTOTP.Delete(tok)
}()
http.Redirect(w, r, "/login/totp?t="+tok, http.StatusSeeOther)
return
}

tok, exp, err := auth.CreateSessionWithTTL(s.DB, u.ID, s.sessionTTL())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
auth.SetSessionCookie(w, r, tok, exp)
_ = models.LogActivity(s.DB, 0, u.Email, "login_success_sso", "ip:"+clientIPFromRequest(r), r.UserAgent(), true)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
45 changes: 45 additions & 0 deletions internal/server/oidc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package server

import (
"strings"
"testing"
)

func TestOIDCConfigReady(t *testing.T) {
full := oidcConfig{
Enabled: true, Issuer: "https://sso.example.com", ClientID: "cid",
ClientSecret: "secret", RedirectURL: "https://caddyui.example.com/auth/oidc/callback",
}
if !full.ready() {
t.Fatal("fully-configured OIDC should be ready")
}
// Each missing required field makes it not ready.
cases := map[string]func(c *oidcConfig){
"disabled": func(c *oidcConfig) { c.Enabled = false },
"no issuer": func(c *oidcConfig) { c.Issuer = "" },
"no clientID": func(c *oidcConfig) { c.ClientID = "" },
"no secret": func(c *oidcConfig) { c.ClientSecret = "" },
"no redirect": func(c *oidcConfig) { c.RedirectURL = "" },
}
for name, mut := range cases {
c := full
mut(&c)
if c.ready() {
t.Fatalf("%s: expected not ready", name)
}
}
}

func TestOIDCRandomIsDistinctURLSafe(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 100; i++ {
v := oidcRandom()
if v == "" || seen[v] {
t.Fatalf("oidcRandom collision or empty: %q", v)
}
if strings.ContainsAny(v, "+/=") {
t.Fatalf("oidcRandom not URL-safe: %q", v)
}
seen[v] = true
}
}
Loading
Loading