mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
# Which Problems Are Solved Add Passkey challenge needed for Create/Set session in the RT model. # How the Problems Are Solved Introduce `PasskeyChallengeCommand` (validate/execute/events) for session creation checks Add a generic `BeginWebAuthNLogin` helper that returns session data + assertion JSON bytes. Add unit tests # Additional Changes N/A # Additional Context - Related to https://github.com/zitadel/zitadel/issues/11035 --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Marco A. <[email protected]>
224 lines
7.7 KiB
Go
224 lines
7.7 KiB
Go
package domain
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
|
"google.golang.org/protobuf/types/known/structpb"
|
|
|
|
"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/zerrors"
|
|
session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2"
|
|
)
|
|
|
|
type ChallengeTypePasskey struct {
|
|
Domain string
|
|
UserVerificationRequirement old_domain.UserVerificationRequirement
|
|
}
|
|
|
|
var _ Commander = (*PasskeyChallengeCommand)(nil)
|
|
var _ Transactional = (*PasskeyChallengeCommand)(nil)
|
|
|
|
type beginLoginFn func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error)
|
|
|
|
// PasskeyChallengeCommand handles the passkey challenge creation during session creation.
|
|
// It supports both U2F and passwordless flows based on user verification requirements.
|
|
type PasskeyChallengeCommand struct {
|
|
ChallengeTypePasskey *ChallengeTypePasskey
|
|
|
|
SessionID string
|
|
InstanceID string
|
|
|
|
user *User
|
|
beginLogin beginLoginFn
|
|
|
|
challengePasskey *SessionChallengePasskey // the generated passkey challenge that is stored in the session.
|
|
webAuthNChallenge *session_grpc.Challenges_WebAuthN // challenge to be set in the CreateSessionResponse
|
|
}
|
|
|
|
// NewPasskeyChallengeCommand creates a new PasskeyChallengeCommand.
|
|
func NewPasskeyChallengeCommand(
|
|
sessionID,
|
|
instanceID string,
|
|
challengePasskey *ChallengeTypePasskey,
|
|
beginLoginFn beginLoginFn,
|
|
) (*PasskeyChallengeCommand, error) {
|
|
|
|
if beginLoginFn == nil {
|
|
if webauthnConfig == nil {
|
|
return nil, zerrors.ThrowInternal(nil, "DOM-jwk5Pe", "begin webauthn login function not set")
|
|
}
|
|
beginLoginFn = webauthnConfig.BeginWebAuthNLogin
|
|
}
|
|
return &PasskeyChallengeCommand{
|
|
ChallengeTypePasskey: challengePasskey,
|
|
SessionID: sessionID,
|
|
InstanceID: instanceID,
|
|
beginLogin: beginLoginFn,
|
|
}, nil
|
|
}
|
|
|
|
// RequiresTransaction implements [Transactional].
|
|
func (p *PasskeyChallengeCommand) RequiresTransaction() {}
|
|
|
|
// Validate implements [Commander].
|
|
// It retrieves the session, the user, and the user's passkeys based on the user verification requirement,
|
|
// and validates that the user is active.
|
|
func (p *PasskeyChallengeCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) {
|
|
if p.ChallengeTypePasskey == nil {
|
|
return nil
|
|
}
|
|
|
|
if p.SessionID == "" {
|
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-EVo5yE", "Errors.Missing.SessionID")
|
|
}
|
|
if p.InstanceID == "" {
|
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-sh8xvQ", "Errors.Missing.InstanceID")
|
|
}
|
|
|
|
// get session
|
|
sessionRepo := opts.sessionRepo
|
|
retrievedSession, err := sessionRepo.Get(
|
|
ctx,
|
|
opts.DB(),
|
|
database.WithCondition(sessionRepo.PrimaryKeyCondition(p.InstanceID, p.SessionID)),
|
|
)
|
|
if err := handleGetError(err, "DOM-zy4hYC", objectTypeSession); err != nil {
|
|
return err
|
|
}
|
|
if retrievedSession.UserID == "" {
|
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-uVyrt2", "Errors.Missing.Session.UserID")
|
|
}
|
|
|
|
// retrieve user and their passkeys based on user verification requirement
|
|
passkeyType := determinePasskeyType(p.ChallengeTypePasskey.UserVerificationRequirement)
|
|
userRepo := opts.userRepo
|
|
retrievedUser, err := userRepo.Get(
|
|
ctx,
|
|
opts.DB(),
|
|
database.WithCondition(userRepo.PrimaryKeyCondition(p.InstanceID, retrievedSession.UserID)),
|
|
database.WithCondition(userRepo.Human().PasskeyConditions().TypeCondition(database.TextOperationEqual, passkeyType)),
|
|
)
|
|
if err := handleGetError(err, "DOM-8cGMtd", objectTypeUser); err != nil {
|
|
return err
|
|
}
|
|
|
|
// ensure the user is a human user
|
|
if retrievedUser.Human == nil {
|
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-nd3f4", "Errors.User.NotHuman")
|
|
}
|
|
|
|
// ensure the user is active
|
|
if retrievedUser.State != UserStateActive {
|
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-bnxBdS", "Errors.User.NotFound")
|
|
}
|
|
|
|
p.user = retrievedUser
|
|
|
|
return nil
|
|
}
|
|
|
|
// Execute implements [Commander].
|
|
// It begins the WebAuthN login process and updates the session with the passkey challenge.
|
|
func (p *PasskeyChallengeCommand) Execute(ctx context.Context, opts *InvokeOpts) (err error) {
|
|
if p.ChallengeTypePasskey == nil {
|
|
return nil
|
|
}
|
|
// begin webauthn login
|
|
sessionData, credentialAssertionData, rpID, err := p.beginWebAuthNLogin(ctx, p.ChallengeTypePasskey.UserVerificationRequirement)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// to set the challenge for CreateSessionResponse
|
|
webAuthNChallenge := &session_grpc.Challenges_WebAuthN{
|
|
PublicKeyCredentialRequestOptions: new(structpb.Struct),
|
|
}
|
|
if err = json.Unmarshal(credentialAssertionData, webAuthNChallenge.PublicKeyCredentialRequestOptions); err != nil {
|
|
return zerrors.ThrowInternal(err, "DOM-liSCA4", "Errors.Unmarshal")
|
|
}
|
|
|
|
// update the session with the passkey challenge
|
|
challengePasskey := &SessionChallengePasskey{
|
|
Challenge: sessionData.Challenge,
|
|
AllowedCredentialIDs: sessionData.AllowedCredentialIDs,
|
|
UserVerification: p.ChallengeTypePasskey.UserVerificationRequirement,
|
|
RPID: rpID,
|
|
LastChallengedAt: time.Now(),
|
|
}
|
|
sessionRepo := opts.sessionRepo
|
|
updated, err := sessionRepo.Update(
|
|
ctx,
|
|
opts.DB(),
|
|
sessionRepo.PrimaryKeyCondition(p.InstanceID, p.SessionID),
|
|
sessionRepo.SetChallenge(challengePasskey),
|
|
)
|
|
if err := handleUpdateError(err, expectedUpdatedRows, updated, "DOM-yd3f4", objectTypeSession); err != nil {
|
|
return err
|
|
}
|
|
p.webAuthNChallenge = webAuthNChallenge
|
|
p.challengePasskey = challengePasskey
|
|
|
|
return nil
|
|
}
|
|
|
|
// Events implements [Commander].
|
|
// It creates a WebAuthN challenged event if a passkey challenge was requested and created.
|
|
func (p *PasskeyChallengeCommand) Events(ctx context.Context, _ *InvokeOpts) ([]eventstore.Command, error) {
|
|
if p.ChallengeTypePasskey == nil {
|
|
return nil, nil
|
|
}
|
|
return []eventstore.Command{
|
|
session.NewWebAuthNChallengedEvent(
|
|
ctx,
|
|
&session.NewAggregate(p.SessionID, p.InstanceID).Aggregate,
|
|
p.challengePasskey.Challenge,
|
|
p.challengePasskey.AllowedCredentialIDs,
|
|
p.challengePasskey.UserVerification,
|
|
p.challengePasskey.RPID,
|
|
),
|
|
}, nil
|
|
}
|
|
|
|
func (p *PasskeyChallengeCommand) GetWebAuthNChallenge() *session_grpc.Challenges_WebAuthN {
|
|
return p.webAuthNChallenge
|
|
}
|
|
|
|
func (p *PasskeyChallengeCommand) String() string {
|
|
return "PasskeyChallengeCommand"
|
|
}
|
|
|
|
// beginWebAuthNLogin starts the WebAuthN login process for the user with the specified user verification requirement.
|
|
func (p *PasskeyChallengeCommand) beginWebAuthNLogin(ctx context.Context, userVerificationDomain old_domain.UserVerificationRequirement) (*webauthn.SessionData, []byte, string, error) {
|
|
webUser := &webAuthNUser{
|
|
userID: p.user.ID,
|
|
username: p.user.Username,
|
|
displayName: p.user.Human.DisplayName,
|
|
creds: PasskeysToCredentials(ctx, p.user.Human.Passkeys, p.ChallengeTypePasskey.Domain),
|
|
}
|
|
sessionData, credentialAssertionData, rpID, err := p.beginLogin(
|
|
ctx,
|
|
webUser,
|
|
p.ChallengeTypePasskey.Domain,
|
|
UserVerificationFromDomain(userVerificationDomain),
|
|
)
|
|
if err != nil {
|
|
return nil, nil, "", err
|
|
}
|
|
return sessionData, credentialAssertionData, rpID, nil
|
|
}
|
|
|
|
// determinePasskeyType determines the passkey type (U2F or Passwordless) based on the user verification requirement.
|
|
func determinePasskeyType(userVerificationRequirement old_domain.UserVerificationRequirement) PasskeyType {
|
|
passkeyType := PasskeyTypeU2F
|
|
if userVerificationRequirement == old_domain.UserVerificationRequirementRequired {
|
|
passkeyType = PasskeyTypePasswordless
|
|
}
|
|
return passkeyType
|
|
}
|