feat(mfa): passkey verification as a second factor
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"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"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
ceremonyPrefix = "km:wa:"
|
||||
ceremonyTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// rpConfig derives the relying party from the request. The RP ID is the host
|
||||
// without its port - WebAuthn forbids a port there - while the origin keeps it.
|
||||
//
|
||||
// This is why the reverse proxy must preserve Host: a proxy rewriting it makes
|
||||
// every passkey on the instance fail to verify, with no error that says so.
|
||||
func rpConfig(c *gin.Context) (string, string) {
|
||||
host := c.Request.Host
|
||||
rpID := host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
rpID = h
|
||||
}
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||
// Only development is ever plain HTTP; WebAuthn permits it on localhost.
|
||||
scheme = "http"
|
||||
}
|
||||
return rpID, scheme + "://" + host
|
||||
}
|
||||
|
||||
func webAuthnFor(c *gin.Context) (*webauthn.WebAuthn, error) {
|
||||
rpID, origin := rpConfig(c)
|
||||
return webauthn.New(&webauthn.Config{
|
||||
RPDisplayName: "Vantage",
|
||||
RPID: rpID,
|
||||
RPOrigins: []string{origin},
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||
UserVerification: protocol.VerificationRequired,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// waUser adapts our records to the library's interface. The handle is random
|
||||
// and per-user: a resident credential hands its user handle to any origin that
|
||||
// asks, so the user ID must not be it.
|
||||
type waUser struct {
|
||||
handle []byte
|
||||
name string
|
||||
credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u waUser) WebAuthnID() []byte { return u.handle }
|
||||
func (u waUser) WebAuthnName() string { return u.name }
|
||||
func (u waUser) WebAuthnDisplayName() string { return u.name }
|
||||
func (u waUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
||||
|
||||
func toLibCredential(c models.WebAuthnCredential) webauthn.Credential {
|
||||
return webauthn.Credential{
|
||||
ID: c.CredentialID,
|
||||
PublicKey: c.PublicKey,
|
||||
AttestationType: "none",
|
||||
Authenticator: webauthn.Authenticator{
|
||||
AAGUID: c.AAGUID,
|
||||
SignCount: c.SignCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func saveCeremony(ctx context.Context, data *webauthn.SessionData) (string, error) {
|
||||
id, err := randomHex(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
blob, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := rdb.Set(ctx, ceremonyPrefix+id, blob, ceremonyTTL).Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// loadCeremony consumes the challenge: a WebAuthn challenge is single use, so
|
||||
// it is deleted as it is read.
|
||||
func loadCeremony(ctx context.Context, id string) (*webauthn.SessionData, error) {
|
||||
blob, err := rdb.GetDel(ctx, ceremonyPrefix+id).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, ErrTicketExpired
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data webauthn.SessionData
|
||||
if err := json.Unmarshal(blob, &data); err != nil {
|
||||
return nil, ErrTicketExpired
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// HandleMFAWebAuthnBegin offers an assertion challenge to a pending sign-in.
|
||||
//
|
||||
// @Summary Begin passkey verification 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/webauthn/begin [post]
|
||||
func HandleMFAWebAuthnBegin(c *gin.Context) {
|
||||
t, _, ok := ticketFromRequest(c, scopeVerify)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
creds, err := services.ListPasskeys(t.InstanceID, t.UserID)
|
||||
if err != nil || len(creds) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"})
|
||||
return
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(t.InstanceID, t.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: t.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})
|
||||
}
|
||||
|
||||
// HandleMFAWebAuthnFinish verifies the assertion and signs the user in.
|
||||
//
|
||||
// @Summary Complete sign-in with a passkey
|
||||
// @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/mfa/webauthn/finish [post]
|
||||
func HandleMFAWebAuthnFinish(c *gin.Context) {
|
||||
t, ticketID, ok := ticketFromRequest(c, scopeVerify)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cred, err := finishAssertion(c, t.InstanceID, t.UserID, t.Email)
|
||||
if err != nil {
|
||||
left, ferr := FailTicket(c.Request.Context(), ticketID)
|
||||
services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor=webauthn")
|
||||
if ferr != nil || left == 0 {
|
||||
abortTicketExpired(c)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "that passkey could not be verified", "code": "invalid_assertion", "attempts_left": left,
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = services.TouchPasskey(t.InstanceID, cred.ID, cred.Authenticator.SignCount)
|
||||
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err := mintSession(c, u, []string{"pwd", services.FactorWebAuthn}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
|
||||
return
|
||||
}
|
||||
_ = DeleteTicket(c.Request.Context(), ticketID)
|
||||
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.
|
||||
func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauthn.Credential, error) {
|
||||
var body struct {
|
||||
CeremonyID string `json:"ceremony_id"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
|
||||
return nil, errors.New("assertion required")
|
||||
}
|
||||
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(body.Credential))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored, err := services.GetPasskeyByCredentialID(instanceID, parsed.RawID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID != "" && stored.UserID != userID {
|
||||
return nil, errors.New("credential belongs to another user")
|
||||
}
|
||||
handle, err := services.WebAuthnHandle(instanceID, stored.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w, err := webAuthnFor(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := waUser{handle: handle, name: email, credentials: []webauthn.Credential{toLibCredential(*stored)}}
|
||||
cred, err := w.ValidateLogin(user, *sessionData, parsed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !cred.Flags.UserVerified {
|
||||
return nil, errors.New("user verification was not performed")
|
||||
}
|
||||
// A counter that fails to advance is the library's clone signal. Zero on
|
||||
// both sides means the authenticator does not keep one, which is normal.
|
||||
if cred.Authenticator.CloneWarning {
|
||||
return nil, errors.New("authenticator may be cloned")
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// A passkey is bound to its RP ID. Getting this wrong does not fail loudly - it
|
||||
// silently makes every existing passkey unusable - so the port-stripping and
|
||||
// scheme rules are pinned here.
|
||||
func TestRPConfig(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, host, proto string
|
||||
wantID, wantOrig string
|
||||
}{
|
||||
{"plain host", "acme.vantage.example.com", "https", "acme.vantage.example.com", "https://acme.vantage.example.com"},
|
||||
{"host with port", "vantage.acme.com:8443", "https", "vantage.acme.com", "https://vantage.acme.com:8443"},
|
||||
{"localhost dev", "localhost:3000", "", "localhost", "http://localhost:3000"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/auth/passkey/begin", nil)
|
||||
c.Request.Host = tc.host
|
||||
if tc.proto != "" {
|
||||
c.Request.Header.Set("X-Forwarded-Proto", tc.proto)
|
||||
}
|
||||
id, origin := rpConfig(c)
|
||||
if id != tc.wantID || origin != tc.wantOrig {
|
||||
t.Fatalf("rpConfig = (%q, %q), want (%q, %q)", id, origin, tc.wantID, tc.wantOrig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user