feat(mfa): account MFA management, passkey registration and step-up
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
// HandleEnrolTOTPSetup starts enrolment for a user the instance requires MFA
|
||||
@@ -91,3 +95,120 @@ func finishEnrolment(c *gin.Context, t *Ticket, ticketID, factor string) {
|
||||
services.LogEvent(t.InstanceID, "mfa.enrolled", u.Email, "", "", "factor="+factor)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
|
||||
}
|
||||
|
||||
// HandleEnrolPasskeyBegin starts forced passkey enrolment during sign-in.
|
||||
// Identical to HandleRegisterPasskeyBegin except the user comes from the
|
||||
// enrol-only ticket rather than a session, since none exists yet.
|
||||
//
|
||||
// @Summary Begin forced passkey enrolment during sign-in
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/enrol/passkey/begin [post]
|
||||
func HandleEnrolPasskeyBegin(c *gin.Context) {
|
||||
t, _, ok := ticketFromRequest(c, scopeEnrol)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
existing, err := services.ListPasskeys(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
lib := make([]webauthn.Credential, 0, len(existing))
|
||||
for _, cr := range existing {
|
||||
lib = append(lib, toLibCredential(cr))
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
options, sessionData, err := w.BeginRegistration(
|
||||
waUser{handle: handle, name: t.Email, credentials: lib},
|
||||
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandleEnrolPasskeyFinish stores the new credential and finishes forced
|
||||
// enrolment, minting the session that HandleEnrolTOTPConfirm also produces.
|
||||
//
|
||||
// @Summary Complete forced passkey enrolment and sign in
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
|
||||
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
|
||||
// @Failure 401 {object} object{error=string,code=string}
|
||||
// @Router /auth/mfa/enrol/passkey/finish [post]
|
||||
func HandleEnrolPasskeyFinish(c *gin.Context) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
CeremonyID string `json:"ceremony_id"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
|
||||
return
|
||||
}
|
||||
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that enrolment expired", "code": "mfa_ticket_expired"})
|
||||
return
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
|
||||
return
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
|
||||
return
|
||||
}
|
||||
cred, err := w.CreateCredential(waUser{handle: handle, name: t.Email}, *sessionData, parsed)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
|
||||
return
|
||||
}
|
||||
if !cred.Flags.UserVerified {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "this passkey does not verify the user"})
|
||||
return
|
||||
}
|
||||
transports := make([]string, 0, len(parsed.Response.Transports))
|
||||
for _, tr := range parsed.Response.Transports {
|
||||
transports = append(transports, string(tr))
|
||||
}
|
||||
if err := services.SavePasskey(t.InstanceID, t.UserID, body.Name,
|
||||
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
|
||||
transports); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
|
||||
return
|
||||
}
|
||||
finishEnrolment(c, t, ticketID, services.FactorWebAuthn)
|
||||
}
|
||||
|
||||
@@ -56,3 +56,15 @@ func RequireStepUp() gin.HandlerFunc {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TouchStepUpFromRequest records a fresh step-up against the session the
|
||||
// request arrived on. It lives in auth because the cookie name and the Redis
|
||||
// key are this package's business.
|
||||
func TouchStepUpFromRequest(c *gin.Context) error {
|
||||
cookie, err := c.Request.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess := GetSessionFromContext(c)
|
||||
return TouchStepUp(c.Request.Context(), cookie.Value, sess)
|
||||
}
|
||||
|
||||
@@ -200,6 +200,123 @@ func HandleMFAWebAuthnFinish(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// HandleRegisterPasskeyBegin starts registration for the signed-in user.
|
||||
//
|
||||
// @Summary Begin passkey registration
|
||||
// @Tags mfa
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
|
||||
// @Router /me/passkeys/begin [post]
|
||||
func HandleRegisterPasskeyBegin(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
existing, err := services.ListPasskeys(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
lib := make([]webauthn.Credential, 0, len(existing))
|
||||
for _, cr := range existing {
|
||||
lib = append(lib, toLibCredential(cr))
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
options, sessionData, err := w.BeginRegistration(
|
||||
waUser{handle: handle, name: sess.Email, credentials: lib},
|
||||
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
id, err := saveCeremony(c.Request.Context(), sessionData)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
|
||||
}
|
||||
|
||||
// HandleRegisterPasskeyFinish stores the new credential.
|
||||
//
|
||||
// @Summary Complete passkey registration
|
||||
// @Tags mfa
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
|
||||
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
|
||||
// @Router /me/passkeys/finish [post]
|
||||
func HandleRegisterPasskeyFinish(c *gin.Context) {
|
||||
sess := GetSessionFromContext(c)
|
||||
var body struct {
|
||||
CeremonyID string `json:"ceremony_id"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
|
||||
return
|
||||
}
|
||||
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "that registration expired", "code": "mfa_ticket_expired"})
|
||||
return
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
|
||||
return
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
|
||||
return
|
||||
}
|
||||
cred, err := w.CreateCredential(waUser{handle: handle, name: sess.Email}, *sessionData, parsed)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
|
||||
return
|
||||
}
|
||||
if !cred.Flags.UserVerified {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "this passkey does not verify the user"})
|
||||
return
|
||||
}
|
||||
transports := make([]string, 0, len(parsed.Response.Transports))
|
||||
for _, t := range parsed.Response.Transports {
|
||||
transports = append(transports, string(t))
|
||||
}
|
||||
if err := services.SavePasskey(sess.InstanceID, sess.UserID, body.Name,
|
||||
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
|
||||
transports); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
|
||||
return
|
||||
}
|
||||
services.LogEvent(sess.InstanceID, "mfa.enrolled", sess.Email, "", "", "factor=webauthn")
|
||||
|
||||
// A first factor earns recovery codes; later ones do not reissue them.
|
||||
m, _ := services.GetUserMFA(sess.InstanceID, sess.UserID)
|
||||
if services.RecoveryCodesRemaining(m) == 0 {
|
||||
codes, err := services.IssueRecoveryCodes(sess.InstanceID, sess.UserID)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
|
||||
return
|
||||
}
|
||||
}
|
||||
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