feat(mfa): passkey verification as a second factor
This commit is contained in:
+10
-1
@@ -6,12 +6,14 @@ require (
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-webauthn/webauthn v0.18.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f
|
||||
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23
|
||||
github.com/knqyf263/go-rpm-version v0.0.0-20260811110310-1815e1f1b790
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/redis/go-redis/v9 v9.22.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/wwt/guac v1.3.2
|
||||
@@ -25,12 +27,17 @@ require (
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.3.1 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.62.0 // indirect
|
||||
github.com/samber/lo v1.53.0 // indirect
|
||||
@@ -39,6 +46,8 @@ require (
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/stretchr/testify v1.12.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yuin/goldmark v1.8.6 // indirect
|
||||
go.etcd.io/bbolt v1.5.0 // indirect
|
||||
|
||||
+18
-2
@@ -1,5 +1,3 @@
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0 h1:EtojZ1d3cN9foHpc/CAI3KzBewYGn4sKWdkWs2MV78Q=
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck=
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.7.0 h1:wwXvHfDKZB44EEj6BXl9O68hLC3kfPz3eak9wvupIRA=
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.7.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck=
|
||||
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM=
|
||||
@@ -27,6 +25,8 @@ github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
|
||||
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||
github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo=
|
||||
@@ -47,6 +47,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w=
|
||||
github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.18.1 h1:KaQw6M+ODLvxHwddyeo6zFhhmicfLup/BClhigB6A+0=
|
||||
github.com/go-webauthn/webauthn v0.18.1/go.mod h1:s4rZTQnKHWxIh6G3yEGqlxvtiLceA1jigll8FpqSgQ8=
|
||||
github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
|
||||
github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
@@ -57,6 +63,10 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
@@ -101,6 +111,8 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
@@ -141,12 +153,16 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
|
||||
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
|
||||
github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
|
||||
@@ -45,6 +45,8 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
r.POST("/auth/login", auth.HandleLocalLogin)
|
||||
r.POST("/auth/mfa/totp", auth.HandleMFATOTP)
|
||||
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/mfa/enrol/totp/setup", auth.HandleEnrolTOTPSetup)
|
||||
r.POST("/auth/mfa/enrol/totp/confirm", auth.HandleEnrolTOTPConfirm)
|
||||
r.POST("/auth/logout", auth.HandleLogout)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
var ErrNoPasskey = errors.New("no such passkey")
|
||||
|
||||
func ListPasskeys(instanceID, userID string) ([]models.WebAuthnCredential, error) {
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
cur, err := db.Col("webauthn_credentials").Find(ctx,
|
||||
bson.M{"instance_id": instanceID, "user_id": userID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
out := []models.WebAuthnCredential{}
|
||||
if err := cur.All(ctx, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetPasskeyByCredentialID resolves a credential inside one instance. The
|
||||
// instance scope is not optional: an unscoped lookup would let a credential
|
||||
// registered on one tenant assert on another.
|
||||
func GetPasskeyByCredentialID(instanceID string, credID []byte) (*models.WebAuthnCredential, error) {
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
var c models.WebAuthnCredential
|
||||
err := db.Col("webauthn_credentials").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "credential_id": credID}).Decode(&c)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrNoPasskey
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func SavePasskey(instanceID, userID, name string, credID, publicKey, aaguid []byte, signCount uint32, transports []string) error {
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
_, err := db.Col("webauthn_credentials").InsertOne(ctx, models.WebAuthnCredential{
|
||||
InstanceID: instanceID,
|
||||
UserID: userID,
|
||||
CredentialID: credID,
|
||||
CredentialIDHex: hex.EncodeToString(credID),
|
||||
PublicKey: publicKey,
|
||||
AAGUID: aaguid,
|
||||
SignCount: signCount,
|
||||
Transports: transports,
|
||||
Name: name,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// TouchPasskey records use and the new signature counter. A counter that fails
|
||||
// to advance can mean a cloned authenticator, so the caller checks it before
|
||||
// calling this.
|
||||
func TouchPasskey(instanceID string, credID []byte, signCount uint32) error {
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
now := time.Now()
|
||||
_, err := db.Col("webauthn_credentials").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "credential_id": credID},
|
||||
bson.M{"$set": bson.M{"sign_count": signCount, "last_used_at": now}})
|
||||
return err
|
||||
}
|
||||
|
||||
func RenamePasskey(instanceID, userID, credIDHex, name string) error {
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
res, err := db.Col("webauthn_credentials").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "user_id": userID, "credential_id_hex": credIDHex},
|
||||
bson.M{"$set": bson.M{"name": name}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return ErrNoPasskey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeletePasskey(instanceID, userID, credIDHex string) error {
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
res, err := db.Col("webauthn_credentials").DeleteOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "user_id": userID, "credential_id_hex": credIDHex})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.DeletedCount == 0 {
|
||||
return ErrNoPasskey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user