mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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.
This commit is contained in:
@@ -22,13 +22,54 @@ type UserV2InviteWriteModel struct {
|
||||
URLTemplate string
|
||||
CodeReturned bool
|
||||
EmailVerified bool
|
||||
AuthMethodSet 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
|
||||
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 {
|
||||
@@ -51,7 +92,10 @@ func (wm *UserV2InviteWriteModel) Reduce() error {
|
||||
wm.CodeReturned = false
|
||||
wm.EmailVerified = false
|
||||
wm.UserState = domain.UserStateActive
|
||||
wm.AuthMethodSet = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != ""
|
||||
wm.authMethods = nil
|
||||
if crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" {
|
||||
wm.addAuthMethod(passwordAuthMethodKey())
|
||||
}
|
||||
case *user.HumanRegisteredEvent:
|
||||
wm.EmptyInviteCode()
|
||||
wm.ApplicationName = ""
|
||||
@@ -60,7 +104,10 @@ func (wm *UserV2InviteWriteModel) Reduce() error {
|
||||
wm.CodeReturned = false
|
||||
wm.EmailVerified = false
|
||||
wm.UserState = domain.UserStateActive
|
||||
wm.AuthMethodSet = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != ""
|
||||
wm.authMethods = nil
|
||||
if crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" {
|
||||
wm.addAuthMethod(passwordAuthMethodKey())
|
||||
}
|
||||
case *user.MachineAddedEvent:
|
||||
wm.EmptyInviteCode()
|
||||
wm.ApplicationName = ""
|
||||
@@ -68,7 +115,7 @@ func (wm *UserV2InviteWriteModel) Reduce() error {
|
||||
wm.URLTemplate = ""
|
||||
wm.CodeReturned = false
|
||||
wm.EmailVerified = false
|
||||
wm.AuthMethodSet = false
|
||||
wm.authMethods = nil
|
||||
case *user.HumanInviteCodeAddedEvent:
|
||||
wm.SetInviteCode(e.Code, e.Expiry, e.CreationDate())
|
||||
wm.URLTemplate = e.URLTemplate
|
||||
@@ -103,14 +150,20 @@ func (wm *UserV2InviteWriteModel) Reduce() error {
|
||||
wm.URLTemplate = ""
|
||||
wm.CodeReturned = false
|
||||
wm.EmailVerified = false
|
||||
wm.AuthMethodSet = false
|
||||
wm.authMethods = nil
|
||||
wm.UserState = domain.UserStateDeleted
|
||||
case *user.HumanPasswordChangedEvent:
|
||||
wm.AuthMethodSet = true
|
||||
wm.addAuthMethod(passwordAuthMethodKey())
|
||||
case *user.UserIDPLinkAddedEvent:
|
||||
wm.AuthMethodSet = true
|
||||
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.AuthMethodSet = true
|
||||
wm.addAuthMethod(passkeyAuthMethodKey(e.WebAuthNTokenID))
|
||||
case *user.HumanPasswordlessRemovedEvent:
|
||||
delete(wm.authMethods, passkeyAuthMethodKey(e.WebAuthNTokenID))
|
||||
}
|
||||
}
|
||||
return wm.WriteModel.Reduce()
|
||||
@@ -153,7 +206,10 @@ func (wm *UserV2InviteWriteModel) Query() *eventstore.SearchQueryBuilder {
|
||||
user.HumanPasswordChangedType,
|
||||
user.UserV1PasswordChangedType,
|
||||
user.UserIDPLinkAddedType,
|
||||
user.UserIDPLinkRemovedType,
|
||||
user.UserIDPLinkCascadeRemovedType,
|
||||
user.HumanPasswordlessTokenVerifiedType,
|
||||
user.HumanPasswordlessTokenRemovedType,
|
||||
).Builder()
|
||||
if wm.ResourceOwner != "" {
|
||||
query.ResourceOwner(wm.ResourceOwner)
|
||||
|
||||
@@ -742,6 +742,114 @@ func TestCommands_CreateInviteCode(t *testing.T) {
|
||||
returnCode: gu.Ptr("code2"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"user already has an auth method, precondition failed",
|
||||
fields{
|
||||
eventstore: expectEventstore(
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(),
|
||||
&user.NewAggregate("userID", "org1").Aggregate,
|
||||
"username", "firstName",
|
||||
"lastName",
|
||||
"nickName",
|
||||
"displayName",
|
||||
language.Afrikaans,
|
||||
domain.GenderUnspecified,
|
||||
"email",
|
||||
false,
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
user.NewUserIDPLinkAddedEvent(context.Background(),
|
||||
&user.NewAggregate("userID", "org1").Aggregate,
|
||||
"idpConfigID", "displayName", "externalUserID",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
checkPermission: newMockPermissionCheckAllowed(),
|
||||
defaultSecretGenerators: &SecretGenerators{},
|
||||
},
|
||||
args{
|
||||
ctx: context.Background(),
|
||||
invite: &CreateUserInvite{
|
||||
UserID: "userID",
|
||||
},
|
||||
},
|
||||
want{
|
||||
err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-EF34g", "Errors.User.AlreadyInitialised"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"create ok after all auth methods removed",
|
||||
fields{
|
||||
eventstore: expectEventstore(
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(),
|
||||
&user.NewAggregate("userID", "org1").Aggregate,
|
||||
"username", "firstName",
|
||||
"lastName",
|
||||
"nickName",
|
||||
"displayName",
|
||||
language.Afrikaans,
|
||||
domain.GenderUnspecified,
|
||||
"email",
|
||||
false,
|
||||
),
|
||||
),
|
||||
// user linked an IDP, then it was removed again
|
||||
eventFromEventPusher(
|
||||
user.NewUserIDPLinkAddedEvent(context.Background(),
|
||||
&user.NewAggregate("userID", "org1").Aggregate,
|
||||
"idpConfigID", "displayName", "externalUserID",
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
user.NewUserIDPLinkRemovedEvent(context.Background(),
|
||||
&user.NewAggregate("userID", "org1").Aggregate,
|
||||
"idpConfigID", "externalUserID",
|
||||
),
|
||||
),
|
||||
),
|
||||
expectPush(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanInviteCodeAddedEvent(context.Background(),
|
||||
&user.NewAggregate("userID", "org1").Aggregate,
|
||||
&crypto.CryptoValue{
|
||||
CryptoType: crypto.TypeEncryption,
|
||||
Algorithm: "enc",
|
||||
KeyID: "id",
|
||||
Crypted: []byte("code"),
|
||||
},
|
||||
time.Hour,
|
||||
"",
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
checkPermission: newMockPermissionCheckAllowed(),
|
||||
newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour),
|
||||
defaultSecretGenerators: &SecretGenerators{},
|
||||
},
|
||||
args{
|
||||
ctx: context.Background(),
|
||||
invite: &CreateUserInvite{
|
||||
UserID: "userID",
|
||||
},
|
||||
},
|
||||
want{
|
||||
details: &domain.ObjectDetails{
|
||||
ResourceOwner: "org1",
|
||||
ID: "userID",
|
||||
},
|
||||
returnCode: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user