0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-04 10:39:43 +08:00
discourse/lib/discourse_webauthn.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

140 lines
3.7 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseWebauthn
ACCEPTABLE_REGISTRATION_TYPE = "webauthn.create"
ACCEPTABLE_AUTHENTICATION_TYPE = "webauthn.get"
SUPPORTED_ALGORITHMS = [
-7, # ES256
-8, # EdDSA
-35, # ES384
-36, # ES512
-37, # PS256
-38, # PS384
-39, # PS512
-257, # RS256 (via freedom patch)
].freeze
VALID_ATTESTATION_FORMATS = %w[none packed fido-u2f].freeze
CHALLENGE_EXPIRY = 5.minutes
class SecurityKeyError < StandardError
end
class InvalidOriginError < SecurityKeyError
end
class InvalidRelyingPartyIdError < SecurityKeyError
end
class UserVerificationError < SecurityKeyError
end
class UserPresenceError < SecurityKeyError
end
class ChallengeMismatchError < SecurityKeyError
end
class InvalidTypeError < SecurityKeyError
end
class UnsupportedPublicKeyAlgorithmError < SecurityKeyError
end
class UnsupportedAttestationFormatError < SecurityKeyError
end
class CredentialIdInUseError < SecurityKeyError
end
class MalformedAttestationError < SecurityKeyError
end
class KeyNotFoundError < SecurityKeyError
end
class MalformedPublicKeyCredentialError < SecurityKeyError
end
class OwnershipError < SecurityKeyError
end
class PublicKeyError < SecurityKeyError
end
class UnknownCOSEAlgorithmError < SecurityKeyError
end
##
# Usage:
#
# These methods should be used in controllers where we
# are challenging the user that has a security key, and
# they must respond with a valid webauthn response and
# credentials.
#
# @param user [User] the user to stage the challenge for
# @param server_session [ServerSession] the session to store the challenge in
def self.stage_challenge(user, server_session)
::DiscourseWebauthn::ChallengeGenerator.generate.commit_to_session(
server_session,
user,
expires: CHALLENGE_EXPIRY,
)
end
##
# Clears the challenge from the user's server session.
#
# @param user [User] the user to clear the challenge for
# @param server_session [ServerSession] the session to clear the challenge from
def self.clear_challenge(user, server_session)
server_session.delete(session_challenge_key(user))
end
# Returns separate allow-lists per WebAuthn ceremony: `allowed_credential_ids`
# for second-factor security keys (user verification discouraged) and
# `passkey_allowed_credential_ids` for passkeys used as 2FA (user
# verification required). Both ceremonies share the same staged challenge;
# the server validates the asserted credential against the intended ceremony.
def self.allowed_credentials(user, server_session, include_passkeys: false)
has_security_keys = user.security_keys_enabled?
has_passkeys = include_passkeys && user.passkeys_available_as_second_factor?
return {} if !has_security_keys && !has_passkeys
response = {}
if has_security_keys
response[:allowed_credential_ids] = user.second_factor_security_key_credential_ids
end
response[:passkey_allowed_credential_ids] = user.passkey_credential_ids if has_passkeys
response[:challenge] = challenge(user, server_session)
response
end
def self.challenge(user, server_session)
server_session[session_challenge_key(user)]
end
def self.rp_id
Rails.env.production? ? Discourse.current_hostname : "localhost"
end
def self.origin
case Rails.env
when "development"
# you might need to change this and the rp_id above
# if you are using a non-default port/hostname locally
"http://localhost:3000"
else
Discourse.base_url_no_prefix
end
end
def self.rp_name
SiteSetting.title
end
def self.session_challenge_key(user)
"staged-webauthn-challenge-#{user&.id}"
end
end