0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 15:18:34 +08:00
discourse/app/models/user_second_factor.rb
Rafael dos Santos Silva 0c90e25e47
DEV: Split passkey and security key WebAuthn ceremonies for 2FA (#40817)
Passkeys used as 2FA (behind `allow_passkeys_for_2fa`) previously shared
a single WebAuthn ceremony with second-factor security keys on
`/session/2fa`: one merged credential allow-list, posted as the
`security_key` method, with `userVerification: "preferred"`. A single
ceremony cannot both require user verification for passkeys and accept
legacy non-UV security keys, so this splits them:

* New `passkey` value (4) in `UserSecondFactor.methods` carries the
ceremony intent on the wire. No rows ever store it; passkeys live in
`user_security_keys`.
* `DiscourseWebauthn.allowed_credentials` now returns
`allowed_credential_ids` (second-factor keys only, as before the
combined ceremony) plus a separate `passkey_allowed_credential_ids`.
* `authenticate_security_key` only accepts second-factor credentials
again; the new `authenticate_passkey` only accepts first-factor
credentials. A passkey assertion posted to the security key ceremony (or
vice versa) fails with an ownership error.
* The `/session/2fa` page shows distinct "Use passkey" (UV required) and
"Use security key" (UV discouraged) actions instead of one mixed button.
* `passkeys_for_2fa_enabled?` is renamed to
`passkeys_available_as_second_factor?` (old name kept as an alias) and
now ignores disabled passkey rows.

No behavior expansion: passkeys still only satisfy `/session/2fa`.

This is the first of three stacked PRs completing the
`allow_passkeys_for_2fa` rollout so passkeys count as valid 2FA
everywhere, including `enforce_second_factor`. The safe ordering is:
every login/recovery path must be able to *challenge* a passkey before
any path starts *trusting* passkey-only accounts as compliant.
2026-06-17 12:52:47 -03:00

66 lines
1.9 KiB
Ruby
Vendored

# frozen_string_literal: true
class UserSecondFactor < ActiveRecord::Base
include SecondFactorManager
MAX_TOTPS_PER_USER = 50
MAX_NAME_LENGTH = 300
belongs_to :user
scope :backup_codes, -> { where(method: UserSecondFactor.methods[:backup_codes], enabled: true) }
scope :totps, -> { where(method: UserSecondFactor.methods[:totp], enabled: true) }
scope :all_totps, -> { where(method: UserSecondFactor.methods[:totp]) }
validates :name, length: { maximum: MAX_NAME_LENGTH }, if: :name_changed?
validate :count_per_user_does_not_exceed_limit, on: :create
# `passkey` is a virtual method: passkeys live in `user_security_keys`
# (factor_type first_factor), never in this table. The enum value exists so
# the `second_factor_method` wire param can distinguish the passkey WebAuthn
# ceremony (user verification required) from the security key one.
def self.methods
@methods ||= Enum.new(totp: 1, backup_codes: 2, security_key: 3, passkey: 4)
end
def totp_object
get_totp_object(data)
end
def totp_provisioning_uri
totp_object.provisioning_uri(user.email)
end
private
def count_per_user_does_not_exceed_limit
if method == UserSecondFactor.methods[:totp]
if self.class.where(method: method, user_id: user_id).count >= MAX_TOTPS_PER_USER
errors.add(:base, I18n.t("login.too_many_authenticators"))
end
end
end
end
# == Schema Information
#
# Table name: user_second_factors
#
# id :bigint not null, primary key
# data :string not null
# enabled :boolean default(FALSE), not null
# last_used :datetime
# method :integer not null
# name :string(300)
# created_at :datetime not null
# updated_at :datetime not null
# user_id :integer not null
#
# Indexes
#
# index_user_second_factors_on_method_and_enabled (method,enabled)
# index_user_second_factors_on_user_id (user_id)
#