From 5ce857777cd72c57881db53fd61588e794950c27 Mon Sep 17 00:00:00 2001 From: ameyadarshan Date: Tue, 11 Feb 2025 09:07:23 +0530 Subject: [PATCH] Add frontend code for DPoP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add controller for DPoP Add documentation Edit personal_access_tokens.md Edit _dpop.html.haml Add locale and controller specs Apply 4 suggestion(s) to 2 file(s) Co-authored-by: Amy Qualls Edit personal_access_tokens.md Add error class Fix markdown Fix lint errors Fix lint errors Fix lint errors Fix undercoverage Fix view and specs Fix view header Fix view Remove user before_action Remove dot Edit personal_access_tokens.md Edit personal_access_tokens.md Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Amy Qualls Edit personal_access_tokens.md Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Amy Qualls Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Amy Qualls Resolve conflicts Fix gitlab.pot Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Eduardo Sanz GarcĂ­a Add gitlab.pot Fix pot file Fix pot --- .../personal_access_tokens_controller.rb | 21 ++++++ .../personal_access_tokens/_dpop.html.haml | 12 ++++ .../personal_access_tokens/index.html.haml | 2 + config/routes/user_settings.rb | 1 + doc/user/profile/personal_access_tokens.md | 69 +++++++++++++++++++ locale/gitlab.pot | 18 +++++ .../personal_access_tokens_controller_spec.rb | 54 +++++++++++++++ .../index.html.haml_spec.rb | 58 ++++++++++++++++ 8 files changed, 235 insertions(+) create mode 100644 app/views/user_settings/personal_access_tokens/_dpop.html.haml create mode 100644 spec/views/user_settings/personal_access_tokens/index.html.haml_spec.rb diff --git a/app/controllers/user_settings/personal_access_tokens_controller.rb b/app/controllers/user_settings/personal_access_tokens_controller.rb index 053b504b3e7fad..60b69704d7be0f 100644 --- a/app/controllers/user_settings/personal_access_tokens_controller.rb +++ b/app/controllers/user_settings/personal_access_tokens_controller.rb @@ -91,6 +91,23 @@ def rotate end end + def toggle_dpop + unless Feature.enabled?(:dpop_authentication, current_user) + redirect_to user_settings_personal_access_tokens_path + return + end + + result = UserPreferences::UpdateService.new(current_user, dpop_params).execute + + if result.success? + flash[:notice] = _('DPoP preference updated.') + else + flash[:warning] = _('Unable to update DPoP preference.') + end + + redirect_to user_settings_personal_access_tokens_path + end + private def finder(options = {}) @@ -101,6 +118,10 @@ def personal_access_token_params params.require(:personal_access_token).permit(:name, :expires_at, :description, scopes: []) end + def dpop_params + params.require(:user).permit(:dpop_enabled) + end + def set_index_vars @scopes = Gitlab::Auth.available_scopes_for(current_user) diff --git a/app/views/user_settings/personal_access_tokens/_dpop.html.haml b/app/views/user_settings/personal_access_tokens/_dpop.html.haml new file mode 100644 index 00000000000000..4d788b76df9af1 --- /dev/null +++ b/app/views/user_settings/personal_access_tokens/_dpop.html.haml @@ -0,0 +1,12 @@ += gitlab_ui_form_for current_user, url: toggle_dpop_user_settings_personal_access_tokens_path, method: :put, html: { data: { testid: 'dpop-form' } } do |f| + .settings-section.js-search-settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h3.gl-heading-4.gl-mb-3 + = s_('AccessTokens|Require Demonstrating Proof of Possession (DPoP) headers') + %p.gl-text-secondary + = s_('AccessTokens|Require DPoP headers to access the REST or GraphQL API with a personal access token.') + = link_to s_('AccessTokens|How do I use DPoP headers?'), help_page_path('user/profile/personal_access_tokens.md', anchor: 'require-dpop-headers-with-personal-access-tokens'), target: '_blank', rel: 'noopener noreferrer' + .form-group + = f.gitlab_ui_checkbox_component :dpop_enabled, s_('AccessTokens|Enable DPoP'), checkbox_options: { checked: current_user.dpop_enabled } + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/user_settings/personal_access_tokens/index.html.haml b/app/views/user_settings/personal_access_tokens/index.html.haml index fab7bd8fe6f83b..d67eaa78c262d4 100644 --- a/app/views/user_settings/personal_access_tokens/index.html.haml +++ b/app/views/user_settings/personal_access_tokens/index.html.haml @@ -32,4 +32,6 @@ - c.with_body do #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, backend_pagination: 'true', initial_active_access_tokens: @active_access_tokens.to_json } } += render 'user_settings/personal_access_tokens/dpop' if Feature.enabled?(:dpop_authentication, current_user) + #js-tokens-app{ data: { tokens_data: tokens_app_data } } diff --git a/config/routes/user_settings.rb b/config/routes/user_settings.rb index cae2c2ffdf1b40..4d3e5d5008a9d2 100644 --- a/config/routes/user_settings.rb +++ b/config/routes/user_settings.rb @@ -14,6 +14,7 @@ end end resources :personal_access_tokens, only: [:index, :create] do + put :toggle_dpop, on: :collection member do put :revoke put :rotate diff --git a/doc/user/profile/personal_access_tokens.md b/doc/user/profile/personal_access_tokens.md index 2d59897d305a22..752d5cf9dfee72 100644 --- a/doc/user/profile/personal_access_tokens.md +++ b/doc/user/profile/personal_access_tokens.md @@ -383,6 +383,75 @@ Prerequisites: You can now create personal access tokens for a service account user with no expiry date. +## Require DPoP headers with personal access tokens + +DETAILS: +**Tier:** Free, Premium, Ultimate +**Offering:** GitLab.com, GitLab Self-Managed + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/181053) in GitLab 17.10 [with a flag](../../administration/feature_flags.md) named `dpop_authentication`. Disabled by default. + +FLAG: +The availability of this feature is controlled by a feature flag. +For more information, see the history. +This feature is available for testing, but not ready for production use. + +Demonstrating Proof of Possession (DPoP) enhances the security of your personal access tokens, +and minimizes the effects of unintended token leaks. When you enable this feature on your +account, all REST and GraphQL API requests containing a PAT must also provide a signed DPoP header. Creating a +signed DPoP header requires your corresponding private SSH key. + +NOTE: +If you enable this feature, all REST and GraphQL API requests without a valid DPoP header fail with a `DpopValidationError`. + +Prerequisites: + +- You must have [added at least one public SSH key](../ssh.md#add-an-ssh-key-to-your-gitlab-account) + to your account, with the **Usage type** of **Signing**, or **Authentication & Signing**. +- You must have installed and configured the [GitLab CLI](../../editor_extensions/gitlab_cli/_index.md) + for your GitLab account. + +To require DPoP on all calls to the REST and GraphQL APIs: + +1. On the left sidebar, select your avatar. +1. Select **Edit profile**. +1. On the left sidebar, select **Access Tokens**. +1. Go to the **Use Demonstrating Proof of Possession** section, and select **Enable DPoP**. +1. Select **Save changes**. +1. To generate a DPoP header with the [GitLab CLI](../../editor_extensions/gitlab_cli/_index.md), + run this command in your terminal. Replace `` with your access token, and `~/.ssh/id_rsa` + with the location of your private key: + + ```shell + bin/glab auth dpop-gen --pat "" --private-key ~/.ssh/id_rsa + ``` + +The DPoP header you generated in the CLI can be used: + +- With the REST API: + + ```shell + curl --header "Private-Token: " \ + --header "DPoP: " \ + "https://gitlab.example.com/api/v4/projects" + ``` + +- With GraphQL: + + ```shell + curl --request POST \ + --header "Content-Type: application/json" \ + --header "Private-Token: " \ + --header "DPoP: " \ + --data '{ + "query": "query { currentUser { id } }" + }' \ + "https://gitlab.example.com/api/graphql" + ``` + +To learn more about DPoP headers, see the blueprint +[Sender Constraining Personal Access Tokens](https://gitlab.com/gitlab-com/gl-security/product-security/appsec/security-feature-blueprints/-/tree/main/sender_constraining_access_tokens). + ## Create a personal access token programmatically {{< details >}} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e5cbfe03fe24f9..02b0c34d9c4b26 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2951,6 +2951,9 @@ msgstr "" msgid "AccessTokens|Created date" msgstr "" +msgid "AccessTokens|Enable DPoP" +msgstr "" + msgid "AccessTokens|Expiration date" msgstr "" @@ -2975,6 +2978,9 @@ msgstr "" msgid "AccessTokens|For example, the application using the token or the purpose of the token. Do not give sensitive information for the name of the token, as it will be visible to all %{resource_type} members." msgstr "" +msgid "AccessTokens|How do I use DPoP headers?" +msgstr "" + msgid "AccessTokens|IP: %{ips}" msgid_plural "AccessTokens|IPs: %{ips}" msgstr[0] "" @@ -3025,6 +3031,12 @@ msgstr "" msgid "AccessTokens|Personal access tokens" msgstr "" +msgid "AccessTokens|Require DPoP headers to access the REST or GraphQL API with a personal access token." +msgstr "" + +msgid "AccessTokens|Require Demonstrating Proof of Possession (DPoP) headers" +msgstr "" + msgid "AccessTokens|Revoke" msgstr "" @@ -18242,6 +18254,9 @@ msgstr "" msgid "DORA4Metrics|You have insufficient permissions to view" msgstr "" +msgid "DPoP preference updated." +msgstr "" + msgid "DSN" msgstr "" @@ -61472,6 +61487,9 @@ msgstr "" msgid "Unable to suggest a path. Please refresh and try again." msgstr "" +msgid "Unable to update DPoP preference." +msgstr "" + msgid "Unable to update label prioritization at this time" msgstr "" diff --git a/spec/controllers/user_settings/personal_access_tokens_controller_spec.rb b/spec/controllers/user_settings/personal_access_tokens_controller_spec.rb index 4997243b2a72f4..2cfa9a83abaa60 100644 --- a/spec/controllers/user_settings/personal_access_tokens_controller_spec.rb +++ b/spec/controllers/user_settings/personal_access_tokens_controller_spec.rb @@ -83,6 +83,60 @@ def created_token it_behaves_like 'GET access tokens are paginated and ordered' end + describe '#toggle_dpop' do + context "when feature flag is enabled" do + before do + stub_feature_flags(dpop_authentication: true) + end + + context "when toggling dpop" do + it "enables dpop" do + put :toggle_dpop, params: { user: { dpop_enabled: "1" } } + expect(access_token_user.dpop_enabled).to be(true) + end + + it "disables dpop" do + put :toggle_dpop, params: { user: { dpop_enabled: "0" } } + expect(access_token_user.dpop_enabled).to be(false) + end + end + + context 'when user preference update succeeds' do + it 'shows a success flash message' do + put :toggle_dpop, params: { user: { dpop_enabled: "1" } } + expect(flash[:notice]).to eq(_('DPoP preference updated.')) + end + end + + context 'when user preference update fails' do + before do + allow_next_instance_of(UserPreferences::UpdateService) do |instance| + allow(instance).to receive(:execute) + .and_return(ServiceResponse.error(message: 'Could not update preference')) + end + end + + it 'shows a failure flash message' do + put :toggle_dpop, params: { user: { dpop_enabled: "1" } } + expect(flash[:warning]).to eq(_('Unable to update DPoP preference.')) + end + end + end + + context "when feature flag is disabled" do + before do + stub_feature_flags(dpop_authentication: false) + end + + it "redirects to controller" do + put :toggle_dpop, params: { user: { dpop_enabled: "1" } } + + expect(response).to redirect_to(user_settings_personal_access_tokens_path) + expect(access_token_user.dpop_enabled).to be(false) + end + end + end + describe '#index' do let!(:active_personal_access_token) { create(:personal_access_token, user: access_token_user) } diff --git a/spec/views/user_settings/personal_access_tokens/index.html.haml_spec.rb b/spec/views/user_settings/personal_access_tokens/index.html.haml_spec.rb new file mode 100644 index 00000000000000..ff1e25ae3eb270 --- /dev/null +++ b/spec/views/user_settings/personal_access_tokens/index.html.haml_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'user_settings/personal_access_tokens/index.html.haml', feature_category: :system_access do + # rubocop:disable RSpec/FactoryBot/AvoidCreate -- we need these objects to be persisted + let(:user) { create(:user) } + let(:personal_access_token) { create(:personal_access_token, user: user) } + # rubocop:enable RSpec/FactoryBot/AvoidCreate + + before do + assign(:user, user) + sign_in(user) + allow(view).to receive(:current_user).and_return(user) + + assign(:active_access_tokens, ::PersonalAccessTokenSerializer.new.represent([personal_access_token])) + assign(:personal_access_token, personal_access_token) + assign(:scopes, [:api, :read_api]) + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(dpop_authentication: false) + end + + it 'does not show dpop options' do + render + + expect(rendered).not_to have_selector('[data-testid="dpop-form"]') + end + end + + context 'when feature flag is enabled' do + before do + stub_feature_flags(dpop_authentication: true) + end + + it 'shows dpop options' do + render + + expect(rendered).to have_selector('[data-testid="dpop-form"]') + end + + it 'shows ticked checkbox for DPoP when it is enabled' do + user.update!(dpop_enabled: true) + render + + expect(rendered).to have_checked_field('user[dpop_enabled]', class: 'custom-control-input') + end + + it 'shows unticked checkbox for DPoP when it is disabled' do + user.update!(dpop_enabled: false) + render + + expect(rendered).not_to have_checked_field('user[dpop_enabled]', class: 'custom-control-input') + end + end +end -- GitLab