diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index df17f89..96bd1fa 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -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) diff --git a/server/internal/auth/passkey_login.go b/server/internal/auth/passkey_login.go new file mode 100644 index 0000000..fb8fc0b --- /dev/null +++ b/server/internal/auth/passkey_login.go @@ -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}) +}