Skip to content

Commit 01ebce1

Browse files
Bewinxedhf
andauthored
feat: store latest challenge/attestation data (#2179)
## What kind of change does this PR introduce? Feature - Store WebAuthn challenge data for customer verification purposes ## What is the current behavior? Currently, WebAuthn challenge data (attestation/assertion responses) is not persisted after verification, making it impossible for customers to review or audit the WebAuthn authentication details. ## What is the new behavior? - Added `last_webauthn_challenge_data` JSONB column to `mfa_factors` table to store the latest challenge verification data - The system now stores the challenge, type (create/request), and parsed credential response after successful WebAuthn verification, THEN deletes the challenge like before. ## Additional context The structure for the JSONb would be like so, based on whether it's a `create` or `request` webauthn operation ```typescript type LastWebAuthnChallengeData = { challenge: ChallengeData, } & { type: "create" credential_response: ParsedCredentialCreationData } | { type: "request" credential_response: ParsedCredentialAssertionData } ```  --------- Co-authored-by: Stojan Dimitrovski <[email protected]>
1 parent 1731466 commit 01ebce1

File tree

3 files changed

+81
-21
lines changed

3 files changed

+81
-21
lines changed

internal/api/mfa.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -906,32 +906,34 @@ func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, param
906906
return err
907907
}
908908
webAuthnSession := *challenge.WebAuthnSessionData.SessionData
909-
// Once the challenge is validated, we consume the challenge
910-
if err := db.Destroy(challenge); err != nil {
911-
return apierrors.NewInternalServerError("Database error deleting challenge").WithInternalError(err)
912-
}
913909

910+
var parsedResponse interface{}
914911
switch params.WebAuthn.Type {
915912
case "create":
916-
parsedResponse, err := wbnprotocol.ParseCredentialCreationResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse))
913+
parsedResponse, err = wbnprotocol.ParseCredentialCreationResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse))
917914
if err != nil {
918915
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_response")
919916
}
920-
credential, err = webAuthn.CreateCredential(user, webAuthnSession, parsedResponse)
917+
credential, err = webAuthn.CreateCredential(user, webAuthnSession, parsedResponse.(*wbnprotocol.ParsedCredentialCreationData))
921918
if err != nil {
922919
return err
923920
}
924921

925922
case "request":
926-
parsedResponse, err := wbnprotocol.ParseCredentialRequestResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse))
923+
parsedResponse, err = wbnprotocol.ParseCredentialRequestResponseBody(bytes.NewReader(params.WebAuthn.CredentialResponse))
927924
if err != nil {
928925
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid credential_response")
929926
}
930-
credential, err = webAuthn.ValidateLogin(user, webAuthnSession, parsedResponse)
927+
credential, err = webAuthn.ValidateLogin(user, webAuthnSession, parsedResponse.(*wbnprotocol.ParsedCredentialAssertionData))
931928
if err != nil {
932929
return apierrors.NewInternalServerError("Failed to validate WebAuthn MFA response").WithInternalError(err)
933930
}
934931
}
932+
933+
// Once the challenge is validated, we consume the challenge
934+
if err := db.Destroy(challenge); err != nil {
935+
return apierrors.NewInternalServerError("Database error deleting challenge").WithInternalError(err)
936+
}
935937
var token *AccessTokenResponse
936938
err = db.Transaction(func(tx *storage.Connection) error {
937939
var terr error
@@ -951,6 +953,10 @@ func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, param
951953
return terr
952954
}
953955
}
956+
957+
if terr = factor.UpdateLastWebAuthnChallenge(tx, challenge, params.WebAuthn.Type, parsedResponse); terr != nil {
958+
return terr
959+
}
954960
user, terr = models.FindUserByID(tx, user.ID)
955961
if terr != nil {
956962
return terr

internal/models/factor.go

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -139,19 +139,20 @@ func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error)
139139
type Factor struct {
140140
ID uuid.UUID `json:"id" db:"id"`
141141
// TODO: Consider removing this nested user field. We don't use it.
142-
User User `json:"-" belongs_to:"user"`
143-
UserID uuid.UUID `json:"-" db:"user_id"`
144-
CreatedAt time.Time `json:"created_at" db:"created_at"`
145-
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
146-
Status string `json:"status" db:"status"`
147-
FriendlyName string `json:"friendly_name,omitempty" db:"friendly_name"`
148-
Secret string `json:"-" db:"secret"`
149-
FactorType string `json:"factor_type" db:"factor_type"`
150-
Challenge []Challenge `json:"-" has_many:"challenges"`
151-
Phone storage.NullString `json:"phone" db:"phone"`
152-
LastChallengedAt *time.Time `json:"last_challenged_at" db:"last_challenged_at"`
153-
WebAuthnCredential *WebAuthnCredential `json:"-" db:"web_authn_credential"`
154-
WebAuthnAAGUID *uuid.UUID `json:"web_authn_aaguid,omitempty" db:"web_authn_aaguid"`
142+
User User `json:"-" belongs_to:"user"`
143+
UserID uuid.UUID `json:"-" db:"user_id"`
144+
CreatedAt time.Time `json:"created_at" db:"created_at"`
145+
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
146+
Status string `json:"status" db:"status"`
147+
FriendlyName string `json:"friendly_name,omitempty" db:"friendly_name"`
148+
Secret string `json:"-" db:"secret"`
149+
FactorType string `json:"factor_type" db:"factor_type"`
150+
Challenge []Challenge `json:"-" has_many:"challenges"`
151+
Phone storage.NullString `json:"phone" db:"phone"`
152+
LastChallengedAt *time.Time `json:"last_challenged_at" db:"last_challenged_at"`
153+
WebAuthnCredential *WebAuthnCredential `json:"-" db:"web_authn_credential"`
154+
WebAuthnAAGUID *uuid.UUID `json:"web_authn_aaguid,omitempty" db:"web_authn_aaguid"`
155+
LastWebAuthnChallengeData *LastWebAuthnChallengeData `json:"last_webauthn_challenge_data,omitempty" db:"last_webauthn_challenge_data"`
155156
}
156157

157158
type WebAuthnCredential struct {
@@ -165,6 +166,40 @@ func (wc *WebAuthnCredential) Value() (driver.Value, error) {
165166
return json.Marshal(wc)
166167
}
167168

169+
type LastWebAuthnChallengeData struct {
170+
Challenge Challenge `json:"challenge"`
171+
Type string `json:"type"`
172+
CredentialResponse json.RawMessage `json:"credential_response"`
173+
}
174+
175+
func (lwcd *LastWebAuthnChallengeData) Value() (driver.Value, error) {
176+
if lwcd == nil {
177+
return nil, nil
178+
}
179+
return json.Marshal(lwcd)
180+
}
181+
182+
func (lwcd *LastWebAuthnChallengeData) Scan(value interface{}) error {
183+
if value == nil {
184+
*lwcd = LastWebAuthnChallengeData{}
185+
return nil
186+
}
187+
var data []byte
188+
switch v := value.(type) {
189+
case []byte:
190+
data = v
191+
case string:
192+
data = []byte(v)
193+
default:
194+
return fmt.Errorf("unsupported type for last_webauthn_challenge_data: %T", value)
195+
}
196+
if len(data) == 0 {
197+
*lwcd = LastWebAuthnChallengeData{}
198+
return nil
199+
}
200+
return json.Unmarshal(data, lwcd)
201+
}
202+
168203
func (wc *WebAuthnCredential) Scan(value interface{}) error {
169204
if value == nil {
170205
wc.Credential = webauthn.Credential{}
@@ -265,6 +300,21 @@ func (f *Factor) SaveWebAuthnCredential(tx *storage.Connection, credential *weba
265300
return tx.UpdateOnly(f, "web_authn_credential", "web_authn_aaguid", "updated_at")
266301
}
267302

303+
func (f *Factor) UpdateLastWebAuthnChallenge(tx *storage.Connection, challenge *Challenge, challengeType string, credentialResponse interface{}) error {
304+
responseData, err := json.Marshal(credentialResponse)
305+
if err != nil {
306+
return fmt.Errorf("failed to marshal credential response: %w", err)
307+
}
308+
309+
f.LastWebAuthnChallengeData = &LastWebAuthnChallengeData{
310+
Challenge: *challenge,
311+
Type: challengeType,
312+
CredentialResponse: json.RawMessage(responseData),
313+
}
314+
315+
return tx.UpdateOnly(f, "last_webauthn_challenge_data", "updated_at")
316+
}
317+
268318
func FindFactorByFactorID(conn *storage.Connection, factorID uuid.UUID) (*Factor, error) {
269319
var factor Factor
270320
err := conn.Find(&factor, factorID)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE {{ index .Options "Namespace" }}.mfa_factors
2+
ADD COLUMN IF NOT EXISTS last_webauthn_challenge_data JSONB;
3+
4+
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';

0 commit comments

Comments
 (0)