mirror of
https://github.com/discourse/discourse.git
synced 2026-08-04 10:39:43 +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.
140 lines
3.7 KiB
Ruby
Vendored
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
|