diff --git a/app/controllers/user_settings/personal_access_tokens_controller.rb b/app/controllers/user_settings/personal_access_tokens_controller.rb index 053b504b3e7fad57e51e465b765b97a94d752d4b..60b69704d7be0f4a5a6957b09a9dc8d6c65a54cf 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 0000000000000000000000000000000000000000..4d788b76df9af1526f267b79916604da7e34105b --- /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 fab7bd8fe6f83b8e1f50184604b43f6f136fe73b..d67eaa78c262d4d4334d08879223ebd5453995ba 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 cae2c2ffdf1b402f3e08b0c96eb8b258fbe63b42..4d3e5d5008a9d2342e4281069dd21b60c3e4afdc 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 2d59897d305a2222003bcd42bd19648d9a18ef49..752d5cf9dfee72871665db4def3f3107f3246d0d 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 `<your_access_token>` with your access token, and `~/.ssh/id_rsa` + with the location of your private key: + + ```shell + bin/glab auth dpop-gen --pat "<your_access_token>" --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: <your_access_token>" \ + --header "DPoP: <dpop-from-glab>" \ + "https://gitlab.example.com/api/v4/projects" + ``` + +- With GraphQL: + + ```shell + curl --request POST \ + --header "Content-Type: application/json" \ + --header "Private-Token: <your_access_token>" \ + --header "DPoP: <dpop-from-glab>" \ + --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 e5cbfe03fe24f9d71a6ba335dbafab61408a8249..02b0c34d9c4b260449095036b28320c485d0179b 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 4997243b2a72f44296fcbd917fea2e55fd82eea7..2cfa9a83abaa6000a41beab900a7fe74fc638e5d 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 0000000000000000000000000000000000000000..ff1e25ae3eb270c04fabfb66ad539579ffdecaeb --- /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