mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-19 01:14:48 -05:00
# Which Problems Are Solved Adds a repository implementation to add/remove recovery codes to the users relational table. # How the Problems Are Solved This is achieved by: * adding the following columns to the users table: `recovery_codes`, `recovery_code_last_successful_check`, `recovery_code_failed_attempts` * adding the `RecoveryCodes` field to the `HumanUser` domain with fields to set recovery `codes`, `lastSuccessfullyCheckedAt` timestamp, and `failedAttempts` * setting `recoveryCodes` in the `Get` user query statement to return a json object with details related to the recovery codes * adding the repository-layer implementation to add/remove recovery codes and set fields related to recovery code checks. * adding projection reducers to handle the following events: `HumanRecoveryCodesAddedEvent`, `HumanRecoveryCodesRemovedEvent`, `HumanRecoveryCodeCheckSucceededEvent`, and `HumanRecoveryCodeCheckFailedEvent` * adding unit tests # Additional Changes * fix the error message when the recovery is empty in `internal/command/user_human_recovery_codes.go` * add a new error message for empty recovery code during checks in `en.yaml` # Additional Context - Closes https://github.com/zitadel/zitadel/issues/11212 - Follow-up: integration tests for the reducers will be added in a different PR after this [PR](https://github.com/zitadel/zitadel/pull/11478) is merged
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@test.ch",
|
|
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@test.ch",
|
|
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@test.ch",
|
|
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@test.ch",
|
|
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@test.ch",
|
|
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@test.ch",
|
|
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.MFA.RecoveryCodes.Empty"),
|
|
},
|
|
{
|
|
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.MFA.RecoveryCodes.Empty"),
|
|
},
|
|
{
|
|
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)
|
|
})
|
|
}
|
|
}
|