From 7446c0f9915e89bf245efcc06f4ea913cdae4901 Mon Sep 17 00:00:00 2001 From: joel Date: Fri, 13 Sep 2024 11:32:00 +0300 Subject: [PATCH] fix: add verify --- internal/api/mfa.go | 131 ++++++++++++++++++++++++++++++++++++-- internal/models/factor.go | 12 ++++ 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/internal/api/mfa.go b/internal/api/mfa.go index 7bb9bea5a..2a2d28ae7 100644 --- a/internal/api/mfa.go +++ b/internal/api/mfa.go @@ -3,6 +3,7 @@ package api import ( "bytes" "crypto/subtle" + "encoding/json" "fmt" "net/http" "net/url" @@ -55,8 +56,9 @@ type ChallengeFactorParams struct { } type VerifyFactorParams struct { - ChallengeID uuid.UUID `json:"challenge_id"` - Code string `json:"code"` + ChallengeID uuid.UUID `json:"challenge_id"` + Code string `json:"code"` + WebAuthn *WebAuthnParams `json:"web_authn,omitempty"` } type ChallengeFactorResponse struct { @@ -862,6 +864,120 @@ func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params * return sendJSON(w, http.StatusOK, token) } +func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, params *VerifyFactorParams) error { + ctx := r.Context() + user := getUser(ctx) + factor := getFactor(ctx) + db := a.db.WithContext(ctx) + var webAuthn *webauthn.WebAuthn + var credential *webauthn.Credential + var err error + switch { + case params.WebAuthn == nil: + return badRequestError(ErrorCodeValidationFailed, "WebAuthn config required") + case factor.IsVerified() && params.WebAuthn.AssertionResponse == nil: + return badRequestError(ErrorCodeValidationFailed, "WebAuthn Assertion Response required to login") + case factor.IsUnverified() && params.WebAuthn.CreationResponse == nil: + return badRequestError(ErrorCodeValidationFailed, "WebAuthn Creation Response required to login") + default: + webAuthn, err = validateWebAuthnConfig(params.WebAuthn) + if err != nil { + return err + } + } + challenge, err := factor.FindChallengeByID(a.db, params.ChallengeID) + if err != nil { + return err + } + // TODO: change so we don't handle pointer + webAuthnSession := *challenge.WebAuthnSessionData.SessionData + + if factor.IsUnverified() { + creationResponseJSON, err := json.Marshal(params.WebAuthn.CreationResponse) + if err != nil { + return badRequestError(ErrorCodeValidationFailed, "Failed to marshal CreationResponse to JSON") + } + creationResponseReader := bytes.NewReader(creationResponseJSON) + parsedResponse, err := wbnprotocol.ParseCredentialCreationResponseBody(creationResponseReader) + if err != nil { + return badRequestError(ErrorCodeValidationFailed, "Invalid credential creation response") + } + + // Destroy right before use + // TODO: Consider wrapping into function + if err := db.Destroy(challenge); err != nil { + return internalServerError("Database error deleting challenge").WithInternalError(err) + } + credential, err = webAuthn.CreateCredential(user, webAuthnSession, parsedResponse) + if err != nil { + return err + } + + } else if factor.IsVerified() { + assertionResponseJSON, err := json.Marshal(params.WebAuthn.AssertionResponse) + if err != nil { + return badRequestError(ErrorCodeValidationFailed, "Failed to marshal AssertionResponse to JSON") + } + assertionResponseReader := bytes.NewReader(assertionResponseJSON) + parsedResponse, err := wbnprotocol.ParseCredentialRequestResponseBody(assertionResponseReader) + if err != nil { + return badRequestError(ErrorCodeValidationFailed, "Invalid credential request response") + } + if err := db.Destroy(challenge); err != nil { + return internalServerError("Database error deleting challenge").WithInternalError(err) + } + credential, err = webAuthn.ValidateLogin(user, webAuthnSession, parsedResponse) + if err != nil { + return internalServerError("error validating WebAuthn credentials") + } + } + var token *AccessTokenResponse + err = db.Transaction(func(tx *storage.Connection) error { + var terr error + if terr = models.NewAuditLogEntry(r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{ + "factor_id": factor.ID, + "challenge_id": challenge.ID, + "factor_type": factor.FactorType, + }); terr != nil { + return terr + } + if terr = challenge.Verify(tx); terr != nil { + return terr + } + if !factor.IsVerified() { + if terr = factor.UpdateStatus(tx, models.FactorStateVerified); terr != nil { + return terr + } + if terr = factor.SaveWebAuthnCredential(tx, credential); terr != nil { + return terr + } + } + user, terr = models.FindUserByID(tx, user.ID) + if terr != nil { + return terr + } + token, terr = a.updateMFASessionAndClaims(r, tx, user, models.MFAWebAuthn, models.GrantParams{ + FactorID: &factor.ID, + }) + if terr != nil { + return terr + } + if terr = models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()); terr != nil { + return internalServerError("Failed to update sessions. %s", terr) + } + if terr = models.DeleteUnverifiedFactors(tx, user, models.WebAuthn); terr != nil { + return internalServerError("Error removing unverified factors. %s", terr) + } + return nil + }) + if err != nil { + return err + } + metering.RecordLogin(string(models.MFACodeLoginAction), user.ID) + + return sendJSON(w, http.StatusOK, token) +} + func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() factor := getFactor(ctx) @@ -878,17 +994,22 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error { switch factor.FactorType { case models.Phone: if !config.MFA.Phone.VerifyEnabled { - return unprocessableEntityError(ErrorCodeMFAPhoneEnrollDisabled, "MFA verification is disabled for Phone") + return unprocessableEntityError(ErrorCodeMFAPhoneVerifyDisabled, "MFA verification is disabled for Phone") } return a.verifyPhoneFactor(w, r, params) case models.TOTP: if !config.MFA.TOTP.VerifyEnabled { - return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA verification is disabled for TOTP") + return unprocessableEntityError(ErrorCodeMFATOTPVerifyDisabled, "MFA verification is disabled for TOTP") } return a.verifyTOTPFactor(w, r, params) + case models.WebAuthn: + if !config.MFA.WebAuthn.VerifyEnabled { + return unprocessableEntityError(ErrorCodeMFAWebAuthnEnrollDisabled, "MFA verification is disabled for WebAuthn") + } + return a.verifyWebAuthnFactor(w, r, params) default: - return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be TOTP or Phone") + return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be totp, phone, or webauthn") } } diff --git a/internal/models/factor.go b/internal/models/factor.go index 6d929cafd..f13cc75f3 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -45,6 +45,7 @@ const ( OTP TOTPSignIn MFAPhone + MFAWebAuthn SSOSAML Recovery Invite @@ -83,6 +84,8 @@ func (authMethod AuthenticationMethod) String() string { return "anonymous" case MFAPhone: return "mfa/phone" + case MFAWebAuthn: + return "mfa/webauthn" } return "" } @@ -116,6 +119,8 @@ func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error) return TokenRefresh, nil case "mfa/sms": return MFAPhone, nil + case "mfa/webauthn": + return MFAWebAuthn, nil } return 0, fmt.Errorf("unsupported authentication method %q", authMethod) } @@ -214,6 +219,13 @@ func (f *Factor) GetSecret(decryptionKeys map[string]string, encrypt bool, encry return f.Secret, encrypt, nil } +func (f *Factor) SaveWebAuthnCredential(tx *storage.Connection, credential *webauthn.Credential) error { + f.WebAuthnCredential = &WebAuthnCredential{ + Credential: *credential, + } + return tx.UpdateOnly(f, "credential", "updated_at") +} + func FindFactorByFactorID(conn *storage.Connection, factorID uuid.UUID) (*Factor, error) { var factor Factor err := conn.Find(&factor, factorID)