mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-18 08:16:39 +00:00
Add user invite link feature for embedded IdP (#5157)
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
@@ -1453,3 +1454,368 @@ func (am *DefaultAccountManager) RejectUser(ctx context.Context, accountID, init
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUserInvite creates an invite link for a new user in the embedded IdP.
|
||||
// The user is NOT created until the invite is accepted.
|
||||
func (am *DefaultAccountManager) CreateUserInvite(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
|
||||
if !IsEmbeddedIdp(am.idpManager) {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
}
|
||||
|
||||
if err := validateUserInvite(invite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !allowed {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
// Check if user already exists in NetBird DB
|
||||
existingUsers, err := am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range existingUsers {
|
||||
if strings.EqualFold(user.Email, invite.Email) {
|
||||
return nil, status.Errorf(status.UserAlreadyExists, "user with this email already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// Check if invite already exists for this email
|
||||
existingInvite, err := am.Store.GetUserInviteByEmail(ctx, store.LockingStrengthNone, accountID, invite.Email)
|
||||
if err != nil {
|
||||
if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound {
|
||||
return nil, fmt.Errorf("failed to check existing invites: %w", err)
|
||||
}
|
||||
}
|
||||
if existingInvite != nil {
|
||||
return nil, status.Errorf(status.AlreadyExists, "invite already exists for this email")
|
||||
}
|
||||
|
||||
// Calculate expiration time
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = types.DefaultInviteExpirationSeconds
|
||||
}
|
||||
|
||||
if expiresIn < types.MinInviteExpirationSeconds {
|
||||
return nil, status.Errorf(status.InvalidArgument, "invite expiration must be at least 1 hour")
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
// Generate invite token
|
||||
inviteID := types.NewInviteID()
|
||||
hashedToken, plainToken, err := types.GenerateInviteToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate invite token: %w", err)
|
||||
}
|
||||
|
||||
// Create the invite record (no user created yet)
|
||||
userInvite := &types.UserInviteRecord{
|
||||
ID: inviteID,
|
||||
AccountID: accountID,
|
||||
Email: invite.Email,
|
||||
Name: invite.Name,
|
||||
Role: invite.Role,
|
||||
AutoGroups: invite.AutoGroups,
|
||||
HashedToken: hashedToken,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
CreatedBy: initiatorUserID,
|
||||
}
|
||||
|
||||
if err := am.Store.SaveUserInvite(ctx, userInvite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
am.StoreEvent(ctx, initiatorUserID, inviteID, accountID, activity.UserInviteLinkCreated, map[string]any{"email": invite.Email})
|
||||
|
||||
return &types.UserInvite{
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: inviteID,
|
||||
Email: invite.Email,
|
||||
Name: invite.Name,
|
||||
Role: invite.Role,
|
||||
AutoGroups: invite.AutoGroups,
|
||||
Status: string(types.UserStatusInvited),
|
||||
Issued: types.UserIssuedAPI,
|
||||
},
|
||||
InviteToken: plainToken,
|
||||
InviteExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserInviteInfo retrieves invite information from a token (public endpoint).
|
||||
func (am *DefaultAccountManager) GetUserInviteInfo(ctx context.Context, token string) (*types.UserInviteInfo, error) {
|
||||
if err := types.ValidateInviteToken(token); err != nil {
|
||||
return nil, status.Errorf(status.InvalidArgument, "invalid invite token: %v", err)
|
||||
}
|
||||
|
||||
hashedToken := types.HashInviteToken(token)
|
||||
invite, err := am.Store.GetUserInviteByHashedToken(ctx, store.LockingStrengthNone, hashedToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the inviter's name
|
||||
invitedBy := ""
|
||||
if invite.CreatedBy != "" {
|
||||
inviter, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, invite.CreatedBy)
|
||||
if err == nil && inviter != nil {
|
||||
invitedBy = inviter.Name
|
||||
}
|
||||
}
|
||||
|
||||
return &types.UserInviteInfo{
|
||||
Email: invite.Email,
|
||||
Name: invite.Name,
|
||||
ExpiresAt: invite.ExpiresAt,
|
||||
Valid: !invite.IsExpired(),
|
||||
InvitedBy: invitedBy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListUserInvites returns all invites for an account.
|
||||
func (am *DefaultAccountManager) ListUserInvites(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) {
|
||||
if !IsEmbeddedIdp(am.idpManager) {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
}
|
||||
|
||||
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !allowed {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
records, err := am.Store.GetAccountUserInvites(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
invites := make([]*types.UserInvite, 0, len(records))
|
||||
for _, record := range records {
|
||||
invites = append(invites, &types.UserInvite{
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: record.ID,
|
||||
Email: record.Email,
|
||||
Name: record.Name,
|
||||
Role: record.Role,
|
||||
AutoGroups: record.AutoGroups,
|
||||
},
|
||||
InviteExpiresAt: record.ExpiresAt,
|
||||
InviteCreatedAt: record.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return invites, nil
|
||||
}
|
||||
|
||||
// AcceptUserInvite accepts an invite and creates the user in both IdP and NetBird DB.
|
||||
func (am *DefaultAccountManager) AcceptUserInvite(ctx context.Context, token, password string) error {
|
||||
if !IsEmbeddedIdp(am.idpManager) {
|
||||
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return status.Errorf(status.InvalidArgument, "password is required")
|
||||
}
|
||||
|
||||
if err := validatePassword(password); err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "invalid password: %v", err)
|
||||
}
|
||||
|
||||
if err := types.ValidateInviteToken(token); err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "invalid invite token: %v", err)
|
||||
}
|
||||
|
||||
hashedToken := types.HashInviteToken(token)
|
||||
invite, err := am.Store.GetUserInviteByHashedToken(ctx, store.LockingStrengthUpdate, hashedToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if invite.IsExpired() {
|
||||
return status.Errorf(status.InvalidArgument, "invite has expired")
|
||||
}
|
||||
|
||||
// Create user in Dex with the provided password
|
||||
embeddedIdp, ok := am.idpManager.(*idp.EmbeddedIdPManager)
|
||||
if !ok {
|
||||
return status.Errorf(status.Internal, "failed to get embedded IdP manager")
|
||||
}
|
||||
|
||||
idpUser, err := embeddedIdp.CreateUserWithPassword(ctx, invite.Email, password, invite.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create user in IdP: %w", err)
|
||||
}
|
||||
|
||||
// Create user in NetBird DB
|
||||
newUser := &types.User{
|
||||
Id: idpUser.ID,
|
||||
AccountID: invite.AccountID,
|
||||
Role: types.StrRoleToUserRole(invite.Role),
|
||||
AutoGroups: invite.AutoGroups,
|
||||
Issued: types.UserIssuedAPI,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Email: invite.Email,
|
||||
Name: invite.Name,
|
||||
}
|
||||
|
||||
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
if err := transaction.SaveUser(ctx, newUser); err != nil {
|
||||
return fmt.Errorf("failed to save user: %w", err)
|
||||
}
|
||||
if err := transaction.DeleteUserInvite(ctx, invite.ID); err != nil {
|
||||
return fmt.Errorf("failed to delete invite: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// Best-effort rollback: delete the IdP user to avoid orphaned records
|
||||
if deleteErr := embeddedIdp.DeleteUser(ctx, idpUser.ID); deleteErr != nil {
|
||||
log.WithContext(ctx).WithError(deleteErr).Errorf("failed to rollback IdP user %s after transaction failure", idpUser.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
am.StoreEvent(ctx, newUser.Id, newUser.Id, invite.AccountID, activity.UserInviteLinkAccepted, map[string]any{"email": invite.Email})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegenerateUserInvite creates a new invite token for an existing invite, invalidating the previous one.
|
||||
func (am *DefaultAccountManager) RegenerateUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) {
|
||||
if !IsEmbeddedIdp(am.idpManager) {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
}
|
||||
|
||||
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !allowed {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
// Get existing invite
|
||||
existingInvite, err := am.Store.GetUserInviteByID(ctx, store.LockingStrengthUpdate, accountID, inviteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate expiration time
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = types.DefaultInviteExpirationSeconds
|
||||
}
|
||||
if expiresIn < types.MinInviteExpirationSeconds {
|
||||
return nil, status.Errorf(status.InvalidArgument, "invite expiration must be at least 1 hour")
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
// Generate new invite token
|
||||
hashedToken, plainToken, err := types.GenerateInviteToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate invite token: %w", err)
|
||||
}
|
||||
|
||||
// Update existing invite with new token and expiration
|
||||
existingInvite.HashedToken = hashedToken
|
||||
existingInvite.ExpiresAt = expiresAt
|
||||
existingInvite.CreatedBy = initiatorUserID
|
||||
|
||||
err = am.Store.SaveUserInvite(ctx, existingInvite)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
am.StoreEvent(ctx, initiatorUserID, existingInvite.ID, accountID, activity.UserInviteLinkRegenerated, map[string]any{"email": existingInvite.Email})
|
||||
|
||||
return &types.UserInvite{
|
||||
UserInfo: &types.UserInfo{
|
||||
ID: existingInvite.ID,
|
||||
Email: existingInvite.Email,
|
||||
Name: existingInvite.Name,
|
||||
Role: existingInvite.Role,
|
||||
AutoGroups: existingInvite.AutoGroups,
|
||||
Status: string(types.UserStatusInvited),
|
||||
Issued: types.UserIssuedAPI,
|
||||
},
|
||||
InviteToken: plainToken,
|
||||
InviteExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteUserInvite deletes an existing invite by ID.
|
||||
func (am *DefaultAccountManager) DeleteUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string) error {
|
||||
if !IsEmbeddedIdp(am.idpManager) {
|
||||
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
}
|
||||
|
||||
allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete)
|
||||
if err != nil {
|
||||
return status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !allowed {
|
||||
return status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
invite, err := am.Store.GetUserInviteByID(ctx, store.LockingStrengthUpdate, accountID, inviteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := am.Store.DeleteUserInvite(ctx, inviteID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
am.StoreEvent(ctx, initiatorUserID, inviteID, accountID, activity.UserInviteLinkDeleted, map[string]any{"email": invite.Email})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const minPasswordLength = 8
|
||||
|
||||
// validatePassword checks password strength requirements:
|
||||
// - Minimum 8 characters
|
||||
// - At least 1 digit
|
||||
// - At least 1 uppercase letter
|
||||
// - At least 1 special character
|
||||
func validatePassword(password string) error {
|
||||
if len(password) < minPasswordLength {
|
||||
return errors.New("password must be at least 8 characters long")
|
||||
}
|
||||
|
||||
var hasDigit, hasUpper, hasSpecial bool
|
||||
for _, c := range password {
|
||||
switch {
|
||||
case unicode.IsDigit(c):
|
||||
hasDigit = true
|
||||
case unicode.IsUpper(c):
|
||||
hasUpper = true
|
||||
case !unicode.IsLetter(c) && !unicode.IsDigit(c):
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
var missing []string
|
||||
if !hasDigit {
|
||||
missing = append(missing, "one digit")
|
||||
}
|
||||
if !hasUpper {
|
||||
missing = append(missing, "one uppercase letter")
|
||||
}
|
||||
if !hasSpecial {
|
||||
missing = append(missing, "one special character")
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return errors.New("password must contain at least " + strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user