feat(mfa): passwordless passkey sign-in

This commit is contained in:
2026-09-16 08:58:26 +00:00
parent 14a1cdb2b0
commit d54d8971b2
2 changed files with 110 additions and 0 deletions
+2
View File
@@ -47,6 +47,8 @@ func RegisterRoutes(r *gin.Engine) {
r.POST("/auth/mfa/recovery", auth.HandleMFARecovery)
r.POST("/auth/mfa/webauthn/begin", auth.HandleMFAWebAuthnBegin)
r.POST("/auth/mfa/webauthn/finish", auth.HandleMFAWebAuthnFinish)
r.POST("/auth/passkey/begin", auth.HandlePasskeyLoginBegin)
r.POST("/auth/passkey/finish", auth.HandlePasskeyLoginFinish)
r.POST("/auth/mfa/enrol/totp/setup", auth.HandleEnrolTOTPSetup)
r.POST("/auth/mfa/enrol/totp/confirm", auth.HandleEnrolTOTPConfirm)
r.POST("/auth/logout", auth.HandleLogout)
+108
View File
@@ -0,0 +1,108 @@
package auth
import (
"errors"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
// HandlePasskeyLoginBegin starts a passwordless sign-in.
//
// It repeats every gate /auth/login applies - instance resolution, the locked
// instance refusal, and the local-login setting - because this is a second
// front door, and a front door that skips the locks is not a shortcut.
//
// @Summary Begin passwordless passkey sign-in
// @Tags auth
// @Produce json
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
// @Failure 403 {object} object{error=string}
// @Router /auth/passkey/begin [post]
func HandlePasskeyLoginBegin(c *gin.Context) {
instanceID, err := resolveLoginInstance(c)
if errors.Is(err, ErrInstanceLocked) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "locked": true})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !services.LocalLoginPermitted(instanceID) {
c.JSON(http.StatusForbidden, gin.H{"error": "password sign-in is disabled for this instance"})
return
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"})
return
}
// Discoverable login: no allowCredentials, so the authenticator offers
// whichever resident credential it holds for this RP ID.
options, sessionData, err := w.BeginDiscoverableLogin(
webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandlePasskeyLoginFinish verifies a discoverable assertion and mints a
// session. A user-verified passkey is possession plus a PIN or biometric, so it
// satisfies require_mfa on its own.
//
// @Summary Complete passwordless passkey sign-in
// @Tags auth
// @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 /auth/passkey/finish [post]
func HandlePasskeyLoginFinish(c *gin.Context) {
instanceID, err := resolveLoginInstance(c)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "sign-in is not available here"})
return
}
if !services.LocalLoginPermitted(instanceID) {
c.JSON(http.StatusForbidden, gin.H{"error": "password sign-in is disabled for this instance"})
return
}
// finishAssertion is given no userID or email, so it resolves the owning
// user itself from the credential ID scoped to this instance - the same
// requirement a discoverable login has to meet.
cred, err := finishAssertion(c, instanceID, "", "")
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "that passkey could not be verified", "code": "invalid_assertion",
})
return
}
stored, err := services.GetPasskeyByCredentialID(instanceID, cred.ID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
u, err := services.GetUserInInstance(instanceID, stored.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := mintSession(c, u, []string{services.FactorWebAuthn}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
_ = services.TouchPasskey(instanceID, cred.ID, cred.Authenticator.SignCount)
c.JSON(http.StatusOK, gin.H{"ok": true})
}