diff --git a/internal/api/mfa.go b/internal/api/mfa.go index 81c80ab92..dd98d66d6 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -906,32 +906,34 @@ func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, param return err } webAuthnSession := *challenge.WebAuthnSessionData.SessionData - // Once the challenge is validated, we consume the challenge - if err := db.Destroy(challenge); err != nil { - return apierrors.NewInternalServerError("Database error deleting challenge").WithInternalError(err) - } + var parsedResponse interface{} switch params.WebAuthn.Type { case "create": - parsedResponse, err := wbnprotocol.ParseCredentialCreationResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse)) + parsedResponse, err = wbnprotocol.ParseCredentialCreationResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse)) if err != nil { return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_response") } - credential, err = webAuthn.CreateCredential(user, webAuthnSession, parsedResponse) + credential, err = webAuthn.CreateCredential(user, webAuthnSession, parsedResponse.(*wbnprotocol.ParsedCredentialCreationData)) if err != nil { return err } case "request": - parsedResponse, err := wbnprotocol.ParseCredentialRequestResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse)) + parsedResponse, err = wbnprotocol.ParseCredentialRequestResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse)) if err != nil { return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_response") } - credential, err = webAuthn.ValidateLogin(user, webAuthnSession, parsedResponse) + credential, err = webAuthn.ValidateLogin(user, webAuthnSession, parsedResponse.(*wbnprotocol.ParsedCredentialAssertionData)) if err != nil { return apierrors.NewInternalServerError("Failed to validate WebAuthn MFA response").WithInternalError(err) } } + + // Once the challenge is validated, we consume the challenge + if err := db.Destroy(challenge); err != nil { + return apierrors.NewInternalServerError("Database error deleting challenge").WithInternalError(err) + } var token *AccessTokenResponse err = db.Transaction(func(tx *storage.Connection) error { var terr error @@ -951,6 +953,10 @@ func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, param return terr } } + + if terr = factor.UpdateLastWebAuthnChallenge(tx, challenge, params.WebAuthn.Type, parsedResponse); terr != nil { + return terr + } user, terr = models.FindUserByID(tx, user.ID) if terr != nil { return terr diff --git a/internal/models/factor.go b/internal/models/factor.go index 48ebd67fd..ad08e02ec 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -139,19 +139,20 @@ func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error) type Factor struct { ID uuid.UUID `json:"id" db:"id"` // TODO: Consider removing this nested user field. We don't use it. - User User `json:"-" belongs_to:"user"` - UserID uuid.UUID `json:"-" db:"user_id"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - Status string `json:"status" db:"status"` - FriendlyName string `json:"friendly_name,omitempty" db:"friendly_name"` - Secret string `json:"-" db:"secret"` - FactorType string `json:"factor_type" db:"factor_type"` - Challenge []Challenge `json:"-" has_many:"challenges"` - Phone storage.NullString `json:"phone" db:"phone"` - LastChallengedAt *time.Time `json:"last_challenged_at" db:"last_challenged_at"` - WebAuthnCredential *WebAuthnCredential `json:"-" db:"web_authn_credential"` - WebAuthnAAGUID *uuid.UUID `json:"web_authn_aaguid,omitempty" db:"web_authn_aaguid"` + User User `json:"-" belongs_to:"user"` + UserID uuid.UUID `json:"-" db:"user_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + Status string `json:"status" db:"status"` + FriendlyName string `json:"friendly_name,omitempty" db:"friendly_name"` + Secret string `json:"-" db:"secret"` + FactorType string `json:"factor_type" db:"factor_type"` + Challenge []Challenge `json:"-" has_many:"challenges"` + Phone storage.NullString `json:"phone" db:"phone"` + LastChallengedAt *time.Time `json:"last_challenged_at" db:"last_challenged_at"` + WebAuthnCredential *WebAuthnCredential `json:"-" db:"web_authn_credential"` + WebAuthnAAGUID *uuid.UUID `json:"web_authn_aaguid,omitempty" db:"web_authn_aaguid"` + LastWebAuthnChallengeData *LastWebAuthnChallengeData `json:"last_webauthn_challenge_data,omitempty" db:"last_webauthn_challenge_data"` } type WebAuthnCredential struct { @@ -165,6 +166,40 @@ func (wc *WebAuthnCredential) Value() (driver.Value, error) { return json.Marshal(wc) } +type LastWebAuthnChallengeData struct { + Challenge Challenge `json:"challenge"` + Type string `json:"type"` + CredentialResponse json.RawMessage `json:"credential_response"` +} + +func (lwcd *LastWebAuthnChallengeData) Value() (driver.Value, error) { + if lwcd == nil { + return nil, nil + } + return json.Marshal(lwcd) +} + +func (lwcd *LastWebAuthnChallengeData) Scan(value interface{}) error { + if value == nil { + *lwcd = LastWebAuthnChallengeData{} + return nil + } + var data []byte + switch v := value.(type) { + case []byte: + data = v + case string: + data = []byte(v) + default: + return fmt.Errorf("unsupported type for last_webauthn_challenge_data: %T", value) + } + if len(data) == 0 { + *lwcd = LastWebAuthnChallengeData{} + return nil + } + return json.Unmarshal(data, lwcd) +} + func (wc *WebAuthnCredential) Scan(value interface{}) error { if value == nil { wc.Credential = webauthn.Credential{} @@ -265,6 +300,21 @@ func (f *Factor) SaveWebAuthnCredential(tx *storage.Connection, credential *weba return tx.UpdateOnly(f, "web_authn_credential", "web_authn_aaguid", "updated_at") } +func (f *Factor) UpdateLastWebAuthnChallenge(tx *storage.Connection, challenge *Challenge, challengeType string, credentialResponse interface{}) error { + responseData, err := json.Marshal(credentialResponse) + if err != nil { + return fmt.Errorf("failed to marshal credential response: %w", err) + } + + f.LastWebAuthnChallengeData = &LastWebAuthnChallengeData{ + Challenge: *challenge, + Type: challengeType, + CredentialResponse: json.RawMessage(responseData), + } + + return tx.UpdateOnly(f, "last_webauthn_challenge_data", "updated_at") +} + func FindFactorByFactorID(conn *storage.Connection, factorID uuid.UUID) (*Factor, error) { var factor Factor err := conn.Find(&factor, factorID) diff --git a/migrations/20250925093508_add_last_webauthn_challenge_data.up.sql b/migrations/20250925093508_add_last_webauthn_challenge_data.up.sql new file mode 100644 index 000000000..42b760948 --- /dev/null +++ b/migrations/20250925093508_add_last_webauthn_challenge_data.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE {{ index .Options "Namespace" }}.mfa_factors +ADD COLUMN IF NOT EXISTS last_webauthn_challenge_data JSONB; + +COMMENT ON COLUMN {{ index .Options "Namespace" }}.mfa_factors.last_webauthn_challenge_data IS 'Stores the latest WebAuthn challenge data including attestation/assertion for customer verification';