implemented api for mfa configuration

This commit is contained in:
jnfrati
2026-04-16 14:53:40 +02:00
parent 77b65d9475
commit 59abdc2363
10 changed files with 135 additions and 21 deletions

View File

@@ -135,8 +135,9 @@ type ManagementConfig struct {
type AuthConfig struct {
Issuer string `yaml:"issuer"`
LocalAuthDisabled bool `yaml:"localAuthDisabled"`
EnableLocalMFA bool `yaml:"enableLocalMFA"`
SignKeyRefreshEnabled bool `yaml:"signKeyRefreshEnabled"`
MfaSessionMaxLifetime string `yaml:"mfaSessionMaxLifetime"`
MfaSessionIdleTimeout string `yaml:"mfaSessionIdleTimeout"`
Storage AuthStorageConfig `yaml:"storage"`
DashboardRedirectURIs []string `yaml:"dashboardRedirectURIs"`
CLIRedirectURIs []string `yaml:"cliRedirectURIs"`
@@ -584,10 +585,11 @@ func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.Emb
cfg := &idp.EmbeddedIdPConfig{
Enabled: true,
EnableMFA: mgmt.Auth.EnableLocalMFA,
Issuer: mgmt.Auth.Issuer,
LocalAuthDisabled: mgmt.Auth.LocalAuthDisabled,
SignKeyRefreshEnabled: mgmt.Auth.SignKeyRefreshEnabled,
MfaSessionMaxLifetime: mgmt.Auth.MfaSessionMaxLifetime,
MfaSessionIdleTimeout: mgmt.Auth.MfaSessionIdleTimeout,
Storage: idp.EmbeddedStorageConfig{
Type: authStorageType,
Config: idp.EmbeddedStorageTypeConfig{

View File

@@ -493,6 +493,20 @@ 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 {

View File

@@ -26,6 +26,7 @@ import (
"github.com/netbirdio/netbird/management/server/networks"
"github.com/netbirdio/netbird/management/server/networks/resources"
"github.com/netbirdio/netbird/management/server/networks/routers"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/settings"
@@ -113,30 +114,45 @@ func (s *BaseServer) AccountManager() account.Manager {
})
}
func isMFAEnabledForAccount(accounts []*types.Account) bool {
if len(accounts) != 1 {
return false
}
settings := accounts[0].Settings
return settings != nil && settings.LocalMfaEnabled
}
func (s *BaseServer) IdpManager() idp.Manager {
return Create(s, func() idp.Manager {
var idpManager idp.Manager
var err error
// Use embedded IdP service if embedded Dex is configured and enabled.
// Legacy IdpManager won't be used anymore even if configured.
embeddedEnabled := s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled
if embeddedEnabled {
idpManager, err = idp.NewEmbeddedIdPManager(context.Background(), s.Config.EmbeddedIdP, s.Metrics())
embeddedMgr, err := idp.NewEmbeddedIdPManager(context.Background(), s.Config.EmbeddedIdP, s.Metrics())
if err != nil {
log.Fatalf("failed to create embedded IDP service: %v", err)
}
return idpManager
if val := isMFAEnabledForAccount(s.Store().GetAllAccounts(context.Background())); val {
embeddedMgr.SetMFAEnabled(context.Background(), val)
}
return embeddedMgr
}
// Fall back to external IdP service
if s.Config.IdpManagerConfig != nil {
idpManager, err = idp.NewManager(context.Background(), *s.Config.IdpManagerConfig, s.Metrics())
idpManager, err := idp.NewManager(context.Background(), *s.Config.IdpManagerConfig, s.Metrics())
if err != nil {
log.Fatalf("failed to create IDP service: %v", err)
}
return idpManager
}
return idpManager
return nil
})
}

View File

@@ -382,6 +382,9 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco
if err = am.handleInactivityExpirationSettings(ctx, oldSettings, newSettings, userID, accountID); err != nil {
return nil, err
}
if err = am.handleLocalMfaSettings(ctx, oldSettings, newSettings, userID, accountID); err != nil {
return nil, err
}
if oldSettings.DNSDomain != newSettings.DNSDomain {
eventMeta := map[string]any{
"old_dns_domain": oldSettings.DNSDomain,
@@ -542,6 +545,29 @@ func (am *DefaultAccountManager) handleInactivityExpirationSettings(ctx context.
return nil
}
func (am *DefaultAccountManager) handleLocalMfaSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) error {
if oldSettings.LocalMfaEnabled == newSettings.LocalMfaEnabled {
return nil
}
embeddedIdp, ok := am.idpManager.(*idp.EmbeddedIdPManager)
if !ok {
return nil
}
if err := embeddedIdp.SetMFAEnabled(ctx, newSettings.LocalMfaEnabled); err != nil {
return fmt.Errorf("failed to toggle MFA: %w", err)
}
if newSettings.LocalMfaEnabled {
am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountLocalMfaEnabled, nil)
} else {
am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountLocalMfaDisabled, nil)
}
return nil
}
func (am *DefaultAccountManager) peerLoginExpirationJob(ctx context.Context, accountID string) func() (time.Duration, bool) {
return func() (time.Duration, bool) {
//nolint

View File

@@ -232,6 +232,11 @@ const (
// DomainValidated indicates that a custom domain was validated
DomainValidated Activity = 120
// AccountLocalMfaEnabled indicates that a user enabled TOTP MFA for local users
AccountLocalMfaEnabled Activity = 121
// AccountLocalMfaDisabled indicates that a user disabled TOTP MFA for local users
AccountLocalMfaDisabled Activity = 122
AccountDeleted Activity = 99999
)
@@ -379,6 +384,9 @@ var activityMap = map[Activity]Code{
AccountPeerExposeEnabled: {"Account peer expose enabled", "account.setting.peer.expose.enable"},
AccountPeerExposeDisabled: {"Account peer expose disabled", "account.setting.peer.expose.disable"},
AccountLocalMfaEnabled: {"Account local MFA enabled", "account.setting.local.mfa.enable"},
AccountLocalMfaDisabled: {"Account local MFA disabled", "account.setting.local.mfa.disable"},
DomainAdded: {"Domain added", "domain.add"},
DomainDeleted: {"Domain deleted", "domain.delete"},
DomainValidated: {"Domain validated", "domain.validate"},

View File

@@ -228,6 +228,9 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS
if req.Settings.AutoUpdateAlways != nil {
returnSettings.AutoUpdateAlways = *req.Settings.AutoUpdateAlways
}
if req.Settings.LocalMfaEnabled != nil {
returnSettings.LocalMfaEnabled = *req.Settings.LocalMfaEnabled
}
return returnSettings, nil
}
@@ -354,6 +357,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
AutoUpdateAlways: &settings.AutoUpdateAlways,
EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled,
LocalAuthDisabled: &settings.LocalAuthDisabled,
LocalMfaEnabled: &settings.LocalMfaEnabled,
}
if settings.NetworkRange.IsValid() {

View File

@@ -51,8 +51,12 @@ type EmbeddedIdPConfig struct {
// Existing local users are preserved and will be able to login again if re-enabled.
// Cannot be enabled if no external identity provider connectors are configured.
LocalAuthDisabled bool
// EnableMFA will enforce TOTP multi factor authentication for local users
EnableMFA bool
// MfaSessionMaxLifetime is the maximum MFA session duration from creation (e.g. "24h").
// Defaults to "24h" if empty.
MfaSessionMaxLifetime string
// MfaSessionIdleTimeout is the idle timeout after which the MFA session expires (e.g. "1h").
// Defaults to "1h" if empty.
MfaSessionIdleTimeout string
// Dashboard Post logout redirect URIs, these are required to tell
// Dex what to allow when an RP-Initiated logout is started by the frontend
// at least one of these must match the dashboard base URL or the dashboard
@@ -179,10 +183,10 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
StaticConnectors: c.StaticConnectors,
}
if c.EnableMFA {
if err := configureMFA(cfg); err != nil {
return nil, err
}
// Always initialize MFA providers and sessions so TOTP can be toggled at runtime.
// MFAChain on clients is NOT set here — it's synced from the DB setting on startup.
if err := configureMFA(cfg, c.MfaSessionMaxLifetime, c.MfaSessionIdleTimeout); err != nil {
return nil, err
}
// Add owner user if provided
@@ -222,7 +226,7 @@ func sanitizePostLogoutRedirectURIs(uris []string) []string {
return result
}
func configureMFA(cfg *dex.YAMLConfig) error {
func configureMFA(cfg *dex.YAMLConfig, sessionMaxLifetime, sessionIdleTimeout string) error {
totpConfig := dex.TOTPConfig{
Issuer: "NetBird",
}
@@ -240,21 +244,27 @@ func configureMFA(cfg *dex.YAMLConfig) error {
ConnectorTypes: []string{"local"},
}}
if sessionMaxLifetime == "" {
sessionMaxLifetime = "24h"
}
if sessionIdleTimeout == "" {
sessionIdleTimeout = "1h"
}
rememberMeEnabled := false
cfg.Sessions = &dex.Sessions{
CookieName: "netbird-session",
AbsoluteLifetime: "24h",
ValidIfNotUsedFor: "1h",
AbsoluteLifetime: sessionMaxLifetime,
ValidIfNotUsedFor: sessionIdleTimeout,
RememberMeCheckedByDefault: &rememberMeEnabled,
SSOSharedWithDefault: "",
}
// Absolutely required, otherwise the dex server will omit the MFA configuration entirely
os.Setenv("DEX_SESSIONS_ENABLED", "true")
for i := range cfg.StaticClients {
cfg.StaticClients[i].MFAChain = []string{"default-totp"}
}
// Note: MFAChain on clients is NOT set here.
// It is toggled at runtime via SetMFAEnabled() based on the account settings DB value.
return nil
}
@@ -291,6 +301,7 @@ type EmbeddedIdPManager struct {
provider *dex.Provider
appMetrics telemetry.AppMetrics
config EmbeddedIdPConfig
mfaEnabled bool
}
// NewEmbeddedIdPManager creates a new instance of EmbeddedIdPManager from a configuration.
@@ -717,6 +728,27 @@ func (m *EmbeddedIdPManager) IsLocalAuthDisabled() bool {
return m.config.LocalAuthDisabled
}
// SetMFAEnabled enables or disables TOTP MFA for local users by updating the MFAChain on OAuth2 clients.
func (m *EmbeddedIdPManager) SetMFAEnabled(ctx context.Context, enabled bool) error {
var mfaChain []string
if enabled {
mfaChain = []string{"default-totp"}
}
if err := m.provider.SetClientsMFAChain(ctx, []string{
staticClientCLI,
staticClientDashboard,
}, mfaChain); err != nil {
return fmt.Errorf("failed to set MFA enabled=%v: %w", enabled, err)
}
m.mfaEnabled = enabled
return nil
}
// IsMFAEnabled returns whether TOTP MFA is currently enabled for local users.
func (m *EmbeddedIdPManager) IsMFAEnabled() bool {
return m.mfaEnabled
}
// HasNonLocalConnectors checks if there are any identity provider connectors other than local.
func (m *EmbeddedIdPManager) HasNonLocalConnectors(ctx context.Context) (bool, error) {
return m.provider.HasNonLocalConnectors(ctx)

View File

@@ -72,6 +72,10 @@ type Settings struct {
// LocalAuthDisabled indicates if local (email/password) authentication is disabled.
// This is a runtime-only field, not stored in the database.
LocalAuthDisabled bool `gorm:"-"`
// LocalMfaEnabled indicates if TOTP MFA is enabled for local users.
// Only applicable when the embedded IDP is enabled.
LocalMfaEnabled bool
}
// Copy copies the Settings struct
@@ -98,6 +102,7 @@ func (s *Settings) Copy() *Settings {
AutoUpdateAlways: s.AutoUpdateAlways,
EmbeddedIdpEnabled: s.EmbeddedIdpEnabled,
LocalAuthDisabled: s.LocalAuthDisabled,
LocalMfaEnabled: s.LocalMfaEnabled,
}
if s.Extra != nil {
settings.Extra = s.Extra.Copy()

View File

@@ -377,6 +377,10 @@ components:
type: boolean
readOnly: true
example: false
local_mfa_enabled:
description: Enables or disables TOTP multi-factor authentication for local users. Only applicable when the embedded identity provider is enabled.
type: boolean
example: false
required:
- peer_login_expiration_enabled
- peer_login_expiration

View File

@@ -1477,6 +1477,9 @@ type AccountSettings struct {
// LocalAuthDisabled Indicates whether local (email/password) authentication is disabled. When true, users can only authenticate via external identity providers. This is a read-only field.
LocalAuthDisabled *bool `json:"local_auth_disabled,omitempty"`
// LocalMfaEnabled Enables or disables TOTP multi-factor authentication for local users. Only applicable when the embedded identity provider is enabled.
LocalMfaEnabled *bool `json:"local_mfa_enabled,omitempty"`
// NetworkRange Allows to define a custom network range for the account in CIDR format
NetworkRange *string `json:"network_range,omitempty"`