diff --git a/app/services/groups/agnostic_token_revocation_service.rb b/app/services/groups/agnostic_token_revocation_service.rb index db481c5419e4961c9dd28e6e40d7b748777c62ee..35e42774fe1011a313dd56888cd462a8b029847c 100644 --- a/app/services/groups/agnostic_token_revocation_service.rb +++ b/app/services/groups/agnostic_token_revocation_service.rb @@ -19,8 +19,6 @@ module Groups # rubocop:disable Gitlab/BoundedContexts -- This service is strict class AgnosticTokenRevocationService < Groups::BaseService AUDIT_SOURCE = :group_token_revocation_service - attr_reader :revocable - def initialize(group, current_user, plaintext) @group = group @current_user = current_user @@ -32,22 +30,16 @@ def execute return error("Group cannot be a subgroup") if group.subgroup? return error("Unauthorized") unless can?(current_user, :admin_group, group) - # Determine the type of token - if plaintext.start_with?(Gitlab::CurrentSettings.current_application_settings.personal_access_token_prefix, - ApplicationSetting.defaults[:personal_access_token_prefix]) - @revocable = PersonalAccessToken.find_by_token(plaintext) - return error('PAT not found') unless revocable + @token = ::Authn::AgnosticTokenIdentifier.token_for(plaintext, AUDIT_SOURCE) + @revocable = token.revocable unless token.blank? + # Perform checks based on token type and group scope: + case token + when ::Authn::Tokens::PersonalAccessToken handle_personal_access_token - elsif plaintext.start_with?(DeployToken::DEPLOY_TOKEN_PREFIX) - @revocable = DeployToken.find_by_token(plaintext) - return error('DeployToken not found') unless revocable && revocable.group_type? - + when ::Authn::Tokens::DeployToken handle_deploy_token - elsif plaintext.start_with?(User::FEED_TOKEN_PREFIX) - @revocable = User.find_by_feed_token(plaintext) - return error('Feed Token not found') unless revocable - + when ::Authn::Tokens::FeedToken handle_feed_token else error('Unsupported token type') @@ -56,7 +48,7 @@ def execute private - attr_reader :plaintext, :group, :current_user + attr_reader :plaintext, :group, :current_user, :token, :revocable def success(revocable, type, api_entity: nil) api_entity ||= type @@ -75,15 +67,11 @@ def error(message) end def handle_personal_access_token + return error('PAT not found') unless revocable + if user_has_group_membership?(revocable.user) # Only revoke active tokens. (Ignore expired tokens) - if revocable.active? - ::PersonalAccessTokens::RevokeService.new( - current_user, - token: revocable, - source: AUDIT_SOURCE - ).execute - end + token.revoke!(current_user) if revocable.active? # Always validate that, if we're returning token info, it # has been successfully revoked @@ -113,17 +101,10 @@ def user_has_group_membership?(user) end def handle_deploy_token + return error('DeployToken not found') unless revocable && revocable.group_type? + if group.self_and_descendants.include?(revocable.group) - if revocable.active? - service = ::Groups::DeployTokens::RevokeService.new( - revocable.group, - current_user, - { id: revocable.id } - ) - - service.source = AUDIT_SOURCE - service.execute - end + token.revoke!(current_user) if revocable.active? return success(revocable, 'DeployToken') if revocable.reset.revoked? end @@ -132,14 +113,12 @@ def handle_deploy_token end def handle_feed_token + return error('Feed Token not found') unless revocable + if user_has_group_membership?(revocable) current_token = revocable.feed_token - response = Users::ResetFeedTokenService.new( - current_user, - user: revocable, - source: AUDIT_SOURCE - ).execute + response = token.revoke!(current_user) # Always validate that, if we're returning token info, it # has been successfully revoked. Feed tokens can only be rotated diff --git a/lib/api/admin/token.rb b/lib/api/admin/token.rb index 9c521397114e925e6978f5a4fed526bf0bfbf0b9..9a54c95526f1540397a6e9c8d727af34fbaf7376 100644 --- a/lib/api/admin/token.rb +++ b/lib/api/admin/token.rb @@ -4,25 +4,14 @@ module API module Admin class Token < ::API::Base feature_category :system_access + AUDIT_SOURCE = :api_admin_token helpers do - def identify_token(token) - if token.start_with?(Gitlab::CurrentSettings.current_application_settings.personal_access_token_prefix, - ApplicationSetting.defaults[:personal_access_token_prefix]) - handle_personal_access_token(token) - elsif token.start_with?(DeployToken::DEPLOY_TOKEN_PREFIX) - handle_deploy_token(token) - else - raise ArgumentError, 'Token type not supported.' - end - end - - def handle_personal_access_token(token) - PersonalAccessToken.find_by_token(token) - end + def identify_token(plaintext) + token = ::Authn::AgnosticTokenIdentifier.token_for(plaintext, AUDIT_SOURCE) + raise ArgumentError, 'Token type not supported.' if token.blank? - def handle_deploy_token(token) - DeployToken.find_by_token(token) + token.revocable end end diff --git a/lib/authn/agnostic_token_identifier.rb b/lib/authn/agnostic_token_identifier.rb new file mode 100644 index 0000000000000000000000000000000000000000..672f03d6eba43135f5ee799850f26abb9f53282c --- /dev/null +++ b/lib/authn/agnostic_token_identifier.rb @@ -0,0 +1,16 @@ +# frozen_string_literal:true + +module Authn + class AgnosticTokenIdentifier + NotFoundError = Class.new(StandardError) + TOKEN_TYPES = [ + ::Authn::Tokens::DeployToken, + ::Authn::Tokens::FeedToken, + ::Authn::Tokens::PersonalAccessToken + ].freeze + + def self.token_for(plaintext, source) + TOKEN_TYPES.find { |x| x.prefix?(plaintext) }&.new(plaintext, source) + end + end +end diff --git a/lib/authn/tokens/deploy_token.rb b/lib/authn/tokens/deploy_token.rb new file mode 100644 index 0000000000000000000000000000000000000000..43b694adf172f222ccfba11dcb0101fa8973ffd8 --- /dev/null +++ b/lib/authn/tokens/deploy_token.rb @@ -0,0 +1,30 @@ +# frozen_string_literal:true + +module Authn + module Tokens + class DeployToken + def self.prefix?(plaintext) + plaintext.start_with?(::DeployToken::DEPLOY_TOKEN_PREFIX) + end + + attr_reader :revocable, :source + + def initialize(plaintext, source) + @revocable = ::DeployToken.find_by_token(plaintext) + @source = source + end + + def revoke!(current_user) + raise ::Authn::AgnosticTokenIdentifier::NotFoundError, 'Not Found' if revocable.blank? + + service = ::Groups::DeployTokens::RevokeService.new( + revocable.group, + current_user, + { id: revocable.id } + ) + service.source = source + service.execute + end + end + end +end diff --git a/lib/authn/tokens/feed_token.rb b/lib/authn/tokens/feed_token.rb new file mode 100644 index 0000000000000000000000000000000000000000..1b425c764038679098ec36a2f2ae768aa7e3749a --- /dev/null +++ b/lib/authn/tokens/feed_token.rb @@ -0,0 +1,28 @@ +# frozen_string_literal:true + +module Authn + module Tokens + class FeedToken + def self.prefix?(plaintext) + plaintext.start_with?(::User::FEED_TOKEN_PREFIX) + end + + attr_reader :revocable, :source + + def initialize(plaintext, source) + @revocable = User.find_by_feed_token(plaintext) + @source = source + end + + def revoke!(current_user) + raise ::Authn::AgnosticTokenIdentifier::NotFoundError, 'Not Found' if revocable.blank? + + Users::ResetFeedTokenService.new( + current_user, + user: revocable, + source: source + ).execute + end + end + end +end diff --git a/lib/authn/tokens/personal_access_token.rb b/lib/authn/tokens/personal_access_token.rb new file mode 100644 index 0000000000000000000000000000000000000000..669debaf2b47aa4ba9a7e798a27d3d47070f543d --- /dev/null +++ b/lib/authn/tokens/personal_access_token.rb @@ -0,0 +1,31 @@ +# frozen_string_literal:true + +module Authn + module Tokens + class PersonalAccessToken + def self.prefix?(plaintext) + plaintext.start_with?( + ::PersonalAccessToken.token_prefix, + ApplicationSetting.defaults[:personal_access_token_prefix] + ) + end + + attr_reader :revocable, :source + + def initialize(plaintext, source) + @revocable = ::PersonalAccessToken.find_by_token(plaintext) + @source = source + end + + def revoke!(current_user) + raise ::Authn::AgnosticTokenIdentifier::NotFoundError, 'Not Found' if revocable.blank? + + ::PersonalAccessTokens::RevokeService.new( + current_user, + token: revocable, + source: source + ).execute + end + end + end +end diff --git a/spec/lib/authn/agnostic_token_identifier_spec.rb b/spec/lib/authn/agnostic_token_identifier_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b3d96cb6a46535aae3bf21e1c0aaec9106a5b218 --- /dev/null +++ b/spec/lib/authn/agnostic_token_identifier_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Authn::AgnosticTokenIdentifier, feature_category: :system_access do + using RSpec::Parameterized::TableSyntax + + let_it_be(:user) { create(:user) } + let_it_be(:deploy_token) { create(:deploy_token).token } + let_it_be(:feed_token) { user.feed_token } + let_it_be(:personal_access_token) { create(:personal_access_token, user: user).token } + + subject(:token) { described_class.token_for(plaintext, :group_token_revocation_service) } + + context 'with supported token types' do + where(:plaintext, :token_type) do + ref(:personal_access_token) | ::Authn::Tokens::PersonalAccessToken + ref(:feed_token) | ::Authn::Tokens::FeedToken + ref(:deploy_token) | ::Authn::Tokens::DeployToken + 'unsupported' | NilClass + end + + with_them do + describe '#initialize' do + it 'finds the correct revocable token type' do + expect(token).to be_instance_of(token_type) + end + end + end + end +end diff --git a/spec/lib/authn/tokens/deploy_token_spec.rb b/spec/lib/authn/tokens/deploy_token_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d79eaf3c40821f1bba0cfba7289bd4d3953d7eca --- /dev/null +++ b/spec/lib/authn/tokens/deploy_token_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Authn::Tokens::DeployToken, feature_category: :system_access do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:deploy_token) { create(:group_deploy_token, group: group).deploy_token } + + subject(:token) { described_class.new(plaintext, :group_token_revocation_service) } + + context 'with valid deploy token' do + let(:plaintext) { deploy_token.token } + let(:valid_revocable) { deploy_token } + + it_behaves_like 'finding the valid revocable' + + describe '#revoke!' do + it 'successfully revokes the token' do + expect(token.revoke!(user)).to be_truthy + end + end + end + + it_behaves_like 'token handling with unsupported token type' +end diff --git a/spec/lib/authn/tokens/feed_token_spec.rb b/spec/lib/authn/tokens/feed_token_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ba28db230161c20869d944ee513cb09951632350 --- /dev/null +++ b/spec/lib/authn/tokens/feed_token_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Authn::Tokens::FeedToken, feature_category: :system_access do + let_it_be(:user) { create(:user) } + let_it_be(:feed_token) { user.feed_token } + + subject(:token) { described_class.new(plaintext, :group_token_revocation_service) } + + context 'with valid feed token' do + let(:plaintext) { feed_token } + let(:valid_revocable) { user } + + it_behaves_like 'finding the valid revocable' + + describe '#revoke!' do + it 'successfully revokes the token' do + expect(token.revoke!(user).status).to eq(:success) + end + end + end + + it_behaves_like 'token handling with unsupported token type' +end diff --git a/spec/lib/authn/tokens/personal_access_token_spec.rb b/spec/lib/authn/tokens/personal_access_token_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..34440046cf483d83e6abbc804d49b3dcf884a352 --- /dev/null +++ b/spec/lib/authn/tokens/personal_access_token_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Authn::Tokens::PersonalAccessToken, feature_category: :system_access do + let_it_be(:user) { create(:user) } + let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } + + subject(:token) { described_class.new(plaintext, :group_token_revocation_service) } + + context 'with valid personal access token' do + let(:plaintext) { personal_access_token.token } + let(:valid_revocable) { personal_access_token } + + it_behaves_like 'finding the valid revocable' + + describe '#revoke!' do + it 'successfully revokes the token' do + expect(token.revoke!(user).status).to eq(:success) + end + end + end + + it_behaves_like 'token handling with unsupported token type' +end diff --git a/spec/support/shared_examples/authn/token_shared_examples.rb b/spec/support/shared_examples/authn/token_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..a0ff50401de40e27112879e34029126efa5b8cd8 --- /dev/null +++ b/spec/support/shared_examples/authn/token_shared_examples.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'token handling with unsupported token type' do + context 'with unsupported token type' do + let_it_be(:plaintext) { 'unsupported' } + + describe '#initialize' do + it 'is nil when the token type is not supported' do + expect(token.revocable).to be_nil + end + end + + describe '#revoke!' do + it 'raises error when the token type is not found' do + expect do + token.revoke!(user) + end + .to raise_error(::Authn::AgnosticTokenIdentifier::NotFoundError, 'Not Found') + end + end + end +end + +RSpec.shared_examples 'finding the valid revocable' do + describe '#initialize' do + it 'finds the plaintext token' do + expect(token.revocable).to eq(valid_revocable) + end + end +end