mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-19 23:29:56 +00:00
[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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
12
idp/dex/web/templates/home.html
Normal file
12
idp/dex/web/templates/home.html
Normal 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" . }}
|
||||
14
idp/dex/web/templates/logout.html
Normal file
14
idp/dex/web/templates/logout.html
Normal 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">← Back to Application</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ template "footer.html" . }}
|
||||
@@ -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
|
||||
|
||||
44
idp/dex/web/templates/totp_verify.html
Normal file
44
idp/dex/web/templates/totp_verify.html
Normal 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" . }}
|
||||
12
idp/dex/web/templates/webauthn_verify.html
Normal file
12
idp/dex/web/templates/webauthn_verify.html
Normal 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" . }}
|
||||
Reference in New Issue
Block a user