diff --git a/app/assets/javascripts/authentication/webauthn/authenticate.js b/app/assets/javascripts/authentication/webauthn/authenticate.js index 78ac66985284237466cacccb1cc0c753f7157685..d29743a64f83553cf55fd71ed01b801edbb27c58 100644 --- a/app/assets/javascripts/authentication/webauthn/authenticate.js +++ b/app/assets/javascripts/authentication/webauthn/authenticate.js @@ -59,8 +59,12 @@ export default class WebAuthnAuthenticate { this.flow.renderTemplate('authenticated'); const container = this.container[0]; container.querySelector('#js-device-response').value = deviceResponse; + + this.login_with_passkey = container.querySelector('#js-passkey-request')?.value; + container.querySelector(this.form).submit(); - this.fallbackButton.classList.add('hidden'); + + !this.login_with_passkey && this.fallbackButton.classList.add('hidden'); } switchToFallbackUI() { diff --git a/app/assets/javascripts/authentication/webauthn/components/registration.vue b/app/assets/javascripts/authentication/webauthn/components/registration.vue index 2fb8e26256f5059005b16e6f152560a7ece28cc5..8317f1a698441127857f4ecaea852ca7bb6b5398 100644 --- a/app/assets/javascripts/authentication/webauthn/components/registration.vue +++ b/app/assets/javascripts/authentication/webauthn/components/registration.vue @@ -71,7 +71,7 @@ export default { STATE_UNSUPPORTED, STATE_WAITING, WEBAUTHN_DOCUMENTATION_PATH, - inject: ['initialError', 'passwordRequired', 'targetPath'], + inject: ['initialError', 'passwordRequired', 'targetPath', 'webauthn_device_type'], data() { return { csrfToken: csrf.token, @@ -114,10 +114,11 @@ export default { this.state = STATE_WAITING; try { + const gon_options = this.webauthn_device_type === 'passkey' ? gon.passkey?.options : gon.webauthn?.options + const credentials = await navigator.credentials.create({ - publicKey: convertCreateParams(gon.webauthn.options), + publicKey: convertCreateParams(gon_options), }); - this.credentials = JSON.stringify(convertCreateResponse(credentials)); this.state = STATE_SUCCESS; } catch (error) { diff --git a/app/assets/javascripts/authentication/webauthn/registration.js b/app/assets/javascripts/authentication/webauthn/registration.js index 67906a24857f2b8db63bac7990f3cea73ea1d784..b58f046bb81c90288c50ca6b27a066e8ae54caab 100644 --- a/app/assets/javascripts/authentication/webauthn/registration.js +++ b/app/assets/javascripts/authentication/webauthn/registration.js @@ -3,20 +3,24 @@ import WebAuthnRegistration from '~/authentication/webauthn/components/registrat import { parseBoolean } from '~/lib/utils/common_utils'; export const initWebAuthnRegistration = () => { - const el = document.querySelector('#js-device-registration'); + const els = document.querySelectorAll('.js-device-registration'); - if (!el) { + if (!els.length) { return null; } - const { initialError, passwordRequired, targetPath } = el.dataset; + els.forEach((el)=>{ + const { initialError, passwordRequired, targetPath, type } = el.dataset; - return new Vue({ - el, - name: 'WebAuthnRegistrationRoot', - provide: { initialError, passwordRequired: parseBoolean(passwordRequired), targetPath }, - render(h) { - return h(WebAuthnRegistration); - }, - }); + const webauthn_device_type = type + + return new Vue({ + el, + name: 'WebAuthnRegistrationRoot', + provide: { initialError, passwordRequired: parseBoolean(passwordRequired), targetPath, webauthn_device_type }, + render(h) { + return h(WebAuthnRegistration); + }, + }); + }) }; diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index ccc1c3e6c855c3b34072b5eaa9f6d87e7a4c2579..be2f40b6109976ee737e1c52e08036ea25117f61 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -26,7 +26,18 @@ def prompt_for_two_factor(user) add_gon_variables setup_webauthn_authentication(user) - render 'devise/sessions/two_factor' + if user.passkeys.present? + handle_authenticate_with_two_factor_via_passkeys + else + render 'devise/sessions/two_factor' + end + end + + def prompt_for_passkey + add_gon_variables + setup_passkey_authentication + + render 'devise/sessions/passkeys', locals: { admin_mode: false } end def handle_locked_user(user) @@ -61,6 +72,14 @@ def authenticate_with_two_factor end end + def handle_authenticate_with_two_factor_via_passkeys + if passkey_params[:device_response].present? + authenticate_with_two_factor_via_passkey + else + prompt_for_passkey + end + end + private def locked_user_redirect_alert(user) @@ -101,6 +120,17 @@ def authenticate_with_two_factor_via_webauthn(user) end end + def authenticate_with_two_factor_via_passkey + existing_user_with_passkey = Authn::Passkeys::AuthenticateService.new(passkey_params[:device_response], + session[:challenge]).execute + + if existing_user_with_passkey + handle_two_factor_success(existing_user_with_passkey) + else + handle_passkeys_failure('WebAuthn', _('Authentication via Passkey failed.')) + end + end + # rubocop: disable CodeReuse/ActiveRecord def setup_webauthn_authentication(user) if user.webauthn_registrations.present? @@ -118,12 +148,24 @@ def setup_webauthn_authentication(user) end # rubocop: enable CodeReuse/ActiveRecord + def setup_passkey_authentication + get_options = WebAuthn::Credential.options_for_get( + allow: [], # Forces discoverable search + user_verification: 'preferred', + extensions: { appid: WebAuthn.configuration.origin } + ) + session[:challenge] = get_options.challenge + gon.push(webauthn: { options: Gitlab::Json.dump(get_options) }) + end + def handle_two_factor_success(user) # Remove any lingering user data from login clear_two_factor_attempt! remember_me(user) if user_params[:remember_me] == '1' sign_in(user, message: :two_factor_authenticated, event: :authentication) + + redirect_to root_path || stored_redirect_uri end def handle_two_factor_failure(user, method, message) @@ -135,6 +177,12 @@ def handle_two_factor_failure(user, method, message) prompt_for_two_factor(user) end + def handle_passkeys_failure(method, message) + Gitlab::AppLogger.info("Failed Login: ip=#{request.remote_ip} method=#{method}") + flash.now[:alert] = message + prompt_for_passkey + end + def send_two_factor_otp_attempt_failed_email(user) user.notification_service.two_factor_otp_attempt_failed(user, request.remote_ip) end diff --git a/app/controllers/profiles/passkeys_controller.rb b/app/controllers/profiles/passkeys_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..0d0185cdd61bc0e346dde30d06bc3213b0897fd0 --- /dev/null +++ b/app/controllers/profiles/passkeys_controller.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +module Profiles + class PasskeysController < Profiles::ApplicationController + skip_before_action :check_two_factor_requirement + before_action :check_passkeys_enabled! + + before_action :ensure_verified_primary_email, only: [:new, :create] + before_action :validate_current_password, + only: [:create, :destroy], + if: :current_password_required? + + helper_method :current_password_required? + helper_method :passkey_registration_show + helper_method :passkey_registrations_show + + feature_category :system_access + + include SafeFormatHelper + include WebauthnHelper + + def new + setup_show_page + end + + def create + @passkey_registration = Authn::Passkeys::RegisterService.new( + current_user, + device_registration_params, + session[:challenge], + first_factor: true + ).execute + + if @passkey_registration.persisted? + session.delete(:challenge) + notice = _("Your Passkey was registered!") + redirect_to profile_two_factor_auth_path, notice: notice + else + setup_passkey_registration # reset the registration data + + flash[notice] = @passkey_registration.errors.full_messages.join(", ") + redirect_to profile_two_factor_auth_path + end + end + + def destroy + ::Authn::Passkeys::DestroyService.new(current_user, current_user, destroy_params[:id]).execute + + redirect_to profile_two_factor_auth_path, status: :found, notice: _("Successfully deleted Passkey.") + end + + private + + def passkey_registration_show + @passkey_registration ||= WebauthnRegistration.passkey.new + end + + def passkey_registration_shows + @passkey_registrations ||= current_user.passkeys.map do |p| + { name: p.name, created_at: p.created_at, delete_path: profile_two_factor_auth_passkey_path(p) } + end + end + + def check_passkeys_enabled! + true + # access_denied! unless Feature.enabled?(:passkeys, current_user) + end + + def validate_current_password + return if current_user.valid_password?(validate_current_password_params[:current_password]) + + current_user.increment_failed_attempts! + + error_message = { message: _('You must provide a valid current password.') } + if validate_current_password_params[:action] == 'create' + @webauthn_error = error_message + else + @error = error_message + end + + setup_show_page + + flash[:notice] = error_message[:message] + + redirect_to profile_two_factor_auth_path + end + + def current_password_required? + !current_user.password_automatically_set? && current_user.allow_password_authentication_for_web? + end + + def device_registration_params + params.require(:device_registration).permit(:device_response, :name) + end + + def destroy_params + params.permit(:id) + end + + def validate_current_password_params + params.permit(%i[current_password action]) + end + + def setup_show_page + if two_factor_authentication_required? && !current_user.two_factor_enabled? + two_factor_auth_actions = { + global: ->(_) do + _('The global settings require you to enable Two-Factor Authentication for your account.') + end, + admin_2fa: ->(_) do + _('Administrator users are required to enable Two-Factor Authentication for their account.') + end, + group: ->(groups) do + groups_notification(groups) + end + } + message = execute_action_for_2fa_reason(two_factor_auth_actions) + message = append_configure_2fa_later(message) unless two_factor_grace_period_expired? + flash.now[:alert] = message + end + + setup_passkey_registration + end + + def setup_passkey_registration + current_user.user_detail.update!(webauthn_xid: WebAuthn.generate_user_id) unless current_user.webauthn_xid + + options = webauthn_options + session[:challenge] = options.challenge + + gon.push(webauthn: { options: options }) + end + + def webauthn_options + webauthn_request_options(passkey: true) + end + + def groups_notification(groups) + group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence + leave_group_links = groups.map do |group| + view_context.link_to safe_format(s_("leave %{group_name}"), group_name: group.full_name), + leave_group_members_path(group), + remote: false, method: :delete + end.to_sentence + + safe_format(s_( + 'The group settings for %{group_links} require you to enable Two-Factor Authentication for your account. ' \ + 'You can %{leave_group_links}.' + ), group_links: group_links, leave_group_links: leave_group_links) + end + + def ensure_verified_primary_email + return if current_user.two_factor_enabled? || current_user.primary_email_verified? + + redirect_to profile_emails_path, + notice: s_('You need to verify your primary email first before enabling Two-Factor Authentication.') + end + + def append_configure_2fa_later(message) + grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours + render_to_string partial: 'configure_later_button', + locals: { message: message, grace_period_deadline: grace_period_deadline } + end + + # def is_passkey_eligible? + + # end + + # def show_passkey_upgrade_prompt! + + # end + end +end diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index 42654bade2f770f8b29fee7a24ee82d659169f4b..03586df48a279b566ceddb3f838f1d3d06985dd5 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -13,9 +13,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController feature_category :system_access include SafeFormatHelper + include WebauthnHelper def show setup_show_page + setup_show_passkey_page end def create @@ -211,12 +213,7 @@ def webauthn_registrations end def webauthn_options - WebAuthn::Credential.options_for_create( - user: { id: current_user.webauthn_xid, name: current_user.username }, - exclude: current_user.webauthn_registrations.map(&:credential_xid), - authenticator_selection: { user_verification: 'discouraged' }, - rp: { name: 'GitLab' } - ) + webauthn_request_options end def groups_notification(groups) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index b8ee5e8a4789d49a850b03a32dcc46cb639c52f0..1647b4c514006e6453c2a862739273e856d11a2f 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -66,6 +66,10 @@ def new super end + def new_passkey + handle_authenticate_with_two_factor_via_passkeys + end + def create super do |resource| # User has successfully signed in, so clear any unused reset token @@ -212,6 +216,11 @@ def user_params params.require(:user).permit(:login, :password, :remember_me, :otp_attempt, :device_response) end + def passkey_params + permitted_list = [:login_with_passkey, :device_response] + params.permit(permitted_list) + end + def find_user strong_memoize(:find_user) do if session[:otp_user_id] && user_params[:login] diff --git a/app/helpers/device_registration_helper.rb b/app/helpers/device_registration_helper.rb index bbdcab76bf5268a5cb81b5a915f847af2699297e..49784d207ea9da5230844fe405ceb238de441068 100644 --- a/app/helpers/device_registration_helper.rb +++ b/app/helpers/device_registration_helper.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true module DeviceRegistrationHelper - def device_registration_data(current_password_required:, target_path:, webauthn_error:) + def device_registration_data(current_password_required:, target_path:, webauthn_error:, type: nil) { initial_error: webauthn_error && webauthn_error[:message], target_path: target_path, - password_required: current_password_required.to_s + password_required: current_password_required.to_s, + type: type } end end diff --git a/app/helpers/webauthn_helper.rb b/app/helpers/webauthn_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..bdf638f7655f659aa72d10bb9aab292792a35d28 --- /dev/null +++ b/app/helpers/webauthn_helper.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module WebauthnHelper + def passkey_vars_for_view + { + passkey_registration: WebauthnRegistration.passkey.new, + passkey_registrations: current_user.passkeys.map do |p| + { + name: p.name, + created_at: p.created_at, + delete_path: profile_two_factor_auth_passkey_path(p) + } + end + } + end + + def setup_show_passkey_page + if two_factor_authentication_required? && !current_user.two_factor_enabled? + two_factor_auth_actions = { + global: ->(_) do + _('The global settings require you to enable Two-Factor Authentication for your account.') + end, + admin_2fa: ->(_) do + _('Administrator users are required to enable Two-Factor Authentication for their account.') + end, + group: ->(groups) do + groups_notification(groups) + end + } + message = execute_action_for_2fa_reason(two_factor_auth_actions) + message = append_configure_2fa_later(message) unless two_factor_grace_period_expired? + flash.now[:alert] = message + end + + setup_passkey_registration + end + + def setup_passkey_registration + current_user.user_detail.update!(webauthn_xid: WebAuthn.generate_user_id) unless current_user.webauthn_xid + + options = passkey_registration_request_options + session[:challenge] = options.challenge + + gon.push(passkey: { options: options }) + end + + def passkey_registration_request_options + webauthn_request_options(passkey: true) + end + + # Returns a request hash for a webauthn device's registration + # + # Can accept an optional argument (passkey: true), to request passkeys specific registration data + # + def webauthn_request_options(passkey: nil) + WebAuthn::Credential.options_for_create( + **(passkey ? passkey_webauthn_request_params : base_webauthn_request_params) + ) + end + + def base_webauthn_request_params + { + user: { id: current_user.webauthn_xid, name: current_user.username }, + exclude: current_user.exisiting_webauthn_credentials, + authenticator_selection: { user_verification: 'discouraged' }, + rp: { name: 'GitLab' } + } + end + + def passkey_webauthn_request_params + { + user: { id: current_user.webauthn_xid, name: current_user.username }, + exclude: current_user.exisiting_webauthn_credentials, + authenticator_selection: { + resident_key: 'required', + user_verification: 'required' + }, + rp: { name: 'GitLab' }, + extensions: { credProps: true } + } + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 5a400003d4ae44258feedbb145183b9bec358fc7..6690abd5941d6a588ac13537d34ac15cffe2405b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -145,6 +145,9 @@ def update_tracked_fields!(request) # Virtual attribute for authenticating by either username or email attr_accessor :login + # Virtual attribute for authenticating with a passkey + attr_accessor :login_with_passkey + # Virtual attribute for impersonator attr_accessor :impersonator @@ -174,7 +177,8 @@ def update_tracked_fields!(request) has_many :expiring_soon_and_unnotified_personal_access_tokens, -> { expiring_and_not_notified_without_impersonation }, class_name: 'PersonalAccessToken' has_many :identities, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent - has_many :webauthn_registrations + has_many :webauthn_registrations, -> { webauthn_credential }, class_name: 'WebauthnRegistration' + has_many :passkeys, -> { passkey }, class_name: 'WebauthnRegistration' has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :saved_replies, class_name: '::Users::SavedReply' has_one :user_synced_attributes_metadata, autosave: true @@ -797,6 +801,7 @@ def self.with_two_factor def self.without_two_factor where + .missing(:passkeys) .missing(:webauthn_registrations) .where(otp_required_for_login: false) end @@ -1142,6 +1147,10 @@ def ends_with_reserved_file_extension?(username) # Instance methods # + def exisiting_webauthn_credentials + webauthn_registrations.pluck(:credential_xid) + passkeys.pluck(:credential_xid) + end + def full_path username end @@ -1309,7 +1318,9 @@ def two_factor_otp_enabled? end def two_factor_webauthn_enabled? - (webauthn_registrations.loaded? && webauthn_registrations.any?) || (!webauthn_registrations.loaded? && webauthn_registrations.exists?) + passkeys.any? || # NB: Eager load + (webauthn_registrations.loaded? && webauthn_registrations.any?) || + (!webauthn_registrations.loaded? && webauthn_registrations.exists?) end def needs_new_otp_secret? diff --git a/app/models/webauthn_registration.rb b/app/models/webauthn_registration.rb index a9b2e89c9355d08be79cb4a78907dec4385bb6d0..6dcf8805c416b05f7f3456f85996bd4604602d8c 100644 --- a/app/models/webauthn_registration.rb +++ b/app/models/webauthn_registration.rb @@ -9,4 +9,12 @@ class WebauthnRegistration < ApplicationRecord validates :name, length: { minimum: 0, allow_nil: false } validates :counter, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: (2**32) - 1 } + + scope :passkey, -> { first_factor } + scope :webauthn_credential, -> { second_factor } + + enum :authentication_level, { + first_factor: 1, + second_factor: 2 + } end diff --git a/app/services/authn/passkeys/authenticate_service.rb b/app/services/authn/passkeys/authenticate_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..77da26946016b2399f652aafd54bf10902c672b9 --- /dev/null +++ b/app/services/authn/passkeys/authenticate_service.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Authn + module Passkeys + class AuthenticateService < BaseService + def initialize(device_response, challenge) + @device_response = device_response + @challenge = challenge + end + + def execute + parsed_device_response = Gitlab::Json.parse(@device_response) + + webauthn_credential = WebAuthn::Credential.from_get(parsed_device_response) + encoded_raw_id = Base64.strict_encode64(webauthn_credential.raw_id) + stored_webauthn_credential = find_matching_credential_xid(encoded_raw_id) + + encoder = WebAuthn.configuration.encoder + + if stored_webauthn_credential && + validate_webauthn_credential(webauthn_credential) && + verify_webauthn_credential(webauthn_credential, stored_webauthn_credential, @challenge, encoder) + + stored_webauthn_credential.update!(counter: webauthn_credential.sign_count) + + return find_matching_user_with_passkey(stored_webauthn_credential) + end + + false + rescue JSON::ParserError, WebAuthn::SignCountVerificationError, WebAuthn::Error + false + end + + private + + def find_matching_credential_xid(possible_user_webauthn_credential_xid) + WebauthnRegistration.passkey.find_by_credential_xid(possible_user_webauthn_credential_xid) + end + + def find_matching_user_with_passkey(existing_credential_xid) + User.find(existing_credential_xid.user_id) + end + + ## + # Validates that webauthn_credential is syntactically valid + # + # duplicated from WebAuthn::PublicKeyCredential#verify + # which can't be used here as we need to call WebAuthn::AuthenticatorAssertionResponse#verify instead + # (which is done in #verify_webauthn_credential) + def validate_webauthn_credential(webauthn_credential) + webauthn_credential.type == WebAuthn::TYPE_PUBLIC_KEY && + webauthn_credential.raw_id && webauthn_credential.id && + webauthn_credential.raw_id == WebAuthn.standard_encoder.decode(webauthn_credential.id) + end + + ## + # Verifies that webauthn_credential matches stored_credential with the given challenge + # + def verify_webauthn_credential(webauthn_credential, stored_credential, challenge, encoder) + # We need to adjust the relaying party id (RP id) we verify against if the registration in question + # is a migrated U2F registration. This is because the appid of U2F and the rp id of WebAuthn differ. + rp_id = if webauthn_credential.client_extension_outputs['appid'] + WebAuthn.configuration.origin + else + URI(WebAuthn.configuration.origin).host + end + + webauthn_credential.response.verify( + encoder.decode(challenge), + public_key: encoder.decode(stored_credential.public_key), + sign_count: stored_credential.counter, + rp_id: rp_id + ) + end + end + end +end diff --git a/app/services/authn/passkeys/destroy_service.rb b/app/services/authn/passkeys/destroy_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..3cfda5d40ff35317c5758a57c3709cca049eda78 --- /dev/null +++ b/app/services/authn/passkeys/destroy_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Authn + module Passkeys + class DestroyService < BaseService + attr_reader :webauthn_registration, :user, :current_user + + def initialize(current_user, user, webauthn_registrations_id) + @current_user = current_user + @user = user + @webauthn_registration = user.passkeys.find(webauthn_registrations_id) + end + + def execute + return error(_('You are not authorized to perform this action')) unless authorized? + + webauthn_registration.destroy + user.reset_backup_codes! if last_two_factor_registration? + end + + private + + def last_two_factor_registration? + user.passkeys.empty? && !user.otp_required_for_login? + end + + def authorized? + current_user.can?(:disable_two_factor, user) + end + end + end +end diff --git a/app/services/authn/passkeys/register_service.rb b/app/services/authn/passkeys/register_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..b8047e1b4a87731512fa9d003781c63870ca49fc --- /dev/null +++ b/app/services/authn/passkeys/register_service.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Authn + module Passkeys + class RegisterService < BaseService + def initialize(user, params, challenge, first_factor: false) + @user = user + @params = params + @authentication_level = first_factor ? :first_factor : :second_factor + @challenge = challenge + end + + def execute + registration = WebauthnRegistration.new + + begin + webauthn_credential = WebAuthn::Credential.from_create(Gitlab::Json.parse(@params[:device_response])) + + # Will fail with WebAuthn::AttestationStatementVerificationError when + # `attestation: 'indirect', 'direct' or 'enterprise'` without a cert verification + webauthn_credential.verify(@challenge) + + @webauthn_credential = webauthn_credential + + # See config/initializers/webauthn.rb for why this is here + # begin + # webauthn_credential.verify(@challenge) + # rescue WebAuthn::AttestationStatementVerificationError + # registration.errors.add(:base, + # _('Attestation verification failed. The device qualifies as a passkey, but its attestation certificate + # could not be fully verified.')) + # # return registration + # end + + unless passkey? + registration.errors.add(:base, _('Your webauthn device is not eligible to register as a passkey')) + return registration + end + + # Returns a boolean if the user completes verification on their authenticator + # + # Browsers `may` force user verification even if we discourage it so, this is the application-level gate + # in case they don't. + # + unless user_verified? + registration.errors.add(:base, _('You must fully verify yourself to register a passkey')) + return registration + end + + registration.update( + credential_xid: Base64.strict_encode64(@webauthn_credential.raw_id), + public_key: @webauthn_credential.public_key, + counter: @webauthn_credential.sign_count, + name: @params[:name], + user: @user, + authentication_level: @authentication_level + ) + rescue JSON::ParserError + registration.errors.add(:base, _('Your Passkey did not send a valid JSON response.')) + rescue WebAuthn::Error => e + registration.errors.add(:base, e.message) + end + + registration + end + + private + + # Return true if the user has validated themselves with the webauthn device with at least + # 1 of 3 verifcation factors (knowledge, possession and inherent) + # + # Proving user_presence (possession && knowledge) || user_verification (possession && inherent) + # should make it 2FA (2/3) + # + # This is required for username-less and passwordless authentication of passkeys + # + # https://www.w3.org/TR/webauthn-2/#test-of-user-presence + def user_verified? + authenticator_data&.user_flagged? + end + + def passkey? + !!@webauthn_credential.client_extension_outputs.dig("credProps", "rk") + end + + # Returns true if your multi-device/synced passkey is fully backed-up/synced + # + # https://www.w3.org/TR/webauthn-3/#sctn-credential-backup + def backed_up_or_synced_passkey? + @webauthn_credential&.backup_eligible? && @webauthn_credential.backed_up? + end + + # Returns true if you have multi-device/synced passkey type + # that can be backed_up/synced but hasn't been + # + # multi-device/synced passkeys are virtual &/or platform authenticators + # + # Hybrid types: (iCloud Keychain-Secure Enclave), (Microsoft TPM-Microsoft account), (Samsung Phone-Samsung Pass) + # Virtual authenticators: 1password, lastpass, ... + def backup_eligible_passkey? + @webauthn_credential&.backup_eligible? + end + + def authenticator_data + @webauthn_credential.response&.authenticator_data + end + + def aaguid + authenticator_data&.aaguid + end + end + end +end diff --git a/app/views/authentication/_authenticate_with_passkey.html.haml b/app/views/authentication/_authenticate_with_passkey.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..824c56eec109ee22d8d134ca34ae69cfee272d29 --- /dev/null +++ b/app/views/authentication/_authenticate_with_passkey.html.haml @@ -0,0 +1,30 @@ +#js-authenticate-token-2fa + += form_with(url: target_path, method: :post, html: { class: "gl-show-field-errors js-2fa-form", aria: { live: 'assertive' } }) do |f| + .form-group + %p.gl-text-bold + = _('Signing in with passkey') + %p + = _("Follow instructions on your browser to continue signing in.") + +-# haml-lint:disable InlineJavaScript +%script#js-authenticate-token-2fa-in-progress{ type: "text/template" } + %p= _("Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.") + +-# haml-lint:disable InlineJavaScript +%script#js-authenticate-token-2fa-error{ type: "text/template" } + .gl-mb-3 + %p <%= error_message %> (<%= error_name %>) + = render Pajamas::ButtonComponent.new(block: true, button_options: { id: 'js-token-2fa-try-again' }) do + = _("Try again?") + +-# haml-lint:disable InlineJavaScript +%script#js-authenticate-token-2fa-authenticated{ type: "text/template" } + %div + %p= _("We heard back from your device. You have been authenticated.") + = form_with(url: target_path, method: :post, scope: :user, local: true, html: { id: 'js-login-token-2fa-form' }) do |f| + - if render_remember_me + - user_params = params[:user].presence || params + = f.hidden_field :remember_me, value: user_params.fetch(:remember_me, 0) + = hidden_field_tag :device_response, nil, id: 'js-device-response' + = hidden_field_tag :login_with_passkey, true, id: 'js-passkey-request' diff --git a/app/views/devise/sessions/passkeys.html.haml b/app/views/devise/sessions/passkeys.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..f74923921ca2c77d3706abfc78a8be876637a393 --- /dev/null +++ b/app/views/devise/sessions/passkeys.html.haml @@ -0,0 +1,4 @@ +- target_path = admin_mode ? admin_session_path : users_passkeys_sign_in_path +- render_remember_me = admin_mode ? false : remember_me_enabled? + += render 'authentication/authenticate_with_passkey', target_path: target_path, render_remember_me: render_remember_me diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index 8918c8d6adaba782a3808e61c8110f3a13ec6e92..1d2ecc6338a3ab1d554e540732d2aea89756015a 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -5,6 +5,8 @@ = render 'shared/divider', text: _("or sign in with") .gl-mt-5.gl-text-center.gl-flex.gl-flex-col.gl-gap-3.js-oauth-login + = render 'devise/shared/passkey_button' + - enabled_button_based_providers.each do |provider| - if step_up_auth_scope.present? = render 'devise/shared/omniauth_provider_button', diff --git a/app/views/devise/shared/_passkey_button.html.haml b/app/views/devise/shared/_passkey_button.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..a957e8eed1d7635512538dd54828d9be45003701 --- /dev/null +++ b/app/views/devise/shared/_passkey_button.html.haml @@ -0,0 +1,10 @@ +- href = users_passkeys_sign_in_path +- logo = 'https://img.icons8.com/?size=64&id=555&format=png&color=000000' +- label = 'passkeys' + +.gl-mt-5.gl-text-center.gl-flex.gl-flex-col.gl-gap-3 += render Pajamas::ButtonComponent.new(href: href, method: :post, form: true, block: true) do + = hidden_field_tag :login_with_passkey, true + = image_tag(logo, alt: label, title: "Sign in with #{label}", class: "gl-button-icon") + %span.gl-button-text + = _('Passkey') diff --git a/app/views/profiles/passkeys/_passkey_registration.html.haml b/app/views/profiles/passkeys/_passkey_registration.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..eda610cc096b142003f1872138a16c57e28825f5 --- /dev/null +++ b/app/views/profiles/passkeys/_passkey_registration.html.haml @@ -0,0 +1,46 @@ +- passkey_registration = passkey_vars_for_view[:passkey_registration] +- passkey_registrations = passkey_vars_for_view[:passkey_registrations] +- has_errors = @webauthn_error || passkey_registration.errors.present? + += render ::Layouts::CrudComponent.new(_('Passkey sign-in'), + icon: 'lock', + count: passkey_registrations&.length, + description: _('Add a passkey to sign-in using biometrics instead of a PIN/password'), + form_options: { class: has_errors ? '' : 'gl-hidden js-toggle-content' }, + options: { class: 'js-toggle-container js-token-card' }) do |c| + - c.with_actions do + = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'js-toggle-button js-toggle-content', data: { testid: 'register-webauthn-button' } }) do + = _('Add Passkey') + - c.with_form do + - if passkey_registration.errors.present? + = form_errors(passkey_registration) + #js-passkey-registration + .js-device-registration{ data: device_registration_data(current_password_required: current_password_required?, target_path: profile_two_factor_auth_passkeys_path, webauthn_error: @webauthn_error, type: "passkey") } + - c.with_body do + - if passkey_registrations.present? + .table-responsive + %table.table.gl-table + %colgroup + %col{ width: "50%" } + %col{ width: "30%" } + %col{ width: "20%" } + %thead + %tr + %th= _('Name') + %th= s_('2FADevice|Registered on') + %th + %tbody + - passkey_registrations.each do |registration| + %tr + %td + - if registration[:name].present? + = registration[:name] + - else + %span.gl-text-subtle + = _("no name set") + %td= registration[:created_at].to_date.to_fs(:medium) + %td{ class: '!gl-py-3' } + .gl-float-right + .js-two-factor-action-confirm{ data: delete_webauthn_device_data(current_password_required?, registration[:delete_path]) } + - else + = _("Not all browsers support Passkeys. See this link for more details") diff --git a/app/views/profiles/two_factor_auths/_webauthn_registration.html.haml b/app/views/profiles/two_factor_auths/_webauthn_registration.html.haml index 97b4d2936f46f453d32fb88ab5f5e14d00a160b8..d7100eee321e830d7c214263b68934efd398ebff 100644 --- a/app/views/profiles/two_factor_auths/_webauthn_registration.html.haml +++ b/app/views/profiles/two_factor_auths/_webauthn_registration.html.haml @@ -12,7 +12,7 @@ - c.with_form do - if @webauthn_registration.errors.present? = form_errors(@webauthn_registration) - #js-device-registration{ data: device_registration_data(current_password_required: current_password_required?, target_path: create_webauthn_profile_two_factor_auth_path, webauthn_error: @webauthn_error) } + .js-device-registration{ data: device_registration_data(current_password_required: current_password_required?, target_path: create_webauthn_profile_two_factor_auth_path, webauthn_error: @webauthn_error) } - c.with_body do - if @registrations.present? .table-responsive diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 6a78c21abe78377d02d3e89a0511c611d2ba334a..dc2c89af3ab57b4cd42664259be3f2b48abe70b2 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -15,6 +15,10 @@ = render ::Layouts::PageHeadingComponent.new(title, options: { class: 'gl-mb-3' }) += render ::Layouts::SettingsSectionComponent.new(_('Register a Passkey')) do |s| + - s.with_body do + = render '/profiles/passkeys/passkey_registration' + = render ::Layouts::SettingsSectionComponent.new(_('Register a one-time password authenticator')) do |c| - c.with_body do = render 'otp_registration', {troubleshooting_link: troubleshooting_link} diff --git a/config/feature_flags/gitlab_com_derisk/passkeys.yml b/config/feature_flags/gitlab_com_derisk/passkeys.yml new file mode 100644 index 0000000000000000000000000000000000000000..f9b19b2db49bb5839a7ccb7b40d3caf78fd37cf0 --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/passkeys.yml @@ -0,0 +1,10 @@ +--- +name: passkeys +description: Gates ongoing passkeys work +feature_issue_url: https://gitlab.com/groups/gitlab-org/-/epics/18886 +introduced_by_url: https://gitlab.com/gitlab-com/content-sites/handbook/-/merge_requests/15256 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/564495 +milestone: '18.3' +group: group::authentication +type: gitlab_com_derisk +default_enabled: false diff --git a/config/initializers/webauthn.rb b/config/initializers/webauthn.rb index 1f37e7c84c375d39260f31ffc6864669408760c4..578d7ebb5555fc3e8ae05578547a2ac6acff9c93 100644 --- a/config/initializers/webauthn.rb +++ b/config/initializers/webauthn.rb @@ -34,4 +34,49 @@ # Default: ["ES256", "PS256", "RS256"] # # config.algorithms << "ES384" + # + ####################### + ######### NB: ######### + # + # FEATURE & ITS ISSUES: Retrieve device_names for all webauthn devices & passkeys + # + # We currently can't verify {webauthn_credential.verify(@challenge)} attestation certificates + # for roaming authentication when `attestation: 'direct'` during WebAuthn device registration. + # + # This is because: + # + # 1. The WebAuthn gem rejects attestations higher than 'none' (like 'indirect', 'direct', 'enterprise') + # with `WebAuthn::AttestationStatementVerificationError` when an authenticator sends additional attestation data + # { normal authData (aaguid, public key..) vs additional attestation statement(alg, signature, x5c cert..) } + # + # 2. Non-MDS but FIDO2 certified authenticators (commonly platform/virtual authenticators) + # do not send extra attestation data at higher level than `attestation: none`. Fortunately, + # they always send aaguids that we can use to find the device_names here: + # https://github.com/passkeydeveloper/passkey-authenticator-aaguids + # + # 3. Unfortunately, roaming authenticators(YubiKey) sends `packed` attestation data that requires certificate + # verification + # + # 4. Both virtual & platform authenticators return the aaguid without requesting more than attestation: 'none', + # but roaming authenticators (security keys) return + # WebAuthn::AuthenticatorData::AttestedCredentialData::ZEROED_AAGUID. + # To force them to return the aaguid, we need a higher attestation level (indirect/direct/enterprise) + # which requires verifying their certificates. + # The'packed' attestation format by the Yubikey requires a 'basic/AttCa' verification. + # + # + # Links: + # + # https://github.com/cedarcode/webauthn-ruby?tab=readme-ov-file#attestation-types + # https://developers.yubico.com/WebAuthn/WebAuthn_Developer_Guide/Attestation.html + # + # See https://github.com/cedarcode/webauthn-ruby/issues/405, + # https://github.com/cedarcode/fido_metadata (maintainable MDS json), + # https://github.com/passkeydeveloper/passkey-authenticator-aaguids (non-MDS virtual authenticator list) + # + # Expected: Use aaguid & device_attestation_cert TO GET aaguid, device_name, dark_logo, light_logo + # # + # config.acceptable_attestation_types = %w[None Self Basic AttCA Basic_or_AttCA] + + # config.attestation_root_certificates_finders = [Authn::WebauthnAttestationFinders::MDSFinder.new] || [] end diff --git a/config/routes/profile.rb b/config/routes/profile.rb index 8361ed9a2805c27d53ed7ae8b0d15e4936a3b8fb..794cda5f0aece802251a93f43fb8a5235f17f391 100644 --- a/config/routes/profile.rb +++ b/config/routes/profile.rb @@ -67,6 +67,8 @@ delete :destroy_otp delete :destroy_webauthn, path: 'destroy_webauthn/:id' end + + resources :passkeys, only: [:new, :create, :destroy] end resources :usage_quotas, only: [:index] diff --git a/config/routes/user.rb b/config/routes/user.rb index 3ad2bb1c84d8fdff8a5c03e4589e34a8ca54f379..25b88d839ead43a01d823cec351ae20642f1171e 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -71,6 +71,8 @@ def override_omniauth(provider, controller, path_prefix = '/users/auth') redirect_path = req.session.delete(:auth_on_failure_path) redirect_path || Rails.application.routes.url_helpers.new_user_session_path } + + post '/users/passkeys/sign_in', to: 'sessions#new_passkey', as: :users_passkeys_sign_in end scope '-/users', module: :users do diff --git a/db/migrate/20250823004105_add_authentication_level_to_webauthn_registrations.rb b/db/migrate/20250823004105_add_authentication_level_to_webauthn_registrations.rb new file mode 100644 index 0000000000000000000000000000000000000000..c072305efe89cbcf8f68bb0e4720a6fe586bf6ed --- /dev/null +++ b/db/migrate/20250823004105_add_authentication_level_to_webauthn_registrations.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class AddAuthenticationLevelToWebauthnRegistrations < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + + milestone '18.3' + + TABLE_NAME = :webauthn_registrations + COLUMN_NAME = :authentication_level + + def up + add_column( + TABLE_NAME, + COLUMN_NAME, + :integer, default: 2, null: false + ) + end + + def down + drop_column( + TABLE_NAME, + COLUMN_NAME + ) + end +end diff --git a/db/schema_migrations/20250823004105 b/db/schema_migrations/20250823004105 new file mode 100644 index 0000000000000000000000000000000000000000..4f605a99da370f55b2679d610ad554020fcd3adf --- /dev/null +++ b/db/schema_migrations/20250823004105 @@ -0,0 +1 @@ +61b4e7fd9dd10f0fe8fc5baebb242743282c5647bf79e976cfdec8f1ff5aea76 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index bc1f58f5cfeb644b1588fbb53bbf2caa92014778..e805a0fa222a61516f4ae4f3d4ec5ded40edd7ad 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -5026,7 +5026,7 @@ PARTITION BY LIST (partition_id); CREATE TABLE p_ci_finished_build_ch_sync_events ( build_id bigint NOT NULL, - partition bigint DEFAULT 1 NOT NULL, + partition bigint DEFAULT 2 NOT NULL, build_finished_at timestamp without time zone NOT NULL, processed boolean DEFAULT false NOT NULL, project_id bigint NOT NULL @@ -5036,7 +5036,7 @@ PARTITION BY LIST (partition); CREATE TABLE p_ci_finished_pipeline_ch_sync_events ( pipeline_id bigint NOT NULL, project_namespace_id bigint NOT NULL, - partition bigint DEFAULT 1 NOT NULL, + partition bigint DEFAULT 3 NOT NULL, pipeline_finished_at timestamp without time zone NOT NULL, processed boolean DEFAULT false NOT NULL ) @@ -9573,35 +9573,35 @@ CREATE TABLE application_settings ( identity_verification_settings jsonb DEFAULT '{}'::jsonb NOT NULL, integrations jsonb DEFAULT '{}'::jsonb NOT NULL, user_seat_management jsonb DEFAULT '{}'::jsonb NOT NULL, + resource_usage_limits jsonb DEFAULT '{}'::jsonb NOT NULL, secret_detection_service_url text DEFAULT ''::text NOT NULL, encrypted_secret_detection_service_auth_token bytea, encrypted_secret_detection_service_auth_token_iv bytea, - resource_usage_limits jsonb DEFAULT '{}'::jsonb NOT NULL, - show_migrate_from_jenkins_banner boolean DEFAULT true NOT NULL, encrypted_ci_job_token_signing_key bytea, encrypted_ci_job_token_signing_key_iv bytea, + show_migrate_from_jenkins_banner boolean DEFAULT true NOT NULL, elasticsearch jsonb DEFAULT '{}'::jsonb NOT NULL, oauth_provider jsonb DEFAULT '{}'::jsonb NOT NULL, observability_settings jsonb DEFAULT '{}'::jsonb NOT NULL, search jsonb DEFAULT '{}'::jsonb NOT NULL, - anti_abuse_settings jsonb DEFAULT '{}'::jsonb NOT NULL, secret_push_protection_available boolean DEFAULT false, + anti_abuse_settings jsonb DEFAULT '{}'::jsonb NOT NULL, vscode_extension_marketplace jsonb DEFAULT '{}'::jsonb NOT NULL, token_prefixes jsonb DEFAULT '{}'::jsonb NOT NULL, ci_cd_settings jsonb DEFAULT '{}'::jsonb NOT NULL, database_reindexing jsonb DEFAULT '{}'::jsonb NOT NULL, duo_chat jsonb DEFAULT '{}'::jsonb NOT NULL, group_settings jsonb DEFAULT '{}'::jsonb NOT NULL, + web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, + lock_web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, model_prompt_cache_enabled boolean DEFAULT true NOT NULL, lock_model_prompt_cache_enabled boolean DEFAULT false NOT NULL, response_limits jsonb DEFAULT '{}'::jsonb NOT NULL, - web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, - lock_web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, tmp_asset_proxy_secret_key jsonb, editor_extensions jsonb DEFAULT '{}'::jsonb NOT NULL, security_and_compliance_settings jsonb DEFAULT '{}'::jsonb NOT NULL, - sdrs_url text, default_profile_preferences jsonb DEFAULT '{}'::jsonb NOT NULL, + sdrs_url text, sdrs_enabled boolean DEFAULT false NOT NULL, sdrs_jwt_signing_key jsonb, resource_access_tokens_settings jsonb DEFAULT '{}'::jsonb NOT NULL, @@ -14491,8 +14491,8 @@ CREATE TABLE duo_workflows_workflows ( allow_agent_to_request_user boolean DEFAULT true NOT NULL, pre_approved_agent_privileges smallint[] DEFAULT '{1,2}'::smallint[] NOT NULL, image text, - environment smallint, namespace_id bigint, + environment smallint, CONSTRAINT check_30ca07a4ef CHECK ((char_length(goal) <= 16384)), CONSTRAINT check_3a9162f1ae CHECK ((char_length(image) <= 2048)), CONSTRAINT check_73884a5839 CHECK ((num_nonnulls(namespace_id, project_id) = 1)), @@ -18555,8 +18555,8 @@ CREATE TABLE namespace_settings ( enterprise_users_extensions_marketplace_opt_in_status smallint DEFAULT 0 NOT NULL, spp_repository_pipeline_access boolean, lock_spp_repository_pipeline_access boolean DEFAULT false NOT NULL, - archived boolean DEFAULT false NOT NULL, token_expiry_notify_inherited boolean DEFAULT true NOT NULL, + archived boolean DEFAULT false NOT NULL, resource_access_token_notify_inherited boolean, lock_resource_access_token_notify_inherited boolean DEFAULT false NOT NULL, pipeline_variables_default_role smallint DEFAULT 2 NOT NULL, @@ -18568,11 +18568,11 @@ CREATE TABLE namespace_settings ( job_token_policies_enabled boolean DEFAULT false NOT NULL, security_policies jsonb DEFAULT '{}'::jsonb NOT NULL, duo_nano_features_enabled boolean, - model_prompt_cache_enabled boolean, - lock_model_prompt_cache_enabled boolean DEFAULT false NOT NULL, disable_invite_members boolean DEFAULT false NOT NULL, web_based_commit_signing_enabled boolean, lock_web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, + model_prompt_cache_enabled boolean, + lock_model_prompt_cache_enabled boolean DEFAULT false NOT NULL, allow_enterprise_bypass_placeholder_confirmation boolean DEFAULT false NOT NULL, enterprise_bypass_expires_at timestamp with time zone, hide_email_on_profile boolean DEFAULT false NOT NULL, @@ -19285,7 +19285,7 @@ CREATE TABLE organization_user_details ( organization_id bigint NOT NULL, user_id bigint NOT NULL, username text NOT NULL, - display_name text NOT NULL, + display_name text, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, CONSTRAINT check_470dbccf9b CHECK ((char_length(display_name) <= 510)), @@ -25264,12 +25264,12 @@ CREATE TABLE user_preferences ( dpop_enabled boolean DEFAULT false NOT NULL, use_work_items_view boolean DEFAULT false NOT NULL, text_editor_type smallint DEFAULT 2 NOT NULL, - merge_request_dashboard_list_type smallint DEFAULT 0 NOT NULL, extensions_marketplace_opt_in_url text, + merge_request_dashboard_list_type smallint DEFAULT 0 NOT NULL, dark_color_scheme_id smallint, work_items_display_settings jsonb DEFAULT '{}'::jsonb NOT NULL, - default_duo_add_on_assignment_id bigint, markdown_maintain_indentation boolean DEFAULT false NOT NULL, + default_duo_add_on_assignment_id bigint, CONSTRAINT check_1d670edc68 CHECK ((time_display_relative IS NOT NULL)), CONSTRAINT check_89bf269f41 CHECK ((char_length(diffs_deletion_color) <= 7)), CONSTRAINT check_9b50d9f942 CHECK ((char_length(extensions_marketplace_opt_in_url) <= 512)), @@ -26387,6 +26387,7 @@ CREATE TABLE webauthn_registrations ( credential_xid text NOT NULL, name text NOT NULL, public_key text NOT NULL, + authentication_level integer DEFAULT 2 NOT NULL, CONSTRAINT check_2f02e74321 CHECK ((char_length(name) <= 255)), CONSTRAINT check_f5ab2b551a CHECK ((char_length(credential_xid) <= 1364)) ); @@ -37790,6 +37791,14 @@ CREATE UNIQUE INDEX index_personal_access_tokens_on_token_digest ON personal_acc CREATE INDEX index_personal_access_tokens_on_user_id ON personal_access_tokens USING btree (user_id); +CREATE INDEX index_personal_access_tokens_on_user_id_and_id_and_created_at ON personal_access_tokens USING btree (user_id, id, created_at) WHERE (impersonation IS FALSE); + +CREATE INDEX index_personal_access_tokens_on_user_id_and_id_and_expires_at ON personal_access_tokens USING btree (user_id, id, expires_at) WHERE (impersonation IS FALSE); + +CREATE INDEX index_personal_access_tokens_on_user_id_and_id_and_name ON personal_access_tokens USING btree (user_id, id, name) WHERE (impersonation IS FALSE); + +CREATE INDEX index_personal_access_tokens_on_user_id_and_id_and_updated_at ON personal_access_tokens USING btree (user_id, id, updated_at) WHERE (impersonation IS FALSE); + CREATE INDEX index_pipeline_metadata_on_name_text_pattern_pipeline_id ON ci_pipeline_metadata USING btree (name text_pattern_ops, pipeline_id); CREATE INDEX index_pipl_users_on_initial_email_sent_at ON pipl_users USING btree (initial_email_sent_at); @@ -38828,7 +38837,7 @@ CREATE INDEX index_user_custom_attributes_on_key_and_value ON user_custom_attrib CREATE UNIQUE INDEX index_user_custom_attributes_on_user_id_and_key ON user_custom_attributes USING btree (user_id, key); -CREATE INDEX index_user_details_on_bot_namespace_id ON user_details USING btree (bot_namespace_id); +CREATE INDEX index_user_details_on_bot_namespace_id_and_user_id ON user_details USING btree (bot_namespace_id, user_id); CREATE INDEX index_user_details_on_enterprise_group_id_and_user_id ON user_details USING btree (enterprise_group_id, user_id); diff --git a/lib/authn/webauthn_attestation_finders.rb b/lib/authn/webauthn_attestation_finders.rb new file mode 100644 index 0000000000000000000000000000000000000000..6810efac4a0187c6b481059931e5469ff8d63b2f --- /dev/null +++ b/lib/authn/webauthn_attestation_finders.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# +# This class is responsible for finding WebAuthn attestation root certificates from the FIDO Metadata Service (MDS). +# It is configured in `config/initializers/webauthn.rb` as part of +# `WebAuthn.configuration.attestation_root_certificates_finders`. +# +# The `call` method takes `attestation_format`, `aaguid`, and `attestation_certificate_key_id` as arguments. +# It should return an array of root certificates that match the given criteria. +# +# For now, it returns an empty array, as the implementation for MDS lookup is not yet complete. +# See the comments in `config/initializers/webauthn.rb` for more details on the challenges +# with attestation verification for roaming authenticators. +# +# TODO: Implement actual MDS lookup to retrieve and verify attestation certificates. +# This will likely involve integrating with a FIDO MDS client library or +# parsing the MDS JSON directly. + +module Authn + module WebauthnAttestationFinders + class MDSFinder + def call(attestation_format:, aaguid:, attestation_certificate_key_id:) # rubocop:disable Lint/UnusedMethodArgument -- reason + [] + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3ad7797ba180e64531e2e0bbe0fddb3a32c4805d..9f3f994305df4439d6d3095aa02d12fb578b7fa5 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3777,6 +3777,9 @@ msgstr "" msgid "Add New Site" msgstr "" +msgid "Add Passkey" +msgstr "" + msgid "Add README" msgstr "" @@ -3831,6 +3834,9 @@ msgstr "" msgid "Add a numbered list" msgstr "" +msgid "Add a passkey to sign-in using biometrics instead of a PIN/password" +msgstr "" + msgid "Add a quick action" msgstr "" @@ -9376,6 +9382,9 @@ msgstr "" msgid "Authentication method updated" msgstr "" +msgid "Authentication via Passkey failed." +msgstr "" + msgid "Authentication via WebAuthn device failed." msgstr "" @@ -27927,6 +27936,9 @@ msgstr "" msgid "Follow" msgstr "" +msgid "Follow instructions on your browser to continue signing in." +msgstr "" + msgid "Followed Users' Activity" msgstr "" @@ -42420,6 +42432,9 @@ msgstr "" msgid "Not adopted" msgstr "" +msgid "Not all browsers support Passkeys. See this link for more details" +msgstr "" + msgid "Not all browsers support WebAuthn. You must save your recovery codes after you first register a two-factor authenticator to be able to sign in." msgstr "" @@ -45940,6 +45955,12 @@ msgstr "" msgid "Passed on" msgstr "" +msgid "Passkey" +msgstr "" + +msgid "Passkey sign-in" +msgstr "" + msgid "Password" msgstr "" @@ -52226,6 +52247,9 @@ msgstr "" msgid "Register / Sign In" msgstr "" +msgid "Register a Passkey" +msgstr "" + msgid "Register a WebAuthn device" msgstr "" @@ -60649,6 +60673,9 @@ msgstr "" msgid "Signing in using your %{label} account without a pre-existing account in %{simple_url} is not allowed." msgstr "" +msgid "Signing in with passkey" +msgstr "" + msgid "SilentMode|All outbound communications are blocked. %{link_start}Learn more%{link_end}." msgstr "" @@ -62430,6 +62457,9 @@ msgstr "" msgid "Successfully deactivated" msgstr "" +msgid "Successfully deleted Passkey." +msgstr "" + msgid "Successfully deleted WebAuthn device." msgstr "" @@ -74199,6 +74229,12 @@ msgstr "" msgid "Your Groups" msgstr "" +msgid "Your Passkey did not send a valid JSON response." +msgstr "" + +msgid "Your Passkey was registered!" +msgstr "" + msgid "Your Personal Access Token was revoked" msgstr "" @@ -74536,6 +74572,9 @@ msgstr "" msgid "Your username is %{username}." msgstr "" +msgid "Your webauthn device is in-eligible to register as a passkey" +msgstr "" + msgid "Your work" msgstr ""