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
|
URLTemplate string
|
||||||
CodeReturned bool
|
CodeReturned bool
|
||||||
EmailVerified 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
|
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 {
|
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 {
|
func newUserV2InviteWriteModel(userID, orgID string) *UserV2InviteWriteModel {
|
||||||
@@ -51,7 +92,10 @@ func (wm *UserV2InviteWriteModel) Reduce() error {
|
|||||||
wm.CodeReturned = false
|
wm.CodeReturned = false
|
||||||
wm.EmailVerified = false
|
wm.EmailVerified = false
|
||||||
wm.UserState = domain.UserStateActive
|
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:
|
case *user.HumanRegisteredEvent:
|
||||||
wm.EmptyInviteCode()
|
wm.EmptyInviteCode()
|
||||||
wm.ApplicationName = ""
|
wm.ApplicationName = ""
|
||||||
@@ -60,7 +104,10 @@ func (wm *UserV2InviteWriteModel) Reduce() error {
|
|||||||
wm.CodeReturned = false
|
wm.CodeReturned = false
|
||||||
wm.EmailVerified = false
|
wm.EmailVerified = false
|
||||||
wm.UserState = domain.UserStateActive
|
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:
|
case *user.MachineAddedEvent:
|
||||||
wm.EmptyInviteCode()
|
wm.EmptyInviteCode()
|
||||||
wm.ApplicationName = ""
|
wm.ApplicationName = ""
|
||||||
@@ -68,7 +115,7 @@ func (wm *UserV2InviteWriteModel) Reduce() error {
|
|||||||
wm.URLTemplate = ""
|
wm.URLTemplate = ""
|
||||||
wm.CodeReturned = false
|
wm.CodeReturned = false
|
||||||
wm.EmailVerified = false
|
wm.EmailVerified = false
|
||||||
wm.AuthMethodSet = false
|
wm.authMethods = nil
|
||||||
case *user.HumanInviteCodeAddedEvent:
|
case *user.HumanInviteCodeAddedEvent:
|
||||||
wm.SetInviteCode(e.Code, e.Expiry, e.CreationDate())
|
wm.SetInviteCode(e.Code, e.Expiry, e.CreationDate())
|
||||||
wm.URLTemplate = e.URLTemplate
|
wm.URLTemplate = e.URLTemplate
|
||||||
@@ -103,14 +150,20 @@ func (wm *UserV2InviteWriteModel) Reduce() error {
|
|||||||
wm.URLTemplate = ""
|
wm.URLTemplate = ""
|
||||||
wm.CodeReturned = false
|
wm.CodeReturned = false
|
||||||
wm.EmailVerified = false
|
wm.EmailVerified = false
|
||||||
wm.AuthMethodSet = false
|
wm.authMethods = nil
|
||||||
wm.UserState = domain.UserStateDeleted
|
wm.UserState = domain.UserStateDeleted
|
||||||
case *user.HumanPasswordChangedEvent:
|
case *user.HumanPasswordChangedEvent:
|
||||||
wm.AuthMethodSet = true
|
wm.addAuthMethod(passwordAuthMethodKey())
|
||||||
case *user.UserIDPLinkAddedEvent:
|
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:
|
case *user.HumanPasswordlessVerifiedEvent:
|
||||||
wm.AuthMethodSet = true
|
wm.addAuthMethod(passkeyAuthMethodKey(e.WebAuthNTokenID))
|
||||||
|
case *user.HumanPasswordlessRemovedEvent:
|
||||||
|
delete(wm.authMethods, passkeyAuthMethodKey(e.WebAuthNTokenID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return wm.WriteModel.Reduce()
|
return wm.WriteModel.Reduce()
|
||||||
@@ -153,7 +206,10 @@ func (wm *UserV2InviteWriteModel) Query() *eventstore.SearchQueryBuilder {
|
|||||||
user.HumanPasswordChangedType,
|
user.HumanPasswordChangedType,
|
||||||
user.UserV1PasswordChangedType,
|
user.UserV1PasswordChangedType,
|
||||||
user.UserIDPLinkAddedType,
|
user.UserIDPLinkAddedType,
|
||||||
|
user.UserIDPLinkRemovedType,
|
||||||
|
user.UserIDPLinkCascadeRemovedType,
|
||||||
user.HumanPasswordlessTokenVerifiedType,
|
user.HumanPasswordlessTokenVerifiedType,
|
||||||
|
user.HumanPasswordlessTokenRemovedType,
|
||||||
).Builder()
|
).Builder()
|
||||||
if wm.ResourceOwner != "" {
|
if wm.ResourceOwner != "" {
|
||||||
query.ResourceOwner(wm.ResourceOwner)
|
query.ResourceOwner(wm.ResourceOwner)
|
||||||
|
|||||||
@@ -742,6 +742,114 @@ func TestCommands_CreateInviteCode(t *testing.T) {
|
|||||||
returnCode: gu.Ptr("code2"),
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user