Files
zitadel/internal/command/user_v2_invite_model.go
T
Max PeintnerandGitHub 11912ac749 fix: allow invite codes for users whose auth methods were all removed (#12453)
# Which Problems Are Solved

When a user has no primary auth method (no password, passkey, or IDP
link), the login app routes them to the verify page and lets them
(re)send an invite code. But clicking **Resend** failed with
`Errors.User.AlreadyInitialised` (surfaced in the login UI as the
misleading "User is already verified!").

The cause was in the invite write model (`UserV2InviteWriteModel`): its
`AuthMethodSet` flag was **sticky**. It was set to `true` the first time
a user ever got a password, passkey, or IDP link, and was never cleared
— the removal events weren't even loaded by the query. So a user who
*once* had an auth method that was later removed was permanently treated
as "initialized," even though `ListUserAuthMethodTypes` (what the login
app checks) correctly reported zero methods. That disagreement blocked
invite creation/resend for legitimately method-less users.

# How the Problems Are Solved

Track the user's auth methods as the **current set** instead of a sticky
flag:
- Replaced the `AuthMethodSet bool` field with an `authMethods` set,
keyed uniquely per method (password / IDP link / passkey).
- Reduce now also handles the removal events (`UserIDPLinkRemoved`,
`UserIDPLinkCascadeRemoved`, `HumanPasswordlessTokenRemoved`), and these
event types were added to the write model's query filter so they're
loaded.
- `AuthMethodSet()` is now simply "does the user currently have ≥1 auth
method," so `CreationAllowed()` reflects reality.

The security guard is unchanged: a user who still has any auth method is
refused. The only behavior change is that a user with **no current**
auth method (including one whose methods were all removed) can again
receive an invite code — matching what the login app already assumes. No
extra queries (same single event filter, a few more event types), and no
data migration (pure re-derivation from existing events).

Added tests covering both an initialized user (still refused) and invite
creation succeeding after all auth methods are removed.
2026-08-12 06:24:28 +00:00

223 lines
7.0 KiB
Go

package command
import (
"time"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
)
type UserV2InviteWriteModel struct {
eventstore.WriteModel
InviteCode *crypto.CryptoValue
InviteCodeCreationDate time.Time
InviteCodeExpiry time.Duration
InviteCheckFailureCount uint8
ApplicationName string
AuthRequestID string
URLTemplate string
CodeReturned bool
EmailVerified bool
// authMethods holds the primary auth methods (password, passkeys, IDP links)
// the user currently has, keyed uniquely per method. It is tracked as the
// current set rather than a sticky flag, so that removing all auth methods
// re-enables the invite flow, mirroring what ListUserAuthMethodTypes reports.
authMethods map[authMethodKey]struct{}
UserState domain.UserState
}
// authMethodKey uniquely identifies a single primary auth method within the
// authMethods set.
// ID components (e.g. an externalUserID supplied by an external IDP) cannot
// collide across method instances regardless of their contents.
type authMethodKey struct {
kind string
idpConfigID string
externalUserID string
passkeyTokenID string
}
func passwordAuthMethodKey() authMethodKey {
return authMethodKey{kind: "password"}
}
func idpAuthMethodKey(idpConfigID, externalUserID string) authMethodKey {
return authMethodKey{kind: "idp", idpConfigID: idpConfigID, externalUserID: externalUserID}
}
func passkeyAuthMethodKey(tokenID string) authMethodKey {
return authMethodKey{kind: "passkey", passkeyTokenID: tokenID}
}
// AuthMethodSet reports whether the user currently has at least one primary
// auth method (password, passkey or IDP link) set up.
func (wm *UserV2InviteWriteModel) AuthMethodSet() bool {
return len(wm.authMethods) > 0
}
func (wm *UserV2InviteWriteModel) CreationAllowed() bool {
return !wm.AuthMethodSet()
}
func (wm *UserV2InviteWriteModel) addAuthMethod(key authMethodKey) {
if wm.authMethods == nil {
wm.authMethods = make(map[authMethodKey]struct{})
}
wm.authMethods[key] = struct{}{}
}
func newUserV2InviteWriteModel(userID, orgID string) *UserV2InviteWriteModel {
return &UserV2InviteWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: userID,
ResourceOwner: orgID,
},
}
}
func (wm *UserV2InviteWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *user.HumanAddedEvent:
wm.EmptyInviteCode()
wm.ApplicationName = ""
wm.AuthRequestID = ""
wm.URLTemplate = ""
wm.CodeReturned = false
wm.EmailVerified = false
wm.UserState = domain.UserStateActive
wm.authMethods = nil
if crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" {
wm.addAuthMethod(passwordAuthMethodKey())
}
case *user.HumanRegisteredEvent:
wm.EmptyInviteCode()
wm.ApplicationName = ""
wm.AuthRequestID = ""
wm.URLTemplate = ""
wm.CodeReturned = false
wm.EmailVerified = false
wm.UserState = domain.UserStateActive
wm.authMethods = nil
if crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" {
wm.addAuthMethod(passwordAuthMethodKey())
}
case *user.MachineAddedEvent:
wm.EmptyInviteCode()
wm.ApplicationName = ""
wm.AuthRequestID = ""
wm.URLTemplate = ""
wm.CodeReturned = false
wm.EmailVerified = false
wm.authMethods = nil
case *user.HumanInviteCodeAddedEvent:
wm.SetInviteCode(e.Code, e.Expiry, e.CreationDate())
wm.URLTemplate = e.URLTemplate
wm.CodeReturned = e.CodeReturned
wm.ApplicationName = e.ApplicationName
wm.AuthRequestID = e.AuthRequestID
case *user.HumanInviteCheckSucceededEvent:
wm.EmptyInviteCode()
case *user.HumanInviteCheckFailedEvent:
wm.InviteCheckFailureCount++
if wm.InviteCheckFailureCount >= 3 || crypto.IsCodeExpired(wm.InviteCodeCreationDate, wm.InviteCodeExpiry) { //TODO: make failure count comparison with wm.InviteCheckFailureCount configurable?
// invalidate the invite code after attempting to verify an expired code, or a wrong code three or more times
// so that a new invite code can be created for this user
wm.EmptyInviteCode()
wm.CodeReturned = false
}
case *user.HumanEmailVerifiedEvent:
wm.EmailVerified = true
wm.EmptyInviteCode()
case *user.UserLockedEvent:
wm.UserState = domain.UserStateLocked
case *user.UserUnlockedEvent:
wm.UserState = domain.UserStateActive
case *user.UserDeactivatedEvent:
wm.UserState = domain.UserStateInactive
case *user.UserReactivatedEvent:
wm.UserState = domain.UserStateActive
case *user.UserRemovedEvent:
wm.EmptyInviteCode()
wm.ApplicationName = ""
wm.AuthRequestID = ""
wm.URLTemplate = ""
wm.CodeReturned = false
wm.EmailVerified = false
wm.authMethods = nil
wm.UserState = domain.UserStateDeleted
case *user.HumanPasswordChangedEvent:
wm.addAuthMethod(passwordAuthMethodKey())
case *user.UserIDPLinkAddedEvent:
wm.addAuthMethod(idpAuthMethodKey(e.IDPConfigID, e.ExternalUserID))
case *user.UserIDPLinkRemovedEvent:
delete(wm.authMethods, idpAuthMethodKey(e.IDPConfigID, e.ExternalUserID))
case *user.UserIDPLinkCascadeRemovedEvent:
delete(wm.authMethods, idpAuthMethodKey(e.IDPConfigID, e.ExternalUserID))
case *user.HumanPasswordlessVerifiedEvent:
wm.addAuthMethod(passkeyAuthMethodKey(e.WebAuthNTokenID))
case *user.HumanPasswordlessRemovedEvent:
delete(wm.authMethods, passkeyAuthMethodKey(e.WebAuthNTokenID))
}
}
return wm.WriteModel.Reduce()
}
func (wm *UserV2InviteWriteModel) SetInviteCode(code *crypto.CryptoValue, expiry time.Duration, creationDate time.Time) {
wm.InviteCode = code
wm.InviteCodeExpiry = expiry
wm.InviteCodeCreationDate = creationDate
wm.InviteCheckFailureCount = 0
}
func (wm *UserV2InviteWriteModel) EmptyInviteCode() {
wm.InviteCode = nil
wm.InviteCodeExpiry = 0
wm.InviteCodeCreationDate = time.Time{}
wm.InviteCheckFailureCount = 0
}
func (wm *UserV2InviteWriteModel) Query() *eventstore.SearchQueryBuilder {
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AddQuery().
AggregateTypes(user.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(
user.UserV1AddedType,
user.HumanAddedType,
user.UserV1RegisteredType,
user.HumanRegisteredType,
user.MachineAddedEventType,
user.HumanInviteCodeAddedType,
user.HumanInviteCheckSucceededType,
user.HumanInviteCheckFailedType,
user.UserV1EmailVerifiedType,
user.HumanEmailVerifiedType,
user.UserLockedType,
user.UserUnlockedType,
user.UserDeactivatedType,
user.UserReactivatedType,
user.UserRemovedType,
user.HumanPasswordChangedType,
user.UserV1PasswordChangedType,
user.UserIDPLinkAddedType,
user.UserIDPLinkRemovedType,
user.UserIDPLinkCascadeRemovedType,
user.HumanPasswordlessTokenVerifiedType,
user.HumanPasswordlessTokenRemovedType,
).Builder()
if wm.ResourceOwner != "" {
query.ResourceOwner(wm.ResourceOwner)
}
return query
}
func (wm *UserV2InviteWriteModel) Aggregate() *user.Aggregate {
return user.NewAggregate(wm.AggregateID, wm.ResourceOwner)
}