Ctrl K
ComunidadGuías…

internal/server/oidc.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: Apache-2.0

package server

import (
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/base64"
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"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"

	// v2.65.0 (issue #139): control what an auto-created SSO account may do.
	// Auto-created users were always given the read-only role with no way to
	// change that short of promoting each one by hand under Users.
	//
	// settingOIDCDefaultRole is the role for a new account when no group
	// mapping matches. Empty means "view", which is what every release
	// before this one did.
	settingOIDCDefaultRole = "oidc_default_role"
	// settingOIDCGroupsClaim names the ID-token claim holding the user's
	// groups ("groups", "roles", "wiki_groups", …). Empty disables mapping.
	settingOIDCGroupsClaim = "oidc_groups_claim"
	// settingOIDCRoleMappings maps those group values to CaddyUI roles, one
	// `group|role` rule per line. First match in file order wins.
	settingOIDCRoleMappings = "oidc_role_mappings"

	// settingOIDCAllowedDomains restricts which verified email domains may
	// sign in through SSO, one domain per line or comma-separated. Empty
	// means every identity the provider authenticates is accepted.
	//
	// v2.67.0: this matters most with a *public* provider. With a private
	// IdP, "anyone my IdP authenticates" is your own staff; with Google or
	// similar it is every account in the world, and with auto-create on that
	// is an account on your CaddyUI for anyone who finds the login page.
	settingOIDCAllowedDomains = "oidc_allowed_domains"

	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
	// v2.65.0 (issue #139): role assignment for auto-created accounts.
	DefaultRole  string
	GroupsClaim  string
	RoleMappings []oidcRoleMapping
	// AllowedDomains is the set of verified email domains permitted to sign
	// in. Empty means no restriction. v2.67.0.
	AllowedDomains []string
}

// parseOIDCAllowedDomains reads the domain list. Entries are lowercased and a
// leading "@" is tolerated, because writing "@example.com" is the obvious
// mistake to make. Blank lines and `#` comments are skipped.
func parseOIDCAllowedDomains(raw string) []string {
	var out []string
	for _, f := range strings.FieldsFunc(raw, func(r rune) bool {
		return r == '\n' || r == '\r' || r == ','
	}) {
		d := strings.ToLower(strings.TrimSpace(f))
		d = strings.TrimPrefix(d, "@")
		if d == "" || strings.HasPrefix(d, "#") {
			continue
		}
		out = append(out, d)
	}
	return out
}

// emailDomainAllowed reports whether this verified address may sign in.
//
// The list gates SSO sign-in *entirely*, not just account creation. An
// administrator who writes "only example.com" means it, and the alternative —
// gating creation alone — would leave an existing outside account able to sign
// in through a provider the admin believes is restricted. Local password
// login is unaffected, so nobody is locked out of the product by this.
//
// Matching is on the exact domain after "@". Subdomains are not implied:
// listing example.com does not admit user@mail.example.com, which has to be
// listed separately.
func (c oidcConfig) emailDomainAllowed(email string) bool {
	if len(c.AllowedDomains) == 0 {
		return true
	}
	at := strings.LastIndex(email, "@")
	if at < 0 || at == len(email)-1 {
		return false
	}
	domain := strings.ToLower(email[at+1:])
	for _, d := range c.AllowedDomains {
		if domain == d {
			return true
		}
	}
	return false
}

// oidcRoleMapping grants a CaddyUI role to members of one IdP group.
type oidcRoleMapping struct {
	Group string
	Role  string
}

// validOIDCRole reports whether role is one CaddyUI recognises. Anything else
// is ignored rather than stored, so a typo in the mapping list cannot hand out
// an unintended level of access — or silently create an account with a role
// no permission check understands.
func validOIDCRole(role string) bool {
	switch role {
	case models.RoleAdmin, models.RoleUser, models.RoleView:
		return true
	}
	return false
}

// parseOIDCRoleMappings reads `group|role` rules, one per line. Blank lines
// and `#` comments are skipped, as is any rule naming a role CaddyUI does not
// have. Order is preserved: the first rule matching any of the user's groups
// decides, so the most privileged mappings belong at the top.
func parseOIDCRoleMappings(raw string) []oidcRoleMapping {
	var out []oidcRoleMapping
	for _, line := range strings.Split(raw, "\n") {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		parts := strings.SplitN(line, "|", 2)
		if len(parts) != 2 {
			continue
		}
		group := strings.TrimSpace(parts[0])
		role := strings.ToLower(strings.TrimSpace(parts[1]))
		if group == "" || !validOIDCRole(role) {
			continue
		}
		out = append(out, oidcRoleMapping{Group: group, Role: role})
	}
	return out
}

// invalidOIDCRoleMappingLines describes every line of a group→role list that
// parseOIDCRoleMappings would discard, so the operator is told at save time
// rather than finding out when someone signs in on the wrong role.
// v2.65.0 (issue #139).
func invalidOIDCRoleMappingLines(raw string) []string {
	var bad []string
	for i, line := range strings.Split(raw, "\n") {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		n := i + 1
		parts := strings.SplitN(line, "|", 2)
		if len(parts) != 2 {
			bad = append(bad, fmt.Sprintf("line %d (%q) has no `|` separating the group from the role", n, line))
			continue
		}
		group := strings.TrimSpace(parts[0])
		role := strings.ToLower(strings.TrimSpace(parts[1]))
		switch {
		case group == "":
			bad = append(bad, fmt.Sprintf("line %d (%q) has no group name", n, line))
		case role == "":
			bad = append(bad, fmt.Sprintf("line %d (%q) has no role", n, line))
		case !validOIDCRole(role):
			bad = append(bad, fmt.Sprintf("line %d names the role %q, which does not exist", n, role))
		}
	}
	return bad
}

// roleForGroups returns the role to give a user with these IdP groups, and
// whether a mapping actually matched. Group comparison is case-insensitive;
// IdPs are inconsistent about casing and a near-miss would silently downgrade
// someone to the default role.
func (c oidcConfig) roleForGroups(groups []string) (string, bool) {
	for _, m := range c.RoleMappings {
		for _, g := range groups {
			if strings.EqualFold(strings.TrimSpace(g), m.Group) {
				return m.Role, true
			}
		}
	}
	return "", false
}

// defaultRole is the role for a new account when no mapping matched.
func (c oidcConfig) defaultRole() string {
	if validOIDCRole(c.DefaultRole) {
		return c.DefaultRole
	}
	return models.RoleView
}

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,
		DefaultRole:    strings.ToLower(get(settingOIDCDefaultRole)),
		GroupsClaim:    get(settingOIDCGroupsClaim),
		RoleMappings:   parseOIDCRoleMappings(mustGetSetting(s.DB, settingOIDCRoleMappings)),
		AllowedDomains: parseOIDCAllowedDomains(mustGetSetting(s.DB, settingOIDCAllowedDomains)),
	}
}

// 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 != ""
}

// oidcStringValues normalises a claim value into a list of strings.
//
// v2.65.0 (issue #139): IdPs disagree about how to express group membership.
// Most send a JSON array of strings, some send a single string, and a few
// send a space- or comma-separated string in one field. Anything else — an
// array of objects, a number — yields no groups rather than an error, so an
// unexpected claim shape simply means "no mapping matched" and the user still
// signs in on the default role.
func oidcStringValues(v any) []string {
	switch t := v.(type) {
	case []any:
		var out []string
		for _, item := range t {
			if s, ok := item.(string); ok {
				if s = strings.TrimSpace(s); s != "" {
					out = append(out, s)
				}
			}
		}
		return out
	case []string:
		return t
	case string:
		var out []string
		for _, s := range strings.FieldsFunc(t, func(r rune) bool { return r == ',' || r == ' ' }) {
			if s = strings.TrimSpace(s); s != "" {
				out = append(out, s)
			}
		}
		return out
	}
	return nil
}

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

// ssoProviderLabel turns an issuer URL into something short enough for a
// badge: https://accounts.google.com -> "Google", https://sso.example.com ->
// "Example". The full issuer is shown alongside it wherever this appears, so
// a wrong guess is never the only thing on screen. v2.68.0.
func ssoProviderLabel(issuer string) string {
	u, err := url.Parse(strings.TrimSpace(issuer))
	if err != nil || u.Host == "" {
		return "SSO"
	}
	host := strings.ToLower(u.Hostname())
	parts := strings.Split(host, ".")
	if len(parts) < 2 {
		return "SSO"
	}
	// The registrable label, i.e. the one before the public suffix. Good
	// enough for a badge on the common cases; co.uk and friends just yield
	// the next label along, which is still recognisable.
	name := parts[len(parts)-2]
	if name == "" {
		return "SSO"
	}
	return strings.ToUpper(name[:1]) + name[1:]
}

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
	}
	// v2.65.0 (issue #139): the groups claim is read separately and
	// defensively — its name is operator-configured and its shape varies by
	// IdP, so it must not be able to fail the whole sign-in.
	var groups []string
	if cfg.GroupsClaim != "" {
		var all map[string]any
		if err := idToken.Claims(&all); err == nil {
			groups = oidcStringValues(all[cfg.GroupsClaim])
		}
	}
	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
	}
	// v2.67.0: the allowed-domain policy, checked before the account lookup
	// so it governs existing users as well as auto-created ones. Gating only
	// creation would let an account from outside the listed domains keep
	// signing in through a provider the administrator believes is restricted.
	if !cfg.emailDomainAllowed(email) {
		log.Printf("oidc: rejecting sign-in for %s: domain not in the allowed list %v", email, cfg.AllowedDomains)
		http.Redirect(w, r, "/login?error=sso_domain", 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
		}
		// v2.65.0 (issue #139): the role comes from a group mapping when one
		// matches, otherwise the configured default (still "view" unless an
		// admin changed it). The password is an unusable random non-bcrypt
		// string, so the account cannot be used to password-log-in.
		role := cfg.defaultRole()
		if mapped, ok := cfg.roleForGroups(groups); ok {
			role = mapped
		}
		log.Printf("oidc: creating account for %s with role %q (groups=%v)", email, role, groups)
		if _, cerr := models.CreateUser(s.DB, email, oidcRandom(), name, role); 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
		}
	}

	// v2.68.0: record that the provider asserted this identity, and which
	// provider it was, so the Users page can show how each account signs in.
	// Written here rather than after the session is issued because this is
	// the point the identity provider has authenticated them — a local
	// second factor may still follow, and that is a separate gate.
	if err := models.RecordSSOLogin(s.DB, u.ID, cfg.Issuer); err != nil {
		// Never fail a sign-in over bookkeeping.
		log.Printf("oidc: recording SSO login for %s: %v", email, err)
	}

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