mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-17 16:35:14 -05:00
feat(session): Passkey Check API with relation tables (#11858)
# Which Problems Are Solved As part of #11035 , this PR implements the Passkey check logic for session validation # How the Problems Are Solved - Refactor webauth FinishLogin to support new domain model - Add webauth config to defaults - Implement passkey check logic and tests - Manual transaction management to avoid stalling the DB while FinishLogin callback is executed - Update passkey Type condition to allow passing a text operation (equal, contains, etc..) # Additional Context This is a cherry-picked PR + minor changes, coming from https://github.com/zitadel/zitadel/pull/11164 - Relates to #11035 --------- Co-authored-by: Fabienne Bühler <fabienne@zitadel.com> Co-authored-by: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com>
This commit is contained in:
co-authored by
Fabienne Bühler
Gayathri Vijayan
parent
ec7a6d5526
commit
022bf74060
@@ -0,0 +1,15 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2"
|
||||
)
|
||||
|
||||
func CheckPasskeyGRPCToDomain(checkPasskey *session_grpc.CheckWebAuthN) ([]byte, error) {
|
||||
if checkPasskey == nil || checkPasskey.GetCredentialAssertionData() == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return json.Marshal(checkPasskey.GetCredentialAssertionData())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2"
|
||||
)
|
||||
|
||||
func TestCheckPasskeyGRPCToDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
tt := []struct {
|
||||
testName string
|
||||
input *session_grpc.CheckWebAuthN
|
||||
expectedBytes []byte
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
testName: "when input is nil should return nil bytes and nil error",
|
||||
input: nil,
|
||||
expectedBytes: nil,
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "when credential assertion data is nil should return nil bytes and nil error",
|
||||
input: &session_grpc.CheckWebAuthN{
|
||||
CredentialAssertionData: nil,
|
||||
},
|
||||
expectedBytes: nil,
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "when credential assertion data is empty should return empty JSON bytes",
|
||||
input: &session_grpc.CheckWebAuthN{
|
||||
CredentialAssertionData: &structpb.Struct{},
|
||||
},
|
||||
expectedBytes: []byte("{}"),
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "when credential assertion data is populated should return marshaled JSON bytes",
|
||||
input: &session_grpc.CheckWebAuthN{
|
||||
CredentialAssertionData: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"publicKeyCredentialRequestOptions": {
|
||||
Kind: &structpb.Value_StructValue{
|
||||
StructValue: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
"challenge": structpb.NewStringValue("Y2hhbGxlbmdl"),
|
||||
"rpId": structpb.NewStringValue("example.com"),
|
||||
"timeout": structpb.NewNumberValue(5000),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedBytes: []byte(`{"publicKeyCredentialRequestOptions":{"challenge":"Y2hhbGxlbmdl","rpId":"example.com","timeout":5000}}`),
|
||||
expectedError: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test
|
||||
result, err := CheckPasskeyGRPCToDomain(tc.input)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, tc.expectedBytes, result)
|
||||
assert.Equal(t, tc.expectedError, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/zitadel/zitadel/backend/v3/storage/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/config/systemdefaults"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/webauthn"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -16,6 +17,7 @@ var (
|
||||
sessionTokenDecryptor SessionTokenDecryptor
|
||||
mfaEncryptionAlgo crypto.EncryptionAlgorithm
|
||||
otpSMSSecretGeneratorConfig *crypto.GeneratorConfig
|
||||
webauthnConfig *webauthn.Config
|
||||
)
|
||||
|
||||
func SetPool(p database.Pool) {
|
||||
@@ -49,3 +51,7 @@ func SetOTPSMSSecretGeneratorConfig(cfg *crypto.GeneratorConfig) {
|
||||
func SetMFAEncryptionAlgorithm(mfaEncryptionAlg crypto.EncryptionAlgorithm) {
|
||||
mfaEncryptionAlgo = mfaEncryptionAlg
|
||||
}
|
||||
|
||||
func SetWebAuthNConfig(cfg *webauthn.Config) {
|
||||
webauthnConfig = cfg
|
||||
}
|
||||
|
||||
@@ -112,6 +112,26 @@ func handleGetError(inputErr error, errorID, objectType string) error {
|
||||
return zerrors.CreateZitadelError(zerrors.KindInternal, inputErr, errorID, fmt.Sprintf("failed fetching %s", objectType), 1)
|
||||
}
|
||||
|
||||
func handleUpdateError(inputErr error, expectedRowCount, actualRowCount int64, errorID, objectType string) error {
|
||||
if inputErr == nil && expectedRowCount == actualRowCount {
|
||||
return nil
|
||||
}
|
||||
|
||||
if inputErr != nil {
|
||||
return zerrors.CreateZitadelError(zerrors.KindInternal, inputErr, errorID, fmt.Sprintf("failed updating %s", objectType), 1)
|
||||
}
|
||||
|
||||
if actualRowCount == 0 {
|
||||
return zerrors.CreateZitadelError(zerrors.KindNotFound, nil, errorID, fmt.Sprintf("%s not found", objectType), 1)
|
||||
}
|
||||
|
||||
if actualRowCount != expectedRowCount {
|
||||
return zerrors.CreateZitadelError(zerrors.KindInternal, NewMultipleObjectsUpdatedError(expectedRowCount, actualRowCount), errorID, "unexpected number of rows updated", 1)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (err *PasswordVerificationError) Is(target error) bool {
|
||||
_, ok := target.(*PasswordVerificationError)
|
||||
return ok
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
func TestUnexpectedQueryTypeError_Error(t *testing.T) {
|
||||
@@ -36,3 +39,72 @@ func TestUnexpectedQueryTypeError_Error(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tt := []struct {
|
||||
name string
|
||||
inputErr error
|
||||
expectedRowCount int64
|
||||
actualRowCount int64
|
||||
errorID string
|
||||
objectType string
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "no error and counts match",
|
||||
inputErr: nil,
|
||||
expectedRowCount: 1,
|
||||
actualRowCount: 1,
|
||||
errorID: "test-001",
|
||||
objectType: "user",
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "input error provided",
|
||||
inputErr: errors.New("db error"),
|
||||
expectedRowCount: 1,
|
||||
actualRowCount: 1,
|
||||
errorID: "test-002",
|
||||
objectType: "session",
|
||||
expectedErr: zerrors.ThrowInternalf(errors.New("db error"), "test-002", "failed updating %s", "session"),
|
||||
},
|
||||
{
|
||||
name: "no rows affected",
|
||||
inputErr: nil,
|
||||
expectedRowCount: 1,
|
||||
actualRowCount: 0,
|
||||
errorID: "test-003",
|
||||
objectType: "idp",
|
||||
expectedErr: zerrors.ThrowNotFoundf(nil, "test-003", "%s not found", "idp"),
|
||||
},
|
||||
{
|
||||
name: "unexpected number of rows updated",
|
||||
inputErr: nil,
|
||||
expectedRowCount: 1,
|
||||
actualRowCount: 5,
|
||||
errorID: "test-004",
|
||||
objectType: "org",
|
||||
expectedErr: zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(1, 5), "test-004", "unexpected number of rows updated"),
|
||||
},
|
||||
{
|
||||
name: "counts mismatch",
|
||||
inputErr: nil,
|
||||
expectedRowCount: 2,
|
||||
actualRowCount: 1,
|
||||
errorID: "test-005",
|
||||
objectType: "project",
|
||||
expectedErr: zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(2, 1), "test-005", "unexpected number of rows updated"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := handleUpdateError(tc.inputErr, tc.expectedRowCount, tc.actualRowCount, tc.errorID, tc.objectType)
|
||||
assert.Equal(t, tc.expectedErr, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/zitadel/zitadel/backend/v3/storage/database"
|
||||
"github.com/zitadel/zitadel/backend/v3/storage/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
type InvokeOpt func(*InvokeOpts)
|
||||
@@ -136,6 +137,34 @@ func (o *InvokeOpts) DB() database.QueryExecutor {
|
||||
return o.db
|
||||
}
|
||||
|
||||
// StartTransactionFromDB returns a [database.Transaction] from the input [database.QueryExecutor].
|
||||
// Optionally, the caller can pass [database.TransactionOptions] for a customised transaction type.
|
||||
//
|
||||
// If db doesn't implement [database.Beginner] an internal error is returned.
|
||||
//
|
||||
// If the transaction [database.Beginner.Begin] call fails, an internal error is returned.
|
||||
//
|
||||
// The caller is in charge of calling [database.Transaction.End], [database.Transaction.Commit]
|
||||
// or [database.Transaction.Rollback] as they see fit.
|
||||
func (o *InvokeOpts) StartTransactionFromDB(ctx context.Context, db database.QueryExecutor, opts *database.TransactionOptions) (database.Transaction, error) {
|
||||
beginner, ok := db.(database.Beginner)
|
||||
if !ok {
|
||||
return nil, zerrors.CreateZitadelError(zerrors.KindInternal, nil, "DOM-LqxZbk", "database doesn't implement database.Beginner", 1)
|
||||
}
|
||||
|
||||
tx, txErr := beginner.Begin(ctx, opts)
|
||||
if txErr != nil {
|
||||
return nil, zerrors.CreateZitadelError(zerrors.KindInternal, txErr, "DOM-sAAd3V", "failed starting transaction", 1)
|
||||
}
|
||||
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
// StartTransaction is the same as [domain.StartTransactionFromDB] but uses the DB provided by [InvokeOpts]
|
||||
func (o *InvokeOpts) StartTransaction(ctx context.Context, opts *database.TransactionOptions) (database.Transaction, error) {
|
||||
return o.StartTransactionFromDB(ctx, o.DB(), opts)
|
||||
}
|
||||
|
||||
func (o *InvokeOpts) LegacyEventstore() eventstore.LegacyEventstore {
|
||||
if o.legacyEventstore != nil {
|
||||
return o.legacyEventstore
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/zitadel/zitadel/backend/v3/storage/database"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
var _ database.QueryExecutor = (*nonBeginnerDB)(nil)
|
||||
var _ database.Transaction = (*transactionDB)(nil)
|
||||
var _ database.Beginner = (*beginnerDB)(nil)
|
||||
var _ database.QueryExecutor = (*beginnerDB)(nil)
|
||||
|
||||
type beginnerDB struct {
|
||||
errToReturn error
|
||||
errOnBegin error
|
||||
}
|
||||
|
||||
func (n *beginnerDB) Begin(ctx context.Context, opts *database.TransactionOptions) (database.Transaction, error) {
|
||||
if n.errToReturn != nil {
|
||||
return nil, n.errToReturn
|
||||
}
|
||||
if n.errOnBegin != nil {
|
||||
return nil, n.errOnBegin
|
||||
}
|
||||
return &transactionDB{}, nil
|
||||
}
|
||||
|
||||
// Exec implements [database.QueryExecutor].
|
||||
func (n *beginnerDB) Exec(ctx context.Context, stmt string, args ...any) (int64, error) {
|
||||
return 0, n.errToReturn
|
||||
}
|
||||
|
||||
// Query implements [database.QueryExecutor].
|
||||
func (n *beginnerDB) Query(ctx context.Context, stmt string, args ...any) (database.Rows, error) {
|
||||
return nil, n.errToReturn
|
||||
}
|
||||
|
||||
// QueryRow implements [database.QueryExecutor].
|
||||
func (n *beginnerDB) QueryRow(ctx context.Context, stmt string, args ...any) database.Row {
|
||||
return nil
|
||||
}
|
||||
|
||||
type transactionDB struct{}
|
||||
|
||||
// Exec implements [database.QueryExecutor].
|
||||
func (n *transactionDB) Exec(ctx context.Context, stmt string, args ...any) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Query implements [database.QueryExecutor].
|
||||
func (n *transactionDB) Query(ctx context.Context, stmt string, args ...any) (database.Rows, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// QueryRow implements [database.QueryExecutor].
|
||||
func (n *transactionDB) QueryRow(ctx context.Context, stmt string, args ...any) database.Row {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Begin implements [database.Transaction].
|
||||
func (n *transactionDB) Begin(ctx context.Context) (database.Transaction, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Commit implements [database.Transaction].
|
||||
func (n *transactionDB) Commit(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// End implements [database.Transaction].
|
||||
func (n *transactionDB) End(ctx context.Context, err error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rollback implements [database.Transaction].
|
||||
func (n *transactionDB) Rollback(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type nonBeginnerDB struct{}
|
||||
|
||||
// Exec implements [database.QueryExecutor].
|
||||
func (n *nonBeginnerDB) Exec(ctx context.Context, stmt string, args ...any) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Query implements [database.QueryExecutor].
|
||||
func (n *nonBeginnerDB) Query(ctx context.Context, stmt string, args ...any) (database.Rows, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// QueryRow implements [database.QueryExecutor].
|
||||
func (n *nonBeginnerDB) QueryRow(ctx context.Context, stmt string, args ...any) database.Row {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStartTransactionFromDB(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
txErr := errors.New("tx error")
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
inputDB database.QueryExecutor
|
||||
expectedError error
|
||||
expectedValidTransaction bool
|
||||
}{
|
||||
{
|
||||
testName: "when input DB doesn't implement database.Beginner should return internal error",
|
||||
inputDB: &nonBeginnerDB{},
|
||||
expectedError: zerrors.CreateZitadelError(zerrors.KindInternal, nil, "DOM-LqxZbk", "database doesn't implement database.Beginner", 1),
|
||||
},
|
||||
{
|
||||
testName: "when transaction Begin fails should return internal error",
|
||||
inputDB: &beginnerDB{errOnBegin: txErr},
|
||||
expectedError: zerrors.CreateZitadelError(zerrors.KindInternal, txErr, "DOM-sAAd3V", "failed starting transaction", 1),
|
||||
},
|
||||
{
|
||||
testName: "when transaction Begin succeeds should return transaction",
|
||||
inputDB: &beginnerDB{},
|
||||
expectedValidTransaction: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Given
|
||||
invokeOpts := InvokeOpts{}
|
||||
|
||||
// Test
|
||||
tx, err := invokeOpts.StartTransactionFromDB(t.Context(), tc.inputDB, nil)
|
||||
|
||||
// Verify
|
||||
assert.ErrorIs(t, err, tc.expectedError)
|
||||
assert.Equal(t, tc.expectedValidTransaction, tx != nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ func (u *UserRepo) Create(ctx context.Context, client database.QueryExecutor, us
|
||||
return u.mock.Create(ctx, client, user)
|
||||
}
|
||||
|
||||
func (u *UserRepo) Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) {
|
||||
return u.mock.Update(ctx, client, condition, changes...)
|
||||
}
|
||||
|
||||
func (u *UserRepo) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) {
|
||||
return u.mock.Delete(ctx, client, condition)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/http"
|
||||
old_domain "github.com/zitadel/zitadel/internal/domain"
|
||||
)
|
||||
|
||||
func PasskeysToCredentials(ctx context.Context, passkeys []*Passkey, rpID string) []webauthn.Credential {
|
||||
creds := make([]webauthn.Credential, 0)
|
||||
|
||||
for _, pkey := range passkeys {
|
||||
if !pkey.VerifiedAt.IsZero() &&
|
||||
(pkey.RelyingPartyID == rpID ||
|
||||
(pkey.RelyingPartyID == "" && rpID == http.DomainContext(ctx).InstanceDomain())) {
|
||||
creds = append(creds, webauthn.Credential{
|
||||
ID: pkey.KeyID,
|
||||
PublicKey: pkey.PublicKey,
|
||||
AttestationType: pkey.AttestationType,
|
||||
Authenticator: webauthn.Authenticator{
|
||||
AAGUID: pkey.AuthenticatorAttestationGUID,
|
||||
SignCount: pkey.SignCount,
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return creds
|
||||
}
|
||||
|
||||
func UserVerificationFromDomain(verification old_domain.UserVerificationRequirement) protocol.UserVerificationRequirement {
|
||||
switch verification {
|
||||
case old_domain.UserVerificationRequirementRequired:
|
||||
return protocol.VerificationRequired
|
||||
case old_domain.UserVerificationRequirementPreferred:
|
||||
return protocol.VerificationPreferred
|
||||
case old_domain.UserVerificationRequirementDiscouraged, old_domain.UserVerificationRequirementUnspecified:
|
||||
fallthrough
|
||||
default:
|
||||
return protocol.VerificationDiscouraged
|
||||
}
|
||||
}
|
||||
|
||||
type webAuthNUser struct {
|
||||
userID string
|
||||
username string
|
||||
displayName string
|
||||
creds []webauthn.Credential
|
||||
}
|
||||
|
||||
// WebAuthnCredentials implements [webauthn.User].
|
||||
func (w *webAuthNUser) WebAuthnCredentials() []webauthn.Credential {
|
||||
return w.creds
|
||||
}
|
||||
|
||||
// WebAuthnDisplayName implements [webauthn.User].
|
||||
func (w *webAuthNUser) WebAuthnDisplayName() string {
|
||||
return w.displayName
|
||||
}
|
||||
|
||||
// WebAuthnID implements [webauthn.User].
|
||||
func (w *webAuthNUser) WebAuthnID() []byte {
|
||||
return []byte(w.userID)
|
||||
}
|
||||
|
||||
// WebAuthnIcon implements [webauthn.User].
|
||||
func (w *webAuthNUser) WebAuthnIcon() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// WebAuthnName implements [webauthn.User].
|
||||
func (w *webAuthNUser) WebAuthnName() string {
|
||||
return w.username
|
||||
}
|
||||
|
||||
var _ webauthn.User = (*webAuthNUser)(nil)
|
||||
@@ -0,0 +1,158 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/zitadel/zitadel/backend/v3/domain"
|
||||
"github.com/zitadel/zitadel/internal/api/http"
|
||||
)
|
||||
|
||||
func TestPasskeysToCredentials(t *testing.T) {
|
||||
verificationDate := time.Now()
|
||||
|
||||
t.Parallel()
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
passkeys []*domain.Passkey
|
||||
rpID string
|
||||
}
|
||||
tt := []struct {
|
||||
name string
|
||||
args args
|
||||
want []webauthn.Credential
|
||||
}{
|
||||
{
|
||||
name: "matching rpID",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
passkeys: []*domain.Passkey{
|
||||
{
|
||||
KeyID: []byte("key1"),
|
||||
PublicKey: []byte("publicKey1"),
|
||||
AttestationType: "attestation1",
|
||||
AuthenticatorAttestationGUID: []byte("aaguid1"),
|
||||
SignCount: 1,
|
||||
RelyingPartyID: "example.com",
|
||||
VerifiedAt: verificationDate,
|
||||
},
|
||||
},
|
||||
rpID: "example.com",
|
||||
},
|
||||
want: []webauthn.Credential{
|
||||
{
|
||||
ID: []byte("key1"),
|
||||
PublicKey: []byte("publicKey1"),
|
||||
AttestationType: "attestation1",
|
||||
Authenticator: webauthn.Authenticator{
|
||||
AAGUID: []byte("aaguid1"),
|
||||
SignCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "not matching rpID",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
passkeys: []*domain.Passkey{
|
||||
{
|
||||
KeyID: []byte("key1"),
|
||||
PublicKey: []byte("publicKey1"),
|
||||
AttestationType: "attestation1",
|
||||
AuthenticatorAttestationGUID: []byte("aaguid1"),
|
||||
SignCount: 1,
|
||||
RelyingPartyID: "other.com",
|
||||
VerifiedAt: verificationDate,
|
||||
},
|
||||
},
|
||||
rpID: "example.com",
|
||||
},
|
||||
want: []webauthn.Credential{},
|
||||
},
|
||||
{
|
||||
name: "no rpID, same host",
|
||||
args: args{
|
||||
ctx: http.WithDomainContext(context.Background(), &http.DomainCtx{
|
||||
InstanceHost: "example.com:443",
|
||||
PublicHost: "example.com:443",
|
||||
Protocol: "https",
|
||||
}),
|
||||
passkeys: []*domain.Passkey{
|
||||
{
|
||||
KeyID: []byte("key1"),
|
||||
PublicKey: []byte("publicKey1"),
|
||||
AttestationType: "attestation1",
|
||||
AuthenticatorAttestationGUID: []byte("aaguid1"),
|
||||
SignCount: 1,
|
||||
RelyingPartyID: "",
|
||||
VerifiedAt: verificationDate,
|
||||
},
|
||||
},
|
||||
rpID: "example.com",
|
||||
},
|
||||
want: []webauthn.Credential{
|
||||
{
|
||||
ID: []byte("key1"),
|
||||
PublicKey: []byte("publicKey1"),
|
||||
AttestationType: "attestation1",
|
||||
Authenticator: webauthn.Authenticator{
|
||||
AAGUID: []byte("aaguid1"),
|
||||
SignCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no rpID, different host",
|
||||
args: args{
|
||||
ctx: http.WithDomainContext(context.Background(), &http.DomainCtx{
|
||||
InstanceHost: "other.com:443",
|
||||
PublicHost: "other.com:443",
|
||||
Protocol: "https",
|
||||
}),
|
||||
passkeys: []*domain.Passkey{
|
||||
{
|
||||
KeyID: []byte("key1"),
|
||||
PublicKey: []byte("publicKey1"),
|
||||
AttestationType: "attestation1",
|
||||
AuthenticatorAttestationGUID: []byte("aaguid1"),
|
||||
SignCount: 1,
|
||||
RelyingPartyID: "",
|
||||
VerifiedAt: verificationDate,
|
||||
},
|
||||
},
|
||||
rpID: "example.com",
|
||||
},
|
||||
want: []webauthn.Credential{},
|
||||
},
|
||||
{
|
||||
name: "pkey not verified should return empty",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
passkeys: []*domain.Passkey{
|
||||
{
|
||||
KeyID: []byte("key1"),
|
||||
PublicKey: []byte("publicKey1"),
|
||||
AttestationType: "attestation1",
|
||||
AuthenticatorAttestationGUID: []byte("aaguid1"),
|
||||
SignCount: 1,
|
||||
RelyingPartyID: "example.com",
|
||||
},
|
||||
},
|
||||
rpID: "example.com",
|
||||
},
|
||||
want: []webauthn.Credential{},
|
||||
},
|
||||
}
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equalf(t, tc.want, domain.PasskeysToCredentials(tc.args.ctx, tc.args.passkeys, tc.args.rpID), "PasskeysToCredentials(%v, %v, %v)", tc.args.ctx, tc.args.passkeys, tc.args.rpID)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"github.com/zitadel/zitadel/backend/v3/storage/database"
|
||||
old_domain "github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/repository/session"
|
||||
"github.com/zitadel/zitadel/internal/repository/user"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
type FinishLoginFunc func(ctx context.Context, sessionData webauthn.SessionData, user webauthn.User, credentials []byte, rpID string) (*webauthn.Credential, error)
|
||||
|
||||
type PasskeyCheckCommand struct {
|
||||
// CheckPasskey is the assertion data for the passkey
|
||||
CheckPasskey []byte
|
||||
|
||||
FinishLoginFn FinishLoginFunc
|
||||
|
||||
SessionID string
|
||||
InstanceID string
|
||||
|
||||
FetchedUser *User
|
||||
FetchedSession *Session
|
||||
|
||||
// For Events()
|
||||
LastVerifiedAt time.Time
|
||||
UserVerified bool
|
||||
PKeyID string
|
||||
PKeySignCount uint32
|
||||
}
|
||||
|
||||
// NewPasskeyCheckCommand initializes a new [PasskeyCheckCommand]
|
||||
//
|
||||
// If finishLoginFn is nil, [webauthnConfig.FinishLoginWithNewDomainModel] will be used.
|
||||
// If that is nil as well, an error will be returned.
|
||||
//
|
||||
// assertionData is passkey assertion required for validation
|
||||
//
|
||||
// The command does not implement [Transactional] due finishLoginFn that might take a long time to execute.
|
||||
// So the DB transaction will be started only after finishLoginFn has been run.
|
||||
func NewPasskeyCheckCommand(sessionID, instanceID string, assertionData []byte, finishLoginFn FinishLoginFunc) (*PasskeyCheckCommand, error) {
|
||||
if webauthnConfig == nil && finishLoginFn == nil {
|
||||
return nil, zerrors.ThrowInternal(nil, "DOM-bhzmHO", "no finish login function set")
|
||||
}
|
||||
|
||||
pcc := &PasskeyCheckCommand{
|
||||
CheckPasskey: assertionData,
|
||||
SessionID: sessionID,
|
||||
InstanceID: instanceID,
|
||||
}
|
||||
|
||||
if finishLoginFn != nil {
|
||||
pcc.FinishLoginFn = finishLoginFn
|
||||
} else {
|
||||
pcc.FinishLoginFn = webauthnConfig.FinishLoginWithNewDomainModel
|
||||
}
|
||||
|
||||
return pcc, nil
|
||||
}
|
||||
|
||||
// Events implements [Commander].
|
||||
func (p *PasskeyCheckCommand) Events(ctx context.Context, opts *InvokeOpts) ([]eventstore.Command, error) {
|
||||
if p.CheckPasskey == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
passkeyChallenge := p.FetchedSession.Challenges.GetPasskeyChallenge()
|
||||
|
||||
events := make([]eventstore.Command, 2)
|
||||
|
||||
sessionAgg := &session.NewAggregate(p.SessionID, p.InstanceID).Aggregate
|
||||
events[0] = session.NewWebAuthNCheckedEvent(ctx, sessionAgg, p.LastVerifiedAt, p.UserVerified)
|
||||
if passkeyChallenge.UserVerification == old_domain.UserVerificationRequirementRequired {
|
||||
events[1] = user.NewHumanPasswordlessSignCountChangedEvent(ctx, sessionAgg, p.PKeyID, p.PKeySignCount)
|
||||
} else {
|
||||
events[1] = user.NewHumanU2FSignCountChangedEvent(ctx, sessionAgg, p.PKeyID, p.PKeySignCount)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// Execute implements [Commander].
|
||||
func (p *PasskeyCheckCommand) Execute(ctx context.Context, opts *InvokeOpts) (err error) {
|
||||
if p.CheckPasskey == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sessionRepo := opts.sessionRepo
|
||||
userRepo := opts.userRepo
|
||||
|
||||
passkeyChallenge := p.FetchedSession.Challenges.GetPasskeyChallenge()
|
||||
|
||||
userPKeys := p.FetchedUser.Human.Passkeys
|
||||
|
||||
webAuthnUsr := &webAuthNUser{
|
||||
userID: p.FetchedUser.ID,
|
||||
username: p.FetchedUser.Username,
|
||||
displayName: p.FetchedUser.Human.DisplayName,
|
||||
creds: PasskeysToCredentials(ctx, userPKeys, passkeyChallenge.RPID),
|
||||
}
|
||||
|
||||
webAuthCreds, err := p.FinishLoginFn(ctx, p.getWebAuthNSessionData(passkeyChallenge, p.FetchedUser.ID), webAuthnUsr, p.CheckPasskey, passkeyChallenge.RPID)
|
||||
if err != nil && (webAuthCreds == nil || webAuthCreds.ID == nil) {
|
||||
return err
|
||||
}
|
||||
|
||||
var matchingPKey *Passkey
|
||||
for _, pkey := range userPKeys {
|
||||
if bytes.Equal(pkey.KeyID, webAuthCreds.ID) {
|
||||
matchingPKey = pkey
|
||||
break
|
||||
}
|
||||
}
|
||||
if matchingPKey == nil {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "DOM-uuxodH", "Errors.User.WebAuthN.NotFound")
|
||||
}
|
||||
p.PKeyID = matchingPKey.ID
|
||||
|
||||
tx, txErr := opts.StartTransaction(ctx, nil)
|
||||
if txErr != nil {
|
||||
return zerrors.ThrowInternal(txErr, "DOM-sAAd3V", "failed starting transaction")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if endErr := tx.End(ctx, txErr); endErr != nil {
|
||||
err = endErr
|
||||
}
|
||||
}()
|
||||
|
||||
p.UserVerified = webAuthCreds.Flags.UserVerified
|
||||
p.LastVerifiedAt = time.Now()
|
||||
rowCount, err := sessionRepo.Update(ctx, tx,
|
||||
sessionRepo.IDCondition(p.SessionID),
|
||||
sessionRepo.SetFactor(&SessionFactorPasskey{LastVerifiedAt: p.LastVerifiedAt, UserVerified: p.UserVerified}),
|
||||
)
|
||||
if err := handleUpdateError(err, 1, rowCount, "DOM-Uadvap", "session"); err != nil {
|
||||
txErr = err
|
||||
return err
|
||||
}
|
||||
|
||||
rowCount, err = userRepo.Update(ctx, tx,
|
||||
database.And(
|
||||
userRepo.Human().PrimaryKeyCondition(p.InstanceID, p.FetchedUser.ID),
|
||||
userRepo.Human().PasskeyConditions().IDCondition(matchingPKey.ID),
|
||||
),
|
||||
userRepo.Human().SetPasskeySignCount(webAuthCreds.Authenticator.SignCount),
|
||||
)
|
||||
if err := handleUpdateError(err, 1, rowCount, "DOM-wdwZYk", "user"); err != nil {
|
||||
txErr = err
|
||||
return err
|
||||
}
|
||||
p.PKeySignCount = webAuthCreds.Authenticator.SignCount
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// String implements [Commander].
|
||||
func (p *PasskeyCheckCommand) String() string {
|
||||
return "PasskeyCheckCommand"
|
||||
}
|
||||
|
||||
// Validate implements [Commander].
|
||||
func (p *PasskeyCheckCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) {
|
||||
if p.CheckPasskey == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.SessionID == "" {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "DOM-4QJa2k", "Errors.Missing.SessionID")
|
||||
}
|
||||
if p.InstanceID == "" {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "DOM-XlOhxU", "Errors.Missing.InstanceID")
|
||||
}
|
||||
|
||||
sessionRepo := opts.sessionRepo
|
||||
userRepo := opts.userRepo
|
||||
|
||||
p.FetchedSession, err = sessionRepo.Get(ctx, opts.DB(), database.WithCondition(sessionRepo.IDCondition(p.SessionID)))
|
||||
if err := handleGetError(err, "DOM-CUnePh", "session"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
passkeyChallenge := p.FetchedSession.Challenges.GetPasskeyChallenge()
|
||||
if passkeyChallenge == nil {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "DOM-lQhNR4", "Errors.Session.WebAuthN.NoChallenge")
|
||||
}
|
||||
|
||||
if p.FetchedSession.UserID == "" {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "DOM-jy0zq7", "Errors.User.UserIDMissing")
|
||||
}
|
||||
|
||||
var passKeyCondition database.Condition
|
||||
if passkeyChallenge.UserVerification == old_domain.UserVerificationRequirementRequired {
|
||||
passKeyCondition = userRepo.Human().PasskeyConditions().TypeCondition(database.TextOperationEqual, PasskeyTypePasswordless)
|
||||
} else {
|
||||
passKeyCondition = userRepo.Human().PasskeyConditions().TypeCondition(database.TextOperationEqual, PasskeyTypeU2F)
|
||||
}
|
||||
|
||||
p.FetchedUser, err = userRepo.Get(ctx, opts.DB(),
|
||||
database.WithCondition(userRepo.IDCondition(p.FetchedSession.UserID)),
|
||||
database.WithCondition(passKeyCondition),
|
||||
)
|
||||
if err := handleGetError(err, "DOM-pB6Mlm", "user"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PasskeyCheckCommand) getWebAuthNSessionData(sessionChallenge *SessionChallengePasskey, userID string) webauthn.SessionData {
|
||||
return webauthn.SessionData{
|
||||
Challenge: sessionChallenge.Challenge,
|
||||
UserID: []byte(userID),
|
||||
AllowedCredentialIDs: sessionChallenge.AllowedCredentialIDs,
|
||||
UserVerification: UserVerificationFromDomain(sessionChallenge.UserVerification),
|
||||
}
|
||||
}
|
||||
|
||||
var _ Commander = (*PasskeyCheckCommand)(nil)
|
||||
@@ -0,0 +1,716 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/zitadel/zitadel/backend/v3/domain"
|
||||
domainmock "github.com/zitadel/zitadel/backend/v3/domain/mock"
|
||||
"github.com/zitadel/zitadel/backend/v3/storage/database"
|
||||
"github.com/zitadel/zitadel/backend/v3/storage/database/dbmock"
|
||||
noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop"
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
old_domain "github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/repository/session"
|
||||
"github.com/zitadel/zitadel/internal/repository/user"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
func TestPasskeyCheckCommand_Validate(t *testing.T) {
|
||||
t.Parallel()
|
||||
getErr := errors.New("get error")
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository
|
||||
userRepo func(ctrl *gomock.Controller) domain.UserRepository
|
||||
cmd *domain.PasskeyCheckCommand
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
testName: "when checkPasskey is nil should return no error",
|
||||
cmd: &domain.PasskeyCheckCommand{},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "when sessionID is not set should return error",
|
||||
cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}},
|
||||
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-4QJa2k", "Errors.Missing.SessionID"),
|
||||
},
|
||||
{
|
||||
testName: "when instanceID is not set should return error",
|
||||
cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}, SessionID: "session-1"},
|
||||
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-XlOhxU", "Errors.Missing.InstanceID"),
|
||||
},
|
||||
{
|
||||
testName: "when retrieving session fails should return error",
|
||||
cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}, SessionID: "session-1", InstanceID: "instance-1"},
|
||||
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||
repo := domainmock.NewSessionRepo(ctrl)
|
||||
idCondition := repo.IDCondition("session-1")
|
||||
repo.EXPECT().
|
||||
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
|
||||
database.WithCondition(
|
||||
idCondition,
|
||||
),
|
||||
)).
|
||||
Times(1).
|
||||
Return(nil, getErr)
|
||||
return repo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
repo := domainmock.NewUserRepo(ctrl)
|
||||
return repo
|
||||
},
|
||||
expectedError: zerrors.ThrowInternal(getErr, "DOM-CUnePh", "failed fetching session"),
|
||||
},
|
||||
{
|
||||
testName: "when session has no passkey challenge should return precondition failed error",
|
||||
cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}, SessionID: "session-1", InstanceID: "instance-1"},
|
||||
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||
repo := domainmock.NewSessionRepo(ctrl)
|
||||
idCondition := repo.IDCondition("session-1")
|
||||
repo.EXPECT().
|
||||
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
|
||||
database.WithCondition(
|
||||
idCondition,
|
||||
),
|
||||
)).
|
||||
Times(1).
|
||||
Return(&domain.Session{
|
||||
ID: "session-1",
|
||||
Challenges: domain.SessionChallenges{},
|
||||
}, nil)
|
||||
return repo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
repo := domainmock.NewUserRepo(ctrl)
|
||||
return repo
|
||||
},
|
||||
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-lQhNR4", "Errors.Session.WebAuthN.NoChallenge"),
|
||||
},
|
||||
{
|
||||
testName: "when session has no user ID should return precondition failed error",
|
||||
cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}, SessionID: "session-1", InstanceID: "instance-1"},
|
||||
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||
repo := domainmock.NewSessionRepo(ctrl)
|
||||
|
||||
idCondition := repo.IDCondition("session-1")
|
||||
repo.EXPECT().
|
||||
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
|
||||
database.WithCondition(
|
||||
idCondition,
|
||||
),
|
||||
)).
|
||||
Times(1).
|
||||
Return(&domain.Session{
|
||||
ID: "session-1",
|
||||
UserID: "",
|
||||
Challenges: domain.SessionChallenges{
|
||||
&domain.SessionChallengePasskey{
|
||||
LastChallengedAt: time.Time{},
|
||||
Challenge: "challenge",
|
||||
AllowedCredentialIDs: [][]byte{},
|
||||
UserVerification: 0,
|
||||
RPID: "example.com",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
return repo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
repo := domainmock.NewUserRepo(ctrl)
|
||||
return repo
|
||||
},
|
||||
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-jy0zq7", "Errors.User.UserIDMissing"),
|
||||
},
|
||||
{
|
||||
testName: "when retrieving user fails should return error",
|
||||
cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}, SessionID: "session-1", InstanceID: "instance-1"},
|
||||
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||
repo := domainmock.NewSessionRepo(ctrl)
|
||||
idCondition := repo.IDCondition("session-1")
|
||||
repo.EXPECT().
|
||||
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
|
||||
database.WithCondition(
|
||||
idCondition,
|
||||
),
|
||||
)).
|
||||
Times(1).
|
||||
Return(&domain.Session{
|
||||
ID: "session-1",
|
||||
UserID: "user-1",
|
||||
Challenges: domain.SessionChallenges{
|
||||
&domain.SessionChallengePasskey{
|
||||
Challenge: "challenge",
|
||||
RPID: "example.com",
|
||||
UserVerification: old_domain.UserVerificationRequirementRequired,
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
return repo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
repo := domainmock.NewUserRepo(ctrl)
|
||||
humanRepo := domainmock.NewHumanRepo(ctrl)
|
||||
repo.EXPECT().Human().Times(1).Return(humanRepo)
|
||||
|
||||
idCondition := repo.IDCondition("user-1")
|
||||
|
||||
pkeyCondition := humanRepo.
|
||||
PasskeyConditions().
|
||||
TypeCondition(database.TextOperationEqual, domain.PasskeyTypePasswordless)
|
||||
|
||||
repo.EXPECT().
|
||||
Get(gomock.Any(), gomock.Any(),
|
||||
dbmock.QueryOptions(database.WithCondition(
|
||||
idCondition,
|
||||
)),
|
||||
dbmock.QueryOptions(database.WithCondition(
|
||||
pkeyCondition)),
|
||||
).
|
||||
Times(1).
|
||||
Return(nil, getErr)
|
||||
return repo
|
||||
},
|
||||
expectedError: zerrors.ThrowInternal(getErr, "DOM-pB6Mlm", "failed fetching user"),
|
||||
},
|
||||
{
|
||||
testName: "when all validations pass should return no error",
|
||||
cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}, SessionID: "session-1", InstanceID: "instance-1"},
|
||||
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||
repo := domainmock.NewSessionRepo(ctrl)
|
||||
idCondition := repo.IDCondition("session-1")
|
||||
repo.EXPECT().
|
||||
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
|
||||
database.WithCondition(
|
||||
idCondition,
|
||||
),
|
||||
)).
|
||||
Times(1).
|
||||
Return(&domain.Session{
|
||||
ID: "session-1",
|
||||
UserID: "user-1",
|
||||
Challenges: domain.SessionChallenges{
|
||||
&domain.SessionChallengePasskey{
|
||||
Challenge: "challenge",
|
||||
RPID: "example.com",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
return repo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
repo := domainmock.NewUserRepo(ctrl)
|
||||
humanRepo := domainmock.NewHumanRepo(ctrl)
|
||||
|
||||
repo.EXPECT().Human().Times(1).Return(humanRepo)
|
||||
idCondition := repo.IDCondition("user-1")
|
||||
|
||||
pkeyCondition := humanRepo.
|
||||
PasskeyConditions().
|
||||
TypeCondition(database.TextOperationEqual, domain.PasskeyTypeU2F)
|
||||
|
||||
repo.EXPECT().
|
||||
Get(gomock.Any(), gomock.Any(),
|
||||
dbmock.QueryOptions(database.WithCondition(
|
||||
idCondition,
|
||||
)),
|
||||
dbmock.QueryOptions(database.WithCondition(
|
||||
pkeyCondition)),
|
||||
).
|
||||
Times(1).Return(&domain.User{
|
||||
ID: "user-1",
|
||||
Username: "testuser",
|
||||
Human: &domain.HumanUser{
|
||||
DisplayName: "Test User",
|
||||
Passkeys: []*domain.Passkey{},
|
||||
},
|
||||
}, nil)
|
||||
return repo
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Given
|
||||
ctx := authz.NewMockContext(tc.cmd.InstanceID, "", "")
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
opts := &domain.InvokeOpts{}
|
||||
domain.WithQueryExecutor(new(noopdb.Pool))(opts)
|
||||
if tc.sessionRepo != nil {
|
||||
domain.WithSessionRepo(tc.sessionRepo(ctrl))(opts)
|
||||
}
|
||||
if tc.userRepo != nil {
|
||||
domain.WithUserRepo(tc.userRepo(ctrl))(opts)
|
||||
}
|
||||
|
||||
// Test
|
||||
err := tc.cmd.Validate(ctx, opts)
|
||||
|
||||
// Verify
|
||||
assert.ErrorIs(t, err, tc.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyCheckCommand_Execute(t *testing.T) {
|
||||
t.Parallel()
|
||||
finishLoginErr := errors.New("finish login error")
|
||||
sessionUpdateErr := errors.New("session update error")
|
||||
userUpdateErr := errors.New("user update error")
|
||||
|
||||
finishLoginErrFn := func(ctx context.Context, sessionData webauthn.SessionData, user webauthn.User, credentials []byte, rpID string) (*webauthn.Credential, error) {
|
||||
return nil, finishLoginErr
|
||||
}
|
||||
finishLoginNonMatchingPasskeyFn := func(ctx context.Context, sessionData webauthn.SessionData, user webauthn.User, credentials []byte, rpID string) (*webauthn.Credential, error) {
|
||||
return &webauthn.Credential{ID: []byte("non-matching-key-id")}, nil
|
||||
}
|
||||
finishLoginOKFn := func(ctx context.Context, sessionData webauthn.SessionData, user webauthn.User, credentials []byte, rpID string) (*webauthn.Credential, error) {
|
||||
return &webauthn.Credential{
|
||||
ID: []byte("key-id-1"),
|
||||
Flags: webauthn.CredentialFlags{
|
||||
UserVerified: true,
|
||||
},
|
||||
Authenticator: webauthn.Authenticator{
|
||||
SignCount: 5,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository
|
||||
userRepo func(ctrl *gomock.Controller) domain.UserRepository
|
||||
finishLoginFn func(ctx context.Context, sessionData webauthn.SessionData, user webauthn.User, credentials []byte, rpID string) (*webauthn.Credential, error)
|
||||
cmd *domain.PasskeyCheckCommand
|
||||
|
||||
expectedError error
|
||||
expectedPasskeySignCount uint32
|
||||
expectedPasskeyID string
|
||||
expectedUserVerified bool
|
||||
}{
|
||||
{
|
||||
testName: "when checkPasskey is nil should return no error",
|
||||
cmd: &domain.PasskeyCheckCommand{},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "when finish login fails should return error",
|
||||
cmd: &domain.PasskeyCheckCommand{
|
||||
CheckPasskey: []byte{},
|
||||
SessionID: "session-1",
|
||||
InstanceID: "instance-1",
|
||||
FetchedUser: &domain.User{
|
||||
ID: "user-1",
|
||||
Username: "testuser",
|
||||
Human: &domain.HumanUser{DisplayName: "Test User", Passkeys: []*domain.Passkey{}},
|
||||
},
|
||||
FetchedSession: &domain.Session{
|
||||
ID: "session-1",
|
||||
UserID: "user-1",
|
||||
Challenges: domain.SessionChallenges{
|
||||
&domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com"},
|
||||
},
|
||||
},
|
||||
FinishLoginFn: finishLoginErrFn,
|
||||
},
|
||||
expectedError: finishLoginErr,
|
||||
},
|
||||
{
|
||||
testName: "when passkey not found should return precondition failed error",
|
||||
cmd: &domain.PasskeyCheckCommand{
|
||||
CheckPasskey: []byte{},
|
||||
SessionID: "session-1",
|
||||
InstanceID: "instance-1",
|
||||
FetchedUser: &domain.User{
|
||||
ID: "user-1",
|
||||
Username: "testuser",
|
||||
Human: &domain.HumanUser{
|
||||
DisplayName: "Test User",
|
||||
Passkeys: []*domain.Passkey{
|
||||
{ID: "pkey-1", KeyID: []byte("key-id-1")},
|
||||
},
|
||||
},
|
||||
},
|
||||
FetchedSession: &domain.Session{
|
||||
ID: "session-1",
|
||||
UserID: "user-1",
|
||||
Challenges: domain.SessionChallenges{
|
||||
&domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com"},
|
||||
},
|
||||
},
|
||||
FinishLoginFn: finishLoginNonMatchingPasskeyFn,
|
||||
},
|
||||
expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-uuxodH", "Errors.User.WebAuthN.NotFound"),
|
||||
},
|
||||
{
|
||||
testName: "when session update fails should return error",
|
||||
cmd: &domain.PasskeyCheckCommand{
|
||||
CheckPasskey: []byte{},
|
||||
SessionID: "session-1",
|
||||
InstanceID: "instance-1",
|
||||
FetchedUser: &domain.User{
|
||||
ID: "user-1",
|
||||
Username: "testuser",
|
||||
Human: &domain.HumanUser{
|
||||
DisplayName: "Test User",
|
||||
Passkeys: []*domain.Passkey{
|
||||
{ID: "pkey-1", KeyID: []byte("key-id-1")},
|
||||
},
|
||||
},
|
||||
},
|
||||
FetchedSession: &domain.Session{
|
||||
ID: "session-1",
|
||||
UserID: "user-1",
|
||||
Challenges: domain.SessionChallenges{
|
||||
&domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com"},
|
||||
},
|
||||
},
|
||||
FinishLoginFn: finishLoginOKFn,
|
||||
},
|
||||
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||
repo := domainmock.NewSessionRepo(ctrl)
|
||||
idCondition := repo.IDCondition("session-1")
|
||||
factorChange := repo.SetFactor(&domain.SessionFactorPasskey{LastVerifiedAt: time.Now(), UserVerified: true})
|
||||
repo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(),
|
||||
idCondition,
|
||||
factorChange).
|
||||
Times(1).
|
||||
Return(int64(0), sessionUpdateErr)
|
||||
return repo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
repo := domainmock.NewUserRepo(ctrl)
|
||||
return repo
|
||||
},
|
||||
expectedError: zerrors.ThrowInternal(sessionUpdateErr, "DOM-Uadvap", "failed updating session"),
|
||||
expectedPasskeyID: "pkey-1",
|
||||
expectedUserVerified: true,
|
||||
},
|
||||
{
|
||||
testName: "when session not found should return not found error",
|
||||
cmd: &domain.PasskeyCheckCommand{
|
||||
CheckPasskey: []byte{},
|
||||
SessionID: "session-1",
|
||||
InstanceID: "instance-1",
|
||||
FetchedUser: &domain.User{
|
||||
ID: "user-1",
|
||||
Username: "testuser",
|
||||
Human: &domain.HumanUser{
|
||||
DisplayName: "Test User",
|
||||
Passkeys: []*domain.Passkey{
|
||||
{ID: "pkey-1", KeyID: []byte("key-id-1")},
|
||||
},
|
||||
},
|
||||
},
|
||||
FetchedSession: &domain.Session{
|
||||
ID: "session-1",
|
||||
UserID: "user-1",
|
||||
Challenges: domain.SessionChallenges{
|
||||
&domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com"},
|
||||
},
|
||||
},
|
||||
FinishLoginFn: finishLoginOKFn,
|
||||
},
|
||||
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||
repo := domainmock.NewSessionRepo(ctrl)
|
||||
idCondition := repo.IDCondition("session-1")
|
||||
factorChange := repo.SetFactor(&domain.SessionFactorPasskey{LastVerifiedAt: time.Now(), UserVerified: true})
|
||||
repo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(),
|
||||
idCondition,
|
||||
factorChange).
|
||||
Times(1).
|
||||
Return(int64(0), nil)
|
||||
return repo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
repo := domainmock.NewUserRepo(ctrl)
|
||||
return repo
|
||||
},
|
||||
expectedError: zerrors.ThrowNotFound(nil, "DOM-Uadvap", "session not found"),
|
||||
expectedPasskeyID: "pkey-1",
|
||||
expectedUserVerified: true,
|
||||
},
|
||||
{
|
||||
testName: "when user update fails should return error",
|
||||
cmd: &domain.PasskeyCheckCommand{
|
||||
CheckPasskey: []byte{},
|
||||
SessionID: "session-1",
|
||||
InstanceID: "instance-1",
|
||||
FetchedUser: &domain.User{
|
||||
ID: "user-1",
|
||||
Username: "testuser",
|
||||
Human: &domain.HumanUser{
|
||||
DisplayName: "Test User",
|
||||
Passkeys: []*domain.Passkey{
|
||||
{ID: "pkey-1", KeyID: []byte("key-id-1")},
|
||||
},
|
||||
},
|
||||
},
|
||||
FetchedSession: &domain.Session{
|
||||
ID: "session-1",
|
||||
UserID: "user-1",
|
||||
Challenges: domain.SessionChallenges{
|
||||
&domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com"},
|
||||
},
|
||||
},
|
||||
FinishLoginFn: finishLoginOKFn,
|
||||
},
|
||||
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||
repo := domainmock.NewSessionRepo(ctrl)
|
||||
idCondition := repo.IDCondition("session-1")
|
||||
factorChange := repo.SetFactor(&domain.SessionFactorPasskey{LastVerifiedAt: time.Now(), UserVerified: true})
|
||||
repo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(),
|
||||
idCondition,
|
||||
factorChange).
|
||||
Times(1).
|
||||
Return(int64(1), nil)
|
||||
return repo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
repo := domainmock.NewUserRepo(ctrl)
|
||||
humanRepo := domainmock.NewHumanRepo(ctrl)
|
||||
repo.EXPECT().Human().Times(3).Return(humanRepo)
|
||||
updateConditions := database.And(
|
||||
humanRepo.PrimaryKeyCondition("instance-1", "user-1"),
|
||||
humanRepo.PasskeyConditions().IDCondition("pkey-1"),
|
||||
)
|
||||
pkeySignCountChange := humanRepo.SetPasskeySignCount(5)
|
||||
repo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(),
|
||||
updateConditions,
|
||||
pkeySignCountChange).
|
||||
Times(1).
|
||||
Return(int64(0), userUpdateErr)
|
||||
return repo
|
||||
},
|
||||
expectedError: zerrors.ThrowInternal(userUpdateErr, "DOM-wdwZYk", "failed updating user"),
|
||||
expectedPasskeyID: "pkey-1",
|
||||
expectedUserVerified: true,
|
||||
},
|
||||
{
|
||||
testName: "when execute succeeds should return no error",
|
||||
cmd: &domain.PasskeyCheckCommand{
|
||||
CheckPasskey: []byte{},
|
||||
SessionID: "session-1",
|
||||
InstanceID: "instance-1",
|
||||
FetchedUser: &domain.User{
|
||||
ID: "user-1",
|
||||
Username: "testuser",
|
||||
Human: &domain.HumanUser{
|
||||
DisplayName: "Test User",
|
||||
Passkeys: []*domain.Passkey{
|
||||
{ID: "pkey-1", KeyID: []byte("key-id-1")},
|
||||
},
|
||||
},
|
||||
},
|
||||
FetchedSession: &domain.Session{
|
||||
ID: "session-1",
|
||||
UserID: "user-1",
|
||||
Challenges: domain.SessionChallenges{
|
||||
&domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com"},
|
||||
},
|
||||
},
|
||||
FinishLoginFn: finishLoginOKFn,
|
||||
},
|
||||
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||
repo := domainmock.NewSessionRepo(ctrl)
|
||||
idCondition := repo.IDCondition("session-1")
|
||||
factorChange := repo.SetFactor(&domain.SessionFactorPasskey{LastVerifiedAt: time.Now(), UserVerified: true})
|
||||
repo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(),
|
||||
idCondition,
|
||||
factorChange).
|
||||
Times(1).
|
||||
Return(int64(1), nil)
|
||||
return repo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
repo := domainmock.NewUserRepo(ctrl)
|
||||
humanRepo := domainmock.NewHumanRepo(ctrl)
|
||||
repo.EXPECT().Human().Times(3).Return(humanRepo)
|
||||
updateConditions := database.And(
|
||||
humanRepo.PrimaryKeyCondition("instance-1", "user-1"),
|
||||
humanRepo.PasskeyConditions().IDCondition("pkey-1"),
|
||||
)
|
||||
pkeySignCountChange := humanRepo.SetPasskeySignCount(5)
|
||||
repo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(),
|
||||
updateConditions,
|
||||
pkeySignCountChange).
|
||||
Times(1).
|
||||
Return(int64(1), nil)
|
||||
return repo
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedPasskeyID: "pkey-1",
|
||||
expectedPasskeySignCount: 5,
|
||||
expectedUserVerified: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Given
|
||||
ctx := authz.NewMockContext(tc.cmd.InstanceID, "", "")
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
opts := &domain.InvokeOpts{}
|
||||
domain.WithQueryExecutor(new(noopdb.Pool))(opts)
|
||||
if tc.sessionRepo != nil {
|
||||
domain.WithSessionRepo(tc.sessionRepo(ctrl))(opts)
|
||||
}
|
||||
if tc.userRepo != nil {
|
||||
domain.WithUserRepo(tc.userRepo(ctrl))(opts)
|
||||
}
|
||||
|
||||
// Test
|
||||
err := tc.cmd.Execute(ctx, opts)
|
||||
|
||||
// Verify
|
||||
assert.ErrorIs(t, err, tc.expectedError)
|
||||
assert.Equal(t, tc.expectedPasskeyID, tc.cmd.PKeyID)
|
||||
assert.Equal(t, tc.expectedPasskeySignCount, tc.cmd.PKeySignCount)
|
||||
assert.Equal(t, tc.expectedUserVerified, tc.cmd.UserVerified)
|
||||
if tc.cmd.CheckPasskey != nil && tc.expectedError == nil {
|
||||
assert.NotZero(t, tc.cmd.LastVerifiedAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyCheckCommand_Events(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tt := []struct {
|
||||
testName string
|
||||
cmd *domain.PasskeyCheckCommand
|
||||
|
||||
expectedEvents []eventstore.Command
|
||||
}{
|
||||
{
|
||||
testName: "when checkPasskey is nil should return no events",
|
||||
cmd: &domain.PasskeyCheckCommand{},
|
||||
expectedEvents: []eventstore.Command{},
|
||||
},
|
||||
{
|
||||
testName: "when user verification is required should return WebAuthNCheckedEvent and PasswordlessSignCountChangedEvent",
|
||||
cmd: &domain.PasskeyCheckCommand{
|
||||
CheckPasskey: []byte{},
|
||||
SessionID: "session-1",
|
||||
InstanceID: "instance-1",
|
||||
FetchedUser: &domain.User{
|
||||
ID: "user-1",
|
||||
Username: "testuser",
|
||||
Human: &domain.HumanUser{
|
||||
DisplayName: "Test User",
|
||||
Passkeys: []*domain.Passkey{
|
||||
{ID: "pkey-1", KeyID: []byte("key-id-1")},
|
||||
},
|
||||
},
|
||||
},
|
||||
FetchedSession: &domain.Session{
|
||||
ID: "session-1",
|
||||
UserID: "user-1",
|
||||
Challenges: domain.SessionChallenges{
|
||||
&domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com", UserVerification: old_domain.UserVerificationRequirementRequired},
|
||||
},
|
||||
},
|
||||
LastVerifiedAt: time.Now(),
|
||||
UserVerified: true,
|
||||
PKeyID: "pkey-1",
|
||||
PKeySignCount: 5,
|
||||
},
|
||||
expectedEvents: []eventstore.Command{
|
||||
session.NewWebAuthNCheckedEvent(t.Context(), nil, time.Now(), true),
|
||||
user.NewHumanPasswordlessSignCountChangedEvent(t.Context(), nil, "pkey-1", 5),
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "when user verification is not required should return WebAuthNCheckedEvent and U2FSignCountChangedEvent",
|
||||
cmd: &domain.PasskeyCheckCommand{
|
||||
CheckPasskey: []byte{},
|
||||
SessionID: "session-1",
|
||||
InstanceID: "instance-1",
|
||||
FetchedUser: &domain.User{
|
||||
ID: "user-1",
|
||||
Username: "testuser",
|
||||
Human: &domain.HumanUser{
|
||||
DisplayName: "Test User",
|
||||
Passkeys: []*domain.Passkey{
|
||||
{ID: "pkey-2", KeyID: []byte("key-id-2")},
|
||||
},
|
||||
},
|
||||
},
|
||||
FetchedSession: &domain.Session{
|
||||
ID: "session-1",
|
||||
UserID: "user-1",
|
||||
Challenges: domain.SessionChallenges{
|
||||
&domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com", UserVerification: old_domain.UserVerificationRequirementPreferred},
|
||||
},
|
||||
},
|
||||
LastVerifiedAt: time.Now(),
|
||||
UserVerified: false,
|
||||
PKeyID: "pkey-2",
|
||||
PKeySignCount: 10,
|
||||
},
|
||||
expectedEvents: []eventstore.Command{
|
||||
session.NewWebAuthNCheckedEvent(t.Context(), nil, time.Now(), false),
|
||||
user.NewHumanU2FSignCountChangedEvent(t.Context(), nil, "pkey-2", 10),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tt {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
// Given
|
||||
ctx := authz.NewMockContext(tc.cmd.InstanceID, "", "")
|
||||
|
||||
opts := &domain.InvokeOpts{}
|
||||
|
||||
// Test
|
||||
events, err := tc.cmd.Events(ctx, opts)
|
||||
|
||||
// Verify
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, events, len(tc.expectedEvents))
|
||||
for i, expectedType := range tc.expectedEvents {
|
||||
assert.IsType(t, expectedType, events[i])
|
||||
switch expectedAssertedType := expectedType.(type) {
|
||||
case *session.WebAuthNCheckedEvent:
|
||||
actualAssertedType, ok := events[i].(*session.WebAuthNCheckedEvent)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tc.cmd.LastVerifiedAt, actualAssertedType.CheckedAt)
|
||||
assert.Equal(t, expectedAssertedType.UserVerified, actualAssertedType.UserVerified)
|
||||
case *user.HumanPasswordlessSignCountChangedEvent:
|
||||
actualAssertedType, ok := events[i].(*user.HumanPasswordlessSignCountChangedEvent)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, expectedAssertedType.WebAuthNTokenID, actualAssertedType.WebAuthNTokenID)
|
||||
assert.Equal(t, expectedAssertedType.SignCount, actualAssertedType.SignCount)
|
||||
case *user.HumanU2FSignCountChangedEvent:
|
||||
actualAssertedType, ok := events[i].(*user.HumanU2FSignCountChangedEvent)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, expectedAssertedType.WebAuthNTokenID, actualAssertedType.WebAuthNTokenID)
|
||||
assert.Equal(t, expectedAssertedType.SignCount, actualAssertedType.SignCount)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -121,12 +121,8 @@ func (p *PasswordCheckCommand) Execute(ctx context.Context, opts *InvokeOpts) (e
|
||||
if changesErr != nil {
|
||||
return changesErr
|
||||
}
|
||||
beginner, ok := opts.DB().(database.Beginner)
|
||||
if !ok {
|
||||
return zerrors.ThrowInternal(nil, "DOM-fEhd79", "database doesn't implement database.Beginner")
|
||||
}
|
||||
|
||||
tx, txErr := beginner.Begin(ctx, nil)
|
||||
tx, txErr := opts.StartTransaction(ctx, nil)
|
||||
if txErr != nil {
|
||||
return zerrors.ThrowInternal(txErr, "DOM-IR1vH2", "failed starting transaction")
|
||||
}
|
||||
|
||||
@@ -207,7 +207,6 @@ type Passkey struct {
|
||||
}
|
||||
|
||||
//go:generate enumer -type PasskeyType -transform lower -trimprefix PasskeyType -json -sql
|
||||
|
||||
type PasskeyType uint8
|
||||
|
||||
const (
|
||||
|
||||
@@ -355,7 +355,7 @@ type HumanPasskeyConditions interface {
|
||||
IDCondition(passkeyID string) database.Condition
|
||||
KeyIDCondition(keyID string) database.Condition
|
||||
ChallengeCondition(challenge []byte) database.Condition
|
||||
TypeCondition(passkeyType PasskeyType) database.Condition
|
||||
TypeCondition(op database.TextOperation, passkeyType PasskeyType) database.Condition
|
||||
}
|
||||
|
||||
type humanPasskeyChanges interface {
|
||||
|
||||
@@ -195,8 +195,8 @@ func (u userPasskey) KeyIDCondition(keyID string) database.Condition {
|
||||
}
|
||||
|
||||
// TypeCondition implements [domain.HumanPasskeyConditions].
|
||||
func (u userPasskey) TypeCondition(passkeyType domain.PasskeyType) database.Condition {
|
||||
return database.NewTextCondition(u.typeColumn(), database.TextOperationEqual, passkeyType.String())
|
||||
func (u userPasskey) TypeCondition(op database.TextOperation, passkeyType domain.PasskeyType) database.Condition {
|
||||
return database.NewTextCondition(u.typeColumn(), op, passkeyType.String())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
|
||||
@@ -1573,7 +1573,7 @@ func Test_user_ListConditions(t *testing.T) {
|
||||
opts: []database.QueryOption{
|
||||
database.WithCondition(database.And(
|
||||
userRepo.InstanceIDCondition(instanceID1),
|
||||
humanRepo.ExistsPasskey(humanRepo.PasskeyConditions().TypeCondition(domain.PasskeyTypeU2F)),
|
||||
humanRepo.ExistsPasskey(humanRepo.PasskeyConditions().TypeCondition(database.TextOperationEqual, domain.PasskeyTypeU2F)),
|
||||
)),
|
||||
},
|
||||
want: want{
|
||||
|
||||
@@ -268,6 +268,9 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
|
||||
DisplayName: config.WebAuthNName,
|
||||
ExternalSecure: config.ExternalSecure,
|
||||
}
|
||||
|
||||
new_domain.SetWebAuthNConfig(webAuthNConfig)
|
||||
|
||||
commands, err := command.StartCommands(ctx,
|
||||
eventstoreClient,
|
||||
cacheConnectors,
|
||||
|
||||
@@ -80,7 +80,7 @@ func (c *Commands) CheckWebAuthN(credentialAssertionData json.Marshaler) Session
|
||||
if err != nil && (credential == nil || credential.ID == nil) {
|
||||
return nil, err
|
||||
}
|
||||
_, token := domain.GetTokenByKeyID(webAuthNTokens.tokens, credential.ID)
|
||||
token := domain.GetTokenByKeyID(webAuthNTokens.tokens, credential.ID)
|
||||
if token == nil {
|
||||
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Aej7i", "Errors.User.WebAuthN.NotFound")
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@ func (c *Commands) finishWebAuthNLogin(ctx context.Context, userID, resourceOwne
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
|
||||
_, token := domain.GetTokenByKeyID(tokens, credential.ID)
|
||||
token := domain.GetTokenByKeyID(tokens, credential.ID)
|
||||
if token == nil {
|
||||
return nil, nil, 0, zerrors.ThrowPreconditionFailed(nil, "COMMAND-3b7zs", "Errors.User.WebAuthN.NotFound")
|
||||
}
|
||||
|
||||
@@ -62,13 +62,13 @@ func GetTokenToVerify(tokens []*WebAuthNToken) (int, *WebAuthNToken) {
|
||||
return -1, nil
|
||||
}
|
||||
|
||||
func GetTokenByKeyID(tokens []*WebAuthNToken, keyID []byte) (int, *WebAuthNToken) {
|
||||
for i, token := range tokens {
|
||||
if bytes.Compare(token.KeyID, keyID) == 0 {
|
||||
return i, token
|
||||
func GetTokenByKeyID(tokens []*WebAuthNToken, keyID []byte) *WebAuthNToken {
|
||||
for _, token := range tokens {
|
||||
if bytes.Equal(token.KeyID, keyID) {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return -1, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
type PasswordlessInitCodeState int32
|
||||
|
||||
@@ -1079,9 +1079,7 @@ func (p *relationalTablesProjection) reducePasskeyAdded(event eventstore.Event)
|
||||
}
|
||||
repo := repository.HumanUserRepository()
|
||||
_, err := repo.Update(ctx, v3_sql.SQLTx(tx),
|
||||
database.And(
|
||||
repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID),
|
||||
),
|
||||
repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID),
|
||||
repo.AddPasskey(&domain.Passkey{
|
||||
ID: e.WebAuthNTokenID,
|
||||
Challenge: []byte(e.Challenge),
|
||||
|
||||
@@ -181,6 +181,30 @@ func (w *Config) FinishLogin(ctx context.Context, user *domain.Human, webAuthN *
|
||||
return credential, nil
|
||||
}
|
||||
|
||||
func (w *Config) FinishLoginWithNewDomainModel(ctx context.Context, sessionData webauthn.SessionData, user webauthn.User, credentials []byte, rpID string) (*webauthn.Credential, error) {
|
||||
assertionData, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(credentials))
|
||||
if err != nil {
|
||||
logging.WithFields("error", tryExtractProtocolErrMsg(err)).Debug("webauthn assertion could not be parsed")
|
||||
return nil, zerrors.ThrowInternal(err, "WEBAU-ytJJmg", "Errors.User.WebAuthN.ValidateLoginFailed")
|
||||
}
|
||||
webAuthNServer, err := w.serverFromContext(ctx, rpID, assertionData.Response.CollectedClientData.Origin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
credential, err := webAuthNServer.ValidateLogin(user, sessionData, assertionData)
|
||||
if err != nil {
|
||||
logging.WithFields("error", tryExtractProtocolErrMsg(err)).Debug("webauthn assertion failed")
|
||||
return nil, zerrors.ThrowInternal(err, "WEBAU-zHfUKX", "Errors.User.WebAuthN.ValidateLoginFailed")
|
||||
}
|
||||
|
||||
if credential.Authenticator.CloneWarning {
|
||||
return credential, zerrors.ThrowInternal(nil, "WEBAU-eDG7kQ", "Errors.User.WebAuthN.CloneWarning")
|
||||
}
|
||||
|
||||
return credential, nil
|
||||
}
|
||||
|
||||
func (w *Config) serverFromContext(ctx context.Context, id, origin string) (*webauthn.WebAuthn, error) {
|
||||
config := w.config(id, origin)
|
||||
if id == "" {
|
||||
|
||||
Reference in New Issue
Block a user