mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-24 01:11:29 +02:00
## Summary Adds a unified `admin` CLI for self-hosted instance administrators in both the management and combined binaries. ## User Management ### `admin user change-password` - Changes a local embedded IdP user's password. - Selects the user with `--email` or `--user-id`. - Reads the new password from `--password` or `--password-file`. - Clears the user's local authentication session so the new password is required on the next login. - **Alias:** `admin user set-password`. ### `admin user reset-mfa` - Resets a local embedded IdP user's MFA enrollment. - Selects the user with `--email` or `--user-id`. - Clears TOTP/WebAuthn enrollment data and removes the local authentication session. - The user will re-enroll MFA on the next login. ## MFA Management ### `admin mfa status` - Shows whether local MFA is enabled in the account settings. - Checks the embedded IdP client configuration and reports whether MFA is enabled there. ### `admin mfa enable` - Enables local MFA for embedded IdP users. - Updates embedded IdP clients and saves the account MFA setting. - Records an audit event on a best-effort basis. ### `admin mfa disable` - Disables local MFA for embedded IdP users. - Updates embedded IdP clients and saves the account MFA setting. - Records an audit event on a best-effort basis. ## Reverse Proxy Tokens ### `admin token create --name <name> [--expires-in <duration>]` - Creates a reverse proxy access token. - Prints the plaintext token once, along with the token ID. - `--expires-in` supports values such as `24h`, `30d`, or `365d`. If omitted, the token never expires. ### `admin token list` - Lists reverse proxy access tokens. - Shows the token ID, name, creation date, expiration, last-used time, and revocation status. - **Alias:** `admin token ls`. ### `admin token revoke <token-id>` - Revokes a reverse proxy access token. - Revoked tokens can no longer authenticate reverse proxy instances. ## Reverse Proxy Management ### `admin proxy disconnect-all` - Lists registered reverse proxy instances and force-marks all connected instances as disconnected. - Useful for repairing stale proxy state after an unclean management server shutdown. - Prompts for confirmation by default. - `--dry-run` previews the changes without applying them. - `--force` skips the confirmation prompt. - Live proxies may appear again after their next heartbeat, reconnect, or re-registration. ## Compatibility Commands ### `token ...` - Deprecated top-level compatibility path. - Behaves the same as `admin token ...`. - Retained so existing scripts using `token create`, `token list`, or `token revoke` continue to work. ## Changes - Adds reusable `management/cmd/admin` command package. - Wires `admin` into `netbird-mgmt` and `combined`. - Adds local user password reset with existing password strength validation. - Adds local MFA enrollment reset by clearing Dex TOTP/WebAuthn credentials and local auth sessions. - Adds local MFA enable/disable/status helpers for embedded IdP deployments. - Moves proxy access token commands under `admin token` for a single admin-focused CLI entry point. - Exports `server.ValidatePassword` for reuse by CLI helpers. ## Tests ```bash go test ./management/cmd/... go test ./management/cmd/admin ./management/cmd ./combined/cmd go test ./management/server -run TestValidatePassword ``` Pre-push lint also passed. ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [x] I added/updated documentation for this change - [ ] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/832 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added self-hosted admin CLI commands for changing passwords, resetting MFA (including WebAuthn), and managing embedded IdP client MFA (enable/disable/status). * Introduced a unified admin command entry point and improved data-directory handling for embedded IdP storage. * **Refactor** * Centralized password strength validation into a shared exported validator. * **Tests** * Added a comprehensive admin command test suite covering password input, selectors, MFA reset, and client MFA state handling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
251 lines
8.4 KiB
Go
251 lines
8.4 KiB
Go
package admincmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/dexidp/dex/storage"
|
|
"github.com/dexidp/dex/storage/memory"
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
nbdex "github.com/netbirdio/netbird/idp/dex"
|
|
"github.com/netbirdio/netbird/management/server/idp"
|
|
mgmtstore "github.com/netbirdio/netbird/management/server/store"
|
|
"github.com/netbirdio/netbird/management/server/types"
|
|
)
|
|
|
|
func newTestIDPStorage(t *testing.T) storage.Storage {
|
|
t.Helper()
|
|
|
|
st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("OldPass1!"), bcrypt.DefaultCost)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, st.CreatePassword(context.Background(), storage.Password{
|
|
Email: "user@example.com",
|
|
Username: "User",
|
|
UserID: "user-1",
|
|
Hash: hash,
|
|
}))
|
|
require.NoError(t, st.CreateUserIdentity(context.Background(), storage.UserIdentity{
|
|
UserID: "user-1",
|
|
ConnectorID: idp.LocalConnectorID,
|
|
MFASecrets: map[string]*storage.MFASecret{
|
|
idp.DefaultTOTPAuthenticatorID: {
|
|
AuthenticatorID: idp.DefaultTOTPAuthenticatorID,
|
|
Type: "TOTP",
|
|
Secret: "otpauth://totp/NetBird:user@example.com?secret=ABC",
|
|
Confirmed: true,
|
|
CreatedAt: time.Now(),
|
|
},
|
|
},
|
|
WebAuthnCredentials: map[string][]storage.WebAuthnCredential{
|
|
"webauthn": {{CredentialID: []byte("credential")}},
|
|
},
|
|
}))
|
|
require.NoError(t, st.CreateAuthSession(context.Background(), storage.AuthSession{
|
|
UserID: "user-1",
|
|
ConnectorID: idp.LocalConnectorID,
|
|
Nonce: "nonce",
|
|
}))
|
|
require.NoError(t, st.CreateClient(context.Background(), storage.Client{ID: idp.StaticClientCLI, Name: "CLI"}))
|
|
require.NoError(t, st.CreateClient(context.Background(), storage.Client{ID: idp.StaticClientDashboard, Name: "Dashboard"}))
|
|
|
|
return st
|
|
}
|
|
|
|
func TestRunChangePassword(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestIDPStorage(t)
|
|
var out bytes.Buffer
|
|
|
|
err := runChangePassword(ctx, st, &out, userSelector{email: "user@example.com"}, "NewPass1!", "")
|
|
require.NoError(t, err)
|
|
require.Contains(t, out.String(), "Password updated")
|
|
|
|
user, err := st.GetPassword(ctx, "user@example.com")
|
|
require.NoError(t, err)
|
|
require.NoError(t, bcrypt.CompareHashAndPassword(user.Hash, []byte("NewPass1!")))
|
|
|
|
_, err = st.GetAuthSession(ctx, "user-1", idp.LocalConnectorID)
|
|
require.ErrorIs(t, err, storage.ErrNotFound)
|
|
}
|
|
|
|
func TestRunChangePasswordValidatesPassword(t *testing.T) {
|
|
st := newTestIDPStorage(t)
|
|
err := runChangePassword(context.Background(), st, io.Discard, userSelector{email: "user@example.com"}, "short", "")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid password")
|
|
}
|
|
|
|
func TestRunResetMFA(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestIDPStorage(t)
|
|
var out bytes.Buffer
|
|
|
|
encodedUserID := nbdex.EncodeDexUserID("user-1", idp.LocalConnectorID)
|
|
err := runResetMFA(ctx, st, &out, userSelector{userID: encodedUserID}, "")
|
|
require.NoError(t, err)
|
|
require.Contains(t, out.String(), "MFA reset")
|
|
|
|
identity, err := st.GetUserIdentity(ctx, "user-1", idp.LocalConnectorID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, identity.MFASecrets)
|
|
require.Empty(t, identity.WebAuthnCredentials)
|
|
|
|
_, err = st.GetAuthSession(ctx, "user-1", idp.LocalConnectorID)
|
|
require.ErrorIs(t, err, storage.ErrNotFound)
|
|
}
|
|
|
|
func TestRunResetMFAWithoutEnrollment(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestIDPStorage(t)
|
|
require.NoError(t, st.UpdateUserIdentity(ctx, "user-1", idp.LocalConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) {
|
|
old.MFASecrets = nil
|
|
old.WebAuthnCredentials = nil
|
|
return old, nil
|
|
}))
|
|
|
|
var out bytes.Buffer
|
|
err := runResetMFA(ctx, st, &out, userSelector{email: "user@example.com"}, "")
|
|
require.NoError(t, err)
|
|
require.Contains(t, out.String(), "No MFA enrollment found")
|
|
}
|
|
|
|
func TestSetIDPClientsMFA(t *testing.T) {
|
|
ctx := context.Background()
|
|
st := newTestIDPStorage(t)
|
|
|
|
require.NoError(t, setIDPClientsMFA(ctx, st, true))
|
|
status, err := idpClientsMFAStatus(ctx, st)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "enabled", status)
|
|
|
|
require.NoError(t, setIDPClientsMFA(ctx, st, false))
|
|
status, err = idpClientsMFAStatus(ctx, st)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "disabled", status)
|
|
}
|
|
|
|
func newTestManagementStore(t *testing.T, localMFAEnabled bool) mgmtstore.Store {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
st, err := mgmtstore.NewStore(ctx, types.SqliteStoreEngine, t.TempDir(), nil, false)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, st.Close(ctx)) })
|
|
require.NoError(t, st.SaveAccount(ctx, &types.Account{
|
|
Id: "account-1",
|
|
Settings: &types.Settings{LocalMfaEnabled: localMFAEnabled},
|
|
}))
|
|
return st
|
|
}
|
|
|
|
func TestRunSetMFAEnabledDoesNotSaveWhenIDPUpdateFails(t *testing.T) {
|
|
ctx := context.Background()
|
|
managementStore := newTestManagementStore(t, false)
|
|
idpStorage := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
|
|
err := runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage}, io.Discard, true)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "embedded IdP client")
|
|
|
|
settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1")
|
|
require.NoError(t, err)
|
|
require.False(t, settings.LocalMfaEnabled)
|
|
}
|
|
|
|
func TestRunSetMFAEnabledUpdatesSettingsAfterIDP(t *testing.T) {
|
|
ctx := context.Background()
|
|
managementStore := newTestManagementStore(t, false)
|
|
idpStorage := newTestIDPStorage(t)
|
|
|
|
err := runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage}, io.Discard, true)
|
|
require.NoError(t, err)
|
|
|
|
settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1")
|
|
require.NoError(t, err)
|
|
require.True(t, settings.LocalMfaEnabled)
|
|
clientStatus, err := idpClientsMFAStatus(ctx, idpStorage)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "enabled", clientStatus)
|
|
}
|
|
|
|
func TestRunSetMFAEnabledSucceedsWithNilEventStore(t *testing.T) {
|
|
ctx := context.Background()
|
|
managementStore := newTestManagementStore(t, false)
|
|
idpStorage := newTestIDPStorage(t)
|
|
var out bytes.Buffer
|
|
var err error
|
|
|
|
require.NotPanics(t, func() {
|
|
err = runSetMFAEnabled(ctx, Resources{Store: managementStore, IDPStorage: idpStorage, EventStore: nil}, &out, true)
|
|
})
|
|
require.NoError(t, err)
|
|
require.Contains(t, out.String(), "Local MFA enabled")
|
|
|
|
settings, err := managementStore.GetAccountSettings(ctx, mgmtstore.LockingStrengthNone, "account-1")
|
|
require.NoError(t, err)
|
|
require.True(t, settings.LocalMfaEnabled)
|
|
}
|
|
|
|
func TestUserSelectorValidate(t *testing.T) {
|
|
require.NoError(t, userSelector{email: " user@example.com "}.validate())
|
|
require.NoError(t, userSelector{userID: "user-1"}.validate())
|
|
require.Error(t, userSelector{}.validate())
|
|
require.Error(t, userSelector{email: "user@example.com", userID: "user-1"}.validate())
|
|
}
|
|
|
|
func TestFindLocalUserNotFound(t *testing.T) {
|
|
st := newTestIDPStorage(t)
|
|
_, err := findLocalUser(context.Background(), st, userSelector{email: "missing@example.com"}, "")
|
|
require.Error(t, err)
|
|
require.True(t, strings.Contains(err.Error(), "not found"))
|
|
}
|
|
|
|
func TestFindLocalUserZeroUsersIncludesStoragePath(t *testing.T) {
|
|
st := memory.New(slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
_, err := findLocalUser(context.Background(), st, userSelector{email: "missing@example.com"}, "/var/lib/netbird/idp.db")
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "no local users exist")
|
|
require.Contains(t, err.Error(), "/var/lib/netbird/idp.db")
|
|
}
|
|
|
|
func TestUserCommandValidatesSelectorBeforeOpeningStorage(t *testing.T) {
|
|
opened := false
|
|
cmd := NewCommands(Openers{
|
|
IDP: func(cmd *cobra.Command, fn func(ctx context.Context, idpStorage storage.Storage, storageFile string) error) error {
|
|
opened = true
|
|
return nil
|
|
},
|
|
})
|
|
cmd.SetArgs([]string{"user", "change-password", "--password", "NewPass1!"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
|
|
err := cmd.Execute()
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "provide exactly one")
|
|
require.False(t, opened)
|
|
}
|
|
|
|
func TestResolvePasswordInputFromStdin(t *testing.T) {
|
|
cmd := &cobra.Command{}
|
|
cmd.SetIn(strings.NewReader("NewPass1!\n"))
|
|
|
|
password, err := resolvePasswordInput(cmd, "", "-")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "NewPass1!", password)
|
|
}
|
|
|
|
func TestResolvePasswordInputRejectsMultipleSources(t *testing.T) {
|
|
_, err := resolvePasswordInput(&cobra.Command{}, "NewPass1!", "-")
|
|
require.Error(t, err)
|
|
}
|