mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
<!-- Please inform yourself about the contribution guidelines on submitting a PR here: https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr. Take note of how PR/commit titles should be written and replace the template texts in the sections below. Don't remove any of the sections. It is important that the commit history clearly shows what is changed and why. Important: By submitting a contribution you agree to the terms from our Licensing Policy as described here: https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions. --> One-time recovery codes are a common multi-factor authentication (MFA) backup method, letting users access their account if they lose other MFA devices. Support for recovery codes can also reduce support burden for users locked out of their accounts and provide a more secure and reliable form of verification than security questions. # Which Problems Are Solved Zitadel currently lacks support for recovery codes. # How the Problems Are Solved This PR partially addresses recovery code support in Zitadel. Importantly, it adds recovery codes as a new 2FA `Factor` and an additional `Check` type for the Session API. ``` Example recovery code flow: 1. User generates N new recovery codes using `POST /v2/users/{user_id}/recovery_codes` 2. Zitadel hashes and stores these codes and returns the un-hashed codes in the response 3. User creates new session with an additional check: `recoveryCode` 4. Code is checked against hash and, if valid, cannot be used again 5. User attempts to adds N more codes using the same endpoint 6. If `remaining_codes + N <= RecoveryCodes->MaxCount` config value, then recovery codes are added in addition to original codes 7. User can remove all recovery codes using `DEL /v2/users/:userId/recovery_codes` ``` This PR adds: - [x] Session recovery_code check support on `POST+PATCH /v2/sessions` endpoints - [x] Adds `mfa_recovery_code_checked_at` column (default null) to `projections.sessions8` table - [x] Support for `SECOND_FACTOR_TYPE_RECOVERY_CODES` as available 2FA method on login policy - [x] Support for importing recovery codes in /import code Missing, will _not_ implement in this PR: - [ ] Admin console support for displaying Recovery Code settings for user(s) - [ ] Zitadel Typescript login support for recovery codes TODO: - [x] Additional unit and integration tests - [x] Error translations # Additional Changes None # Additional Context - Closes #6898 --------- Co-authored-by: Livio Spring <[email protected]>
1038 lines
26 KiB
Go
1038 lines
26 KiB
Go
package command
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/text/language"
|
|
|
|
"github.com/zitadel/zitadel/internal/api/authz"
|
|
"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"
|
|
"github.com/zitadel/zitadel/internal/zerrors"
|
|
)
|
|
|
|
func TestCommands_ImportHumanRecoveryCodes(t *testing.T) {
|
|
ctx := authz.NewMockContext("inst1", "org1", "user1")
|
|
type fields struct {
|
|
eventstore func(*testing.T) *eventstore.Eventstore
|
|
}
|
|
type args struct {
|
|
ctx context.Context
|
|
userID string
|
|
resourceOwner string
|
|
codes []domain.ImportHumanRecoveryCode
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
fields fields
|
|
args args
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "user not found, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(), // user not found
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
resourceOwner: "org1",
|
|
codes: []domain.ImportHumanRecoveryCode{
|
|
{RawCode: "code1"},
|
|
{RawCode: "code2"},
|
|
},
|
|
},
|
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-uXHNj", "Errors.User.NotFound"),
|
|
},
|
|
{
|
|
name: "recovery codes already exist, add to existing",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
"username",
|
|
"firstname",
|
|
"lastname",
|
|
"nickname",
|
|
"displayname",
|
|
language.German,
|
|
domain.GenderUnspecified,
|
|
"[email protected]",
|
|
true,
|
|
),
|
|
),
|
|
),
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
[]string{"hashedcode1", "hashedcode2"},
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
expectPush(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
[]string{"$plain$$code1", "$plain$$code2"},
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
resourceOwner: "org1",
|
|
codes: []domain.ImportHumanRecoveryCode{
|
|
{RawCode: "code1"},
|
|
{RawCode: "code2"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "empty codes, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
resourceOwner: "org1",
|
|
codes: []domain.ImportHumanRecoveryCode{},
|
|
},
|
|
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-vee93", "Errors.User.MFA.RecoveryCodes.CountInvalid"),
|
|
},
|
|
{
|
|
name: "max count exceeded with existing codes, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
"username",
|
|
"firstname",
|
|
"lastname",
|
|
"nickname",
|
|
"displayname",
|
|
language.German,
|
|
domain.GenderUnspecified,
|
|
"[email protected]",
|
|
true,
|
|
),
|
|
),
|
|
),
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
[]string{"code1", "code2", "code3", "code4", "code5", "code6", "code7", "code8"},
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
resourceOwner: "org1",
|
|
codes: []domain.ImportHumanRecoveryCode{ // 8 existing + 3 new = 11 > max 10
|
|
{RawCode: "code9"},
|
|
{RawCode: "code10"},
|
|
{RawCode: "code11"},
|
|
},
|
|
},
|
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-53cjw", "Errors.User.MFA.RecoveryCodes.MaxCountExceeded"),
|
|
},
|
|
{
|
|
name: "successful import",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
"username",
|
|
"firstname",
|
|
"lastname",
|
|
"nickname",
|
|
"displayname",
|
|
language.German,
|
|
domain.GenderUnspecified,
|
|
"[email protected]",
|
|
true,
|
|
),
|
|
),
|
|
),
|
|
expectFilter(), // no existing recovery codes
|
|
expectPush(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
[]string{"$plain$$code1", "$plain$$code2"},
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
resourceOwner: "org1",
|
|
codes: []domain.ImportHumanRecoveryCode{
|
|
{RawCode: "code1"},
|
|
{RawCode: "code2"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := &Commands{
|
|
eventstore: tt.fields.eventstore(t),
|
|
userPasswordHasher: mockPasswordHasher(""),
|
|
multifactors: domain.MultifactorConfigs{
|
|
RecoveryCodes: domain.RecoveryCodesConfig{
|
|
MaxCount: 10,
|
|
Format: domain.RecoveryCodeFormatAlphanumeric,
|
|
Length: 8,
|
|
WithHyphen: false,
|
|
},
|
|
},
|
|
}
|
|
err := c.ImportHumanRecoveryCodes(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.codes)
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCommands_GenerateRecoveryCodes(t *testing.T) {
|
|
ctx := authz.NewMockContext("inst1", "org1", "user1")
|
|
type fields struct {
|
|
eventstore func(*testing.T) *eventstore.Eventstore
|
|
checkPermission domain.PermissionCheck
|
|
}
|
|
type args struct {
|
|
ctx context.Context
|
|
userID string
|
|
count int
|
|
resourceOwner string
|
|
authRequest *domain.AuthRequest
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
fields fields
|
|
args args
|
|
want *RecoveryCodesDetails
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "missing userID, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "",
|
|
count: 2,
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-4kje7", "Errors.User.UserIDMissing"),
|
|
},
|
|
{
|
|
name: "user not found, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(), // user not found
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
count: 2,
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-uXHNj", "Errors.User.NotFound"),
|
|
},
|
|
{
|
|
name: "permission denied, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanAddedEvent(ctx,
|
|
&user.NewAggregate("user2", "org1").Aggregate,
|
|
"username",
|
|
"firstname",
|
|
"lastname",
|
|
"nickname",
|
|
"displayname",
|
|
language.German,
|
|
domain.GenderUnspecified,
|
|
"[email protected]",
|
|
true,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
checkPermission: newMockPermissionCheckNotAllowed(),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user2",
|
|
count: 2,
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
|
|
},
|
|
{
|
|
name: "invalid count (zero), error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
count: 0,
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-7c0nx", "Errors.User.RecoveryCodes.CountInvalid"),
|
|
},
|
|
{
|
|
name: "invalid count (too high), error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
"username",
|
|
"firstname",
|
|
"lastname",
|
|
"nickname",
|
|
"displayname",
|
|
language.German,
|
|
domain.GenderUnspecified,
|
|
"[email protected]",
|
|
true,
|
|
),
|
|
),
|
|
),
|
|
expectFilter(), // no existing recovery codes
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
count: 15, // exceeds max count of 10
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-8f2k9", "Errors.User.MFA.RecoveryCodes.MaxCountExceeded"),
|
|
},
|
|
{
|
|
name: "max count exceeded with existing codes, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
"username",
|
|
"firstname",
|
|
"lastname",
|
|
"nickname",
|
|
"displayname",
|
|
language.German,
|
|
domain.GenderUnspecified,
|
|
"[email protected]",
|
|
true,
|
|
),
|
|
),
|
|
),
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
[]string{"code1", "code2", "code3", "code4", "code5", "code6", "code7", "code8"},
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
count: 5, // 8 existing + 5 new = 13 > max 10
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-8f2k9", "Errors.User.MFA.RecoveryCodes.MaxCountExceeded"),
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := &Commands{
|
|
eventstore: tt.fields.eventstore(t),
|
|
checkPermission: tt.fields.checkPermission,
|
|
userPasswordHasher: mockPasswordHasher(""),
|
|
multifactors: domain.MultifactorConfigs{
|
|
RecoveryCodes: domain.RecoveryCodesConfig{
|
|
MaxCount: 10,
|
|
Format: domain.RecoveryCodeFormatAlphanumeric,
|
|
Length: 8,
|
|
WithHyphen: false,
|
|
},
|
|
},
|
|
}
|
|
got, err := c.GenerateRecoveryCodes(tt.args.ctx, tt.args.userID, tt.args.count, tt.args.resourceOwner, tt.args.authRequest)
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
if tt.want != nil {
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, tt.want.ResourceOwner, got.ResourceOwner)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCommands_RemoveRecoveryCodes(t *testing.T) {
|
|
ctx := authz.NewMockContext("inst1", "org1", "user1")
|
|
type fields struct {
|
|
eventstore func(*testing.T) *eventstore.Eventstore
|
|
checkPermission domain.PermissionCheck
|
|
}
|
|
type args struct {
|
|
ctx context.Context
|
|
userID string
|
|
resourceOwner string
|
|
authRequest *domain.AuthRequest
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
fields fields
|
|
args args
|
|
want *domain.ObjectDetails
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "missing userID, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "",
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-l2n9r", "Errors.User.UserIDMissing"),
|
|
},
|
|
{
|
|
name: "permission denied, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user2", "org1").Aggregate,
|
|
[]string{"code1", "code2"},
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
checkPermission: newMockPermissionCheckNotAllowed(),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user2",
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
|
|
},
|
|
{
|
|
name: "user locked, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
[]string{"code1", "code2"},
|
|
nil,
|
|
),
|
|
),
|
|
eventFromEventPusher(
|
|
user.NewUserLockedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-d9u8q", "Errors.User.Locked"),
|
|
},
|
|
{
|
|
name: "recovery codes not added, ok",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(), // no recovery codes
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
resourceOwner: "org1",
|
|
},
|
|
want: &domain.ObjectDetails{
|
|
ResourceOwner: "org1",
|
|
ID: "user1",
|
|
},
|
|
},
|
|
{
|
|
name: "successful removal",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
[]string{"code1", "code2"},
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
expectPush(
|
|
user.NewHumanRecoveryCodeRemovedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
resourceOwner: "org1",
|
|
},
|
|
want: &domain.ObjectDetails{
|
|
ResourceOwner: "org1",
|
|
ID: "user1",
|
|
},
|
|
},
|
|
{
|
|
name: "successful removal with auth request",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
[]string{"code1", "code2"},
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
expectPush(
|
|
user.NewHumanRecoveryCodeRemovedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
&user.AuthRequestInfo{
|
|
ID: "authRequestID",
|
|
UserAgentID: "userAgentID",
|
|
BrowserInfo: &user.BrowserInfo{
|
|
UserAgent: "user-agent",
|
|
AcceptLanguage: "en",
|
|
RemoteIP: net.IP{192, 0, 2, 1},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
resourceOwner: "org1",
|
|
authRequest: &domain.AuthRequest{
|
|
ID: "authRequestID",
|
|
AgentID: "userAgentID",
|
|
BrowserInfo: &domain.BrowserInfo{
|
|
UserAgent: "user-agent",
|
|
AcceptLanguage: "en",
|
|
RemoteIP: net.IP{192, 0, 2, 1},
|
|
},
|
|
},
|
|
},
|
|
want: &domain.ObjectDetails{
|
|
ResourceOwner: "org1",
|
|
ID: "user1",
|
|
},
|
|
},
|
|
{
|
|
name: "successful removal, other user",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user2", "org1").Aggregate,
|
|
[]string{"code1", "code2"},
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
expectPush(
|
|
user.NewHumanRecoveryCodeRemovedEvent(ctx,
|
|
&user.NewAggregate("user2", "org1").Aggregate,
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
checkPermission: newMockPermissionCheckAllowed(),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user2",
|
|
resourceOwner: "org1",
|
|
},
|
|
want: &domain.ObjectDetails{
|
|
ResourceOwner: "org1",
|
|
ID: "user2",
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := &Commands{
|
|
eventstore: tt.fields.eventstore(t),
|
|
checkPermission: tt.fields.checkPermission,
|
|
}
|
|
got, err := c.RemoveRecoveryCodes(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authRequest)
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
if tt.want != nil {
|
|
assert.Equal(t, tt.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCommands_HumanCheckRecoveryCode(t *testing.T) {
|
|
ctx := authz.NewMockContext("inst1", "org1", "user1")
|
|
type fields struct {
|
|
eventstore func(*testing.T) *eventstore.Eventstore
|
|
}
|
|
type args struct {
|
|
ctx context.Context
|
|
userID string
|
|
code string
|
|
resourceOwner string
|
|
authRequest *domain.AuthRequest
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
fields fields
|
|
args args
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "missing code, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
code: "",
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.UserIDMissing"),
|
|
},
|
|
{
|
|
name: "missing userID, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "",
|
|
code: "validcode",
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9s2", "Errors.User.UserIDMissing"),
|
|
},
|
|
{
|
|
name: "user locked, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
[]string{"$plain$$validcode", "$plain$$validcode2"},
|
|
nil,
|
|
),
|
|
),
|
|
eventFromEventPusher(
|
|
user.NewUserLockedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
code: "validcode",
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-2w6oa", "Errors.User.Locked"),
|
|
},
|
|
{
|
|
name: "recovery codes not ready, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(), // no recovery codes
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
code: "validcode",
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-84rgg", "Errors.User.MFA.RecoveryCodes.NotReady"),
|
|
},
|
|
{
|
|
name: "valid code, successful check",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
[]string{"$plain$$validcode", "$plain$$validcode2"},
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
expectFilter(), // additional lock check
|
|
expectPush(
|
|
user.NewHumanRecoveryCodeCheckSucceededEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
"$plain$$validcode",
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
code: "validcode",
|
|
resourceOwner: "org1",
|
|
},
|
|
},
|
|
{
|
|
name: "valid code, successful check with auth request",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
[]string{"$plain$$validcode", "$plain$$validcode2"},
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
expectFilter(), // additional lock check
|
|
expectPush(
|
|
user.NewHumanRecoveryCodeCheckSucceededEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
"$plain$$validcode",
|
|
&user.AuthRequestInfo{
|
|
ID: "authRequestID",
|
|
UserAgentID: "userAgentID",
|
|
BrowserInfo: &user.BrowserInfo{
|
|
UserAgent: "user-agent",
|
|
AcceptLanguage: "en",
|
|
RemoteIP: net.IP{192, 0, 2, 1},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
code: "validcode",
|
|
resourceOwner: "org1",
|
|
authRequest: &domain.AuthRequest{
|
|
ID: "authRequestID",
|
|
AgentID: "userAgentID",
|
|
BrowserInfo: &domain.BrowserInfo{
|
|
UserAgent: "user-agent",
|
|
AcceptLanguage: "en",
|
|
RemoteIP: net.IP{192, 0, 2, 1},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "valid code, locked in the meantime, error",
|
|
fields: fields{
|
|
eventstore: expectEventstore(
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewHumanRecoveryCodesAddedEvent(ctx,
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
[]string{"$plain$$validcode", "$plain$$validcode2"},
|
|
nil,
|
|
),
|
|
),
|
|
),
|
|
expectFilter(
|
|
eventFromEventPusher(
|
|
user.NewUserLockedEvent(context.Background(),
|
|
&user.NewAggregate("user1", "org1").Aggregate,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
},
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
code: "validcode",
|
|
resourceOwner: "org1",
|
|
},
|
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-ASV12", "Errors.User.Locked"),
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
c := &Commands{
|
|
eventstore: tt.fields.eventstore(t),
|
|
userPasswordHasher: mockPasswordHasher(""),
|
|
}
|
|
err := c.HumanCheckRecoveryCode(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.authRequest)
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCommands_checkRecoveryCode(t *testing.T) {
|
|
ctx := authz.NewMockContext("inst1", "org1", "user1")
|
|
hasher := mockPasswordHasher("")
|
|
|
|
queryReducer := func(ctx context.Context, r eventstore.QueryReducer) error {
|
|
// Basic query reducer that doesn't set any specific state
|
|
return nil
|
|
}
|
|
queryReducerError := func(ctx context.Context, r eventstore.QueryReducer) error {
|
|
return io.ErrClosedPipe
|
|
}
|
|
|
|
// Query reducer that sets up a write model with valid recovery codes
|
|
queryReducerWithCodes := func(ctx context.Context, r eventstore.QueryReducer) error {
|
|
switch wm := r.(type) {
|
|
case *HumanRecoveryCodeWriteModel:
|
|
wm.State = domain.MFAStateReady
|
|
wm.codes = []string{"$plain$$validcode", "$plain$$validcode2"}
|
|
case *OrgLockoutPolicyWriteModel:
|
|
// Set up lockout policy - no lockout for this test
|
|
wm.MaxOTPAttempts = 0
|
|
wm.State = domain.PolicyStateActive
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Query reducer that sets up a locked user
|
|
queryReducerUserLocked := func(ctx context.Context, r eventstore.QueryReducer) error {
|
|
switch wm := r.(type) {
|
|
case *HumanRecoveryCodeWriteModel:
|
|
wm.State = domain.MFAStateReady
|
|
wm.codes = []string{"$plain$$validcode", "$plain$$validcode2"}
|
|
wm.userLocked = true
|
|
case *OrgLockoutPolicyWriteModel:
|
|
// Default lockout policy
|
|
wm.State = domain.PolicyStateActive
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Query reducer that sets up recovery codes not ready
|
|
queryReducerNotReady := func(ctx context.Context, r eventstore.QueryReducer) error {
|
|
switch wm := r.(type) {
|
|
case *HumanRecoveryCodeWriteModel:
|
|
wm.State = domain.MFAStateNotReady
|
|
case *OrgLockoutPolicyWriteModel:
|
|
// Default lockout policy
|
|
wm.State = domain.PolicyStateActive
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Query reducer that sets up a locked user after the first check
|
|
queryReducerLockedAfterFirstCheck := func(ctx context.Context, r eventstore.QueryReducer) error {
|
|
switch wm := r.(type) {
|
|
case *HumanRecoveryCodeWriteModel:
|
|
wm.State = domain.MFAStateReady
|
|
// simulate lock after first check when the codes are already set by the first reduction
|
|
if len(wm.codes) > 0 {
|
|
wm.userLocked = true
|
|
}
|
|
wm.codes = []string{"$plain$$validcode", "$plain$$validcode2"}
|
|
case *OrgLockoutPolicyWriteModel:
|
|
// Default lockout policy
|
|
wm.State = domain.PolicyStateActive
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type args struct {
|
|
ctx context.Context
|
|
userID string
|
|
code string
|
|
resourceOwner string
|
|
authRequest *domain.AuthRequest
|
|
queryReducer func(ctx context.Context, r eventstore.QueryReducer) error
|
|
secretHasher *crypto.Hasher
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
wantCommands int
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "missing code, error",
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
code: "",
|
|
resourceOwner: "org1",
|
|
queryReducer: queryReducer,
|
|
secretHasher: hasher,
|
|
},
|
|
wantCommands: 0,
|
|
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.UserIDMissing"),
|
|
},
|
|
{
|
|
name: "missing userID, error",
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "",
|
|
code: "validcode",
|
|
resourceOwner: "org1",
|
|
queryReducer: queryReducer,
|
|
secretHasher: hasher,
|
|
},
|
|
wantCommands: 0,
|
|
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9s2", "Errors.User.UserIDMissing"),
|
|
},
|
|
{
|
|
name: "query reducer error",
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
code: "validcode",
|
|
resourceOwner: "org1",
|
|
queryReducer: queryReducerError,
|
|
secretHasher: hasher,
|
|
},
|
|
wantCommands: 0,
|
|
wantErr: io.ErrClosedPipe,
|
|
},
|
|
{
|
|
name: "user locked, error",
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
code: "validcode",
|
|
resourceOwner: "org1",
|
|
queryReducer: queryReducerUserLocked,
|
|
secretHasher: hasher,
|
|
},
|
|
wantCommands: 0,
|
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-2w6oa", "Errors.User.Locked"),
|
|
},
|
|
{
|
|
name: "recovery codes not ready, error",
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
code: "validcode",
|
|
resourceOwner: "org1",
|
|
queryReducer: queryReducerNotReady,
|
|
secretHasher: hasher,
|
|
},
|
|
wantCommands: 0,
|
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-84rgg", "Errors.User.MFA.RecoveryCodes.NotReady"),
|
|
},
|
|
{
|
|
name: "invalid code, returns failed event and error",
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
code: "invalidcode",
|
|
resourceOwner: "org1",
|
|
queryReducer: queryReducerWithCodes,
|
|
secretHasher: hasher,
|
|
},
|
|
wantCommands: 1, // should return failed event command
|
|
wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-6uvh0", "Errors.User.MFA.RecoveryCodes.InvalidCode"),
|
|
},
|
|
{
|
|
name: "valid code, returns success event",
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
code: "validcode",
|
|
resourceOwner: "org1",
|
|
queryReducer: queryReducerWithCodes,
|
|
secretHasher: hasher,
|
|
},
|
|
wantCommands: 1, // should return success event command
|
|
wantErr: nil,
|
|
},
|
|
{
|
|
name: "valid code, locked in the meantime, error",
|
|
args: args{
|
|
ctx: ctx,
|
|
userID: "user1",
|
|
code: "validcode",
|
|
resourceOwner: "org1",
|
|
queryReducer: queryReducerLockedAfterFirstCheck,
|
|
secretHasher: hasher,
|
|
},
|
|
wantCommands: 0,
|
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-ASV12", "Errors.User.Locked"),
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
commands, err := checkRecoveryCode(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.authRequest, tt.args.queryReducer, tt.args.secretHasher)
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
assert.Len(t, commands, tt.wantCommands)
|
|
})
|
|
}
|
|
}
|