diff --git a/server/cmd/main.go b/server/cmd/main.go index d7a599e..650cc24 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -138,6 +138,10 @@ func runSchemaSetup() { log.Fatalf("failed to ensure auth indexes: %v", err) } + if err := services.EnsureMFAIndexes(); err != nil { + log.Fatalf("failed to ensure mfa indexes: %v", err) + } + if err := services.EnsureAPITokenIndexes(); err != nil { log.Fatalf("api token indexes: %v", err) } diff --git a/server/internal/models/settings.go b/server/internal/models/settings.go index a19aede..09ccbdd 100644 --- a/server/internal/models/settings.go +++ b/server/internal/models/settings.go @@ -11,3 +11,7 @@ type ( // APITokenMaxDays re-exports shared.APITokenMaxDays so server/internal/services // can read the token lifetime cap without importing shared/models directly. func APITokenMaxDays(s *Settings) int { return shared.APITokenMaxDays(s) } + +// RequireMFA re-exports shared.RequireMFA so services can read the MFA policy +// without importing shared/models directly. +func RequireMFA(s *Settings) bool { return shared.RequireMFA(s) } diff --git a/server/internal/models/user_mfa.go b/server/internal/models/user_mfa.go new file mode 100644 index 0000000..eb7ffef --- /dev/null +++ b/server/internal/models/user_mfa.go @@ -0,0 +1,61 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// UserMFA is one user's multi-factor enrolment. It is a separate collection +// rather than fields on User because User lives in vantage-shared, which Vantage +// HQ also writes: MFA is a control-plane concern per instance. +type UserMFA struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"-"` + UserID string `bson:"user_id" json:"-"` + + // WebAuthnHandle is a random 64 bytes, never the user ID: the handle is + // returned to any origin that asks a resident credential for it. + WebAuthnHandle []byte `bson:"webauthn_handle" json:"-"` + + // TOTPSecretEnc is AES-256-GCM hex via services.encryptString. Mirrored in + // vantage-shared's backup.ciphertextFields - change one, change the other. + TOTPSecretEnc string `bson:"totp_secret_enc,omitempty" json:"-"` + + // TOTPConfirmedAt nil means setup was started but never confirmed, which + // does not count as an enrolled factor. + TOTPConfirmedAt *time.Time `bson:"totp_confirmed_at,omitempty" json:"totp_confirmed_at,omitempty"` + + RecoveryCodes []RecoveryCode `bson:"recovery_codes,omitempty" json:"-"` + + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +// RecoveryCode stores only a SHA-256 hash: a leaked database yields no working +// codes, exactly as api_tokens and agent tokens do. +type RecoveryCode struct { + Hash string `bson:"hash"` + UsedAt *time.Time `bson:"used_at,omitempty"` +} + +// WebAuthnCredential is one passkey. Nothing here is secret - a public key is +// public - so no field is encrypted. +type WebAuthnCredential struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"-"` + UserID string `bson:"user_id" json:"-"` + + CredentialID []byte `bson:"credential_id" json:"-"` + PublicKey []byte `bson:"public_key" json:"-"` + SignCount uint32 `bson:"sign_count" json:"-"` + AAGUID []byte `bson:"aaguid" json:"-"` + Transports []string `bson:"transports,omitempty" json:"transports,omitempty"` + + // CredentialIDHex is the browser-facing identifier for rename and delete. + // The raw bytes never reach a URL. + CredentialIDHex string `bson:"credential_id_hex" json:"id"` + + Name string `bson:"name" json:"name"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + LastUsedAt *time.Time `bson:"last_used_at,omitempty" json:"last_used_at,omitempty"` +} diff --git a/server/internal/services/coreindexes.go b/server/internal/services/coreindexes.go index 32a9af5..e0e3f78 100644 --- a/server/internal/services/coreindexes.go +++ b/server/internal/services/coreindexes.go @@ -41,3 +41,28 @@ func EnsureAuthIndexes() error { } return nil } + +// EnsureMFAIndexes is fatal on failure like EnsureAuthIndexes, and for the same +// reason: these unique indexes are a security property, not an optimisation. A +// duplicate (instance_id, user_id) would make "this user's factors" ambiguous, +// and a duplicate credential_id would let an assertion resolve to two users. +func EnsureMFAIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if _, err := db.Col("user_mfa").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "user_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + + _, err := db.Col("webauthn_credentials").Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "credential_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + {Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "user_id", Value: 1}}}, + }) + return err +} diff --git a/server/internal/services/mfa_scoped_test.go b/server/internal/services/mfa_scoped_test.go new file mode 100644 index 0000000..a50ec60 --- /dev/null +++ b/server/internal/services/mfa_scoped_test.go @@ -0,0 +1,21 @@ +package services + +import "testing" + +// A tenant-scoped collection missing from ScopedCollections outlives its +// instance when the instance is purged - here that means a former customer's +// TOTP secrets and passkeys stay in the database forever. +func TestMFACollectionsAreScoped(t *testing.T) { + for _, name := range []string{"user_mfa", "webauthn_credentials"} { + found := false + for _, got := range ScopedCollections { + if got == name { + found = true + break + } + } + if !found { + t.Errorf("%s is not in ScopedCollections", name) + } + } +} diff --git a/server/internal/services/migrate_instance.go b/server/internal/services/migrate_instance.go index 778358a..aa1825e 100644 --- a/server/internal/services/migrate_instance.go +++ b/server/internal/services/migrate_instance.go @@ -52,6 +52,8 @@ var ScopedCollections = []string{ "patch_policies", "patch_runs", "patch_run_outputs", + "user_mfa", + "webauthn_credentials", } // collectionRenames maps the two collections whose names change. Ordered so the