feat(mfa): passwordless passkey sign-in
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user