diff --git a/doc/api/personal_access_tokens.md b/doc/api/personal_access_tokens.md index 9d3edbab0ae00b978c9a5437abdaec27279f5839..938c37ee3e437a5f3584e0e0181526057b9557a5 100644 --- a/doc/api/personal_access_tokens.md +++ b/doc/api/personal_access_tokens.md @@ -211,9 +211,16 @@ Example response: ## Rotate a personal access token -> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/403042) in GitLab 16.0 +Rotate a personal access token. Revokes the previous token and creates a new token that expires in one week + +You can either: + +- Use the personal access token ID. +- Pass the personal access token to the API in a request header. -Rotate a personal access token. Revokes the previous token and creates a new token that expires in one week. +### Use a personal access token ID + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/403042) in GitLab 16.0 In GitLab 16.6 and later, you can use the `expires_at` parameter to set a different expiry date. This non-default expiry date can be up to a maximum of one year from the rotation date. @@ -250,7 +257,7 @@ Example response: } ``` -### Responses +#### Responses - `200: OK` if the existing token is successfully revoked and the new token successfully created. - `400: Bad Request` if not rotated successfully. @@ -259,6 +266,50 @@ Example response: - Token with the specified ID does not exist. - `404: Not Found` if the user is an administrator but the token with the specified ID does not exist. +### Use a request header + +Requires: + +- `api` scope. + +You can use the `expires_at` parameter to set a different expiry date. This non-default expiry date can be up to a maximum of one year from the rotation date. + +```plaintext +POST /personal_access_tokens/self/rotate +``` + +```shell +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/personal_access_tokens/self/rotate" +``` + +Example response: + +```json +{ + "id": 42, + "name": "Rotated Token", + "revoked": false, + "created_at": "2023-08-01T15:00:00.000Z", + "scopes": ["api"], + "user_id": 1337, + "last_used_at": null, + "active": true, + "expires_at": "2023-08-15", + "token": "s3cr3t" +} +``` + +#### Responses + +- `200: OK` if the existing token is successfully revoked and the new token successfully created. +- `400: Bad Request` if not rotated successfully. +- `401: Unauthorized` if either: + - The token does not exist. + - The token has expired. + - The token has been revoked. +- `403: Forbidden` if the token is not allowed to rotate itself. +- `405: Method Not Allowed` if the token is not a personal access token. + ### Automatic reuse detection > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/395352) in GitLab 16.3 diff --git a/lib/api/api.rb b/lib/api/api.rb index 9982051d6954bb9ed30590690ae6d484c1f0f91d..cecb0d9001ebed0f73fce3ea6270f996143883e8 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -296,6 +296,7 @@ def initialize(location_url) mount ::API::Pages mount ::API::PagesDomains mount ::API::PersonalAccessTokens::SelfInformation + mount ::API::PersonalAccessTokens::SelfRotation mount ::API::PersonalAccessTokens mount ::API::ProjectAvatar mount ::API::ProjectClusters diff --git a/lib/api/helpers/personal_access_tokens_helpers.rb b/lib/api/helpers/personal_access_tokens_helpers.rb index 4fd72d89f4c7cb2a5d20a61b85fe6e52675c83a1..99ff48251608ff5e28668d8b65b4361d63a9e206 100644 --- a/lib/api/helpers/personal_access_tokens_helpers.rb +++ b/lib/api/helpers/personal_access_tokens_helpers.rb @@ -33,6 +33,18 @@ def revoke_token(token) service.success? ? no_content! : bad_request!(nil) end + + def rotate_token(token, params) + service = ::PersonalAccessTokens::RotateService.new(current_user, token).execute(params) + + if service.success? + status :ok + + service.payload[:personal_access_token] + else + bad_request!(service.message) + end + end end end end diff --git a/lib/api/personal_access_tokens.rb b/lib/api/personal_access_tokens.rb index de00b66ead3a693c01fd0b39b029701e3cb14700..8bcacc7f32fc5679ce8a1081f5aac8e491cfa8fd 100644 --- a/lib/api/personal_access_tokens.rb +++ b/lib/api/personal_access_tokens.rb @@ -82,16 +82,9 @@ class PersonalAccessTokens < ::API::Base token = PersonalAccessToken.find_by_id(params[:id]) if Ability.allowed?(current_user, :manage_user_personal_access_token, token&.user) - response = ::PersonalAccessTokens::RotateService.new(current_user, token).execute(declared_params) + new_token = rotate_token(token, declared_params) - if response.success? - status :ok - - new_token = response.payload[:personal_access_token] - present new_token, with: Entities::PersonalAccessTokenWithToken - else - bad_request!(response.message) - end + present new_token, with: Entities::PersonalAccessTokenWithToken else # Only admins should be informed if the token doesn't exist current_user.can_admin_all_resources? ? not_found! : unauthorized! diff --git a/lib/api/personal_access_tokens/self_rotation.rb b/lib/api/personal_access_tokens/self_rotation.rb new file mode 100644 index 0000000000000000000000000000000000000000..da21fca8b554be5689c42db671481850129db76d --- /dev/null +++ b/lib/api/personal_access_tokens/self_rotation.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module API + class PersonalAccessTokens + class SelfRotation < ::API::Base + include APIGuard + + feature_category :system_access + + helpers ::API::Helpers::PersonalAccessTokensHelpers + + allow_access_with_scope :api + + before { authenticate! } + + resource :personal_access_tokens do + desc 'Rotate a personal access token' do + detail 'Rotates a personal access token by passing it to the API in a header' + success code: 200, model: Entities::PersonalAccessTokenWithToken + failure [ + { code: 400, message: 'Bad Request' }, + { code: 401, message: 'Unauthorized' }, + { code: 403, message: 'Forbidden' }, + { code: 405, message: 'Method not allowed' } + ] + tags %w[personal_access_tokens] + end + params do + optional :expires_at, + type: Date, + desc: "The expiration date of the token", + documentation: { example: '2021-01-31' } + end + post 'self/rotate' do + not_allowed! unless access_token.is_a? PersonalAccessToken + forbidden! if current_user.project_bot? + + new_token = rotate_token(access_token, declared_params) + + present new_token, with: Entities::PersonalAccessTokenWithToken + end + end + end + end +end diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 25465e73b95d95e7fab56a44dbb1522aee7b7552..b4ba2a8e50ea98fff521612d68bf599374c3ef5d 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -426,7 +426,8 @@ def revoke_token_family(token) end def access_token_rotation_request? - current_request.path.match(%r{access_tokens/\d+/rotate$}) + current_request.path.match(%r{access_tokens/\d+/rotate$}) || + current_request.path.match(%r{/personal_access_tokens/self/rotate$}) end end end diff --git a/spec/requests/api/personal_access_tokens/self_rotation_spec.rb b/spec/requests/api/personal_access_tokens/self_rotation_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..697f8f3a07fb79f2f74786e841e80bf94b5a71d6 --- /dev/null +++ b/spec/requests/api/personal_access_tokens/self_rotation_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::PersonalAccessTokens::SelfRotation, feature_category: :system_access do + let(:path) { '/personal_access_tokens/self/rotate' } + let(:token) { create(:personal_access_token, user: current_user) } + let(:expiry_date) { Date.today + 1.week } + let(:params) { {} } + + let_it_be(:current_user) { create(:user) } + + describe 'POST /personal_access_tokens/self/rotate' do + subject(:rotate_token) { post(api(path, personal_access_token: token), params: params) } + + shared_examples 'rotating token succeeds' do + it 'rotate token', :aggregate_failures do + rotate_token + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['token']).not_to eq(token.token) + expect(json_response['expires_at']).to eq(expiry_date.to_s) + expect(token.reload).to be_revoked + end + end + + shared_examples 'rotating token denied' do |status| + it 'cannot rotate token' do + rotate_token + + expect(response).to have_gitlab_http_status(status) + end + end + + context 'when current_user is an administrator', :enable_admin_mode do + let(:current_user) { create(:admin) } + + it_behaves_like 'rotating token succeeds' + + context 'when expiry is defined' do + let(:expiry_date) { Date.today + 1.month } + let(:params) { { expires_at: expiry_date } } + + it_behaves_like 'rotating token succeeds' + end + + context 'with impersonated token' do + let(:token) { create(:personal_access_token, :impersonation, user: current_user) } + + it_behaves_like 'rotating token succeeds' + end + + Gitlab::Auth.all_available_scopes.each do |scope| + context "with a '#{scope}' scoped token" do + let(:current_user) { create(:admin) } + let(:token) { create(:personal_access_token, scopes: [scope], user: current_user) } + + if [Gitlab::Auth::API_SCOPE].include? scope + it_behaves_like 'rotating token succeeds' + else + it_behaves_like 'rotating token denied', :forbidden + end + end + end + end + + context 'when current_user is not an administrator' do + let(:current_user) { create(:user) } + + it_behaves_like 'rotating token succeeds' + + context 'when expiry is defined' do + let(:expiry_date) { Date.today + 1.month } + let(:params) { { expires_at: expiry_date } } + + it_behaves_like 'rotating token succeeds' + end + + context 'with impersonated token' do + let(:token) { create(:personal_access_token, :impersonation, user: current_user) } + + it_behaves_like 'rotating token succeeds' + end + + Gitlab::Auth.all_available_scopes.each do |scope| + context "with a '#{scope}' scoped token" do + let(:current_user) { create(:user) } + let(:token) { create(:personal_access_token, scopes: [scope], user: current_user) } + + if [Gitlab::Auth::API_SCOPE].include? scope + it_behaves_like 'rotating token succeeds' + else + it_behaves_like 'rotating token denied', :forbidden + end + end + end + end + + context 'when token is invalid' do + let(:current_user) { create(:user) } + let(:token) { instance_double(PersonalAccessToken, token: 'invalidtoken') } + + it_behaves_like 'rotating token denied', :unauthorized + end + + context 'with a revoked token' do + let(:token) { create(:personal_access_token, :revoked, user: current_user) } + + it_behaves_like 'rotating token denied', :unauthorized + end + + context 'with an expired token' do + let(:token) { create(:personal_access_token, expires_at: 1.day.ago, user: current_user) } + + it_behaves_like 'rotating token denied', :unauthorized + end + + context 'with a rotated token' do + let(:token) { create(:personal_access_token, :revoked, user: current_user) } + let!(:child_token) { create(:personal_access_token, previous_personal_access_token_id: token.id) } + + it_behaves_like 'rotating token denied', :unauthorized + + it 'revokes token family' do + rotate_token + + expect(child_token.reload).to be_revoked + end + end + + context 'with an OAuth token' do + subject(:rotate_token) { post(api(path, oauth_access_token: token), params: params) } + + context 'with default scope' do + let(:token) { create(:oauth_access_token) } + + it_behaves_like 'rotating token denied', :forbidden + end + + Gitlab::Auth.all_available_scopes.each do |scope| + context "with a '#{scope}' scoped token" do + let(:token) { create(:oauth_access_token, scopes: [scope]) } + + if [Gitlab::Auth::API_SCOPE].include? scope + it_behaves_like 'rotating token denied', :method_not_allowed + else + it_behaves_like 'rotating token denied', :forbidden + end + end + end + end + + context 'with a deploy token' do + let(:token) { create(:deploy_token) } + let(:headers) { { Gitlab::Auth::AuthFinders::DEPLOY_TOKEN_HEADER => token.token } } + + subject(:rotate_token) { post(api(path), params: params, headers: headers) } + + it_behaves_like 'rotating token denied', :unauthorized + end + + context 'with a job token' do + let(:job) { create(:ci_build, :running, user: current_user) } + + subject(:rotate_token) { post(api(path, job_token: job.token), params: params) } + + it_behaves_like 'rotating token denied', :unauthorized + end + + context 'when current_user is a project bot' do + let(:current_user) { create(:user, :project_bot) } + + it_behaves_like 'rotating token denied', :forbidden + + context 'when expiry is defined' do + let(:expiry_date) { Date.today + 1.month } + let(:params) { { expires_at: expiry_date } } + + it_behaves_like 'rotating token denied', :forbidden + end + + context 'with impersonated token' do + let(:token) { create(:personal_access_token, :impersonation, user: current_user) } + + it_behaves_like 'rotating token denied', :forbidden + end + + Gitlab::Auth.resource_bot_scopes.each do |scope| + context "with a '#{scope}' scoped token" do + let(:token) { create(:personal_access_token, scopes: [scope], user: current_user) } + + it_behaves_like 'rotating token denied', :forbidden + end + end + end + end +end