mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
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.
307 lines
8.8 KiB
Ruby
Vendored
307 lines
8.8 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
module SecondFactorManager
|
|
TOTP_ALLOWED_DRIFT_SECONDS = 30
|
|
|
|
extend ActiveSupport::Concern
|
|
|
|
SecondFactorAuthenticationResult =
|
|
Struct.new(
|
|
:ok,
|
|
:error,
|
|
:reason,
|
|
:backup_enabled,
|
|
:security_key_enabled,
|
|
:totp_enabled,
|
|
:multiple_second_factor_methods,
|
|
:used_2fa_method,
|
|
)
|
|
|
|
def create_totp(opts = {})
|
|
require_rotp
|
|
UserSecondFactor.create!(
|
|
{ user_id: id, method: UserSecondFactor.methods[:totp], data: ROTP::Base32.random }.merge(
|
|
opts,
|
|
),
|
|
)
|
|
end
|
|
|
|
def get_totp_object(data)
|
|
require_rotp
|
|
ROTP::TOTP.new(data, issuer: SiteSetting.title.gsub(":", ""))
|
|
end
|
|
|
|
def totp_provisioning_uri(data)
|
|
get_totp_object(data).provisioning_uri(email)
|
|
end
|
|
|
|
def authenticate_totp(token)
|
|
totps = user_second_factors&.totps
|
|
authenticated = false
|
|
totps.each do |totp|
|
|
last_used = 0
|
|
|
|
last_used = totp.last_used.to_i if totp.last_used
|
|
|
|
authenticated =
|
|
token.present? &&
|
|
totp.totp_object.verify(
|
|
token,
|
|
drift_ahead: TOTP_ALLOWED_DRIFT_SECONDS,
|
|
drift_behind: TOTP_ALLOWED_DRIFT_SECONDS,
|
|
after: last_used,
|
|
)
|
|
|
|
if authenticated
|
|
totp.update!(last_used: DateTime.now)
|
|
break
|
|
end
|
|
end
|
|
!!authenticated
|
|
end
|
|
|
|
def totp_enabled?
|
|
!SiteSetting.enable_discourse_connect && SiteSetting.enable_local_logins &&
|
|
user_second_factors&.totps&.exists?
|
|
end
|
|
|
|
def backup_codes_enabled?
|
|
!SiteSetting.enable_discourse_connect && SiteSetting.enable_local_logins &&
|
|
user_second_factors&.backup_codes&.exists?
|
|
end
|
|
|
|
def security_keys_enabled?
|
|
!SiteSetting.enable_discourse_connect && SiteSetting.enable_local_logins &&
|
|
security_keys&.where(
|
|
factor_type: UserSecurityKey.factor_types[:second_factor],
|
|
enabled: true,
|
|
)&.exists?
|
|
end
|
|
|
|
def passkeys_available_as_second_factor?
|
|
SiteSetting.allow_passkeys_for_2fa && SiteSetting.enable_passkeys &&
|
|
!SiteSetting.enable_discourse_connect && SiteSetting.enable_local_logins &&
|
|
security_keys&.where(
|
|
factor_type: UserSecurityKey.factor_types[:first_factor],
|
|
enabled: true,
|
|
)&.exists?
|
|
end
|
|
alias_method :passkeys_for_2fa_enabled?, :passkeys_available_as_second_factor?
|
|
|
|
# Passkey-as-2FA (`passkeys_available_as_second_factor?`) is intentionally
|
|
# excluded: it only satisfies `/session/2fa`. Password login, email login,
|
|
# and password reset have no passkey UI yet, so counting passkeys here would
|
|
# make those flows skip 2FA for passkey-only users.
|
|
def has_any_second_factor_methods_enabled?
|
|
totp_enabled? || security_keys_enabled?
|
|
end
|
|
|
|
def has_multiple_second_factor_methods?
|
|
security_keys_enabled? && totp_or_backup_codes_enabled?
|
|
end
|
|
|
|
def totp_or_backup_codes_enabled?
|
|
totp_enabled? || backup_codes_enabled?
|
|
end
|
|
|
|
def only_security_keys_enabled?
|
|
security_keys_enabled? && !totp_or_backup_codes_enabled?
|
|
end
|
|
|
|
def only_totp_or_backup_codes_enabled?
|
|
!security_keys_enabled? && totp_or_backup_codes_enabled?
|
|
end
|
|
|
|
def remaining_backup_codes
|
|
user_second_factors&.backup_codes&.count
|
|
end
|
|
|
|
def authenticate_second_factor(params, server_session)
|
|
ok_result = SecondFactorAuthenticationResult.new(true)
|
|
if !security_keys_enabled? && !totp_or_backup_codes_enabled? &&
|
|
(!passkeys_available_as_second_factor? || params[:second_factor_method].blank?)
|
|
return ok_result
|
|
end
|
|
|
|
second_factor_token = params[:second_factor_token]
|
|
second_factor_method = params[:second_factor_method]&.to_i
|
|
|
|
if second_factor_method.blank? || UserSecondFactor.methods[second_factor_method].blank?
|
|
return invalid_second_factor_method_result
|
|
end
|
|
|
|
if !valid_second_factor_method_for_user?(second_factor_method)
|
|
return not_enabled_second_factor_method_result
|
|
end
|
|
|
|
case second_factor_method
|
|
when UserSecondFactor.methods[:totp]
|
|
if authenticate_totp(second_factor_token)
|
|
ok_result.used_2fa_method = UserSecondFactor.methods[:totp]
|
|
return ok_result
|
|
else
|
|
return invalid_totp_or_backup_code_result
|
|
end
|
|
when UserSecondFactor.methods[:backup_codes]
|
|
if authenticate_backup_code(second_factor_token)
|
|
ok_result.used_2fa_method = UserSecondFactor.methods[:backup_codes]
|
|
return ok_result
|
|
else
|
|
return invalid_totp_or_backup_code_result
|
|
end
|
|
when UserSecondFactor.methods[:security_key]
|
|
if authenticate_security_key(server_session, second_factor_token)
|
|
ok_result.used_2fa_method = UserSecondFactor.methods[:security_key]
|
|
return ok_result
|
|
else
|
|
return invalid_security_key_result
|
|
end
|
|
when UserSecondFactor.methods[:passkey]
|
|
if authenticate_passkey(server_session, second_factor_token)
|
|
ok_result.used_2fa_method = UserSecondFactor.methods[:passkey]
|
|
return ok_result
|
|
else
|
|
return invalid_security_key_result
|
|
end
|
|
end
|
|
|
|
# if we have gotten down to this point without being
|
|
# OK or invalid something has gone very weird.
|
|
invalid_second_factor_method_result
|
|
rescue ::DiscourseWebauthn::SecurityKeyError => err
|
|
invalid_security_key_result(err.message)
|
|
end
|
|
|
|
def valid_second_factor_method_for_user?(method)
|
|
case method
|
|
when UserSecondFactor.methods[:totp]
|
|
return totp_enabled?
|
|
when UserSecondFactor.methods[:backup_codes]
|
|
return backup_codes_enabled?
|
|
when UserSecondFactor.methods[:security_key]
|
|
return security_keys_enabled?
|
|
when UserSecondFactor.methods[:passkey]
|
|
return passkeys_available_as_second_factor?
|
|
end
|
|
false
|
|
end
|
|
|
|
def authenticate_security_key(server_session, security_key_credential)
|
|
::DiscourseWebauthn::AuthenticationService.new(
|
|
self,
|
|
security_key_credential,
|
|
session: server_session,
|
|
factor_type: [UserSecurityKey.factor_types[:second_factor]],
|
|
).authenticate_security_key
|
|
end
|
|
|
|
def authenticate_passkey(server_session, security_key_credential)
|
|
::DiscourseWebauthn::AuthenticationService.new(
|
|
self,
|
|
security_key_credential,
|
|
session: server_session,
|
|
factor_type: [UserSecurityKey.factor_types[:first_factor]],
|
|
).authenticate_security_key
|
|
end
|
|
|
|
def invalid_totp_or_backup_code_result
|
|
invalid_second_factor_authentication_result(
|
|
I18n.t("login.invalid_second_factor_code"),
|
|
"invalid_second_factor",
|
|
)
|
|
end
|
|
|
|
def invalid_security_key_result(error_message = nil)
|
|
invalid_second_factor_authentication_result(
|
|
error_message || I18n.t("login.invalid_security_key"),
|
|
"invalid_security_key",
|
|
)
|
|
end
|
|
|
|
def invalid_second_factor_method_result
|
|
invalid_second_factor_authentication_result(
|
|
I18n.t("login.invalid_second_factor_method"),
|
|
"invalid_second_factor_method",
|
|
)
|
|
end
|
|
|
|
def not_enabled_second_factor_method_result
|
|
invalid_second_factor_authentication_result(
|
|
I18n.t("login.not_enabled_second_factor_method"),
|
|
"not_enabled_second_factor_method",
|
|
)
|
|
end
|
|
|
|
def invalid_second_factor_authentication_result(error_message, reason)
|
|
SecondFactorAuthenticationResult.new(
|
|
false,
|
|
error_message,
|
|
reason,
|
|
backup_codes_enabled?,
|
|
security_keys_enabled?,
|
|
totp_enabled?,
|
|
has_multiple_second_factor_methods?,
|
|
)
|
|
end
|
|
|
|
def generate_backup_codes
|
|
codes = []
|
|
10.times { codes << SecureRandom.hex(16) }
|
|
|
|
codes_json =
|
|
codes.map do |code|
|
|
salt = SecureRandom.hex(16)
|
|
{ salt: salt, code_hash: hash_backup_code(code, salt) }
|
|
end
|
|
|
|
if user_second_factors.backup_codes.empty?
|
|
create_backup_codes(codes_json)
|
|
else
|
|
user_second_factors.where(method: UserSecondFactor.methods[:backup_codes]).destroy_all
|
|
create_backup_codes(codes_json)
|
|
end
|
|
|
|
codes
|
|
end
|
|
|
|
def create_backup_codes(codes)
|
|
codes.each do |code|
|
|
UserSecondFactor.create!(
|
|
user_id: id,
|
|
data: code.to_json,
|
|
enabled: true,
|
|
method: UserSecondFactor.methods[:backup_codes],
|
|
)
|
|
end
|
|
end
|
|
|
|
def authenticate_backup_code(backup_code)
|
|
if backup_code.present?
|
|
codes = user_second_factors&.backup_codes
|
|
|
|
codes.each do |code|
|
|
parsed_data = JSON.parse(code.data)
|
|
stored_code = parsed_data["code_hash"]
|
|
stored_salt = parsed_data["salt"]
|
|
backup_hash = hash_backup_code(backup_code, stored_salt)
|
|
next unless backup_hash == stored_code
|
|
|
|
code.update(enabled: false, last_used: DateTime.now)
|
|
return true
|
|
end
|
|
false
|
|
end
|
|
false
|
|
end
|
|
|
|
def hash_backup_code(code, salt)
|
|
# Backup codes have high entropy, so we can afford to use
|
|
# a lower number of iterations than for user-specific passwords
|
|
iterations = Rails.env.test? ? 10 : 64_000
|
|
Pbkdf2.hash_password(code, salt, iterations, "sha256")
|
|
end
|
|
|
|
def require_rotp
|
|
require "rotp" if !defined?(ROTP)
|
|
end
|
|
end
|