[management] Enable MFA for local users (#5804)

* wip: totp for local users

* fix providers not getting populated

* polished UI and fix post_login_redirect_uri

* fix: make sure logout is only prompted from oidc flow

Signed-off-by: jnfrati <nicofrati@gmail.com>

* update templates

Signed-off-by: jnfrati <nicofrati@gmail.com>

* deps: update dex dependency

Signed-off-by: jnfrati <nicofrati@gmail.com>

* fix qube issues

Signed-off-by: jnfrati <nicofrati@gmail.com>

* replace window with globalThis on home html

Signed-off-by: jnfrati <nicofrati@gmail.com>

* fixed coderabbit comments

Signed-off-by: jnfrati <nicofrati@gmail.com>

* debug

* remove unused config and rename totp issuer

* deps: update dex reference to latest

* add dashboard post logout redirect uri to embedded config

* implemented api for mfa configuration

* update docs and config parsing

* catch error on idp manager init mfa

* fix tests

* Add remember me  for MFA

* Add cookie encryption and session share between tabs

* fixed logout showing non actionable error and session cookie encription key

* fixed missing mfa settings on sql query for account

* fix code index for mfa activity

---------

Signed-off-by: jnfrati <nicofrati@gmail.com>
Co-authored-by: braginini <bangvalo@gmail.com>
This commit is contained in:
Nicolas Frati
2026-05-08 16:31:20 +02:00
committed by GitHub
parent 7da94a4956
commit e89aad09f5
23 changed files with 791 additions and 87 deletions

View File

@@ -51,6 +51,70 @@ type YAMLConfig struct {
// StaticPasswords cause the server use this list of passwords rather than
// querying the storage.
StaticPasswords []Password `yaml:"staticPasswords" json:"staticPasswords"`
// Sessions holds authentication session configuration.
// Requires DEX_SESSIONS_ENABLED=true feature flag.
Sessions *Sessions `yaml:"sessions" json:"sessions"`
// MFA holds multi-factor authentication configuration.
MFA MFAConfig `yaml:"mfa" json:"mfa"`
}
type Sessions struct {
// CookieName is the name of the session cookie. Defaults to "dex_session".
CookieName string `yaml:"cookieName" json:"cookieName"`
// AbsoluteLifetime is the maximum session lifetime from creation. Defaults to "24h".
AbsoluteLifetime string `yaml:"absoluteLifetime" json:"absoluteLifetime"`
// ValidIfNotUsedFor is the idle timeout. Defaults to "1h".
ValidIfNotUsedFor string `yaml:"validIfNotUsedFor" json:"validIfNotUsedFor"`
// RememberMeCheckedByDefault controls the default state of the "remember me" checkbox.
RememberMeCheckedByDefault *bool `yaml:"rememberMeCheckedByDefault" json:"rememberMeCheckedByDefault"`
// CookieEncryptionKey is the AES key for encrypting session cookies.
// Must be 16, 24, or 32 bytes for AES-128, AES-192, or AES-256.
// If empty, cookies are not encrypted.
CookieEncryptionKey string `yaml:"cookieEncryptionKey" json:"cookieEncryptionKey"`
// SSOSharedWithDefault is the default SSO sharing policy for clients without explicit ssoSharedWith.
// "all" = share with all clients, "none" = share with no one (default: "none").
SSOSharedWithDefault string `yaml:"ssoSharedWithDefault" json:"ssoSharedWithDefault"`
}
type MFAConfig struct {
Authenticators []MFAAuthenticator `yaml:"authenticators" json:"authenticators"`
}
type MFAAuthenticator struct {
ID string `yaml:"id" json:"id"`
Type string `yaml:"type" json:"type"`
Config map[string]interface{} `yaml:"config" json:"config"`
ConnectorTypes []string `yaml:"connectorTypes" json:"connectorTypes"`
}
type TOTPConfig struct {
Issuer string `yaml:"issuer" json:"issuer"`
}
// WebAuthnConfig holds configuration for a WebAuthn authenticator.
type WebAuthnConfig struct {
// RPDisplayName is the human-readable relying party name shown in the browser
// dialog during key registration and authentication (e.g., "My Company SSO").
RPDisplayName string `yaml:"rpDisplayName" json:"rpDisplayName"`
// RPID is the relying party identifier — must match the domain in the browser
// address bar. If empty, derived from the issuer URL hostname.
// Example: "auth.example.com"
RPID string `yaml:"rpID" json:"rpID"`
// RPOrigins is the list of allowed origins for WebAuthn ceremonies.
// If empty, derived from the issuer URL (scheme + host).
// Example: ["https://auth.example.com"]
RPOrigins []string `yaml:"rpOrigins" json:"rpOrigins"`
// AttestationPreference controls what attestation data the authenticator should provide:
// "none" — don't request attestation (simpler, more private)
// "indirect" — authenticator may anonymize attestation (default)
// "direct" — request full attestation (for enterprise key model verification)
AttestationPreference string `yaml:"attestationPreference" json:"attestationPreference"`
// Timeout is the duration allowed for the browser WebAuthn ceremony
// (registration or login). Defaults to "60s".
Timeout string `yaml:"timeout" json:"timeout"`
}
// Web is the config format for the HTTP server.
@@ -116,7 +180,6 @@ type Storage struct {
Config map[string]interface{} `yaml:"config" json:"config"`
}
// Password represents a static user configuration
type Password storage.Password
func (p *Password) UnmarshalYAML(node *yaml.Node) error {
@@ -429,9 +492,98 @@ func (c *YAMLConfig) Validate() error {
if !c.EnablePasswordDB && len(c.StaticPasswords) != 0 {
return fmt.Errorf("cannot specify static passwords without enabling password db")
}
return nil
}
func buildTotpConfig(auth MFAAuthenticator) (*server.TOTPProvider, error) {
data, err := json.Marshal(auth.Config)
if err != nil {
return nil, fmt.Errorf("failed to marshal TOTP config id: %s - %w", auth.ID, err)
}
var cfg TOTPConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse TOTP config id: %s - %w", auth.ID, err)
}
return server.NewTOTPProvider(cfg.Issuer, auth.ConnectorTypes), nil
}
func buildWebAuthnConfig(auth MFAAuthenticator, issuerURL string) (*server.WebAuthnProvider, error) {
data, err := json.Marshal(auth.Config)
if err != nil {
return nil, fmt.Errorf("failed to marshal WebAuthn config id: %s - %w", auth.ID, err)
}
var cfg WebAuthnConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse WebAuthn config id: %s - %w", auth.ID, err)
}
provider, err := server.NewWebAuthnProvider(cfg.RPDisplayName, cfg.RPID, cfg.RPOrigins,
cfg.AttestationPreference, cfg.Timeout, issuerURL, auth.ConnectorTypes)
if err != nil {
return nil, fmt.Errorf("failed to create WebAuthn provider id: %s - err: %w", auth.ID, err)
}
return provider, nil
}
func buildMFAProviders(authenticators []MFAAuthenticator, issuerURL string, logger *slog.Logger) map[string]server.MFAProvider {
if len(authenticators) == 0 {
return nil
}
providers := make(map[string]server.MFAProvider, len(authenticators))
for _, auth := range authenticators {
switch auth.Type {
case "TOTP":
provider, err := buildTotpConfig(auth)
if err != nil {
logger.Error("failed to parse TOTP config", "id", auth.ID, "err", err)
continue
}
providers[auth.ID] = provider
logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type)
case "WebAuthn":
provider, err := buildWebAuthnConfig(auth, issuerURL)
if err != nil {
logger.Error("failed to parse WebAuthn config", "id", auth.ID, "err", err)
continue
}
providers[auth.ID] = provider
logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type)
default:
logger.Error("unknown MFA authenticator type, skipping", "id", auth.ID, "type", auth.Type)
}
}
return providers
}
func buildSessionsConfig(sessions *Sessions) *server.SessionConfig {
if sessions == nil {
return nil
}
if sessions.RememberMeCheckedByDefault == nil {
defaultRememberMeCheckedByDefault := false
sessions.RememberMeCheckedByDefault = &defaultRememberMeCheckedByDefault
}
absoluteLifetime, _ := parseDuration(sessions.AbsoluteLifetime)
validIfNotUsedFor, _ := parseDuration(sessions.ValidIfNotUsedFor)
return &server.SessionConfig{
CookieEncryptionKey: []byte(sessions.CookieEncryptionKey),
CookieName: sessions.CookieName,
AbsoluteLifetime: absoluteLifetime,
ValidIfNotUsedFor: validIfNotUsedFor,
RememberMeCheckedByDefault: *sessions.RememberMeCheckedByDefault,
SSOSharedWithDefault: sessions.SSOSharedWithDefault,
}
}
// ToServerConfig converts YAMLConfig to dex server.Config
func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) server.Config {
cfg := server.Config{
@@ -448,6 +600,8 @@ func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) s
Dir: c.Frontend.Dir,
Extra: c.Frontend.Extra,
},
SessionConfig: buildSessionsConfig(c.Sessions),
MFAProviders: buildMFAProviders(c.MFA.Authenticators, c.Issuer, logger),
}
// Use embedded NetBird-styled templates if no custom dir specified
@@ -460,11 +614,6 @@ func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) s
}
// Apply expiry settings
if c.Expiry.SigningKeys != "" {
if d, err := parseDuration(c.Expiry.SigningKeys); err == nil {
cfg.RotateKeysAfter = d
}
}
if c.Expiry.IDTokens != "" {
if d, err := parseDuration(c.Expiry.IDTokens); err == nil {
cfg.IDTokensValidFor = d

View File

@@ -18,6 +18,7 @@ import (
dexapi "github.com/dexidp/dex/api/v2"
"github.com/dexidp/dex/server"
"github.com/dexidp/dex/server/signer"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/sql"
jose "github.com/go-jose/go-jose/v4"
@@ -70,7 +71,7 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
// Ensure data directory exists
if err := os.MkdirAll(config.DataDir, 0700); err != nil {
if err := os.MkdirAll(config.DataDir, 0o700); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err)
}
@@ -101,6 +102,15 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) {
return nil, fmt.Errorf("failed to create refresh token policy: %w", err)
}
localSignerConfig := signer.LocalConfig{
KeysRotationPeriod: "6h",
}
localSigner, err := localSignerConfig.Open(ctx, stor, 24*time.Hour, time.Now, logger)
if err != nil {
return nil, fmt.Errorf("failed to create local signer: %w", err)
}
// Build Dex server config - use Dex's types directly
dexConfig := server.Config{
Issuer: issuer,
@@ -110,12 +120,12 @@ func NewProvider(ctx context.Context, config *Config) (*Provider, error) {
ContinueOnConnectorFailure: true,
Logger: logger,
PrometheusRegistry: prometheus.NewRegistry(),
RotateKeysAfter: 6 * time.Hour,
IDTokensValidFor: 24 * time.Hour,
RefreshTokenPolicy: refreshPolicy,
Web: server.WebConfig{
Issuer: "NetBird",
},
Signer: localSigner,
}
dexSrv, err := server.NewServer(ctx, dexConfig)
@@ -167,6 +177,14 @@ func NewProviderFromYAML(ctx context.Context, yamlConfig *YAMLConfig) (*Provider
return nil, fmt.Errorf("failed to create refresh token policy: %w", err)
}
localSigner, err := getSigner(ctx, stor, yamlConfig, logger)
if err != nil {
stor.Close()
return nil, fmt.Errorf("failed to create local signer: %w", err)
}
dexConfig.Signer = localSigner
dexSrv, err := server.NewServer(ctx, dexConfig)
if err != nil {
stor.Close()
@@ -182,6 +200,32 @@ func NewProviderFromYAML(ctx context.Context, yamlConfig *YAMLConfig) (*Provider
}, nil
}
func getSigner(ctx context.Context, stor storage.Storage, yamlConfig *YAMLConfig, logger *slog.Logger) (signer.Signer, error) {
// Parse expiry durations
idTokensValidFor := 24 * time.Hour // default
if yamlConfig.Expiry.IDTokens != "" {
var err error
idTokensValidFor, err = parseDuration(yamlConfig.Expiry.IDTokens)
if err != nil {
return nil, fmt.Errorf("invalid config value %q for id token expiry: %v", yamlConfig.Expiry.IDTokens, err)
}
}
localSignerConfig := &signer.LocalConfig{
KeysRotationPeriod: "720h", // 30 Days
}
if yamlConfig.Expiry.SigningKeys != "" {
if _, err := parseDuration(yamlConfig.Expiry.SigningKeys); err != nil {
return nil, fmt.Errorf("invalid config value %q for signing key expiry: %v", yamlConfig.Expiry.SigningKeys, err)
}
localSignerConfig.KeysRotationPeriod = yamlConfig.Expiry.SigningKeys
}
return localSignerConfig.Open(ctx, stor, idTokensValidFor, time.Now, logger)
}
// initializeStorage sets up connectors, passwords, and clients in storage
func initializeStorage(ctx context.Context, stor storage.Storage, cfg *YAMLConfig) error {
if cfg.EnablePasswordDB {
@@ -241,6 +285,8 @@ func ensureStaticClients(ctx context.Context, stor storage.Storage, clients []st
old.RedirectURIs = client.RedirectURIs
old.Name = client.Name
old.Public = client.Public
old.PostLogoutRedirectURIs = client.PostLogoutRedirectURIs
old.MFAChain = client.MFAChain
return old, nil
}); err != nil {
return fmt.Errorf("failed to update client %s: %w", client.ID, err)
@@ -253,9 +299,6 @@ func ensureStaticClients(ctx context.Context, stor storage.Storage, clients []st
func buildDexConfig(yamlConfig *YAMLConfig, stor storage.Storage, logger *slog.Logger) server.Config {
cfg := yamlConfig.ToServerConfig(stor, logger)
cfg.PrometheusRegistry = prometheus.NewRegistry()
if cfg.RotateKeysAfter == 0 {
cfg.RotateKeysAfter = 24 * 30 * time.Hour
}
if cfg.IDTokensValidFor == 0 {
cfg.IDTokensValidFor = 24 * time.Hour
}
@@ -450,10 +493,34 @@ func (p *Provider) Storage() storage.Storage {
return p.storage
}
// SetClientsMFAChain updates the MFAChain field on the dashboard and CLI OAuth2 clients.
// Pass a non-empty slice (e.g. []string{"default-totp"}) to enable MFA, or nil to disable it.
func (p *Provider) SetClientsMFAChain(ctx context.Context, clientIDs []string, mfaChain []string) error {
for _, clientID := range clientIDs {
if err := p.storage.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) {
old.MFAChain = mfaChain
return old, nil
}); err != nil {
return fmt.Errorf("failed to update MFA chain on client %s: %w", clientID, err)
}
}
return nil
}
// Handler returns the Dex server as an http.Handler for embedding in another server.
// The handler expects requests with path prefix "/oauth2/".
func (p *Provider) Handler() http.Handler {
return p.dexServer
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Dex's /logout endpoint requires id_token_hint for RP-initiated logout with
// post_logout_redirect_uri. If the dashboard calls logout without one, avoid
// rendering Dex's non-actionable Bad Request page and send the user home.
if strings.HasSuffix(r.URL.Path, "/logout") && r.FormValue("id_token_hint") == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
p.dexServer.ServeHTTP(w, r)
})
}
// CreateUser creates a new user with the given email, username, and password.

View File

@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
@@ -144,6 +146,30 @@ func TestEncodeDexUserID_MatchesDexFormat(t *testing.T) {
assert.Equal(t, knownEncodedID, reEncoded)
}
func TestHandlerRedirectsLogoutWithoutIDTokenHint(t *testing.T) {
ctx := context.Background()
tmpDir, err := os.MkdirTemp("", "dex-logout-handler-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
provider, err := NewProvider(ctx, &Config{
Issuer: "http://localhost:5556/oauth2",
Port: 5556,
DataDir: tmpDir,
})
require.NoError(t, err)
defer func() { _ = provider.Stop(ctx) }()
req := httptest.NewRequest(http.MethodGet, "/oauth2/logout?post_logout_redirect_uri=https://example.com", nil)
rec := httptest.NewRecorder()
provider.Handler().ServeHTTP(rec, req)
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/", rec.Header().Get("Location"))
}
func TestCreateUserInTempDB(t *testing.T) {
ctx := context.Background()

View File

@@ -0,0 +1,12 @@
{{ template "header.html" . }}
<script>globalThis.location.replace("/");</script>
<noscript>
<div class="nb-card">
<h1 class="nb-heading">Redirecting…</h1>
<p class="nb-subheading">You are being redirected to the NetBird dashboard.</p>
<a href="/" class="nb-btn" style="display:block;text-align:center;text-decoration:none">Go to Dashboard</a>
</div>
</noscript>
{{ template "footer.html" . }}

View File

@@ -0,0 +1,14 @@
{{ template "header.html" . }}
<div class="nb-card">
<h1 class="nb-heading">Logged Out</h1>
<p class="nb-subheading">You have been successfully logged out.</p>
{{ if .BackURL }}
<div class="nb-back-link">
<a href="{{ .BackURL }}" class="nb-link">&larr; Back to Application</a>
</div>
{{ end }}
</div>
{{ template "footer.html" . }}

View File

@@ -18,6 +18,7 @@
id="login"
name="login"
class="nb-input"
autocomplete="username"
placeholder="Enter your {{ .UsernamePrompt | lower }}"
{{ if .Username }}value="{{ .Username }}"{{ else }}autofocus{{ end }}
required
@@ -31,6 +32,7 @@
id="password"
name="password"
class="nb-input"
autocomplete="current-password"
placeholder="Enter your password"
{{ if .Invalid }}autofocus{{ end }}
required

View File

@@ -0,0 +1,44 @@
{{ template "header.html" . }}
<div class="nb-card">
<h1 class="nb-heading">Two-factor authentication</h1>
{{ if not (eq .QRCode "") }}
<p class="nb-subheading">Scan the QR code below using your authenticator app, then enter the code.</p>
<div style="text-align: center; margin: 1em 0;">
<img src="data:image/png;base64,{{ .QRCode }}" alt="QR code" width="200" height="200"/>
</div>
{{ else }}
<p class="nb-subheading">Enter the code from your authenticator app.</p>
{{ end }}
<form method="post" action="{{ .PostURL }}">
{{ if .Invalid }}
<div class="nb-error">
Invalid code. Please try again.
</div>
{{ end }}
<div class="nb-form-group">
<label class="nb-label" for="totp">One-time code</label>
<input
type="text"
id="totp"
name="totp"
class="nb-input"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
autocomplete="one-time-code"
placeholder="000000"
autofocus
required
>
</div>
<button type="submit" id="submit-login" class="nb-btn">
Verify
</button>
</form>
</div>
{{ template "footer.html" . }}

View File

@@ -0,0 +1,12 @@
{{ template "header.html" . }}
<script>globalThis.location.replace("/");</script>
<noscript>
<div class="nb-card">
<h1 class="nb-heading">Redirecting…</h1>
<p class="nb-subheading">You are being redirected to the NetBird dashboard.</p>
<a href="/" class="nb-btn" style="display:block;text-align:center;text-decoration:none">Go to Dashboard</a>
</div>
</noscript>
{{ template "footer.html" . }}