feat: implement register Passkey user API v2 (#5873)

* command/crypto: DRY the code

- reuse the the algorithm switch to create a secret generator
- add a verifyCryptoCode function

* command: crypto code tests

* migrate webauthn package

* finish integration tests with webauthn mock client
This commit is contained in:
Tim Möhlmann
2023-05-24 13:22:00 +03:00
committed by GitHub
parent 6839a5c203
commit a301c40f9f
44 changed files with 2528 additions and 517 deletions

View File

@@ -0,0 +1,16 @@
package authz
import (
"context"
"github.com/zitadel/zitadel/internal/errors"
)
// UserIDInCTX checks if the userID
// equals the authenticated user in the context.
func UserIDInCTX(ctx context.Context, userID string) error {
if GetCtxData(ctx).UserID != userID {
return errors.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong")
}
return nil
}

View File

@@ -0,0 +1,36 @@
package grpc
import (
"testing"
"google.golang.org/protobuf/reflect/protoreflect"
)
// AllFieldsSet recusively checks if all values in a message
// have a non-zero value.
func AllFieldsSet(t testing.TB, msg protoreflect.Message, ignoreTypes ...protoreflect.FullName) {
ignore := make(map[protoreflect.FullName]bool, len(ignoreTypes))
for _, name := range ignoreTypes {
ignore[name] = true
}
md := msg.Descriptor()
name := md.FullName()
if ignore[name] {
return
}
fields := md.Fields()
for i := 0; i < fields.Len(); i++ {
fd := fields.Get(i)
if !msg.Has(fd) {
t.Errorf("not all fields set in %q, missing %q", name, fd.Name())
continue
}
if fd.Kind() == protoreflect.MessageKind {
AllFieldsSet(t, msg.Get(fd).Message(), ignoreTypes...)
}
}
}

View File

@@ -11,38 +11,13 @@ import (
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/zitadel/zitadel/internal/api/grpc"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha"
)
var ignoreMessageTypes = map[protoreflect.FullName]bool{
"google.protobuf.Duration": true,
}
// allFieldsSet recusively checks if all values in a message
// have a non-zero value.
func allFieldsSet(t testing.TB, msg protoreflect.Message) {
md := msg.Descriptor()
name := md.FullName()
if ignoreMessageTypes[name] {
return
}
fields := md.Fields()
for i := 0; i < fields.Len(); i++ {
fd := fields.Get(i)
if !msg.Has(fd) {
t.Errorf("not all fields set in %q, missing %q", name, fd.Name())
continue
}
if fd.Kind() == protoreflect.MessageKind {
allFieldsSet(t, msg.Get(fd).Message())
}
}
}
var ignoreTypes = []protoreflect.FullName{"google.protobuf.Duration"}
func Test_loginSettingsToPb(t *testing.T) {
arg := &query.LoginPolicy{
@@ -100,7 +75,7 @@ func Test_loginSettingsToPb(t *testing.T) {
}
got := loginSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect())
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
if !proto.Equal(got, want) {
t.Errorf("loginSettingsToPb() =\n%v\nwant\n%v", got, want)
}
@@ -241,7 +216,7 @@ func Test_passwordSettingsToPb(t *testing.T) {
}
got := passwordSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect())
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
if !proto.Equal(got, want) {
t.Errorf("passwordSettingsToPb() =\n%v\nwant\n%v", got, want)
}
@@ -295,7 +270,7 @@ func Test_brandingSettingsToPb(t *testing.T) {
}
got := brandingSettingsToPb(arg, "http://example.com")
allFieldsSet(t, got.ProtoReflect())
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
if !proto.Equal(got, want) {
t.Errorf("brandingSettingsToPb() =\n%v\nwant\n%v", got, want)
}
@@ -315,7 +290,7 @@ func Test_domainSettingsToPb(t *testing.T) {
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
}
got := domainSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect())
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
if !proto.Equal(got, want) {
t.Errorf("domainSettingsToPb() =\n%v\nwant\n%v", got, want)
}
@@ -337,7 +312,7 @@ func Test_legalSettingsToPb(t *testing.T) {
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
}
got := legalAndSupportSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect())
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
if !proto.Equal(got, want) {
t.Errorf("legalSettingsToPb() =\n%v\nwant\n%v", got, want)
}
@@ -353,7 +328,7 @@ func Test_lockoutSettingsToPb(t *testing.T) {
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
}
got := lockoutSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect())
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
if !proto.Equal(got, want) {
t.Errorf("lockoutSettingsToPb() =\n%v\nwant\n%v", got, want)
}
@@ -387,7 +362,7 @@ func Test_identityProvidersToPb(t *testing.T) {
got := identityProvidersToPb(arg)
require.Len(t, got, len(got))
for i, v := range got {
allFieldsSet(t, v.ProtoReflect())
grpc.AllFieldsSet(t, v.ProtoReflect(), ignoreTypes...)
if !proto.Equal(v, want[i]) {
t.Errorf("identityProvidersToPb() =\n%v\nwant\n%v", got, want)
}

View File

@@ -0,0 +1,104 @@
package user
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func (s *Server) RegisterPasskey(ctx context.Context, req *user.RegisterPasskeyRequest) (resp *user.RegisterPasskeyResponse, err error) {
var (
resourceOwner = authz.GetCtxData(ctx).ResourceOwner
authenticator = passkeyAuthenticatorToDomain(req.GetAuthenticator())
)
if code := req.GetCode(); code != nil {
return passkeyRegistrationDetailsToPb(
s.command.RegisterUserPasskeyWithCode(ctx, req.GetUserId(), resourceOwner, authenticator, code.Id, code.Code, s.userCodeAlg),
)
}
return passkeyRegistrationDetailsToPb(
s.command.RegisterUserPasskey(ctx, req.GetUserId(), resourceOwner, authenticator),
)
}
func passkeyAuthenticatorToDomain(pa user.PasskeyAuthenticator) domain.AuthenticatorAttachment {
switch pa {
case user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_UNSPECIFIED:
return domain.AuthenticatorAttachmentUnspecified
case user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM:
return domain.AuthenticatorAttachmentPlattform
case user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_CROSS_PLATFORM:
return domain.AuthenticatorAttachmentCrossPlattform
default:
return domain.AuthenticatorAttachmentUnspecified
}
}
func passkeyRegistrationDetailsToPb(details *domain.PasskeyRegistrationDetails, err error) (*user.RegisterPasskeyResponse, error) {
if err != nil {
return nil, err
}
return &user.RegisterPasskeyResponse{
Details: object.DomainToDetailsPb(details.ObjectDetails),
PasskeyId: details.PasskeyID,
PublicKeyCredentialCreationOptions: details.PublicKeyCredentialCreationOptions,
}, nil
}
func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) {
resourceOwner := authz.GetCtxData(ctx).ResourceOwner
objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.GetUserId(), resourceOwner, req.GetPasskeyName(), "", req.GetPublicKeyCredential())
if err != nil {
return nil, err
}
return &user.VerifyPasskeyRegistrationResponse{
Details: object.DomainToDetailsPb(objectDetails),
}, nil
}
func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *user.CreatePasskeyRegistrationLinkRequest) (resp *user.CreatePasskeyRegistrationLinkResponse, err error) {
resourceOwner := authz.GetCtxData(ctx).ResourceOwner
switch medium := req.Medium.(type) {
case nil:
return passkeyDetailsToPb(
s.command.AddUserPasskeyCode(ctx, req.GetUserId(), resourceOwner, s.userCodeAlg),
)
case *user.CreatePasskeyRegistrationLinkRequest_SendLink:
return passkeyDetailsToPb(
s.command.AddUserPasskeyCodeURLTemplate(ctx, req.GetUserId(), resourceOwner, s.userCodeAlg, medium.SendLink.GetUrlTemplate()),
)
case *user.CreatePasskeyRegistrationLinkRequest_ReturnCode:
return passkeyCodeDetailsToPb(
s.command.AddUserPasskeyCodeReturn(ctx, req.GetUserId(), resourceOwner, s.userCodeAlg),
)
default:
return nil, caos_errs.ThrowUnimplementedf(nil, "USERv2-gaD8y", "verification oneOf %T in method CreatePasskeyRegistrationLink not implemented", medium)
}
}
func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) {
if err != nil {
return nil, err
}
return &user.CreatePasskeyRegistrationLinkResponse{
Details: object.DomainToDetailsPb(details),
}, nil
}
func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) {
if err != nil {
return nil, err
}
return &user.CreatePasskeyRegistrationLinkResponse{
Details: object.DomainToDetailsPb(details.ObjectDetails),
Code: &user.PasskeyRegistrationCode{
Id: details.CodeID,
Code: details.Code,
},
}, nil
}

View File

@@ -0,0 +1,309 @@
//go:build integration
package user_test
import (
"context"
"testing"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/internal/webauthn"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func TestServer_RegisterPasskey(t *testing.T) {
userID := createHumanUser(t).GetUserId()
reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{
UserId: userID,
Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
})
require.NoError(t, err)
client := webauthn.NewClient(Tester.Config.WebAuthNName, Tester.Config.ExternalDomain, "https://"+Tester.Host())
type args struct {
ctx context.Context
req *user.RegisterPasskeyRequest
}
tests := []struct {
name string
args args
want *user.RegisterPasskeyResponse
wantErr bool
}{
{
name: "missing user id",
args: args{
ctx: CTX,
req: &user.RegisterPasskeyRequest{},
},
wantErr: true,
},
{
name: "register code",
args: args{
ctx: CTX,
req: &user.RegisterPasskeyRequest{
UserId: userID,
Code: reg.GetCode(),
Authenticator: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM,
},
},
want: &user.RegisterPasskeyResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "reuse code (not allowed)",
args: args{
ctx: CTX,
req: &user.RegisterPasskeyRequest{
UserId: userID,
Code: reg.GetCode(),
Authenticator: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM,
},
},
wantErr: true,
},
{
name: "wrong code",
args: args{
ctx: CTX,
req: &user.RegisterPasskeyRequest{
UserId: userID,
Code: &user.PasskeyRegistrationCode{
Id: reg.GetCode().GetId(),
Code: "foobar",
},
Authenticator: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_CROSS_PLATFORM,
},
},
wantErr: true,
},
{
name: "user mismatch",
args: args{
ctx: CTX,
req: &user.RegisterPasskeyRequest{
UserId: userID,
},
},
wantErr: true,
},
/* TODO after we are able to obtain a Bearer token for a human user
{
name: "human user",
args: args{
ctx: CTX,
req: &user.RegisterPasskeyRequest{
UserId: humanUserID,
},
},
want: &user.RegisterPasskeyResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
*/
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.RegisterPasskey(tt.args.ctx, tt.args.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, got)
integration.AssertDetails(t, tt.want, got)
if tt.want != nil {
assert.NotEmpty(t, got.GetPasskeyId())
assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions())
_, err := client.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions())
require.NoError(t, err)
}
})
}
}
func TestServer_VerifyPasskeyRegistration(t *testing.T) {
userID := createHumanUser(t).GetUserId()
reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{
UserId: userID,
Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
})
require.NoError(t, err)
pkr, err := Client.RegisterPasskey(CTX, &user.RegisterPasskeyRequest{
UserId: userID,
Code: reg.GetCode(),
})
require.NoError(t, err)
require.NotEmpty(t, pkr.GetPasskeyId())
require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions())
client := webauthn.NewClient(Tester.Config.WebAuthNName, Tester.Config.ExternalDomain, "https://"+Tester.Host())
attestationResponse, err := client.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
require.NoError(t, err)
type args struct {
ctx context.Context
req *user.VerifyPasskeyRegistrationRequest
}
tests := []struct {
name string
args args
want *user.VerifyPasskeyRegistrationResponse
wantErr bool
}{
{
name: "missing user id",
args: args{
ctx: CTX,
req: &user.VerifyPasskeyRegistrationRequest{
PasskeyId: pkr.GetPasskeyId(),
PublicKeyCredential: []byte(attestationResponse),
PasskeyName: "nice name",
},
},
wantErr: true,
},
{
name: "success",
args: args{
ctx: CTX,
req: &user.VerifyPasskeyRegistrationRequest{
UserId: userID,
PasskeyId: pkr.GetPasskeyId(),
PublicKeyCredential: attestationResponse,
PasskeyName: "nice name",
},
},
want: &user.VerifyPasskeyRegistrationResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "wrong credential",
args: args{
ctx: CTX,
req: &user.VerifyPasskeyRegistrationRequest{
UserId: userID,
PasskeyId: pkr.GetPasskeyId(),
PublicKeyCredential: []byte("attestationResponseattestationResponseattestationResponse"),
PasskeyName: "nice name",
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.VerifyPasskeyRegistration(tt.args.ctx, tt.args.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, got)
integration.AssertDetails(t, tt.want, got)
})
}
}
func TestServer_CreatePasskeyRegistrationLink(t *testing.T) {
userID := createHumanUser(t).GetUserId()
type args struct {
ctx context.Context
req *user.CreatePasskeyRegistrationLinkRequest
}
tests := []struct {
name string
args args
want *user.CreatePasskeyRegistrationLinkResponse
wantCode bool
wantErr bool
}{
{
name: "missing user id",
args: args{
ctx: CTX,
req: &user.CreatePasskeyRegistrationLinkRequest{},
},
wantErr: true,
},
{
name: "send default mail",
args: args{
ctx: CTX,
req: &user.CreatePasskeyRegistrationLinkRequest{
UserId: userID,
},
},
want: &user.CreatePasskeyRegistrationLinkResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "send custom url",
args: args{
ctx: CTX,
req: &user.CreatePasskeyRegistrationLinkRequest{
UserId: userID,
Medium: &user.CreatePasskeyRegistrationLinkRequest_SendLink{
SendLink: &user.SendPasskeyRegistrationLink{
UrlTemplate: gu.Ptr("https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}"),
},
},
},
},
want: &user.CreatePasskeyRegistrationLinkResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "return code",
args: args{
ctx: CTX,
req: &user.CreatePasskeyRegistrationLinkRequest{
UserId: userID,
Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
},
},
want: &user.CreatePasskeyRegistrationLinkResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
wantCode: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.CreatePasskeyRegistrationLink(tt.args.ctx, tt.args.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, got)
integration.AssertDetails(t, tt.want, got)
if tt.wantCode {
assert.NotEmpty(t, got.GetCode().GetId())
assert.NotEmpty(t, got.GetCode().GetId())
}
})
}
}

View File

@@ -0,0 +1,210 @@
package user
import (
"io"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/grpc"
"github.com/zitadel/zitadel/internal/domain"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func Test_passkeyAuthenticatorToDomain(t *testing.T) {
tests := []struct {
pa user.PasskeyAuthenticator
want domain.AuthenticatorAttachment
}{
{
pa: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_UNSPECIFIED,
want: domain.AuthenticatorAttachmentUnspecified,
},
{
pa: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM,
want: domain.AuthenticatorAttachmentPlattform,
},
{
pa: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_CROSS_PLATFORM,
want: domain.AuthenticatorAttachmentCrossPlattform,
},
{
pa: 999,
want: domain.AuthenticatorAttachmentUnspecified,
},
}
for _, tt := range tests {
t.Run(tt.pa.String(), func(t *testing.T) {
got := passkeyAuthenticatorToDomain(tt.pa)
assert.Equal(t, tt.want, got)
})
}
}
func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
type args struct {
details *domain.PasskeyRegistrationDetails
err error
}
tests := []struct {
name string
args args
want *user.RegisterPasskeyResponse
}{
{
name: "an error",
args: args{
details: nil,
err: io.ErrClosedPipe,
},
},
{
name: "ok",
args: args{
details: &domain.PasskeyRegistrationDetails{
ObjectDetails: &domain.ObjectDetails{
Sequence: 22,
EventDate: time.Unix(3000, 22),
ResourceOwner: "me",
},
PasskeyID: "123",
PublicKeyCredentialCreationOptions: []byte{1, 2, 3},
},
err: nil,
},
want: &user.RegisterPasskeyResponse{
Details: &object.Details{
Sequence: 22,
ChangeDate: &timestamppb.Timestamp{
Seconds: 3000,
Nanos: 22,
},
ResourceOwner: "me",
},
PasskeyId: "123",
PublicKeyCredentialCreationOptions: []byte{1, 2, 3},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := passkeyRegistrationDetailsToPb(tt.args.details, tt.args.err)
require.ErrorIs(t, err, tt.args.err)
assert.Equal(t, tt.want, got)
if tt.want != nil {
grpc.AllFieldsSet(t, got.ProtoReflect())
}
})
}
}
func Test_passkeyDetailsToPb(t *testing.T) {
type args struct {
details *domain.ObjectDetails
err error
}
tests := []struct {
name string
args args
want *user.CreatePasskeyRegistrationLinkResponse
}{
{
name: "an error",
args: args{
details: nil,
err: io.ErrClosedPipe,
},
},
{
name: "ok",
args: args{
details: &domain.ObjectDetails{
Sequence: 22,
EventDate: time.Unix(3000, 22),
ResourceOwner: "me",
},
err: nil,
},
want: &user.CreatePasskeyRegistrationLinkResponse{
Details: &object.Details{
Sequence: 22,
ChangeDate: &timestamppb.Timestamp{
Seconds: 3000,
Nanos: 22,
},
ResourceOwner: "me",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := passkeyDetailsToPb(tt.args.details, tt.args.err)
require.ErrorIs(t, err, tt.args.err)
assert.Equal(t, tt.want, got)
})
}
}
func Test_passkeyCodeDetailsToPb(t *testing.T) {
type args struct {
details *domain.PasskeyCodeDetails
err error
}
tests := []struct {
name string
args args
want *user.CreatePasskeyRegistrationLinkResponse
}{
{
name: "an error",
args: args{
details: nil,
err: io.ErrClosedPipe,
},
},
{
name: "ok",
args: args{
details: &domain.PasskeyCodeDetails{
ObjectDetails: &domain.ObjectDetails{
Sequence: 22,
EventDate: time.Unix(3000, 22),
ResourceOwner: "me",
},
CodeID: "123",
Code: "456",
},
err: nil,
},
want: &user.CreatePasskeyRegistrationLinkResponse{
Details: &object.Details{
Sequence: 22,
ChangeDate: &timestamppb.Timestamp{
Seconds: 3000,
Nanos: 22,
},
ResourceOwner: "me",
},
Code: &user.PasskeyRegistrationCode{
Id: "123",
Code: "456",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := passkeyCodeDetailsToPb(tt.args.details, tt.args.err)
require.ErrorIs(t, err, tt.args.err)
assert.Equal(t, tt.want, got)
if tt.want != nil {
grpc.AllFieldsSet(t, got.ProtoReflect())
}
})
}
}

View File

@@ -31,7 +31,7 @@ type Commands struct {
httpClient *http.Client
checkPermission domain.PermissionCheck
newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, codeAlg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error)
newCode cryptoCodeFunc
eventstore *eventstore.Eventstore
static static.Storage
@@ -108,7 +108,7 @@ func StartCommands(
webauthnConfig: webAuthN,
httpClient: httpClient,
checkPermission: permissionCheck,
newEmailCode: newEmailCode,
newCode: newCryptoCodeWithExpiry,
sessionTokenCreator: sessionTokenCreator(idGenerator, sessionAlg),
sessionTokenVerifier: sessionTokenVerifier,
}
@@ -139,11 +139,21 @@ func StartCommands(
return repo, nil
}
func AppendAndReduce(object interface {
type AppendReducer interface {
AppendEvents(...eventstore.Event)
// TODO: Why is it allowed to return an error here?
Reduce() error
}, events ...eventstore.Event) error {
}
func (c *Commands) pushAppendAndReduce(ctx context.Context, object AppendReducer, cmds ...eventstore.Command) error {
events, err := c.eventstore.Push(ctx, cmds...)
if err != nil {
return err
}
return AppendAndReduce(object, events...)
}
func AppendAndReduce(object AppendReducer, events ...eventstore.Event) error {
object.AppendEvents(events...)
return object.Reduce()
}

View File

@@ -10,6 +10,8 @@ import (
"github.com/zitadel/zitadel/internal/errors"
)
type cryptoCodeFunc func(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (*CryptoCodeWithExpiry, error)
type CryptoCodeWithExpiry struct {
Crypted *crypto.CryptoValue
Plain string
@@ -17,42 +19,50 @@ type CryptoCodeWithExpiry struct {
}
func newCryptoCodeWithExpiry(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (*CryptoCodeWithExpiry, error) {
config, err := secretGeneratorConfig(ctx, filter, typ)
gen, config, err := secretGenerator(ctx, filter, typ, alg)
if err != nil {
return nil, err
}
code := new(CryptoCodeWithExpiry)
switch a := alg.(type) {
case crypto.HashAlgorithm:
code.Crypted, code.Plain, err = crypto.NewCode(crypto.NewHashGenerator(*config, a))
case crypto.EncryptionAlgorithm:
code.Crypted, code.Plain, err = crypto.NewCode(crypto.NewEncryptionGenerator(*config, a))
default:
return nil, errors.ThrowInternal(nil, "COMMA-RreV6", "Errors.Internal")
}
crypted, plain, err := crypto.NewCode(gen)
if err != nil {
return nil, err
}
return &CryptoCodeWithExpiry{
Crypted: crypted,
Plain: plain,
Expiry: config.Expiry,
}, nil
}
code.Expiry = config.Expiry
return code, nil
func verifyCryptoCode(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto, creation time.Time, expiry time.Duration, crypted *crypto.CryptoValue, plain string) error {
gen, _, err := secretGenerator(ctx, filter, typ, alg)
if err != nil {
return err
}
return crypto.VerifyCode(creation, expiry, crypted, plain, gen)
}
func newCryptoCodeWithPlain(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (value *crypto.CryptoValue, plain string, err error) {
config, err := secretGeneratorConfig(ctx, filter, typ)
gen, _, err := secretGenerator(ctx, filter, typ, alg)
if err != nil {
return nil, "", err
}
return crypto.NewCode(gen)
}
func secretGenerator(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (crypto.Generator, *crypto.GeneratorConfig, error) {
config, err := secretGeneratorConfig(ctx, filter, typ)
if err != nil {
return nil, nil, err
}
switch a := alg.(type) {
case crypto.HashAlgorithm:
return crypto.NewCode(crypto.NewHashGenerator(*config, a))
return crypto.NewHashGenerator(*config, a), config, nil
case crypto.EncryptionAlgorithm:
return crypto.NewCode(crypto.NewEncryptionGenerator(*config, a))
return crypto.NewEncryptionGenerator(*config, a), config, nil
default:
return nil, nil, errors.ThrowInternalf(nil, "COMMA-RreV6", "Errors.Internal unsupported crypto algorithm type %T", a)
}
return nil, "", errors.ThrowInvalidArgument(nil, "V2-NGESt", "Errors.Internal")
}
func secretGeneratorConfig(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType) (*crypto.GeneratorConfig, error) {

View File

@@ -0,0 +1,242 @@
package command
import (
"context"
"io"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/command/preparation"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/instance"
)
func mockCode(code string, exp time.Duration) cryptoCodeFunc {
return func(ctx context.Context, filter preparation.FilterToQueryReducer, _ domain.SecretGeneratorType, alg crypto.Crypto) (*CryptoCodeWithExpiry, error) {
return &CryptoCodeWithExpiry{
Crypted: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte(code),
},
Plain: code,
Expiry: exp,
}, nil
}
}
var (
testGeneratorConfig = crypto.GeneratorConfig{
Length: 12,
Expiry: 60000000000,
IncludeLowerLetters: true,
IncludeUpperLetters: true,
IncludeDigits: true,
IncludeSymbols: true,
}
)
func testSecretGeneratorAddedEvent(typ domain.SecretGeneratorType) *instance.SecretGeneratorAddedEvent {
return instance.NewSecretGeneratorAddedEvent(context.Background(),
&instance.NewAggregate("inst1").Aggregate, typ,
testGeneratorConfig.Length,
testGeneratorConfig.Expiry,
testGeneratorConfig.IncludeLowerLetters,
testGeneratorConfig.IncludeUpperLetters,
testGeneratorConfig.IncludeDigits,
testGeneratorConfig.IncludeSymbols,
)
}
func Test_newCryptoCode(t *testing.T) {
type args struct {
typ domain.SecretGeneratorType
alg crypto.Crypto
}
tests := []struct {
name string
eventstore *eventstore.Eventstore
args args
wantErr error
}{
{
name: "filter config error",
eventstore: eventstoreExpect(t, expectFilterError(io.ErrClosedPipe)),
args: args{
typ: domain.SecretGeneratorTypeVerifyEmailCode,
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
eventstore: eventstoreExpect(t, expectFilter(
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
)),
args: args{
typ: domain.SecretGeneratorTypeVerifyEmailCode,
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := newCryptoCodeWithExpiry(context.Background(), tt.eventstore.Filter, tt.args.typ, tt.args.alg)
require.ErrorIs(t, err, tt.wantErr)
if tt.wantErr == nil {
require.NotNil(t, got)
assert.NotNil(t, got.Crypted)
assert.NotEmpty(t, got)
assert.Equal(t, testGeneratorConfig.Expiry, got.Expiry)
}
})
}
}
func Test_verifyCryptoCode(t *testing.T) {
es := eventstoreExpect(t, expectFilter(
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
))
code, err := newCryptoCodeWithExpiry(context.Background(), es.Filter, domain.SecretGeneratorTypeVerifyEmailCode, crypto.CreateMockHashAlg(gomock.NewController(t)))
require.NoError(t, err)
type args struct {
typ domain.SecretGeneratorType
alg crypto.Crypto
expiry time.Duration
crypted *crypto.CryptoValue
plain string
}
tests := []struct {
name string
eventsore *eventstore.Eventstore
args args
wantErr bool
}{
{
name: "filter config error",
eventsore: eventstoreExpect(t, expectFilterError(io.ErrClosedPipe)),
args: args{
typ: domain.SecretGeneratorTypeVerifyEmailCode,
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
expiry: code.Expiry,
crypted: code.Crypted,
plain: code.Plain,
},
wantErr: true,
},
{
name: "success",
eventsore: eventstoreExpect(t, expectFilter(
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
)),
args: args{
typ: domain.SecretGeneratorTypeVerifyEmailCode,
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
expiry: code.Expiry,
crypted: code.Crypted,
plain: code.Plain,
},
},
{
name: "wrong plain",
eventsore: eventstoreExpect(t, expectFilter(
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
)),
args: args{
typ: domain.SecretGeneratorTypeVerifyEmailCode,
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
expiry: code.Expiry,
crypted: code.Crypted,
plain: "wrong",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := verifyCryptoCode(context.Background(), tt.eventsore.Filter, tt.args.typ, tt.args.alg, time.Now(), tt.args.expiry, tt.args.crypted, tt.args.plain)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func Test_secretGenerator(t *testing.T) {
type args struct {
typ domain.SecretGeneratorType
alg crypto.Crypto
}
tests := []struct {
name string
eventsore *eventstore.Eventstore
args args
want crypto.Generator
wantConf *crypto.GeneratorConfig
wantErr error
}{
{
name: "filter config error",
eventsore: eventstoreExpect(t, expectFilterError(io.ErrClosedPipe)),
args: args{
typ: domain.SecretGeneratorTypeVerifyEmailCode,
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
},
wantErr: io.ErrClosedPipe,
},
{
name: "hash generator",
eventsore: eventstoreExpect(t, expectFilter(
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
)),
args: args{
typ: domain.SecretGeneratorTypeVerifyEmailCode,
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
},
want: crypto.NewHashGenerator(testGeneratorConfig, crypto.CreateMockHashAlg(gomock.NewController(t))),
wantConf: &testGeneratorConfig,
},
{
name: "encryption generator",
eventsore: eventstoreExpect(t, expectFilter(
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
)),
args: args{
typ: domain.SecretGeneratorTypeVerifyEmailCode,
alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
want: crypto.NewEncryptionGenerator(testGeneratorConfig, crypto.CreateMockEncryptionAlg(gomock.NewController(t))),
wantConf: &testGeneratorConfig,
},
{
name: "unsupported type",
eventsore: eventstoreExpect(t, expectFilter(
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
)),
args: args{
typ: domain.SecretGeneratorTypeVerifyEmailCode,
alg: nil,
},
wantErr: errors.ThrowInternalf(nil, "COMMA-RreV6", "Errors.Internal unsupported crypto algorithm type %T", nil),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, gotConf, err := secretGenerator(context.Background(), tt.eventsore.Filter, tt.args.typ, tt.args.alg)
require.ErrorIs(t, err, tt.wantErr)
assert.IsType(t, tt.want, got)
assert.Equal(t, tt.wantConf, gotConf)
})
}
}

View File

@@ -23,6 +23,6 @@ func (e *Email) Validate() error {
return e.Address.Validate()
}
func newEmailCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
return newCryptoCodeWithExpiry(ctx, filter, domain.SecretGeneratorTypeVerifyEmailCode, alg)
func (c *Commands) newEmailCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
return c.newCode(ctx, filter, domain.SecretGeneratorTypeVerifyEmailCode, alg)
}

View File

@@ -31,7 +31,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
idGenerator id.Generator
userPasswordAlg crypto.HashAlgorithm
codeAlg crypto.EncryptionAlgorithm
newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error)
newCode cryptoCodeFunc
}
type args struct {
ctx context.Context
@@ -446,7 +446,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
newEmailCode: mockEmailCode("emailCode", time.Hour),
newCode: mockCode("emailCode", time.Hour),
},
args: args{
ctx: context.Background(),
@@ -526,7 +526,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
newEmailCode: mockEmailCode("emailCode", time.Hour),
newCode: mockCode("emailCode", time.Hour),
},
args: args{
ctx: context.Background(),
@@ -1202,7 +1202,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
userPasswordAlg: tt.fields.userPasswordAlg,
userEncryption: tt.fields.codeAlg,
idGenerator: tt.fields.idGenerator,
newEmailCode: tt.fields.newEmailCode,
newCode: tt.fields.newCode,
}
err := r.AddHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.allowInitMail)
if tt.res.err == nil {
@@ -3992,18 +3992,3 @@ func TestAddHumanCommand(t *testing.T) {
})
}
}
func mockEmailCode(code string, exp time.Duration) func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
return func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
return &CryptoCodeWithExpiry{
Crypted: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte(code),
},
Plain: code,
Expiry: exp,
}, nil
}
}

View File

@@ -539,7 +539,7 @@ func (c *Commands) humanAddPasswordlessInitCode(ctx context.Context, userID, res
}
if !direct {
codeEventCreator = func(ctx context.Context, agg *eventstore.Aggregate, id string, cryptoCode *crypto.CryptoValue, exp time.Duration) eventstore.Command {
return usr_repo.NewHumanPasswordlessInitCodeRequestedEvent(ctx, agg, id, cryptoCode, exp)
return usr_repo.NewHumanPasswordlessInitCodeRequestedEvent(ctx, agg, id, cryptoCode, exp, "", false)
}
}
codeEvent := codeEventCreator(ctx, UserAggregateFromWriteModel(&initCode.WriteModel), codeID, cryptoCode, passwordlessCodeGenerator.Expiry())

View File

@@ -512,6 +512,9 @@ func (wm *HumanPasswordlessInitCodeWriteModel) appendRequestedEvent(e *user.Huma
wm.CryptoCode = e.Code
wm.Expiration = e.Expiry
wm.State = domain.PasswordlessInitCodeStateRequested
if e.CodeReturned {
wm.State = domain.PasswordlessInitCodeStateActive
}
}
func (wm *HumanPasswordlessInitCodeWriteModel) appendCheckFailedEvent(e *user.HumanPasswordlessInitCodeCheckFailedEvent) {

View File

@@ -199,7 +199,7 @@ func TestCommands_ChangeUserEmailURLTemplate(t *testing.T) {
email: "email-changed@test.ch",
urlTmpl: "{{",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate"),
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "permission missing",

View File

@@ -0,0 +1,156 @@
package command
import (
"context"
"io"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
)
// RegisterUserPasskey creates a passkey registration for the current authenticated user.
// UserID, ussualy taken from the request is compaired against the user ID in the context.
func (c *Commands) RegisterUserPasskey(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment) (*domain.PasskeyRegistrationDetails, error) {
if err := authz.UserIDInCTX(ctx, userID); err != nil {
return nil, err
}
return c.registerUserPasskey(ctx, userID, resourceOwner, authenticator)
}
// RegisterUserPasskeyWithCode registers a new passkey for a unauthenticated user id.
// The resource is protected by the code, identified by the codeID.
func (c *Commands) RegisterUserPasskeyWithCode(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment, codeID, code string, alg crypto.EncryptionAlgorithm) (*domain.PasskeyRegistrationDetails, error) {
event, err := c.verifyUserPasskeyCode(ctx, userID, resourceOwner, codeID, code, alg)
if err != nil {
return nil, err
}
return c.registerUserPasskey(ctx, userID, resourceOwner, authenticator, event)
}
type eventCallback func(context.Context, *eventstore.Aggregate) eventstore.Command
// verifyUserPasskeyCode verifies a passkey code, identified by codeID and userID.
// A code can only be used once.
// Upon success an event callback is returned, which must be called after
// all other events for the current request are created.
// This prevent consuming a code when another error occurred after verification.
func (c *Commands) verifyUserPasskeyCode(ctx context.Context, userID, resourceOwner, codeID, code string, alg crypto.EncryptionAlgorithm) (eventCallback, error) {
wm := NewHumanPasswordlessInitCodeWriteModel(userID, codeID, resourceOwner)
err := c.eventstore.FilterToQueryReducer(ctx, wm)
if err != nil {
return nil, err
}
err = verifyCryptoCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordlessInitCode, alg, wm.ChangeDate, wm.Expiration, wm.CryptoCode, code)
if err != nil || wm.State != domain.PasswordlessInitCodeStateActive {
c.verifyUserPasskeyCodeFailed(ctx, wm)
return nil, caos_errs.ThrowInvalidArgument(err, "COMMAND-Eeb2a", "Errors.User.Code.Invalid")
}
return func(ctx context.Context, userAgg *eventstore.Aggregate) eventstore.Command {
return user.NewHumanPasswordlessInitCodeCheckSucceededEvent(ctx, userAgg, codeID)
}, nil
}
func (c *Commands) verifyUserPasskeyCodeFailed(ctx context.Context, wm *HumanPasswordlessInitCodeWriteModel) {
userAgg := UserAggregateFromWriteModel(&wm.WriteModel)
_, err := c.eventstore.Push(ctx, user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, wm.CodeID))
logging.WithFields("userID", userAgg.ID).OnError(err).Error("RegisterUserPasskeyWithCode push failed")
}
func (c *Commands) registerUserPasskey(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment, events ...eventCallback) (*domain.PasskeyRegistrationDetails, error) {
wm, userAgg, webAuthN, err := c.createUserPasskey(ctx, userID, resourceOwner, authenticator)
if err != nil {
return nil, err
}
return c.pushUserPasskey(ctx, wm, userAgg, webAuthN, events...)
}
func (c *Commands) createUserPasskey(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment) (*HumanWebAuthNWriteModel, *eventstore.Aggregate, *domain.WebAuthNToken, error) {
passwordlessTokens, err := c.getHumanPasswordlessTokens(ctx, userID, resourceOwner)
if err != nil {
return nil, nil, nil, err
}
return c.addHumanWebAuthN(ctx, userID, resourceOwner, false, passwordlessTokens, authenticator, domain.UserVerificationRequirementRequired)
}
func (c *Commands) pushUserPasskey(ctx context.Context, wm *HumanWebAuthNWriteModel, userAgg *eventstore.Aggregate, webAuthN *domain.WebAuthNToken, events ...eventCallback) (*domain.PasskeyRegistrationDetails, error) {
cmds := make([]eventstore.Command, len(events)+1)
cmds[0] = user.NewHumanPasswordlessAddedEvent(ctx, userAgg, wm.WebauthNTokenID, webAuthN.Challenge)
for i, event := range events {
cmds[i+1] = event(ctx, userAgg)
}
err := c.pushAppendAndReduce(ctx, wm, cmds...)
if err != nil {
return nil, err
}
return &domain.PasskeyRegistrationDetails{
ObjectDetails: writeModelToObjectDetails(&wm.WriteModel),
PasskeyID: wm.WebauthNTokenID,
PublicKeyCredentialCreationOptions: webAuthN.CredentialCreationData,
}, nil
}
// AddUserPasskeyCode generates a Passkey code and sends an email
// with the default generated URL (pointing to zitadel).
func (c *Commands) AddUserPasskeyCode(ctx context.Context, userID, resourceOwner string, alg crypto.EncryptionAlgorithm) (*domain.ObjectDetails, error) {
details, err := c.addUserPasskeyCode(ctx, userID, resourceOwner, alg, "", false)
if err != nil {
return nil, err
}
return details.ObjectDetails, err
}
// AddUserPasskeyCodeURLTemplate generates a Passkey code and sends an email
// with the URL created from passed template string.
// The template is executed as a test, before pushing to the eventstore.
func (c *Commands) AddUserPasskeyCodeURLTemplate(ctx context.Context, userID, resourceOwner string, alg crypto.EncryptionAlgorithm, urlTmpl string) (*domain.ObjectDetails, error) {
if err := domain.RenderPasskeyURLTemplate(io.Discard, urlTmpl, userID, resourceOwner, "codeID", "code"); err != nil {
return nil, err
}
details, err := c.addUserPasskeyCode(ctx, userID, resourceOwner, alg, urlTmpl, false)
if err != nil {
return nil, err
}
return details.ObjectDetails, err
}
// AddUserPasskeyCodeReturn generates and returns a Passkey code.
// No email will be send to the user.
func (c *Commands) AddUserPasskeyCodeReturn(ctx context.Context, userID, resourceOwner string, alg crypto.EncryptionAlgorithm) (*domain.PasskeyCodeDetails, error) {
return c.addUserPasskeyCode(ctx, userID, resourceOwner, alg, "", true)
}
func (c *Commands) addUserPasskeyCode(ctx context.Context, userID, resourceOwner string, alg crypto.EncryptionAlgorithm, urlTmpl string, returnCode bool) (*domain.PasskeyCodeDetails, error) {
codeID, err := c.idGenerator.Next()
if err != nil {
return nil, err
}
code, err := c.newCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordlessInitCode, alg)
if err != nil {
return nil, err
}
wm := NewHumanPasswordlessInitCodeWriteModel(userID, codeID, resourceOwner)
err = c.eventstore.FilterToQueryReducer(ctx, wm)
if err != nil {
return nil, err
}
agg := UserAggregateFromWriteModel(&wm.WriteModel)
cmd := user.NewHumanPasswordlessInitCodeRequestedEvent(ctx, agg, codeID, code.Crypted, code.Expiry, urlTmpl, returnCode)
err = c.pushAppendAndReduce(ctx, wm, cmd)
if err != nil {
return nil, err
}
return &domain.PasskeyCodeDetails{
ObjectDetails: writeModelToObjectDetails(&wm.WriteModel),
CodeID: codeID,
Code: code.Plain,
}, nil
}

View File

@@ -0,0 +1,915 @@
package command
import (
"context"
"io"
"testing"
"time"
"github.com/golang/mock/gomock"
"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"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository"
"github.com/zitadel/zitadel/internal/id"
id_mock "github.com/zitadel/zitadel/internal/id/mock"
"github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/repository/user"
webauthn_helper "github.com/zitadel/zitadel/internal/webauthn"
)
func TestCommands_RegisterUserPasskey(t *testing.T) {
ctx := authz.NewMockContextWithPermissions("instance1", "org1", "user1", nil)
ctx = authz.WithRequestedDomain(ctx, "example.com")
webauthnConfig := &webauthn_helper.Config{
DisplayName: "test",
ExternalSecure: true,
}
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
eventstore *eventstore.Eventstore
idGenerator id.Generator
}
type args struct {
userID string
resourceOwner string
authenticator domain.AuthenticatorAttachment
}
tests := []struct {
name string
fields fields
args args
want *domain.PasskeyRegistrationDetails
wantErr error
}{
{
name: "wrong user",
args: args{
userID: "foo",
resourceOwner: "org1",
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
},
wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"),
},
{
name: "get human passwordless error",
fields: fields{
eventstore: eventstoreExpect(t,
expectFilterError(io.ErrClosedPipe),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
},
wantErr: io.ErrClosedPipe,
},
{
name: "id generator error",
fields: fields{
eventstore: eventstoreExpect(t,
expectFilter(), // getHumanPasswordlessTokens
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(ctx,
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
)),
expectFilter(eventFromEventPusher(
org.NewOrgAddedEvent(ctx,
&org.NewAggregate("org1").Aggregate,
"org1",
),
)),
expectFilter(eventFromEventPusher(
org.NewDomainPolicyAddedEvent(ctx,
&org.NewAggregate("org1").Aggregate,
false, false, false,
),
)),
),
idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe),
},
args: args{
userID: "user1",
resourceOwner: "org1",
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
},
wantErr: io.ErrClosedPipe,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
webauthnConfig: webauthnConfig,
}
_, err := c.RegisterUserPasskey(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authenticator)
require.ErrorIs(t, err, tt.wantErr)
// successful case can't be tested due to random challenge.
})
}
}
func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) {
ctx := authz.WithRequestedDomain(context.Background(), "example.com")
webauthnConfig := &webauthn_helper.Config{
DisplayName: "test",
ExternalSecure: true,
}
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
es := eventstoreExpect(t,
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
)
code, err := newCryptoCodeWithExpiry(ctx, es.Filter, domain.SecretGeneratorTypePasswordlessInitCode, alg)
require.NoError(t, err)
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
eventstore *eventstore.Eventstore
idGenerator id.Generator
}
type args struct {
userID string
resourceOwner string
authenticator domain.AuthenticatorAttachment
codeID string
code string
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "code verification error",
fields: fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg, "123", code.Crypted, time.Minute, "", false,
),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
),
),
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
expectPush([]*repository.Event{eventFromEventPusher(
user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, "123"),
)}),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
codeID: "123",
code: "wrong",
},
wantErr: caos_errs.ThrowInvalidArgument(err, "COMMAND-Eeb2a", "Errors.User.Code.Invalid"),
},
{
name: "code verification ok, get human passwordless error",
fields: fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg, "123", code.Crypted, time.Minute, "", false,
),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
),
),
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
expectFilterError(io.ErrClosedPipe),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
codeID: "123",
code: code.Plain,
},
wantErr: io.ErrClosedPipe,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
webauthnConfig: webauthnConfig,
}
_, err := c.RegisterUserPasskeyWithCode(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authenticator, tt.args.codeID, tt.args.code, alg)
require.ErrorIs(t, err, tt.wantErr)
// successful case can't be tested due to random challenge.
})
}
}
func TestCommands_verifyUserPasskeyCode(t *testing.T) {
ctx := authz.WithRequestedDomain(context.Background(), "example.com")
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
es := eventstoreExpect(t,
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
)
code, err := newCryptoCodeWithExpiry(ctx, es.Filter, domain.SecretGeneratorTypePasswordlessInitCode, alg)
require.NoError(t, err)
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
eventstore *eventstore.Eventstore
}
type args struct {
userID string
resourceOwner string
codeID string
code string
}
tests := []struct {
name string
fields fields
args args
want *user.HumanPasswordlessInitCodeCheckSucceededEvent
wantErr error
}{
{
name: "filter error",
fields: fields{
eventstore: eventstoreExpect(t,
expectFilterError(io.ErrClosedPipe),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
codeID: "123",
},
wantErr: io.ErrClosedPipe,
},
{
name: "code verification error",
fields: fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg, "123", code.Crypted, time.Minute, "", false,
),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
),
),
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
expectPush([]*repository.Event{eventFromEventPusher(
user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, "123"),
)}),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
codeID: "123",
code: "wrong",
},
wantErr: caos_errs.ThrowInvalidArgument(err, "COMMAND-Eeb2a", "Errors.User.Code.Invalid"),
},
{
name: "success",
fields: fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg, "123", code.Crypted, time.Minute, "", false,
),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
),
),
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
codeID: "123",
code: code.Plain,
},
want: user.NewHumanPasswordlessInitCodeCheckSucceededEvent(ctx, userAgg, "123"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore,
}
got, err := c.verifyUserPasskeyCode(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.codeID, tt.args.code, alg)
require.ErrorIs(t, err, tt.wantErr)
if tt.wantErr == nil {
assert.Equal(t, tt.want, got(ctx, userAgg))
}
})
}
}
func TestCommands_pushUserPasskey(t *testing.T) {
ctx := authz.WithRequestedDomain(context.Background(), "example.com")
webauthnConfig := &webauthn_helper.Config{
DisplayName: "test",
ExternalSecure: true,
}
userAgg := &user.NewAggregate("user1", "org1").Aggregate
prep := []expect{
expectFilter(), // getHumanPasswordlessTokens
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(ctx,
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
)),
expectFilter(eventFromEventPusher(
org.NewOrgAddedEvent(ctx,
&org.NewAggregate("org1").Aggregate,
"org1",
),
)),
expectFilter(eventFromEventPusher(
org.NewDomainPolicyAddedEvent(ctx,
&org.NewAggregate("org1").Aggregate,
false, false, false,
),
)),
expectFilter(eventFromEventPusher(
user.NewHumanWebAuthNAddedEvent(eventstore.NewBaseEventForPush(
ctx, &org.NewAggregate("org1").Aggregate, user.HumanPasswordlessTokenAddedType,
), "111", "challenge"),
)),
}
type args struct {
events []eventCallback
}
tests := []struct {
name string
expectPush func(challenge string) expect
args args
wantErr error
}{
{
name: "push error",
expectPush: func(challenge string) expect {
return expectPushFailed(io.ErrClosedPipe, []*repository.Event{eventFromEventPusher(
user.NewHumanPasswordlessAddedEvent(ctx,
userAgg, "123", challenge,
),
)})
},
args: args{},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
expectPush: func(challenge string) expect {
return expectPush([]*repository.Event{eventFromEventPusher(
user.NewHumanPasswordlessAddedEvent(ctx,
userAgg, "123", challenge,
),
)})
},
args: args{},
},
{
name: "initcode succeeded event",
expectPush: func(challenge string) expect {
return expectPush([]*repository.Event{
eventFromEventPusher(
user.NewHumanPasswordlessAddedEvent(ctx,
userAgg, "123", challenge,
),
),
eventFromEventPusher(
user.NewHumanPasswordlessInitCodeCheckSucceededEvent(ctx, userAgg, "123"),
),
})
},
args: args{
events: []eventCallback{func(ctx context.Context, userAgg *eventstore.Aggregate) eventstore.Command {
return user.NewHumanPasswordlessInitCodeCheckSucceededEvent(ctx, userAgg, "123")
}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: eventstoreExpect(t, prep...),
webauthnConfig: webauthnConfig,
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
}
wm, userAgg, webAuthN, err := c.createUserPasskey(ctx, "user1", "org1", domain.AuthenticatorAttachmentCrossPlattform)
require.NoError(t, err)
c.eventstore = eventstoreExpect(t, tt.expectPush(webAuthN.Challenge))
got, err := c.pushUserPasskey(ctx, wm, userAgg, webAuthN, tt.args.events...)
require.ErrorIs(t, err, tt.wantErr)
if tt.wantErr == nil {
assert.NotEmpty(t, got.PublicKeyCredentialCreationOptions)
assert.Equal(t, "123", got.PasskeyID)
assert.Equal(t, "org1", got.ObjectDetails.ResourceOwner)
}
})
}
}
func TestCommands_AddUserPasskeyCode(t *testing.T) {
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
newCode cryptoCodeFunc
eventstore *eventstore.Eventstore
idGenerator id.Generator
}
type args struct {
userID string
resourceOwner string
}
tests := []struct {
name string
fields fields
args args
want *domain.ObjectDetails
wantErr error
}{
{
name: "id generator error",
fields: fields{
newCode: newCryptoCodeWithExpiry,
eventstore: eventstoreExpect(t),
idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
fields: fields{
newCode: mockCode("passkey1", time.Minute),
eventstore: eventstoreExpect(t,
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
)),
expectPush([]*repository.Event{
eventFromEventPusher(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg,
"123", &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("passkey1"),
}, time.Minute, "", false,
),
),
}),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
newCode: tt.fields.newCode,
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
}
got, err := c.AddUserPasskeyCode(context.Background(), tt.args.userID, tt.args.resourceOwner, alg)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}
func TestCommands_AddUserPasskeyCodeURLTemplate(t *testing.T) {
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
newCode cryptoCodeFunc
eventstore *eventstore.Eventstore
idGenerator id.Generator
}
type args struct {
userID string
resourceOwner string
urlTmpl string
}
tests := []struct {
name string
fields fields
args args
want *domain.ObjectDetails
wantErr error
}{
{
name: "template error",
fields: fields{
newCode: newCryptoCodeWithExpiry,
eventstore: eventstoreExpect(t),
},
args: args{
userID: "user1",
resourceOwner: "org1",
urlTmpl: "{{",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "id generator error",
fields: fields{
newCode: newCryptoCodeWithExpiry,
eventstore: eventstoreExpect(t),
idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe),
},
args: args{
userID: "user1",
resourceOwner: "org1",
urlTmpl: "https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}",
},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
fields: fields{
newCode: mockCode("passkey1", time.Minute),
eventstore: eventstoreExpect(t,
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
)),
expectPush([]*repository.Event{
eventFromEventPusher(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg,
"123", &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("passkey1"),
},
time.Minute,
"https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}",
false,
),
),
}),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
},
args: args{
userID: "user1",
resourceOwner: "org1",
urlTmpl: "https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
newCode: tt.fields.newCode,
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
}
got, err := c.AddUserPasskeyCodeURLTemplate(context.Background(), tt.args.userID, tt.args.resourceOwner, alg, tt.args.urlTmpl)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}
func TestCommands_AddUserPasskeyCodeReturn(t *testing.T) {
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
newCode cryptoCodeFunc
eventstore *eventstore.Eventstore
idGenerator id.Generator
}
type args struct {
userID string
resourceOwner string
}
tests := []struct {
name string
fields fields
args args
want *domain.PasskeyCodeDetails
wantErr error
}{
{
name: "id generator error",
fields: fields{
newCode: newCryptoCodeWithExpiry,
eventstore: eventstoreExpect(t),
idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
fields: fields{
newCode: mockCode("passkey1", time.Minute),
eventstore: eventstoreExpect(t,
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
)),
expectPush([]*repository.Event{
eventFromEventPusher(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg,
"123", &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("passkey1"),
}, time.Minute, "", true,
),
),
}),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
want: &domain.PasskeyCodeDetails{
ObjectDetails: &domain.ObjectDetails{
ResourceOwner: "org1",
},
CodeID: "123",
Code: "passkey1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
newCode: tt.fields.newCode,
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
}
got, err := c.AddUserPasskeyCodeReturn(context.Background(), tt.args.userID, tt.args.resourceOwner, alg)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}
func TestCommands_addUserPasskeyCode(t *testing.T) {
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
newCode cryptoCodeFunc
eventstore *eventstore.Eventstore
idGenerator id.Generator
}
type args struct {
userID string
resourceOwner string
}
tests := []struct {
name string
fields fields
args args
want *domain.PasskeyCodeDetails
wantErr error
}{
{
name: "id generator error",
fields: fields{
newCode: newCryptoCodeWithExpiry,
eventstore: eventstoreExpect(t),
idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
wantErr: io.ErrClosedPipe,
},
{
name: "crypto error",
fields: fields{
newCode: newCryptoCodeWithExpiry,
eventstore: eventstoreExpect(t, expectFilterError(io.ErrClosedPipe)),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
wantErr: io.ErrClosedPipe,
},
{
name: "filter query error",
fields: fields{
newCode: newCryptoCodeWithExpiry,
eventstore: eventstoreExpect(t,
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
expectFilterError(io.ErrClosedPipe),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
wantErr: io.ErrClosedPipe,
},
{
name: "push error",
fields: fields{
newCode: mockCode("passkey1", time.Minute),
eventstore: eventstoreExpect(t,
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
)),
expectPushFailed(io.ErrClosedPipe, []*repository.Event{
eventFromEventPusher(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"123", &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("passkey1"),
}, time.Minute, "", false,
),
),
}),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
fields: fields{
newCode: mockCode("passkey1", time.Minute),
eventstore: eventstoreExpect(t,
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
)),
expectPush([]*repository.Event{
eventFromEventPusher(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg,
"123", &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("passkey1"),
}, time.Minute, "", false,
),
),
}),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
want: &domain.PasskeyCodeDetails{
ObjectDetails: &domain.ObjectDetails{
ResourceOwner: "org1",
},
CodeID: "123",
Code: "passkey1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
newCode: tt.fields.newCode,
eventstore: tt.fields.eventstore,
idGenerator: tt.fields.idGenerator,
}
got, err := c.addUserPasskeyCode(context.Background(), tt.args.userID, tt.args.resourceOwner, alg, "", false)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}

View File

@@ -4,12 +4,10 @@ import (
"io"
"regexp"
"strings"
"text/template"
"time"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/errors"
caos_errs "github.com/zitadel/zitadel/internal/errors"
es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models"
)
@@ -73,19 +71,8 @@ type ConfirmURLData struct {
OrgID string
}
// RenderConfirmURLTemplate parses and renders tmplStr.
// RenderConfirmURLTemplate parses and renders tmpl.
// userID, code and orgID are passed into the [ConfirmURLData].
// "%s%s?userID=%s&code=%s&orgID=%s"
func RenderConfirmURLTemplate(w io.Writer, tmplStr, userID, code, orgID string) error {
tmpl, err := template.New("").Parse(tmplStr)
if err != nil {
return caos_errs.ThrowInvalidArgument(err, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate")
}
data := &ConfirmURLData{userID, code, orgID}
if err = tmpl.Execute(w, data); err != nil {
return caos_errs.ThrowInvalidArgument(err, "USERv2-ohSi5", "Errors.User.Email.InvalidURLTemplate")
}
return nil
func RenderConfirmURLTemplate(w io.Writer, tmpl, userID, code, orgID string) error {
return renderURLTemplate(w, tmpl, &ConfirmURLData{userID, code, orgID})
}

View File

@@ -81,10 +81,10 @@ func TestEmailValid(t *testing.T) {
func TestRenderConfirmURLTemplate(t *testing.T) {
type args struct {
tmplStr string
userID string
code string
orgID string
tmpl string
userID string
code string
orgID string
}
tests := []struct {
name string
@@ -95,30 +95,30 @@ func TestRenderConfirmURLTemplate(t *testing.T) {
{
name: "invalid template",
args: args{
tmplStr: "{{",
userID: "user1",
code: "123",
orgID: "org1",
tmpl: "{{",
userID: "user1",
code: "123",
orgID: "org1",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate"),
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "execution error",
args: args{
tmplStr: "{{.Foo}}",
userID: "user1",
code: "123",
orgID: "org1",
tmpl: "{{.Foo}}",
userID: "user1",
code: "123",
orgID: "org1",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ohSi5", "Errors.User.Email.InvalidURLTemplate"),
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-ieYa7", "Errors.User.InvalidURLTemplate"),
},
{
name: "success",
args: args{
tmplStr: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
userID: "user1",
code: "123",
orgID: "org1",
tmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
userID: "user1",
code: "123",
orgID: "org1",
},
want: "https://example.com/email/verify?userID=user1&code=123&orgID=org1",
},
@@ -126,7 +126,7 @@ func TestRenderConfirmURLTemplate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var w strings.Builder
err := RenderConfirmURLTemplate(&w, tt.args.tmplStr, tt.args.userID, tt.args.code, tt.args.orgID)
err := RenderConfirmURLTemplate(&w, tt.args.tmpl, tt.args.userID, tt.args.code, tt.args.orgID)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, w.String())
})

View File

@@ -0,0 +1,19 @@
package domain
import (
"io"
"text/template"
caos_errs "github.com/zitadel/zitadel/internal/errors"
)
func renderURLTemplate(w io.Writer, tmpl string, data any) error {
parsed, err := template.New("").Parse(tmpl)
if err != nil {
return caos_errs.ThrowInvalidArgument(err, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate")
}
if err = parsed.Execute(w, data); err != nil {
return caos_errs.ThrowInvalidArgument(err, "DOMAIN-ieYa7", "Errors.User.InvalidURLTemplate")
}
return nil
}

View File

@@ -0,0 +1,56 @@
package domain
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
caos_errs "github.com/zitadel/zitadel/internal/errors"
)
func Test_renderURLTemplate(t *testing.T) {
type args struct {
tmpl string
data any
}
tests := []struct {
name string
args args
wantW string
wantErr error
}{
{
name: "parse error",
args: args{
tmpl: "{{",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "execution error",
args: args{
tmpl: "{{.Some}}",
data: struct{ Foo int }{Foo: 1},
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-ieYa7", "Errors.User.InvalidURLTemplate"),
},
{
name: "success",
args: args{
tmpl: "{{.Foo}}",
data: struct{ Foo int }{Foo: 1},
},
wantW: "1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := &bytes.Buffer{}
err := renderURLTemplate(w, tt.args.tmpl, tt.args.data)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.wantW, w.String())
})
}
}

View File

@@ -0,0 +1,29 @@
package domain
import "io"
type PasskeyURLData struct {
UserID string
OrgID string
CodeID string
Code string
}
// RenderPasskeyURLTemplate parses and renders tmpl.
// userID, orgID, codeID and code are passed into the [PasskeyURLData].
func RenderPasskeyURLTemplate(w io.Writer, tmpl, userID, orgID, codeID, code string) error {
return renderURLTemplate(w, tmpl, &PasskeyURLData{userID, orgID, codeID, code})
}
type PasskeyCodeDetails struct {
*ObjectDetails
CodeID string
Code string
}
type PasskeyRegistrationDetails struct {
*ObjectDetails
PasskeyID string
PublicKeyCredentialCreationOptions []byte
}

View File

@@ -0,0 +1,54 @@
package domain
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
caos_errs "github.com/zitadel/zitadel/internal/errors"
)
func TestRenderPasskeyURLTemplate(t *testing.T) {
type args struct {
tmpl string
userID string
orgID string
codeID string
code string
}
tests := []struct {
name string
args args
wantW string
wantErr error
}{
{
name: "parse error",
args: args{
tmpl: "{{",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "success",
args: args{
tmpl: "https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}",
userID: "user1",
orgID: "org1",
codeID: "99",
code: "123",
},
wantW: "https://example.com/passkey/register?userID=user1&orgID=org1&codeID=99&code=123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := &bytes.Buffer{}
err := RenderPasskeyURLTemplate(w, tt.args.tmpl, tt.args.userID, tt.args.orgID, tt.args.codeID, tt.args.code)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.wantW, w.String())
})
}
}

View File

@@ -394,6 +394,9 @@ func (u *userNotifier) reducePasswordlessCodeRequested(event eventstore.Event) (
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-EDtjd", "reduce.wrong.event.type %s", user.HumanPasswordlessInitCodeAddedType)
}
if e.CodeReturned {
return crdb.NewNoOpStatement(e), nil
}
ctx := HandlerContext(event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, map[string]interface{}{"id": e.ID}, user.HumanPasswordlessInitCodeSentType)
if err != nil {
@@ -442,7 +445,7 @@ func (u *userNotifier) reducePasswordlessCodeRequested(event eventstore.Event) (
e,
u.metricSuccessfulDeliveriesEmail,
u.metricFailedDeliveriesEmail,
).SendPasswordlessRegistrationLink(notifyUser, origin, code, e.ID)
).SendPasswordlessRegistrationLink(notifyUser, origin, code, e.ID, e.URLTemplate)
if err != nil {
return nil, err
}

View File

@@ -12,27 +12,6 @@ import (
)
func TestNotify_SendEmailVerificationCode(t *testing.T) {
type res struct {
url string
args map[string]interface{}
messageType string
allowUnverifiedNotificationChannel bool
}
notify := func(dst *res) Notify {
return func(
url string,
args map[string]interface{},
messageType string,
allowUnverifiedNotificationChannel bool,
) error {
dst.url = url
dst.args = args
dst.messageType = messageType
dst.allowUnverifiedNotificationChannel = allowUnverifiedNotificationChannel
return nil
}
}
type args struct {
user *query.NotifyUser
origin string
@@ -42,7 +21,7 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
tests := []struct {
name string
args args
want *res
want *notifyResult
wantErr error
}{
{
@@ -56,7 +35,7 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
code: "123",
urlTmpl: "",
},
want: &res{
want: &notifyResult{
url: "https://example.com/ui/login/mail/verification?userID=user1&code=123&orgID=org1",
args: map[string]interface{}{"Code": "123"},
messageType: domain.VerifyEmailMessageType,
@@ -74,8 +53,8 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
code: "123",
urlTmpl: "{{",
},
want: &res{},
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate"),
want: &notifyResult{},
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "template success",
@@ -88,7 +67,7 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
code: "123",
urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
},
want: &res{
want: &notifyResult{
url: "https://example.com/email/verify?userID=user1&code=123&orgID=org1",
args: map[string]interface{}{"Code": "123"},
messageType: domain.VerifyEmailMessageType,
@@ -98,8 +77,8 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := new(res)
err := notify(got).SendEmailVerificationCode(tt.args.user, tt.args.origin, tt.args.code, tt.args.urlTmpl)
got, notify := mockNotify()
err := notify.SendEmailVerificationCode(tt.args.user, tt.args.origin, tt.args.code, tt.args.urlTmpl)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})

View File

@@ -1,12 +1,24 @@
package types
import (
"strings"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendPasswordlessRegistrationLink(user *query.NotifyUser, origin, code, codeID string) error {
url := domain.PasswordlessInitCodeLink(origin+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code)
func (notify Notify) SendPasswordlessRegistrationLink(user *query.NotifyUser, origin, code, codeID, urlTmpl string) error {
var url string
if urlTmpl == "" {
url = domain.PasswordlessInitCodeLink(origin+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code)
} else {
var buf strings.Builder
if err := domain.RenderPasskeyURLTemplate(&buf, urlTmpl, user.ID, user.ResourceOwner, codeID, code); err != nil {
return err
}
url = buf.String()
}
return notify(url, nil, domain.PasswordlessRegistrationMessageType, true)
}

View File

@@ -0,0 +1,88 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query"
)
func TestNotify_SendPasswordlessRegistrationLink(t *testing.T) {
type args struct {
user *query.NotifyUser
origin string
code string
codeID string
urlTmpl string
}
tests := []struct {
name string
args args
want *notifyResult
wantErr error
}{
{
name: "default URL",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: "https://example.com",
code: "123",
codeID: "456",
urlTmpl: "",
},
want: &notifyResult{
url: "https://example.com/ui/login/login/passwordless/init?userID=user1&orgID=org1&codeID=456&code=123",
messageType: domain.PasswordlessRegistrationMessageType,
allowUnverifiedNotificationChannel: true,
},
},
{
name: "template error",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: "https://example.com",
code: "123",
codeID: "456",
urlTmpl: "{{",
},
want: &notifyResult{},
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "template success",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: "https://example.com",
code: "123",
codeID: "456",
urlTmpl: "https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}",
},
want: &notifyResult{
url: "https://example.com/passkey/register?userID=user1&orgID=org1&codeID=456&code=123",
messageType: domain.PasswordlessRegistrationMessageType,
allowUnverifiedNotificationChannel: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, notify := mockNotify()
err := notify.SendPasswordlessRegistrationLink(tt.args.user, tt.args.origin, tt.args.code, tt.args.codeID, tt.args.urlTmpl)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}

View File

@@ -0,0 +1,23 @@
package types
type notifyResult struct {
url string
args map[string]interface{}
messageType string
allowUnverifiedNotificationChannel bool
}
// mockNotify returns a notifyResult and Notify function for easy mocking.
// The notifyResult will only be populated after Notify is called.
func mockNotify() (*notifyResult, Notify) {
dst := new(notifyResult)
return dst, func(url string, args map[string]interface{}, messageType string, allowUnverifiedNotificationChannel bool) error {
*dst = notifyResult{
url: url,
args: args,
messageType: messageType,
allowUnverifiedNotificationChannel: allowUnverifiedNotificationChannel,
}
return nil
}
}

View File

@@ -315,9 +315,11 @@ func HumanPasswordlessInitCodeAddedEventMapper(event *repository.Event) (eventst
type HumanPasswordlessInitCodeRequestedEvent struct {
eventstore.BaseEvent `json:"-"`
ID string `json:"id"`
Code *crypto.CryptoValue `json:"code"`
Expiry time.Duration `json:"expiry"`
ID string `json:"id"`
Code *crypto.CryptoValue `json:"code"`
Expiry time.Duration `json:"expiry"`
URLTemplate string `json:"url_template,omitempty"`
CodeReturned bool `json:"code_returned,omitempty"`
}
func (e *HumanPasswordlessInitCodeRequestedEvent) Data() interface{} {
@@ -334,6 +336,8 @@ func NewHumanPasswordlessInitCodeRequestedEvent(
id string,
code *crypto.CryptoValue,
expiry time.Duration,
urlTmpl string,
codeReturned bool,
) *HumanPasswordlessInitCodeRequestedEvent {
return &HumanPasswordlessInitCodeRequestedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
@@ -341,9 +345,11 @@ func NewHumanPasswordlessInitCodeRequestedEvent(
aggregate,
HumanPasswordlessInitCodeRequestedType,
),
ID: id,
Code: code,
Expiry: expiry,
ID: id,
Code: code,
Expiry: expiry,
URLTemplate: urlTmpl,
CodeReturned: codeReturned,
}
}

View File

@@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: Benutzer konnte in der gewünschten Organisation nicht gefunden werden
NotAllowedOrg: Benutzer gehört nicht der benötigten Organisation an
UserIDMissing: User ID fehlt
UserIDWrong: "Der Anforderungsbenutzer ist nicht gleich dem authentifizierten Benutzer"
DomainPolicyNil: Organisation Policy ist leer
EmailAsUsernameNotAllowed: Benutzername darf keine E-Mail Adresse sein
Invalid: Benutzerdaten sind ungültig
@@ -67,6 +68,7 @@ Errors:
NoChanges: Keine Änderungen gefunden
InitCodeNotFound: Kein Initialisierungs-Code gefunden
UsernameNotChanged: Benutzername wurde nicht verändert
InvalidURLTemplate: URL Template ist ungültig
Profile:
NotFound: Profil nicht gefunden
NotChanged: Profil nicht verändert
@@ -81,7 +83,6 @@ Errors:
NotChanged: Email wurde nicht geändert
Empty: Email ist leer
IDMissing: Email ID fehlt
InvalidURLTemplate: URL Template ist ungültig
Phone:
NotFound: Telefonnummer nicht gefunden
Invalid: Telefonnummer ist ungültig

View File

@@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: User could not be found on chosen organization
NotAllowedOrg: User is no member of the required organization
UserIDMissing: User ID missing
UserIDWrong: "Request user not equal to authenticated user"
DomainPolicyNil: Organisation Policy is empty
EmailAsUsernameNotAllowed: Email is not allowed as username
Invalid: Userdata is invalid
@@ -67,6 +68,7 @@ Errors:
NoChanges: No changes found
InitCodeNotFound: Initialization Code not found
UsernameNotChanged: Username not changed
InvalidURLTemplate: URL Template is invalid
Profile:
NotFound: Profile not found
NotChanged: Profile not changed
@@ -81,7 +83,6 @@ Errors:
NotChanged: Email not changed
Empty: Email is empty
IDMissing: Email ID is missing
InvalidURLTemplate: URL Template is invalid
Phone:
NotFound: Phone not found
Invalid: Phone is invalid

View File

@@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: El usuario no pudo encontrarse en la organización elegida
NotAllowedOrg: El usuario no es miembro de la organización requerida
UserIDMissing: Falta el ID de usuario
UserIDWrong: "Solicitud de usuario no igual al usuario autenticado"
DomainPolicyNil: Falta la política de la organización
EmailAsUsernameNotAllowed: La dirección de Email no se permite como nombre de usuario
Invalid: Los datos de usuario no son válidos
@@ -67,6 +68,7 @@ Errors:
NoChanges: No se encontraron cambios
InitCodeNotFound: Código de inicialización no encontrado
UsernameNotChanged: El nombre de usuario no cambió
InvalidURLTemplate: La plantilla URL no es válida
Profile:
NotFound: Perfil no encontrado
NotChanged: El perfil no ha cambiado
@@ -81,7 +83,6 @@ Errors:
NotChanged: El email no ha cambiado
Empty: El email no está vacío
IDMissing: Falta el ID del email
InvalidURLTemplate: La plantilla URL no es válida
Phone:
NotFound: Teléfono no encontrado
Invalid: El teléfono no es válido

View File

@@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: L'utilisateur n'a pas été trouvé dans l'organisation choisie
NotAllowedOrg: L'utilisateur n'est pas membre de l'organisation requise
UserIDMissing: L'ID de l'utilisateur est manquant
UserIDWrong: "L'utilisateur de la demande n'est pas égal à l'utilisateur authentifié"
DomainPolicyNil: La politique de l'organisation est vide
EmailAsUsernameNotAllowed: L'email n'est pas autorisé comme nom d'utilisateur
Invalid: Les données de l'utilisateur ne sont pas valides
@@ -67,6 +68,7 @@ Errors:
NoChanges: Aucun changement trouvé
InitCodeNotFound: Code d'initialisation non trouvé
UsernameNotChanged: Nom d'utilisateur non modifié
InvalidURLTemplate: Le modèle d'URL n'est pas valide
Profile:
NotFound: Profil non trouvé
NotChanged: Le profil n'a pas changé
@@ -81,7 +83,6 @@ Errors:
NotChanged: L'adresse électronique n'a pas changé
Empty: Email est vide
IDMissing: Email ID manquant
InvalidURLTemplate: Le modèle d'URL n'est pas valide
Phone:
Notfound: Téléphone non trouvé
Invalid: Le téléphone n'est pas valide

View File

@@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: L'utente non è stato trovato nell'organizzazione scelta
NotAllowedOrg: L'utente non è membro dell'organizzazione richiesta
UserIDMissing: ID utente mancante
UserIDWrong: "Utente richiesta non uguale all'utente autenticato"
DomainPolicyNil: Impostazione Org IAM mancante
EmailAsUsernameNotAllowed: L'e-mail non è consentita come nome utente
Invalid: I dati utente non sono validi
@@ -67,6 +68,7 @@ Errors:
NoChanges: Nessun cambiamento trovato
InitCodeNotFound: Codice di inizializzazione non trovato
UsernameNotChanged: Nome utente non cambiato
InvalidURLTemplate: Il modello di URL non è valido
Profile:
NotFound: Profilo non trovato
NotChanged: Profilo non cambiato
@@ -81,7 +83,6 @@ Errors:
NotChanged: Email non cambiata
Empty: Email è vuota
IDMissing: Email ID mancante
InvalidURLTemplate: Il modello di URL non è valido
Phone:
NotFound: Telefono non trovato
Invalid: Il telefono non è valido

View File

@@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: ユーザーが選択した組織内で見つかりません
NotAllowedOrg: ユーザーが必要な組織のメンバーでありません
UserIDMissing: ユーザーIDがありません
UserIDWrong: "リクエストユーザーが認証されたユーザーと等しくない"
DomainPolicyNil: 組織ポリシーが空です
EmailAsUsernameNotAllowed: メールアドレスはユーザー名として使用できません
Invalid: 無効なユーザーデータです
@@ -67,6 +68,7 @@ Errors:
NoChanges: 変更は見つかりません
InitCodeNotFound: 初期化コードが見つかりません
UsernameNotChanged: ユーザー名は変更されていません
InvalidURLTemplate: URLテンプレートが無効です
Profile:
NotFound: プロファイルが見つかりません
NotChanged: プロファイルが変更されていません
@@ -76,7 +78,6 @@ Errors:
Invalid: 無効なメールアドレスです
AlreadyVerified: メールアドレスはすでに検証済みです
NotChanged: メールアドレスが変更されていません
InvalidURLTemplate: URLテンプレートが無効です
Phone:
NotFound: 電話番号が見つかりません
Invalid: 無効な電話番号です

View File

@@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: Użytkownik nie został znaleziony w wybranej organizacji
NotAllowedOrg: Użytkownik nie jest członkiem wymaganej organizacji
UserIDMissing: Brakuje ID użytkownika
UserIDWrong: "Żądanie użytkownika nie jest równe uwierzytelnionemu użytkownikowi"
DomainPolicyNil: Polityka organizacji jest pusta
EmailAsUsernameNotAllowed: Adres e-mail nie jest dozwolony jako nazwa użytkownika
Invalid: Dane użytkownika są nieprawidłowe
@@ -67,6 +68,7 @@ Errors:
NoChanges: Nie znaleziono zmian
InitCodeNotFound: Kod inicjalizacji nie znaleziony
UsernameNotChanged: Nazwa użytkownika nie została zmieniona
InvalidURLTemplate: Szablon URL jest nieprawidłowy
Profile:
NotFound: Profil nie znaleziony
NotChanged: Profil nie zmieniony
@@ -81,7 +83,6 @@ Errors:
NotChanged: Adres e-mail nie zmieniony
Empty: Adres e-mail jest pusty
IDMissing: Adres e-mail ID brakuje
InvalidURLTemplate: Szablon URL jest nieprawidłowy
Phone:
NotFound: Numer telefonu nie znaleziony
Invalid: Numer telefonu jest nieprawidłowy

View File

@@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: 在所选组织中找不到用户
NotAllowedOrg: 用户不是所需组织的成员
UserIDMissing: 缺少用户 ID
UserIDWrong: "请求用户不等于经过身份验证的用户"
DomainPolicyNil: 组织策略为空
EmailAsUsernameNotAllowed: 电子邮件不允许作为用户名
Invalid: 用户数据无效
@@ -67,6 +68,7 @@ Errors:
NoChanges: 未发现任何更改
InitCodeNotFound: 未找到初始化验证码
UsernameNotChanged: 用户名未更改
InvalidURLTemplate: URL模板无效
Profile:
NotFound: 未找到个人资料
NotChanged: 个人资料未更改
@@ -81,7 +83,6 @@ Errors:
NotChanged: 电子邮件未更改
Empty: 电子邮件是空的
IDMissing: 电子邮件ID丢失
InvalidURLTemplate: URL模板无效
Phone:
NotFound: 手机号码未找到
Invalid: 手机号码无效

View File

@@ -0,0 +1,36 @@
package webauthn
import (
"fmt"
"github.com/descope/virtualwebauthn"
)
type Client struct {
rp virtualwebauthn.RelyingParty
auth virtualwebauthn.Authenticator
credential virtualwebauthn.Credential
}
func NewClient(name, domain, origin string) *Client {
rp := virtualwebauthn.RelyingParty{
Name: name,
ID: domain,
Origin: origin,
}
return &Client{
rp: rp,
auth: virtualwebauthn.NewAuthenticator(),
credential: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2),
}
}
func (c *Client) CreateAttestationResponse(options []byte) ([]byte, error) {
parsedAttestationOptions, err := virtualwebauthn.ParseAttestationOptions(string(options))
if err != nil {
return nil, fmt.Errorf("webauthn.Client.CreateAttestationResponse: %w", err)
}
return []byte(virtualwebauthn.CreateAttestationResponse(
c.rp, c.auth, c.credential, *parsedAttestationOptions,
)), nil
}

View File

@@ -1,8 +1,9 @@
package webauthn
import (
"github.com/duo-labs/webauthn/protocol"
"github.com/duo-labs/webauthn/webauthn"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/zitadel/zitadel/internal/domain"
)

View File

@@ -5,8 +5,8 @@ import (
"context"
"encoding/json"
"github.com/duo-labs/webauthn/protocol"
"github.com/duo-labs/webauthn/webauthn"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
@@ -177,9 +177,13 @@ func (w *Config) FinishLogin(ctx context.Context, user *domain.Human, webAuthN *
func (w *Config) serverFromContext(ctx context.Context) (*webauthn.WebAuthn, error) {
instance := authz.GetInstance(ctx)
return webauthn.New(&webauthn.Config{
webAuthn, err := webauthn.New(&webauthn.Config{
RPDisplayName: w.DisplayName,
RPID: instance.RequestedDomain(),
RPOrigin: http.BuildOrigin(instance.RequestedHost(), w.ExternalSecure),
RPOrigins: []string{http.BuildOrigin(instance.RequestedHost(), w.ExternalSecure)},
})
if err != nil {
return nil, caos_errs.ThrowInternal(err, "WEBAU-UX9ta", "Errors.User.WebAuthN.ServerConfig")
}
return webAuthn, nil
}

View File

@@ -0,0 +1,58 @@
package webauthn
import (
"context"
"testing"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/api/authz"
caos_errs "github.com/zitadel/zitadel/internal/errors"
)
func TestConfig_serverFromContext(t *testing.T) {
type args struct {
ctx context.Context
}
tests := []struct {
name string
args args
want *webauthn.WebAuthn
wantErr error
}{
{
name: "webauthn error",
args: args{context.Background()},
wantErr: caos_errs.ThrowInternal(nil, "WEBAU-UX9ta", "Errors.User.WebAuthN.ServerConfig"),
},
{
name: "success",
args: args{authz.WithRequestedDomain(context.Background(), "example.com")},
want: &webauthn.WebAuthn{
Config: &webauthn.Config{
RPDisplayName: "DisplayName",
RPID: "example.com",
RPOrigins: []string{"https://example.com"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := &Config{
DisplayName: "DisplayName",
ExternalSecure: true,
}
got, err := w.serverFromContext(tt.args.ctx)
require.ErrorIs(t, err, tt.wantErr)
if tt.want != nil {
require.NotNil(t, got)
assert.Equal(t, tt.want.Config.RPDisplayName, got.Config.RPDisplayName)
assert.Equal(t, tt.want.Config.RPID, got.Config.RPID)
assert.Equal(t, tt.want.Config.RPOrigins, got.Config.RPOrigins)
}
})
}
}