mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-08 18:59:10 +02:00
feat: add settings for limiting what passkeys can be used (#1662)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
co-authored by
Alessandro Segala
parent
448d271c94
commit
d6a47243db
@@ -59,13 +59,16 @@ func (a *appConfigActor) Bootstrap(parentCtx context.Context, data actor.Envelop
|
||||
return fmt.Errorf("error retrieving actor state: %w", err)
|
||||
}
|
||||
|
||||
// If we already have a state, nothing else to do
|
||||
// Upgrade existing state with defaults added by newer Pocket ID versions
|
||||
if state != nil {
|
||||
return nil
|
||||
state = state.Clone()
|
||||
if !state.applyDefaults() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the request data contains legacy config to init from
|
||||
if data != nil {
|
||||
// Check if a new state should be initialized from legacy configuration
|
||||
if state == nil && data != nil {
|
||||
payload := appConfigActorBootstrap{}
|
||||
err = data.Decode(&payload)
|
||||
if err != nil {
|
||||
@@ -80,12 +83,12 @@ func (a *appConfigActor) Bootstrap(parentCtx context.Context, data actor.Envelop
|
||||
}
|
||||
}
|
||||
|
||||
// If we still have no state, generate a new default config
|
||||
// Initialize a new state with defaults when no legacy configuration exists
|
||||
if state == nil {
|
||||
state = getDefaultConfig()
|
||||
}
|
||||
|
||||
// Save the updated state
|
||||
// Persist new and upgraded state eagerly so every later activation sees the complete model
|
||||
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
err = a.client.SetState(ctx, state, nil)
|
||||
|
||||
@@ -61,6 +61,10 @@ type AppConfigModel struct {
|
||||
LdapAttributeGroupName AppConfigValue `json:"ldapAttributeGroupName"`
|
||||
LdapAdminGroupName AppConfigValue `json:"ldapAdminGroupName"`
|
||||
LdapSoftDeleteUsers AppConfigValue `json:"ldapSoftDeleteUsers" type:"bool"`
|
||||
// WebAuthn
|
||||
WebauthnUserVerification AppConfigValue `json:"webauthnUserVerification"`
|
||||
WebauthnAllowSyncedPasskeys AppConfigValue `json:"webauthnAllowSyncedPasskeys" type:"bool"`
|
||||
WebauthnAuthenticatorAttachment AppConfigValue `json:"webauthnAuthenticatorAttachment"`
|
||||
// OIDC
|
||||
CIMDURLAllowlist AppConfigValue `json:"cimdUrlAllowlist"` // JSON-encoded array of strings
|
||||
}
|
||||
@@ -147,11 +151,33 @@ func getDefaultConfig() *AppConfigModel {
|
||||
LdapAttributeGroupName: "",
|
||||
LdapAdminGroupName: "",
|
||||
LdapSoftDeleteUsers: "true",
|
||||
// WebAuthn
|
||||
WebauthnUserVerification: "required",
|
||||
WebauthnAllowSyncedPasskeys: "true",
|
||||
WebauthnAuthenticatorAttachment: "any",
|
||||
// OIDC
|
||||
CIMDURLAllowlist: "[]",
|
||||
}
|
||||
}
|
||||
|
||||
// applyDefaults fills empty properties from the default configuration and reports whether the model changed
|
||||
func (m *AppConfigModel) applyDefaults() bool {
|
||||
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
|
||||
values := reflect.ValueOf(m).Elem()
|
||||
changed := false
|
||||
|
||||
for i := range values.NumField() {
|
||||
if values.Field(i).String() != "" || defaults.Field(i).String() == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
values.Field(i).Set(defaults.Field(i))
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// Replace updates every configuration property with the values from the input DTO
|
||||
// An empty string value resets the corresponding property to its default value
|
||||
func (m *AppConfigModel) Replace(input dto.AppConfigUpdateDto) {
|
||||
|
||||
@@ -106,6 +106,25 @@ func TestAppConfigModel_Replace(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppConfigModel_ApplyDefaults(t *testing.T) {
|
||||
m := &AppConfigModel{AppName: "Custom Name"}
|
||||
|
||||
assert.True(t, m.applyDefaults())
|
||||
assert.Equal(t, AppConfigValue("Custom Name"), m.AppName)
|
||||
|
||||
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
|
||||
values := reflect.ValueOf(m).Elem()
|
||||
modelType := values.Type()
|
||||
for i := range values.NumField() {
|
||||
if modelType.Field(i).Name == "AppName" {
|
||||
continue
|
||||
}
|
||||
assert.Equal(t, defaults.Field(i).Interface(), values.Field(i).Interface(), modelType.Field(i).Name)
|
||||
}
|
||||
|
||||
assert.False(t, m.applyDefaults())
|
||||
}
|
||||
|
||||
func TestAppConfigModel_Clone(t *testing.T) {
|
||||
t.Run("clones every property", func(t *testing.T) {
|
||||
// Populate every property with a unique marker so we can assert each one is copied
|
||||
|
||||
@@ -122,6 +122,10 @@ func PasskeyUserVerificationRequired(cause error) *Error {
|
||||
return Wrap(cause, CodePasskeyUserVerificationRequired, http.StatusBadRequest, "Your passkey couldn't verify you. If you're using a security key, configure a FIDO2 PIN and try again")
|
||||
}
|
||||
|
||||
func SyncedPasskeyNotAllowed() *Error {
|
||||
return New(CodeSyncedPasskeyNotAllowed, http.StatusBadRequest, "Synced passkeys are not allowed")
|
||||
}
|
||||
|
||||
func ReservedClaim(key string) *Error {
|
||||
return New(CodeReservedClaim, http.StatusBadRequest, fmt.Sprintf("Claim %s is reserved and can't be used", key)).
|
||||
WithDetail("key", key).
|
||||
|
||||
@@ -26,6 +26,7 @@ const (
|
||||
CodeInvalidWebAuthnResponse Code = "invalid_webauthn_response"
|
||||
CodeWebAuthnAuthenticationFailed Code = "webauthn_authentication_failed"
|
||||
CodePasskeyUserVerificationRequired Code = "passkey_user_verification_required"
|
||||
CodeSyncedPasskeyNotAllowed Code = "synced_passkey_not_allowed"
|
||||
CodeInvalidWebAuthnSession Code = "invalid_webauthn_session"
|
||||
CodeUserNotFound Code = "user_not_found"
|
||||
CodeUserDisabled Code = "user_disabled"
|
||||
|
||||
@@ -50,6 +50,9 @@ type AppConfigUpdateDto struct {
|
||||
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
||||
LdapAdminGroupName string `json:"ldapAdminGroupName"`
|
||||
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"`
|
||||
WebauthnUserVerification string `json:"webauthnUserVerification" binding:"required,oneof=required preferred"`
|
||||
WebauthnAllowSyncedPasskeys string `json:"webauthnAllowSyncedPasskeys" binding:"required,oneof=true false"`
|
||||
WebauthnAuthenticatorAttachment string `json:"webauthnAuthenticatorAttachment" binding:"required,oneof=any platform cross-platform"`
|
||||
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"`
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
|
||||
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
|
||||
|
||||
@@ -40,13 +40,18 @@ func (h *handler) beginRegistration(c *gin.Context) error {
|
||||
}
|
||||
|
||||
func (h *handler) verifyRegistration(c *gin.Context) error {
|
||||
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||
if err != nil {
|
||||
return apperror.MissingSessionID()
|
||||
}
|
||||
|
||||
userID := c.GetString("userID")
|
||||
credential, err := h.service.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP())
|
||||
credential, err := h.service.VerifyRegistration(c.Request.Context(), dbConfig, sessionID, userID, c.Request, c.ClientIP())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -61,7 +66,12 @@ func (h *handler) verifyRegistration(c *gin.Context) error {
|
||||
}
|
||||
|
||||
func (h *handler) beginLogin(c *gin.Context) error {
|
||||
options, err := h.service.BeginLogin(c.Request.Context())
|
||||
dbConfig, err := h.appConfig.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error loading app configuration: %w", err)
|
||||
}
|
||||
|
||||
options, err := h.service.BeginLogin(c.Request.Context(), dbConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ func (s *Service) BeginRegistration(ctx context.Context, dbConfig *appconfig.App
|
||||
|
||||
options, session, err := s.webAuthn.BeginRegistration(
|
||||
&user,
|
||||
gowebauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
|
||||
AuthenticatorAttachment: authenticatorAttachment(dbConfig),
|
||||
UserVerification: userVerificationRequirement(dbConfig),
|
||||
}),
|
||||
gowebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
|
||||
gowebauthn.WithExclusions(user.WebAuthnCredentialDescriptors()),
|
||||
gowebauthn.WithExtensions(map[string]any{"credProps": true}), // Required for Firefox Android to properly save the key in Google password manager
|
||||
@@ -128,7 +132,7 @@ func (s *Service) BeginRegistration(ctx context.Context, dbConfig *appconfig.App
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) VerifyRegistration(ctx context.Context, sessionID string, userID string, r *http.Request, ipAddress string) (model.WebauthnCredential, error) {
|
||||
func (s *Service) VerifyRegistration(ctx context.Context, dbConfig *appconfig.AppConfigModel, sessionID string, userID string, r *http.Request, ipAddress string) (model.WebauthnCredential, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -171,6 +175,9 @@ func (s *Service) VerifyRegistration(ctx context.Context, sessionID string, user
|
||||
if err != nil {
|
||||
return model.WebauthnCredential{}, classifyPasskeyError(err, apperror.InvalidWebAuthnResponse)
|
||||
}
|
||||
if err := validateCredentialPolicy(dbConfig, credential); err != nil {
|
||||
return model.WebauthnCredential{}, err
|
||||
}
|
||||
|
||||
// Determine passkey name using AAGUID and User-Agent
|
||||
passkeyName := s.determinePasskeyName(credential.Authenticator.AAGUID)
|
||||
@@ -214,8 +221,10 @@ func (s *Service) determinePasskeyName(aaguid []byte) string {
|
||||
return "New Passkey" // Default fallback
|
||||
}
|
||||
|
||||
func (s *Service) BeginLogin(ctx context.Context) (*PublicKeyCredentialRequestOptions, error) {
|
||||
options, session, err := s.webAuthn.BeginDiscoverableLogin()
|
||||
func (s *Service) BeginLogin(ctx context.Context, dbConfig *appconfig.AppConfigModel) (*PublicKeyCredentialRequestOptions, error) {
|
||||
options, session, err := s.webAuthn.BeginDiscoverableLogin(
|
||||
gowebauthn.WithUserVerification(userVerificationRequirement(dbConfig)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -408,6 +417,37 @@ func (s *Service) updateWebAuthnConfig(dbConfig *appconfig.AppConfigModel) {
|
||||
s.webAuthn.Config.RPDisplayName = dbConfig.AppName.String()
|
||||
}
|
||||
|
||||
func userVerificationRequirement(dbConfig *appconfig.AppConfigModel) protocol.UserVerificationRequirement {
|
||||
if dbConfig.WebauthnUserVerification == "preferred" {
|
||||
return protocol.VerificationPreferred
|
||||
}
|
||||
|
||||
return protocol.VerificationRequired
|
||||
}
|
||||
|
||||
func authenticatorAttachment(dbConfig *appconfig.AppConfigModel) protocol.AuthenticatorAttachment {
|
||||
switch dbConfig.WebauthnAuthenticatorAttachment {
|
||||
case "platform":
|
||||
return protocol.Platform
|
||||
case "cross-platform":
|
||||
return protocol.CrossPlatform
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func allowsSyncedPasskeys(dbConfig *appconfig.AppConfigModel) bool {
|
||||
return dbConfig.WebauthnAllowSyncedPasskeys.IsTrue()
|
||||
}
|
||||
|
||||
func validateCredentialPolicy(dbConfig *appconfig.AppConfigModel, credential *gowebauthn.Credential) error {
|
||||
if !allowsSyncedPasskeys(dbConfig) && credential.Flags.BackupEligible {
|
||||
return apperror.SyncedPasskeyNotAllowed()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateReauthenticationTokenWithAccessToken(ctx context.Context, accessToken string) (string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
gowebauthn "github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -143,6 +144,85 @@ func TestWebAuthnDisplayNameUsesRequestConfig(t *testing.T) {
|
||||
require.Equal(t, "Custom App", service.webAuthn.Config.RPDisplayName)
|
||||
}
|
||||
|
||||
func TestBeginCeremoniesUseRequestConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
userVerification appconfig.AppConfigValue
|
||||
authenticator appconfig.AppConfigValue
|
||||
wantUserVerification protocol.UserVerificationRequirement
|
||||
wantAuthenticator protocol.AuthenticatorAttachment
|
||||
}{
|
||||
{
|
||||
name: "required verification with any authenticator",
|
||||
userVerification: "required",
|
||||
authenticator: "any",
|
||||
wantUserVerification: protocol.VerificationRequired,
|
||||
wantAuthenticator: "",
|
||||
},
|
||||
{
|
||||
name: "required verification with a platform authenticator",
|
||||
userVerification: "required",
|
||||
authenticator: "platform",
|
||||
wantUserVerification: protocol.VerificationRequired,
|
||||
wantAuthenticator: protocol.Platform,
|
||||
},
|
||||
{
|
||||
name: "preferred verification with a cross-platform authenticator",
|
||||
userVerification: "preferred",
|
||||
authenticator: "cross-platform",
|
||||
wantUserVerification: protocol.VerificationPreferred,
|
||||
wantAuthenticator: protocol.CrossPlatform,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "configured-user"},
|
||||
Username: "configured-user",
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
service, err := newService(Dependencies{
|
||||
DB: db,
|
||||
AppURL: "https://example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
dbConfig := &appconfig.AppConfigModel{
|
||||
AppName: "Configured App",
|
||||
WebauthnUserVerification: tc.userVerification,
|
||||
WebauthnAuthenticatorAttachment: tc.authenticator,
|
||||
}
|
||||
|
||||
registration, err := service.BeginRegistration(t.Context(), dbConfig, user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantUserVerification, registration.Response.AuthenticatorSelection.UserVerification)
|
||||
assert.Equal(t, tc.wantAuthenticator, registration.Response.AuthenticatorSelection.AuthenticatorAttachment)
|
||||
assert.Equal(t, protocol.ResidentKeyRequirementRequired, registration.Response.AuthenticatorSelection.ResidentKey)
|
||||
|
||||
login, err := service.BeginLogin(t.Context(), dbConfig)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantUserVerification, login.Response.UserVerification)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncedPasskeyPolicy(t *testing.T) {
|
||||
credential := &gowebauthn.Credential{
|
||||
Flags: gowebauthn.CredentialFlags{BackupEligible: true},
|
||||
}
|
||||
|
||||
require.NoError(t, validateCredentialPolicy(&appconfig.AppConfigModel{WebauthnAllowSyncedPasskeys: "true"}, credential))
|
||||
|
||||
err := validateCredentialPolicy(&appconfig.AppConfigModel{WebauthnAllowSyncedPasskeys: "false"}, credential)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeSyncedPasskeyNotAllowed))
|
||||
|
||||
credential.Flags.BackupEligible = false
|
||||
require.NoError(t, validateCredentialPolicy(&appconfig.AppConfigModel{WebauthnAllowSyncedPasskeys: "false"}, credential))
|
||||
}
|
||||
|
||||
func TestClassifyPasskeyErrorRecognizesMissingUserVerification(t *testing.T) {
|
||||
rpIDHash := make([]byte, 32)
|
||||
authenticatorData := protocol.AuthenticatorData{
|
||||
@@ -213,7 +293,7 @@ func TestCeremoniesRejectSessionThatDoesNotExist(t *testing.T) {
|
||||
t.Run("registration rejects an unknown session", func(t *testing.T) {
|
||||
service := setupService(t)
|
||||
|
||||
_, err := service.VerifyRegistration(t.Context(), "does-not-exist", userID, nil, "127.0.0.1")
|
||||
_, err := service.VerifyRegistration(t.Context(), &appconfig.AppConfigModel{}, "does-not-exist", userID, nil, "127.0.0.1")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeInvalidWebAuthnSession))
|
||||
|
||||
Reference in New Issue
Block a user