fix(mfa): wire up webauthn step-up routes
The missing /me/step-up/webauthn/begin and /finish were a plan defect, not an acceptable gap: a user whose only factor is a passkey was offered only recovery codes for step-up, burning one every ten minutes. Adds the handlers in package auth (session-authenticated, not themselves behind RequireStepUp, modeled on HandleMFAWebAuthnBegin/finishAssertion) and registers both routes behind the same RateLimitAuth() as /me/step-up. StepUpModal now offers "Use passkey" when the server names webauthn and the browser supports WebAuthn.
This commit is contained in:
@@ -119,6 +119,8 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.PATCH("/me/passkeys/:id", renamePasskey)
|
||||
apiGroup.DELETE("/me/passkeys/:id", auth.RequireStepUp(), deletePasskey)
|
||||
apiGroup.POST("/me/step-up", RateLimitAuth(), stepUp)
|
||||
apiGroup.POST("/me/step-up/webauthn/begin", RateLimitAuth(), auth.HandleStepUpWebAuthnBegin)
|
||||
apiGroup.POST("/me/step-up/webauthn/finish", RateLimitAuth(), auth.HandleStepUpWebAuthnFinish)
|
||||
apiGroup.DELETE("/org/users/:id/mfa", auth.RequireRole("owner", "admin"), auth.RequireStepUp(), resetUserMFA)
|
||||
|
||||
apiGroup.GET("/openapi.json", getOpenAPI)
|
||||
|
||||
@@ -317,6 +317,83 @@ func HandleRegisterPasskeyFinish(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// HandleStepUpWebAuthnBegin offers an assertion challenge for step-up
|
||||
// re-authentication: the signed-in user proving it is still them before a
|
||||
// guarded action, rather than a pending ticket proving it during sign-in.
|
||||
//
|
||||
// @Summary Begin passkey step-up
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Failure 400 {object} object{error=string}
|
||||
// @Router /me/step-up/webauthn/begin [post]
|
||||
func HandleStepUpWebAuthnBegin(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
creds, err := services.ListPasskeys(sess.InstanceID, sess.UserID)
|
||||
if err != nil || len(creds) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
lib := make([]webauthn.Credential, 0, len(creds))
|
||||
for _, cr := range creds {
|
||||
lib = append(lib, toLibCredential(cr))
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: sess.Email, credentials: lib},
|
||||
webauthn.WithUserVerification(protocol.VerificationRequired))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandleStepUpWebAuthnFinish verifies the assertion and records a fresh
|
||||
// step-up for the current session. Unlike HandleMFAWebAuthnFinish this does
|
||||
// not mint a session: the caller is already signed in, this only proves they
|
||||
// still hold the passkey. finishAssertion is called with the session's own
|
||||
// user ID, so a credential belonging to somebody else is refused rather than
|
||||
// stepping up this session on their behalf.
|
||||
//
|
||||
// @Summary Complete passkey step-up
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,credential=object} true "Assertion"
|
||||
// @Success 200 {object} object{ok=bool}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /me/step-up/webauthn/finish [post]
|
||||
func HandleStepUpWebAuthnFinish(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
cred, err := finishAssertion(c, sess.InstanceID, sess.UserID, sess.Email)
|
||||
if err != nil {
|
||||
services.LogEvent(sess.InstanceID, "step_up.failed", sess.Email, "", "", "factor=webauthn")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that passkey could not be verified", "code": "invalid_assertion"})
|
||||
return
|
||||
}
|
||||
_ = services.TouchPasskey(sess.InstanceID, cred.ID, cred.Authenticator.SignCount)
|
||||
if err := TouchStepUpFromRequest(c); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"})
|
||||
return
|
||||
}
|
||||
services.LogEvent(sess.InstanceID, "step_up.ok", sess.Email, "", "", "factor=webauthn")
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// finishAssertion is shared by second-factor sign-in, passwordless sign-in and
|
||||
// step-up, so the verification rules (user verification, clone detection,
|
||||
// instance scope) exist once.
|
||||
|
||||
Reference in New Issue
Block a user