diff --git a/combined/cmd/config.go b/combined/cmd/config.go index 3cbe7f172..37018cbed 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -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{ diff --git a/idp/dex/provider.go b/idp/dex/provider.go index 40d1c9ece..eb0c844ef 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -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 { diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index 374ea5c81..0cbf4148d 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -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 }) } diff --git a/management/server/account.go b/management/server/account.go index d90b46659..97ec77de7 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -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 diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go index ddc3e00c3..7ea2f6391 100644 --- a/management/server/activity/codes.go +++ b/management/server/activity/codes.go @@ -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"}, diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index cc5567e3d..072be66f1 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -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() { diff --git a/management/server/idp/embedded.go b/management/server/idp/embedded.go index 45526f445..905fee3f3 100644 --- a/management/server/idp/embedded.go +++ b/management/server/idp/embedded.go @@ -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) diff --git a/management/server/types/settings.go b/management/server/types/settings.go index 4ea79ec72..61298651d 100644 --- a/management/server/types/settings.go +++ b/management/server/types/settings.go @@ -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() diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 0b855db67..0cf3695e1 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -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 diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 0317b8183..21561dc63 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -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"`