833 lines
40 KiB
Go
833 lines
40 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/middleware"
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/QuantumNous/new-api/service"
|
|
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
|
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type passkeyTestBody struct {
|
|
*strings.Reader
|
|
}
|
|
|
|
type passkeyDomainBegin struct {
|
|
FlowToken string `json:"flow_token"`
|
|
RPIDs []string `json:"rp_ids"`
|
|
Options struct {
|
|
PublicKey struct {
|
|
Challenge string `json:"challenge"`
|
|
RPID string `json:"rpId"`
|
|
RP struct {
|
|
ID string `json:"id"`
|
|
} `json:"rp"`
|
|
} `json:"publicKey"`
|
|
} `json:"options"`
|
|
}
|
|
|
|
func TestGetStatusDoesNotExposePasskeyOrigins(t *testing.T) {
|
|
settings := system_setting.GetPasskeySettings()
|
|
originalSettings := *settings
|
|
originalOptionMap := common.OptionMap
|
|
originalServerAddress := system_setting.ServerAddress
|
|
t.Cleanup(func() {
|
|
*settings = originalSettings
|
|
common.OptionMap = originalOptionMap
|
|
system_setting.ServerAddress = originalServerAddress
|
|
})
|
|
common.OptionMap = map[string]string{}
|
|
system_setting.ServerAddress = "https://www.example.com"
|
|
*settings = system_setting.PasskeySettings{Enabled: true, RPID: "example.com"}
|
|
|
|
for _, origins := range []string{"https://www.example.com,https://private.example.com", "", "[]"} {
|
|
t.Run("origins="+origins, func(t *testing.T) {
|
|
settings.Origins = origins
|
|
response := httptest.NewRecorder()
|
|
context, _ := gin.CreateTestContext(response)
|
|
context.Request = httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
|
|
|
GetStatus(context)
|
|
|
|
require.Equal(t, http.StatusOK, response.Code)
|
|
var payload struct {
|
|
Success bool `json:"success"`
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &payload))
|
|
require.True(t, payload.Success)
|
|
assert.NotContains(t, payload.Data, "passkey_origins")
|
|
assert.NotContains(t, response.Body.String(), "private.example.com")
|
|
assert.Equal(t, true, payload.Data["passkey_login"])
|
|
assert.Equal(t, origins, settings.Origins)
|
|
})
|
|
}
|
|
}
|
|
|
|
func passkeyDomainRequest(t *testing.T, path string, payload any, identity service.AuthIdentity, origin string, handler gin.HandlerFunc) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
body, err := common.Marshal(payload)
|
|
require.NoError(t, err)
|
|
return securityEnrollmentRequest(http.MethodPost, path, string(body), "", identity, func(c *gin.Context) {
|
|
c.Request.Header.Set("Origin", origin)
|
|
handler(c)
|
|
})
|
|
}
|
|
|
|
func decodePasskeyDomainBegin(t *testing.T, response *httptest.ResponseRecorder) passkeyDomainBegin {
|
|
t.Helper()
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.True(t, result.Success, response.Body.String())
|
|
var begin passkeyDomainBegin
|
|
require.NoError(t, common.Unmarshal(result.Data, &begin))
|
|
return begin
|
|
}
|
|
|
|
// Reuse the existing software authenticator, replacing the domain-bound bytes
|
|
// and recomputing the signature exactly as a browser/authenticator would.
|
|
func passkeyDomainAssertion(t *testing.T, key *ecdsa.PrivateKey, challenge, rpID, origin string, userID int, verified bool) map[string]any {
|
|
t.Helper()
|
|
var assertion map[string]any
|
|
require.NoError(t, common.Unmarshal(securityPasskeyResponse(t, key, challenge, false, 1, verified), &assertion))
|
|
response := assertion["response"].(map[string]any)
|
|
clientData, err := common.Marshal(map[string]string{"type": "webauthn.get", "challenge": challenge, "origin": origin})
|
|
require.NoError(t, err)
|
|
authData, err := base64.RawURLEncoding.DecodeString(response["authenticatorData"].(string))
|
|
require.NoError(t, err)
|
|
rpHash := sha256.Sum256([]byte(rpID))
|
|
copy(authData, rpHash[:])
|
|
clientHash := sha256.Sum256(clientData)
|
|
signedHash := sha256.Sum256(append(append([]byte{}, authData...), clientHash[:]...))
|
|
signature, err := ecdsa.SignASN1(rand.Reader, key, signedHash[:])
|
|
require.NoError(t, err)
|
|
response["clientDataJSON"] = base64.RawURLEncoding.EncodeToString(clientData)
|
|
response["authenticatorData"] = base64.RawURLEncoding.EncodeToString(authData)
|
|
response["signature"] = base64.RawURLEncoding.EncodeToString(signature)
|
|
response["userHandle"] = base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(userID)))
|
|
return assertion
|
|
}
|
|
|
|
func TestPasskeyDomainsPreserveCredentialsAcrossVerificationFlows(t *testing.T) {
|
|
for _, kind := range []string{"direct", "login factor", "sensitive action", "historical mixed case"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
key := newSecurityLoginPasskey(t, user.Id)
|
|
settings := system_setting.GetPasskeySettings()
|
|
legacyRPID := "www.example.com"
|
|
if kind == "historical mixed case" {
|
|
legacyRPID = "WWW.example.com"
|
|
}
|
|
settings.LegacyRPIDs = legacyRPID
|
|
settings.Origins = "https://example.com,https://www.example.com"
|
|
beginPath, finishPath := "/api/user/passkey/login/begin", "/api/user/passkey/login/finish"
|
|
beginHandler, finishHandler := PasskeyLoginBegin, PasskeyLoginFinish
|
|
request := map[string]any{"rp_id": legacyRPID}
|
|
if kind == "login factor" {
|
|
pending, err := service.StartLoginVerification(user, "password", nil)
|
|
require.NoError(t, err)
|
|
request["flow_token"] = pending.FlowToken
|
|
beginPath, finishPath = "/api/user/login/passkey/begin", "/api/user/login/passkey/finish"
|
|
beginHandler, finishHandler = LoginPasskeyBegin, LoginPasskeyFinish
|
|
} else if kind == "sensitive action" {
|
|
request["scope"] = service.VerificationScopeAccessTokenGenerate
|
|
request["context"] = map[string]any{"scopes": []string{"profile:read"}, "expires_at": 0}
|
|
beginPath, finishPath = "/api/user/passkey/verify/begin", "/api/user/passkey/verify/finish"
|
|
beginHandler, finishHandler = PasskeyVerifyBegin, PasskeyVerifyFinish
|
|
}
|
|
begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, beginPath, request, identity, "https://www.example.com", beginHandler))
|
|
assert.Equal(t, legacyRPID, begin.Options.PublicKey.RPID)
|
|
assert.Equal(t, []string{"example.com", legacyRPID}, begin.RPIDs)
|
|
before, err := model.GetPasskeyByUserID(user.Id)
|
|
require.NoError(t, err)
|
|
require.Nil(t, before.RPID)
|
|
finish := map[string]any{"flow_token": begin.FlowToken, "credential": passkeyDomainAssertion(t, key, begin.Options.PublicKey.Challenge, legacyRPID, "https://www.example.com", user.Id, true)}
|
|
if kind == "login factor" {
|
|
finish["flow_token"], finish["passkey_flow_token"] = request["flow_token"], begin.FlowToken
|
|
}
|
|
response := passkeyDomainRequest(t, finishPath, finish, identity, "https://www.example.com", finishHandler)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.True(t, result.Success, response.Body.String())
|
|
after, err := model.GetPasskeyByUserID(user.Id)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, after.RPID)
|
|
assert.Equal(t, legacyRPID, *after.RPID)
|
|
assert.Equal(t, before.CredentialID, after.CredentialID)
|
|
assert.Equal(t, before.PublicKey, after.PublicKey)
|
|
if kind == "sensitive action" {
|
|
var proof service.SecurityProof
|
|
require.NoError(t, common.Unmarshal(result.Data, &proof))
|
|
assert.Equal(t, service.VerificationScopeAccessTokenGenerate, proof.Scope)
|
|
}
|
|
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
|
|
require.NoError(t, err)
|
|
replay := passkeyDomainRequest(t, finishPath, finish, identity, "https://www.example.com", finishHandler)
|
|
require.NoError(t, common.Unmarshal(replay.Body.Bytes(), &result))
|
|
assert.False(t, result.Success)
|
|
afterCount, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, count, afterCount)
|
|
// Once identified, a browser hint cannot override the credential's binding.
|
|
wa, ids, err := passkeysvc.BuildLoginWebAuthn(httptest.NewRequest(http.MethodPost, beginPath, nil), "example.com", *after.RPID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, legacyRPID, wa.Config.RPID)
|
|
assert.Equal(t, []string{legacyRPID}, ids)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPasskeyDomainsRejectInvalidAssertions(t *testing.T) {
|
|
for _, failure := range []string{"wrong RP ID", "unlisted origin", "origin outside RP scope", "wrong user", "bad signature", "missing user verification", "expired challenge", "removed domain", "known different domain"} {
|
|
t.Run(failure, func(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
key := newSecurityLoginPasskey(t, user.Id)
|
|
settings := system_setting.GetPasskeySettings()
|
|
settings.LegacyRPIDs = "www.example.com"
|
|
settings.Origins = "https://example.com,https://www.example.com"
|
|
begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, "/api/user/passkey/login/begin", map[string]string{"rp_id": "www.example.com"}, identity, "https://www.example.com", PasskeyLoginBegin))
|
|
rpID, origin, handle := "www.example.com", "https://www.example.com", user.Id
|
|
switch failure {
|
|
case "wrong RP ID":
|
|
rpID = "example.com"
|
|
case "unlisted origin":
|
|
origin = "https://evil.example.com"
|
|
case "origin outside RP scope":
|
|
origin = "https://example.com"
|
|
case "wrong user":
|
|
handle++
|
|
case "expired challenge":
|
|
flow, err := model.GetAuthFlow(begin.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposePasskeyLogin})
|
|
require.NoError(t, err)
|
|
var payload struct {
|
|
SessionData webauthn.SessionData `json:"session_data"`
|
|
Security passkeysvc.FlowSecurity `json:"security"`
|
|
}
|
|
require.NoError(t, common.UnmarshalJsonStr(flow.Payload, &payload))
|
|
payload.SessionData.Expires = time.Now().Add(-time.Second)
|
|
encoded, err := common.Marshal(payload)
|
|
require.NoError(t, err)
|
|
require.NoError(t, model.DB.Model(flow).Update("payload", string(encoded)).Error)
|
|
case "removed domain":
|
|
settings.LegacyRPIDs = ""
|
|
case "known different domain":
|
|
require.NoError(t, model.DB.Model(&model.PasskeyCredential{}).Where("user_id = ?", user.Id).Update("rp_id", "example.com").Error)
|
|
}
|
|
assertion := passkeyDomainAssertion(t, key, begin.Options.PublicKey.Challenge, rpID, origin, handle, failure != "missing user verification")
|
|
if failure == "bad signature" {
|
|
assertion["response"].(map[string]any)["signature"] = "AA"
|
|
}
|
|
response := passkeyDomainRequest(t, "/api/user/passkey/login/finish", map[string]any{"flow_token": begin.FlowToken, "credential": assertion}, identity, "https://www.example.com", PasskeyLoginFinish)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
assert.False(t, result.Success, response.Body.String())
|
|
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
|
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 1, count)
|
|
stored, err := model.GetPasskeyByUserID(user.Id)
|
|
require.NoError(t, err)
|
|
if failure == "known different domain" {
|
|
assert.Equal(t, "example.com", *stored.RPID)
|
|
} else {
|
|
assert.Nil(t, stored.RPID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPasskeyDomainChoicesRespectOriginAndConfiguration(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
settings := system_setting.GetPasskeySettings()
|
|
settings.LegacyRPIDs = "www.example.com"
|
|
settings.Origins = "https://example.com,https://www.example.com"
|
|
for _, test := range []struct {
|
|
origin, hint string
|
|
success bool
|
|
ids []string
|
|
}{
|
|
{"https://www.example.com", "", true, []string{"example.com", "www.example.com"}},
|
|
{"https://example.com", "", true, []string{"example.com"}},
|
|
{"https://example.com", "www.example.com", false, nil},
|
|
{"https://www.example.com", "unconfigured.example.com", false, nil},
|
|
{"https://attacker.test", "", false, nil},
|
|
} {
|
|
response := passkeyDomainRequest(t, "/api/user/passkey/login/begin", map[string]string{"rp_id": test.hint}, identity, test.origin, PasskeyLoginBegin)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
assert.Equal(t, test.success, result.Success, "%+v", test)
|
|
if test.success {
|
|
assert.Equal(t, test.ids, decodePasskeyDomainBegin(t, response).RPIDs)
|
|
} else {
|
|
assert.Equal(t, "PASSKEY_RP_ID_UNAVAILABLE", result.Code)
|
|
}
|
|
}
|
|
settings.RPID, settings.LegacyRPIDs, settings.Origins, settings.AllowInsecureOrigin = "localhost", "", "http://localhost:3000,http://localhost:3001", true
|
|
for _, origin := range []string{"http://localhost:3000", "http://localhost:3001"} {
|
|
begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, "/api/user/passkey/login/begin", nil, identity, origin, PasskeyLoginBegin))
|
|
assert.Equal(t, "localhost", begin.Options.PublicKey.RPID)
|
|
}
|
|
}
|
|
|
|
func setupPasskeyDomainOptions(t *testing.T) {
|
|
t.Helper()
|
|
require.NoError(t, model.DB.AutoMigrate(&model.Option{}))
|
|
// These tests assert on the whole options table. The server-managed legacy
|
|
// access token deadline written by the enrollment fixture is unrelated.
|
|
require.NoError(t, model.DB.Delete(&model.Option{Key: "LegacyAccessTokenRetireAt"}).Error)
|
|
common.OptionMapRWMutex.RLock()
|
|
previousOptions := maps.Clone(common.OptionMap)
|
|
previousAddress := system_setting.ServerAddress
|
|
common.OptionMapRWMutex.RUnlock()
|
|
t.Cleanup(func() {
|
|
common.OptionMapRWMutex.Lock()
|
|
common.OptionMap = previousOptions
|
|
system_setting.ServerAddress = previousAddress
|
|
common.OptionMapRWMutex.Unlock()
|
|
})
|
|
}
|
|
|
|
func TestPasskeyDomainRemovalProtectsExistingAndUnknownCredentials(t *testing.T) {
|
|
for _, binding := range []string{"www.example.com", ""} {
|
|
t.Run("binding="+binding, func(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
setupPasskeyDomainOptions(t)
|
|
settings := system_setting.GetPasskeySettings()
|
|
settings.LegacyRPIDs = "www.example.com"
|
|
settings.Origins = "https://example.com,https://www.example.com"
|
|
credential := &model.PasskeyCredential{UserID: user.Id, CredentialID: "existing", PublicKey: "key"}
|
|
if binding != "" {
|
|
credential.RPID = &binding
|
|
}
|
|
require.NoError(t, model.DB.Create(credential).Error)
|
|
assert.Error(t, model.UpdateOption("passkey.legacy_rp_ids", ""))
|
|
assert.Equal(t, "www.example.com", system_setting.PasskeySettingsSnapshot().LegacyRPIDs)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPasskeyDomainRemovalWithoutAffectedCredentialsNeedsNoOverride(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
setupPasskeyDomainOptions(t)
|
|
settings := system_setting.GetPasskeySettings()
|
|
settings.LegacyRPIDs = "www.example.com,WWW.example.com"
|
|
upper := "WWW.example.com"
|
|
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, RPID: &upper, CredentialID: "uppercase", PublicKey: "key"}).Error)
|
|
change, err := model.UpdatePasskeyDomainOptions(map[string]string{"passkey.legacy_rp_ids": upper}, false, "")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"www.example.com"}, change.RemovedRPIDs)
|
|
assert.Zero(t, change.AffectedCredentials)
|
|
assert.Zero(t, change.UnknownCredentials)
|
|
assert.False(t, change.ConfirmationRequired)
|
|
assert.Equal(t, upper, settings.LegacyRPIDs)
|
|
}
|
|
|
|
func TestPasskeySnapshotDoesNotMaterializeDefaults(t *testing.T) {
|
|
setupSecurityEnrollmentTest(t)
|
|
setupPasskeyDomainOptions(t)
|
|
settings := system_setting.GetPasskeySettings()
|
|
settings.RPID, settings.Origins = "", ""
|
|
system_setting.ServerAddress = "https://www.example.com"
|
|
assert.Equal(t, "www.example.com", system_setting.PasskeySettingsSnapshot().EffectiveRPID())
|
|
assert.Empty(t, settings.RPID)
|
|
assert.Empty(t, settings.Origins)
|
|
}
|
|
|
|
func TestPasskeyDomainPreviewConfirmationAndAudit(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
setupPasskeyDomainOptions(t)
|
|
settings := system_setting.GetPasskeySettings()
|
|
settings.LegacyRPIDs = "www.example.com,WWW.example.com"
|
|
settings.Origins = "https://example.com,https://www.example.com"
|
|
lower, upper, empty := "www.example.com", "WWW.example.com", ""
|
|
for i, binding := range []*string{&lower, &upper, nil, &empty, &lower} {
|
|
credential := &model.PasskeyCredential{UserID: user.Id + i, RPID: binding, CredentialID: fmt.Sprint(i), PublicKey: "key"}
|
|
require.NoError(t, model.DB.Create(credential).Error)
|
|
if i == 4 {
|
|
require.NoError(t, model.DB.Delete(credential).Error)
|
|
}
|
|
}
|
|
request := map[string]any{"rp_id": "example.com", "legacy_rp_ids": upper, "origins": settings.Origins, "preview": true}
|
|
response := passkeyDomainRequest(t, "/api/option/passkey/domains", request, identity, "https://example.com", UpdatePasskeyDomains)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.True(t, result.Success, response.Body.String())
|
|
var preview model.PasskeyDomainChange
|
|
require.NoError(t, common.Unmarshal(result.Data, &preview))
|
|
assert.Equal(t, []string{lower}, preview.RemovedRPIDs)
|
|
assert.EqualValues(t, 1, preview.AffectedCredentials)
|
|
assert.EqualValues(t, 2, preview.UnknownCredentials)
|
|
assert.True(t, preview.ConfirmationRequired)
|
|
require.NotEmpty(t, preview.RemovalConfirmation)
|
|
options, err := model.AllOption()
|
|
require.NoError(t, err)
|
|
assert.Empty(t, options, "preview must not materialize default rows")
|
|
assert.Equal(t, "www.example.com,WWW.example.com", settings.LegacyRPIDs)
|
|
assert.ErrorIs(t, model.UpdateOptionsBulk(map[string]string{"passkey.legacy_rp_ids": upper, "Notice": "must roll back"}), model.ErrPasskeyDomainRemovalConfirmation)
|
|
blocked := passkeyDomainRequest(t, "/api/option/", map[string]string{"key": "passkey.legacy_rp_ids", "value": upper}, identity, "https://example.com", UpdateOption)
|
|
assert.Equal(t, http.StatusConflict, blocked.Code)
|
|
assert.Contains(t, blocked.Body.String(), "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED")
|
|
request["preview"] = false
|
|
request["removal_confirmation"] = preview.RemovalConfirmation
|
|
// A new unknown credential makes the reviewed impact stale.
|
|
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id + 10, CredentialID: "new", PublicKey: "key"}).Error)
|
|
response = passkeyDomainRequest(t, "/api/option/passkey/domains", request, identity, "https://example.com", UpdatePasskeyDomains)
|
|
assert.Equal(t, http.StatusConflict, response.Code)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.NoError(t, common.Unmarshal(result.Data, &preview))
|
|
assert.EqualValues(t, 3, preview.UnknownCredentials)
|
|
options, err = model.AllOption()
|
|
require.NoError(t, err)
|
|
assert.Empty(t, options)
|
|
request["removal_confirmation"] = preview.RemovalConfirmation
|
|
response = passkeyDomainRequest(t, "/api/option/passkey/domains", request, identity, "https://example.com", UpdatePasskeyDomains)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.True(t, result.Success, response.Body.String())
|
|
assert.Equal(t, upper, settings.LegacyRPIDs)
|
|
var audits []model.AuditLog
|
|
require.NoError(t, model.LOG_DB.Find(&audits).Error)
|
|
for _, audit := range audits {
|
|
if audit.Action == "option.passkey_domains_blocked" {
|
|
assert.False(t, audit.Success)
|
|
assert.Equal(t, http.StatusConflict, audit.Status)
|
|
}
|
|
if audit.Action == "option.passkey_domains_confirmed" {
|
|
assert.True(t, audit.Success)
|
|
require.NotNil(t, audit.Other.Op)
|
|
params, err := common.Marshal(audit.Other.Op.Params)
|
|
require.NoError(t, err)
|
|
var impact struct {
|
|
Known, Unknown int64
|
|
Confirmed bool
|
|
RemovedRPIDs []string `json:"removed_rp_ids"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(params, &impact))
|
|
assert.EqualValues(t, 1, impact.Known)
|
|
assert.EqualValues(t, 3, impact.Unknown)
|
|
assert.True(t, impact.Confirmed)
|
|
assert.Equal(t, []string{lower}, impact.RemovedRPIDs)
|
|
}
|
|
}
|
|
encoded, err := common.Marshal(audits)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(encoded), "option.passkey_domains_confirmed")
|
|
assert.Contains(t, string(encoded), "option.passkey_domains_blocked")
|
|
assert.NotContains(t, string(encoded), preview.RemovalConfirmation)
|
|
// Simulate an old node's cache after removal: persistence must use DB trust.
|
|
settings.LegacyRPIDs = lower + "," + upper
|
|
assert.ErrorIs(t, model.UpdatePasskeyAssertionState(user.Id, &webauthn.Credential{ID: []byte("0")}, time.Now(), lower), system_setting.ErrPasskeyRPIDUnavailable)
|
|
assert.ErrorIs(t, model.RegisterPasskeyForSession(identity, &model.PasskeyCredential{UserID: user.Id, RPID: &lower, CredentialID: "replacement", PublicKey: "key"}), system_setting.ErrPasskeyRPIDUnavailable)
|
|
}
|
|
|
|
func TestPasskeyDomainSettingsAcceptExactHTTPSInternalHosts(t *testing.T) {
|
|
for _, test := range []struct {
|
|
rpID, origins string
|
|
valid bool
|
|
}{
|
|
{"intranet", "https://intranet:8443", true},
|
|
{"intranet", "https://child.intranet", false},
|
|
{"intranet", "http://intranet", false},
|
|
{"com", "https://com", false},
|
|
{"127.0.0.1", "https://127.0.0.1", false},
|
|
{"intranet:8443", "https://intranet:8443", false},
|
|
{"localhost", "http://localhost:3000", true},
|
|
} {
|
|
t.Run(test.rpID+"/"+test.origins, func(t *testing.T) {
|
|
setupSecurityEnrollmentTest(t)
|
|
setupPasskeyDomainOptions(t)
|
|
change, err := model.UpdatePasskeyDomainOptions(map[string]string{"passkey.rp_id": test.rpID, "passkey.legacy_rp_ids": "", "passkey.origins": test.origins}, false, "")
|
|
if !test.valid {
|
|
assert.ErrorIs(t, err, system_setting.ErrPasskeyRPIDInvalid)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Equal(t, test.rpID, change.RPID)
|
|
assert.Equal(t, "example.com", change.LegacyRPIDs)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPasskeyImplicitDomainChangesRetainPreviousTrust(t *testing.T) {
|
|
setupSecurityEnrollmentTest(t)
|
|
setupPasskeyDomainOptions(t)
|
|
settings := system_setting.GetPasskeySettings()
|
|
settings.RPID, settings.Origins = "", ""
|
|
system_setting.ServerAddress = "https://www.example.com"
|
|
require.NoError(t, model.UpdateOption("ServerAddress", "https://example.com"))
|
|
assert.Equal(t, []string{"example.com", "www.example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs())
|
|
assert.Equal(t, "https://www.example.com,https://example.com", settings.Origins)
|
|
// Moving the retained RP into the primary is not a removal.
|
|
change, err := model.UpdatePasskeyDomainOptions(map[string]string{"passkey.rp_id": "www.example.com", "passkey.legacy_rp_ids": ""}, false, "")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, change.RemovedRPIDs)
|
|
assert.Equal(t, "example.com", change.LegacyRPIDs)
|
|
}
|
|
|
|
func TestPasskeyRegistrationAndDomainRemovalSerialize(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
setupPasskeyDomainOptions(t)
|
|
system_setting.GetPasskeySettings().Origins = "https://example.com,https://www.example.com"
|
|
require.NoError(t, model.UpdateOption("passkey.legacy_rp_ids", "www.example.com"))
|
|
registering, release := make(chan struct{}), make(chan struct{})
|
|
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("passkey_registration_barrier", func(tx *gorm.DB) {
|
|
if _, ok := tx.Statement.Dest.(*model.PasskeyCredential); ok {
|
|
close(registering)
|
|
<-release
|
|
}
|
|
}))
|
|
t.Cleanup(func() { _ = model.DB.Callback().Create().Remove("passkey_registration_barrier") })
|
|
rpID := "www.example.com"
|
|
registered, removed := make(chan error, 1), make(chan error, 1)
|
|
go func() {
|
|
registered <- model.RegisterPasskeyForSession(identity, &model.PasskeyCredential{UserID: user.Id, RPID: &rpID, CredentialID: "concurrent", PublicKey: "key"})
|
|
}()
|
|
<-registering
|
|
removing := make(chan struct{})
|
|
go func() {
|
|
close(removing)
|
|
removed <- model.UpdateOption("passkey.legacy_rp_ids", "")
|
|
}()
|
|
<-removing
|
|
// A concurrent snapshot must remain read-only while the write is pending.
|
|
assert.Equal(t, []string{"example.com", "www.example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs())
|
|
close(release)
|
|
require.NoError(t, <-registered)
|
|
assert.ErrorIs(t, <-removed, model.ErrPasskeyDomainRemovalConfirmation)
|
|
stored, err := model.GetPasskeyByUserID(user.Id)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, stored.RPID)
|
|
assert.Equal(t, rpID, *stored.RPID)
|
|
}
|
|
|
|
func TestPasskeyDomainEndpointRequiresRoot(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
setupPasskeyDomainOptions(t)
|
|
bundle, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "domain-settings-test")
|
|
require.NoError(t, err)
|
|
router := gin.New()
|
|
router.PUT("/api/option/passkey/domains", middleware.RootAuth(), UpdatePasskeyDomains)
|
|
for _, bearer := range []string{"", bundle.AccessToken} {
|
|
request := httptest.NewRequest(http.MethodPut, "/api/option/passkey/domains", strings.NewReader(`{"rp_id":"example.com","legacy_rp_ids":"","origins":"https://example.com","preview":true}`))
|
|
if bearer != "" {
|
|
request.Header.Set("Authorization", "Bearer "+bearer)
|
|
}
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
assert.Contains(t, []int{http.StatusUnauthorized, http.StatusForbidden}, response.Code)
|
|
}
|
|
options, err := model.AllOption()
|
|
require.NoError(t, err)
|
|
assert.Empty(t, options)
|
|
}
|
|
|
|
func TestPasskeyDefaultsDoNotRestrictUnrelatedServerAddressSetup(t *testing.T) {
|
|
setupSecurityEnrollmentTest(t)
|
|
setupPasskeyDomainOptions(t)
|
|
settings := system_setting.GetPasskeySettings()
|
|
settings.Enabled, settings.RPID, settings.LegacyRPIDs, settings.Origins = false, "", "", ""
|
|
system_setting.ServerAddress = ""
|
|
require.NoError(t, model.UpdateOption("ServerAddress", "http://127.0.0.1:3000"))
|
|
assert.Equal(t, "http://127.0.0.1:3000", system_setting.ServerAddress)
|
|
assert.Empty(t, settings.RPID)
|
|
assert.Empty(t, settings.LegacyRPIDs)
|
|
}
|
|
|
|
func TestPasskeyRegistrationRejectsUnavailableRequestOrigin(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
settings := system_setting.GetPasskeySettings()
|
|
settings.RPID = "www.example.com"
|
|
settings.LegacyRPIDs = "example.com"
|
|
settings.Origins = "https://example.com,https://www.example.com"
|
|
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
|
|
response := securityEnrollmentRequest(http.MethodPost, "/api/user/passkey/register/begin", "", proof, identity, func(c *gin.Context) {
|
|
c.Request.Header.Set("Origin", "https://example.com")
|
|
PasskeyRegisterBegin(c)
|
|
})
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
assert.False(t, result.Success)
|
|
assert.Equal(t, "PASSKEY_RP_ID_UNAVAILABLE", result.Code)
|
|
var count int64
|
|
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposePasskeyRegister).Count(&count).Error)
|
|
assert.Zero(t, count)
|
|
}
|
|
|
|
func TestPasskeyRPIDRotationRetainsDefaultsAndRollsBackOnFailure(t *testing.T) {
|
|
setupSecurityEnrollmentTest(t)
|
|
setupPasskeyDomainOptions(t)
|
|
settings := system_setting.GetPasskeySettings()
|
|
settings.RPID, settings.LegacyRPIDs = "", ""
|
|
settings.Origins = "https://www.example.com,https://example.com,https://login.example.com"
|
|
system_setting.ServerAddress = "https://www.example.com"
|
|
require.NoError(t, model.DB.Create(&model.Option{Key: "passkey.rp_id", Value: ""}).Error)
|
|
require.NoError(t, model.UpdateOption("passkey.rp_id", "example.com"))
|
|
assert.Equal(t, []string{"example.com", "www.example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs())
|
|
// Simulate a second node with an older local cache. Persisted domains win.
|
|
system_setting.GetPasskeySettings().RPID = "www.example.com"
|
|
require.NoError(t, model.UpdateOption("passkey.rp_id", "login.example.com"))
|
|
assert.Equal(t, []string{"login.example.com", "www.example.com", "example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs())
|
|
before, err := model.AllOption()
|
|
require.NoError(t, err)
|
|
snapshot := system_setting.PasskeySettingsSnapshot()
|
|
failure := errors.New("synthetic option write failure")
|
|
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("passkey_option_failure", func(tx *gorm.DB) {
|
|
if option, ok := tx.Statement.Dest.(*model.Option); ok && option.Key == "passkey.legacy_rp_ids" {
|
|
tx.AddError(failure)
|
|
}
|
|
}))
|
|
assert.ErrorIs(t, model.UpdateOptionsBulk(map[string]string{"passkey.rp_id": "next.example.com", "passkey.legacy_rp_ids": "www.example.com"}), failure)
|
|
require.NoError(t, model.DB.Callback().Update().Remove("passkey_option_failure"))
|
|
after, err := model.AllOption()
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, before, after)
|
|
assert.Equal(t, snapshot, system_setting.PasskeySettingsSnapshot())
|
|
for _, invalid := range []string{"*.example.com", "https://example.com", "example.com:443", "example.com/path", "com"} {
|
|
assert.ErrorIs(t, model.UpdateOption("passkey.rp_id", invalid), system_setting.ErrPasskeyRPIDInvalid)
|
|
assert.ErrorIs(t, model.UpdateOption("passkey.legacy_rp_ids", invalid), system_setting.ErrPasskeyRPIDInvalid)
|
|
}
|
|
assert.Equal(t, snapshot, system_setting.PasskeySettingsSnapshot())
|
|
// Older settings accepted mixed case. Rotation must preserve its hash input.
|
|
require.NoError(t, model.DB.Model(&model.Option{}).Where(&model.Option{Key: "passkey.rp_id"}).Update("value", "WWW.example.com").Error)
|
|
require.NoError(t, model.UpdateOption("passkey.rp_id", "next.example.com"))
|
|
assert.Contains(t, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs(), "WWW.example.com")
|
|
}
|
|
|
|
func TestPasskeyRPIDRotationKeepsInFlightRegistration(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
setupPasskeyDomainOptions(t)
|
|
system_setting.GetPasskeySettings().Origins = "https://example.com,https://www.example.com"
|
|
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
|
|
begin := decodePasskeyDomainBegin(t, securityEnrollmentRequest(http.MethodPost, "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin))
|
|
assert.Equal(t, "example.com", begin.Options.PublicKey.RP.ID)
|
|
require.NoError(t, model.UpdateOption("passkey.rp_id", "www.example.com"))
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
response := passkeyDomainRequest(t, "/api/user/passkey/register/finish", passkeyFinishRequest{FlowToken: begin.FlowToken, Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0)}, identity, "https://example.com", PasskeyRegisterFinish)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.True(t, result.Success, response.Body.String())
|
|
credential, err := model.GetPasskeyByUserID(user.Id)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, credential.RPID)
|
|
assert.Equal(t, "example.com", *credential.RPID)
|
|
// A later registration uses the current primary, not the retained domain.
|
|
wa, err := passkeysvc.BuildWebAuthn(httptest.NewRequest(http.MethodPost, "https://www.example.com/api/user/passkey/register/begin", nil))
|
|
require.NoError(t, err)
|
|
options, _, err := wa.BeginRegistration(passkeysvc.NewWebAuthnUser(user, credential))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "www.example.com", options.Response.RelyingParty.ID)
|
|
}
|
|
|
|
func TestPasskeyDomainFailuresDoNotLogCredentials(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
var output bytes.Buffer
|
|
common.LogWriterMu.Lock()
|
|
previousWriter := gin.DefaultErrorWriter
|
|
gin.DefaultErrorWriter = &output
|
|
common.LogWriterMu.Unlock()
|
|
t.Cleanup(func() {
|
|
common.LogWriterMu.Lock()
|
|
gin.DefaultErrorWriter = previousWriter
|
|
common.LogWriterMu.Unlock()
|
|
})
|
|
response := securityEnrollmentRequest(http.MethodPost, "/api/user/passkey/login/finish", "", "", service.AuthIdentity{}, func(c *gin.Context) {
|
|
c.Set("passkey_rp_id", "www.example.com")
|
|
writeSecurityOperationError(c, protocol.ErrVerification.WithDetails("private-flow-token").WithInfo("private-challenge"))
|
|
})
|
|
assert.Contains(t, response.Body.String(), "SECURITY_VERIFICATION_FAILED")
|
|
assert.Contains(t, output.String(), "verification_error")
|
|
assert.Contains(t, output.String(), "www.example.com")
|
|
for _, secret := range []string{"private-flow-token", "private-challenge"} {
|
|
assert.NotContains(t, response.Body.String(), secret)
|
|
assert.NotContains(t, output.String(), secret)
|
|
}
|
|
}
|
|
|
|
// The web console translates message_key, so every request language gets the
|
|
// same English source text.
|
|
func TestPasskeyDomainErrorsReturnMessageKeys(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
setupPasskeyDomainOptions(t)
|
|
system_setting.GetPasskeySettings().LegacyRPIDs = "www.example.com"
|
|
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "unknown-domain", PublicKey: "key"}).Error)
|
|
const (
|
|
invalid = "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard."
|
|
unavailable = "This Passkey domain is not available on this website. Use its original website or another verification method."
|
|
removal = "Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."
|
|
)
|
|
for _, language := range []string{"zh-CN", "zh-TW", "en"} {
|
|
t.Run(language, func(t *testing.T) {
|
|
for _, request := range []struct {
|
|
path, body, code, message string
|
|
handler gin.HandlerFunc
|
|
}{
|
|
{"/api/option/", `{"key":"passkey.rp_id","value":"localhost:3000"}`, "PASSKEY_RP_ID_INVALID", invalid, UpdateOption},
|
|
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":"localhost:3001"}`, "PASSKEY_RP_ID_INVALID", invalid, UpdateOption},
|
|
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":""}`, "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED", removal, UpdateOption},
|
|
{"/api/user/passkey/login/begin", `{"rp_id":"unconfigured.example.com"}`, "PASSKEY_RP_ID_UNAVAILABLE", unavailable, PasskeyLoginBegin},
|
|
} {
|
|
response := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(response)
|
|
method := http.MethodPost
|
|
if request.path == "/api/option/" {
|
|
method = http.MethodPut
|
|
}
|
|
c.Request = httptest.NewRequest(method, request.path, strings.NewReader(request.body))
|
|
c.Request.Header.Set("Accept-Language", language)
|
|
c.Request.Header.Set("Origin", "https://example.com")
|
|
c.Set("id", user.Id)
|
|
c.Set("role", common.RoleRootUser)
|
|
request.handler(c)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
expectedStatus := http.StatusOK
|
|
if request.code == "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED" {
|
|
expectedStatus = http.StatusConflict
|
|
}
|
|
assert.Equal(t, expectedStatus, response.Code)
|
|
assert.False(t, result.Success)
|
|
assert.Equal(t, request.code, result.Code)
|
|
assert.Equal(t, request.message, result.Message)
|
|
assert.Equal(t, request.message, result.MessageKey)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPasskeyRPIDMigrationPreservesExistingCredentials(t *testing.T) {
|
|
for _, upgrade := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("upgrade=%t", upgrade), func(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
key := newSecurityLoginPasskey(t, user.Id)
|
|
before, err := model.GetPasskeyByUserID(user.Id)
|
|
require.NoError(t, err)
|
|
if upgrade {
|
|
// This schema is copied from the latest released model at
|
|
// v1.0.0-rc.36, with the same GORM and database driver versions.
|
|
require.NoError(t, model.DB.Migrator().DropTable(&model.PasskeyCredential{}))
|
|
require.NoError(t, model.DB.AutoMigrate(&passkeyCredentialBeforeRPID{}))
|
|
encoded, err := common.Marshal(before)
|
|
require.NoError(t, err)
|
|
var legacy passkeyCredentialBeforeRPID
|
|
require.NoError(t, common.Unmarshal(encoded, &legacy))
|
|
require.NoError(t, model.DB.Create(&legacy).Error)
|
|
}
|
|
pool, err := model.DB.DB()
|
|
require.NoError(t, err)
|
|
for range 2 {
|
|
// Each migration represents a new application startup. Discard
|
|
// PostgreSQL's old SELECT * prepared plans along with its old pool.
|
|
pool.SetMaxIdleConns(0)
|
|
pool.SetMaxIdleConns(2)
|
|
require.NoError(t, model.DB.AutoMigrate(&model.PasskeyCredential{}))
|
|
}
|
|
after, err := model.GetPasskeyByUserID(user.Id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, before, after)
|
|
assert.True(t, model.DB.Migrator().HasIndex(&model.PasskeyCredential{}, "idx_passkey_credentials_deleted_at"))
|
|
assert.Error(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "another-key", PublicKey: "key"}).Error)
|
|
assert.Error(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id + 1, CredentialID: before.CredentialID, PublicKey: "key"}).Error)
|
|
system_setting.GetPasskeySettings().LegacyRPIDs = "www.example.com"
|
|
system_setting.GetPasskeySettings().Origins = "https://example.com,https://www.example.com"
|
|
begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, "/api/user/passkey/login/begin", map[string]string{"rp_id": "www.example.com"}, identity, "https://www.example.com", PasskeyLoginBegin))
|
|
response := passkeyDomainRequest(t, "/api/user/passkey/login/finish", map[string]any{"flow_token": begin.FlowToken, "credential": passkeyDomainAssertion(t, key, begin.Options.PublicKey.Challenge, "www.example.com", "https://www.example.com", user.Id, true)}, identity, "https://www.example.com", PasskeyLoginFinish)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
assert.True(t, result.Success, response.Body.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
func (*passkeyTestBody) Close() error { return nil }
|
|
|
|
func TestParsePasskeyFinishRequestDoesNotRewriteRequestBody(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
bodyText := `{"flow_token":"flow-1","credential":{"id":"credential-1"}}`
|
|
body := &passkeyTestBody{Reader: strings.NewReader(bodyText)}
|
|
request := httptest.NewRequest(http.MethodPost, "/api/user/passkey/register/finish", nil)
|
|
request.Body = body
|
|
request.ContentLength = int64(len(bodyText))
|
|
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
|
context.Request = request
|
|
|
|
parsed, err := parsePasskeyFinishRequest(context)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "flow-1", parsed.FlowToken)
|
|
assert.JSONEq(t, `{"id":"credential-1"}`, string(parsed.Credential))
|
|
assert.Same(t, body, context.Request.Body)
|
|
assert.Equal(t, int64(len(bodyText)), context.Request.ContentLength)
|
|
}
|
|
|
|
func TestPasskeyRegisterFinishRejectsUnapprovedFlowWithoutConsumingIt(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
system_setting.GetPasskeySettings().UserVerification = "required"
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
payload, err := common.Marshal(map[string]any{"scope": service.VerificationScopePasskeyRegister})
|
|
require.NoError(t, err)
|
|
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
|
|
Purpose: model.AuthFlowPurposePasskeyRegister, UserId: identity.UserID, SessionId: identity.SessionID,
|
|
Payload: string(payload), ExpiresAt: time.Now().Add(time.Minute),
|
|
})
|
|
require.NoError(t, err)
|
|
body, err := common.Marshal(passkeyFinishRequest{
|
|
FlowToken: token, Credential: securityPasskeyResponse(t, key, "test-challenge", true, 0),
|
|
})
|
|
require.NoError(t, err)
|
|
response := securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(body), "", identity, PasskeyRegisterFinish)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
assert.False(t, result.Success)
|
|
assert.Equal(t, "AUTH_FLOW_INVALID", result.Code)
|
|
_, err = model.GetPasskeyByUserID(identity.UserID)
|
|
assert.ErrorIs(t, err, model.ErrPasskeyNotFound)
|
|
flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposePasskeyRegister})
|
|
require.NoError(t, err)
|
|
assert.Nil(t, flow.ConsumedAt)
|
|
}
|
|
|
|
type passkeyCredentialBeforeRPID struct {
|
|
ID int `json:"id" gorm:"primaryKey"`
|
|
UserID int `json:"user_id" gorm:"uniqueIndex;not null"`
|
|
CredentialID string `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded
|
|
PublicKey string `json:"public_key" gorm:"type:text;not null"` // base64 encoded
|
|
AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"`
|
|
AAGUID string `json:"aaguid" gorm:"type:varchar(512)"` // base64 encoded
|
|
SignCount uint32 `json:"sign_count" gorm:"default:0"`
|
|
CloneWarning bool `json:"clone_warning"`
|
|
UserPresent bool `json:"user_present"`
|
|
UserVerified bool `json:"user_verified"`
|
|
BackupEligible bool `json:"backup_eligible"`
|
|
BackupState bool `json:"backup_state"`
|
|
Transports string `json:"transports" gorm:"type:text"`
|
|
Attachment string `json:"attachment" gorm:"type:varchar(32)"`
|
|
LastUsedAt *time.Time `json:"last_used_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
|
}
|
|
|
|
func (passkeyCredentialBeforeRPID) TableName() string { return "passkey_credentials" }
|