diff --git a/ee/app/services/arkose/user_verification_service.rb b/ee/app/services/arkose/user_verification_service.rb index 82aff20c37b1bb5529e1458f69ea46ce0261ad4f..fbb777131c60f946bc6832be975f81f61fa82e3e 100644 --- a/ee/app/services/arkose/user_verification_service.rb +++ b/ee/app/services/arkose/user_verification_service.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true module Arkose class UserVerificationService - attr_reader :url, :session_token, :user + attr_reader :url, :session_token, :user, :response ARKOSE_LABS_DEFAULT_NAMESPACE = 'client' ARKOSE_LABS_DEFAULT_SUBDOMAIN = 'verify-api' - ALLOWLIST_TELLTALE = 'gitlab1-whitelist-qa-team' def initialize(session_token:, user:) @session_token = session_token @@ -13,14 +12,16 @@ def initialize(session_token:, user:) end def execute - response = Gitlab::HTTP.perform_request(Net::HTTP::Post, arkose_verify_url, body: body).parsed_response - logger.info(build_message(response)) + json_response = Gitlab::HTTP.perform_request(Net::HTTP::Post, arkose_verify_url, body: body).parsed_response + @response = VerifyResponse.new(json_response) - return false if invalid_token(response) + logger.info(build_message) - add_or_update_arkose_attributes(response) + return false if response.invalid_token? - allowlisted?(response) || (challenge_solved?(response) && low_risk?(response)) + add_or_update_arkose_attributes + + response.allowlisted? || (response.challenge_solved? && response.low_risk?) rescue StandardError => error payload = { session_token: session_token, log_data: user.id } Gitlab::ExceptionLogFormatter.format!(error, payload) @@ -32,53 +33,23 @@ def execute private - def add_or_update_arkose_attributes(response) + def add_or_update_arkose_attributes return if Gitlab::Database.read_only? - custom_attributes = custom_attributes(response) - UserCustomAttribute.upsert_custom_attributes(custom_attributes) end - def custom_attributes(response) + def custom_attributes custom_attributes = [] - custom_attributes.push({ key: 'arkose_session', value: session_id(response) }) - custom_attributes.push({ key: 'arkose_risk_band', value: risk_band(response) }) - custom_attributes.push({ key: 'arkose_global_score', value: global_score(response) }) - custom_attributes.push({ key: 'arkose_custom_score', value: custom_score(response) }) + custom_attributes.push({ key: 'arkose_session', value: response.session_id }) + custom_attributes.push({ key: 'arkose_risk_band', value: response.risk_band }) + custom_attributes.push({ key: 'arkose_global_score', value: response.global_score }) + custom_attributes.push({ key: 'arkose_custom_score', value: response.custom_score }) custom_attributes.map! { |custom_attribute| custom_attribute.merge({ user_id: user.id }) } custom_attributes end - def custom_score(response) - response&.dig('session_risk', 'custom', 'score') || 0 - end - - def global_score(response) - response&.dig('session_risk', 'global', 'score') || 0 - end - - def risk_band(response) - response&.dig('session_risk', 'risk_band') || 'Unavailable' - end - - def session_id(response) - response&.dig('session_details', 'session') || 'Unavailable' - end - - def risk_category(response) - response&.dig('session_risk', 'risk_category') || 'Unavailable' - end - - def global_telltale_list(response) - response&.dig('session_risk', 'global', 'telltales') || 'Unavailable' - end - - def custom_telltale_list(response) - response&.dig('session_risk', 'custom', 'telltales') || 'Unavailable' - end - def body { private_key: Settings.arkose_private_api_key, @@ -91,49 +62,28 @@ def logger Gitlab::AppLogger end - def build_message(response) + def build_message Gitlab::ApplicationContext.current.symbolize_keys.merge( { message: 'Arkose verify response', - response: response, + response: response.response, username: user.username - }.merge(arkose_payload(response)) + }.merge(arkose_payload) ) end - def arkose_payload(response) + def arkose_payload { - 'arkose.session_id': session_id(response), - 'arkose.global_score': global_score(response), - 'arkose.global_telltale_list': global_telltale_list(response), - 'arkose.custom_score': custom_score(response), - 'arkose.custom_telltale_list': custom_telltale_list(response), - 'arkose.risk_band': risk_band(response), - 'arkose.risk_category': risk_category(response) + 'arkose.session_id': response.session_id, + 'arkose.global_score': response.global_score, + 'arkose.global_telltale_list': response.global_telltale_list, + 'arkose.custom_score': response.custom_score, + 'arkose.custom_telltale_list': response.custom_telltale_list, + 'arkose.risk_band': response.risk_band, + 'arkose.risk_category': response.risk_category } end - def invalid_token(response) - response&.key?('error') - end - - def challenge_solved?(response) - solved = response&.dig('session_details', 'solved') - solved.nil? ? true : solved - end - - def low_risk?(response) - return true unless Feature.enabled?(:arkose_labs_prevent_login) - - risk_band = risk_band(response) - risk_band.present? ? risk_band != 'High' : true - end - - def allowlisted?(response) - telltale_list = response&.dig('session_details', 'telltale_list') || [] - telltale_list.include?(ALLOWLIST_TELLTALE) - end - def arkose_verify_url arkose_labs_namespace = ::Gitlab::CurrentSettings.arkose_labs_namespace subdomain = if arkose_labs_namespace == ARKOSE_LABS_DEFAULT_NAMESPACE diff --git a/ee/lib/arkose/verify_response.rb b/ee/lib/arkose/verify_response.rb new file mode 100644 index 0000000000000000000000000000000000000000..60e7fc05d9ac67a78f6ebfdffe7a081d3a50ae75 --- /dev/null +++ b/ee/lib/arkose/verify_response.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Arkose + class VerifyResponse + attr_reader :response + + ALLOWLIST_TELLTALE = 'gitlab1-whitelist-qa-team' + + def initialize(response) + @response = response + end + + def invalid_token? + response&.key?('error') + end + + def error + response["error"] + end + + def challenge_solved? + solved = response&.dig('session_details', 'solved') + solved.nil? ? true : solved + end + + def low_risk? + return true unless Feature.enabled?(:arkose_labs_prevent_login) + + risk_band.present? ? risk_band != 'High' : true + end + + def allowlisted? + telltale_list = response&.dig('session_details', 'telltale_list') || [] + telltale_list.include?(ALLOWLIST_TELLTALE) + end + + def custom_score + response&.dig('session_risk', 'custom', 'score') || 0 + end + + def global_score + response&.dig('session_risk', 'global', 'score') || 0 + end + + def risk_band + response&.dig('session_risk', 'risk_band') || 'Unavailable' + end + + def session_id + response&.dig('session_details', 'session') || 'Unavailable' + end + + def risk_category + response&.dig('session_risk', 'risk_category') || 'Unavailable' + end + + def global_telltale_list + response&.dig('session_risk', 'global', 'telltales') || 'Unavailable' + end + + def custom_telltale_list + response&.dig('session_risk', 'custom', 'telltales') || 'Unavailable' + end + end +end diff --git a/ee/spec/fixtures/arkose/allowlisted_response.json b/ee/spec/fixtures/arkose/allowlisted_response.json new file mode 100644 index 0000000000000000000000000000000000000000..a29551805f68485455bcb4a52312de027943e37b --- /dev/null +++ b/ee/spec/fixtures/arkose/allowlisted_response.json @@ -0,0 +1,76 @@ +{ + "session_details": { + "solved": false, + "session": "22612c147bb418c8.2570749403", + "session_created": "2021-08-29T23:13:03+00:00", + "check_answer": "2021-08-29T23:13:16+00:00", + "verified": "2021-08-30T00:19:32+00:00", + "attempted": true, + "security_level": 30, + "session_is_legit": false, + "previously_verified": true, + "session_timed_out": true, + "suppress_limited": false, + "theme_arg_invalid": false, + "suppressed": false, + "punishable_actioned": false, + "telltale_user": "eng-1362-game3-py-0.", + "failed_low_sec_validation": false, + "lowsec_error": null, + "lowsec_level_denied": null, + "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36", + "ip_rep_list": null, + "game_number_limit_reached": false, + "user_language_shown": "en", + "telltale_list": [ + "gitlab1-whitelist-qa-team" + ], + "optional": null + }, + "fingerprint": { + "browser_characteristics": { + "browser_name": "Chrome", + "browser_version": "92.0.4515.159", + "color_depth": 24, + "session_storage": false, + "indexed_database": false, + "canvas_fingerprint": 1652956012 + }, + "device_characteristics": { + "operating_system": null, + "operating_system_version": null, + "screen_resolution": [ + 1920, + 1080 + ], + "max_resolution_supported": [ + 1920, + 1057 + ], + "behavior": false, + "cpu_class": "unknown", + "platform": "MacIntel", + "touch_support": false, + "hardware_concurrency": 8 + }, + "user_preferences": { + "timezone_offset": -600 + } + }, + "ip_intelligence": { + "user_ip": "10.211.121.196", + "is_tor": false, + "is_vpn": true, + "is_proxy": true, + "is_bot": true, + "country": "AU", + "region": "New South Wales", + "city": "Sydney", + "isp": "Amazon.com", + "public_access_point": false, + "connection_type": "Data Center", + "latitude": "-38.85120035", + "longitude": "106.21220398", + "timezone": "Australia/Sydney" + } +} diff --git a/ee/spec/fixtures/arkose/invalid_token.json b/ee/spec/fixtures/arkose/invalid_token.json new file mode 100644 index 0000000000000000000000000000000000000000..e4004ba76814f2f3b69bc8f086306a27aa684bc9 --- /dev/null +++ b/ee/spec/fixtures/arkose/invalid_token.json @@ -0,0 +1,4 @@ +{ + "error": "DENIED ACCESS", + "verified": "2022-08-12T07:57:31Z" +} \ No newline at end of file diff --git a/ee/spec/lib/arkose/verify_response_spec.rb b/ee/spec/lib/arkose/verify_response_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..42814c422f22a331a9072c076b73c207b22d94c8 --- /dev/null +++ b/ee/spec/lib/arkose/verify_response_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Arkose::VerifyResponse do + def parse_json(file_path) + Gitlab::Json.parse(File.read(Rails.root.join(file_path))) + end + + let(:invalid_token_response) do + parse_json('ee/spec/fixtures/arkose/invalid_token.json') + end + + let(:unsolved_challenge_response) do + parse_json('ee/spec/fixtures/arkose/failed_ec_response.json') + end + + let(:low_risk_response) do + parse_json('ee/spec/fixtures/arkose/successfully_solved_ec_response.json') + end + + let(:high_risk_response) do + parse_json('ee/spec/fixtures/arkose/successfully_solved_ec_response_high_risk.json') + end + + let(:allowlisted_response) do + parse_json('ee/spec/fixtures/arkose/allowlisted_response.json') + end + + describe '#invalid_token?' do + subject { described_class.new(json_response).invalid_token? } + + context 'when token is invalid' do + let(:json_response) { invalid_token_response } + + it { is_expected.to eq true } + end + + context 'when token is valid' do + let(:json_response) { unsolved_challenge_response } + + it { is_expected.to eq false } + end + end + + describe '#error' do + let(:json_response) { invalid_token_response } + + subject { described_class.new(json_response).error } + + it { is_expected.to eq 'DENIED ACCESS' } + end + + describe '#challenge_solved?' do + subject { described_class.new(json_response).challenge_solved? } + + context 'when response does not contain solved data' do + let(:json_response) { Gitlab::Json.parse("{}") } + + it { is_expected.to eq true } + end + + context 'when response contains solved data' do + let(:json_response) { unsolved_challenge_response } + + it { is_expected.to eq false } + end + end + + describe '#low_risk?' do + subject { described_class.new(json_response).low_risk? } + + context 'when arkose_labs_prevent_login feature flag is disabled' do + let(:json_response) { Gitlab::Json.parse("{}") } + + before do + stub_feature_flags(arkose_labs_prevent_login: false) + end + + it { is_expected.to eq true } + end + + context 'when response does not contain session_risk.risk_band data' do + let(:json_response) { Gitlab::Json.parse("{}") } + + it { is_expected.to eq true } + end + + context 'when response contains session_risk.risk_band != "High"' do + let(:json_response) { low_risk_response } + + it { is_expected.to eq true } + end + + context 'when response contains session_risk.risk_band == "High"' do + let(:json_response) { high_risk_response } + + it { is_expected.to eq false } + end + end + + describe '#allowlisted?' do + subject { described_class.new(json_response).allowlisted? } + + context 'when session_details.telltale_list data includes ALLOWLIST_TELLTALE' do + let(:json_response) { allowlisted_response } + + it { is_expected.to eq true } + end + + context 'when session_details.telltale_list data does not include ALLOWLIST_TELLTALE' do + let(:json_response) { high_risk_response } + + it { is_expected.to eq false } + end + + context 'when response does not include session_details.telltale_list data' do + let(:json_response) { Gitlab::Json.parse("{}") } + + it { is_expected.to eq false } + end + end + + describe 'other methods' do + using RSpec::Parameterized::TableSyntax + + subject(:response) { described_class.new(json_response) } + + context 'when response has the correct data' do + let(:global_telltale_list) do + [ + { "name" => "g-h-cfp-1000000000", "weight" => "7" }, + { "name" => "g-os-impersonation-win", "weight" => "8" } + ] + end + + let(:custom_telltale_list) do + [ + { "name" => "outdated-browser-customer-2", "weight" => "100" }, + { "name" => "outdated-os-customer", "weight" => "100" } + ] + end + + where(:method, :expected_value) do + :custom_score | "100" + :global_score | "15" + :risk_band | "High" + :session_id | "22612c147bb418c8.2570749403" + :risk_category | "BOT-STD" + :global_telltale_list | lazy { global_telltale_list } + :custom_telltale_list | lazy { custom_telltale_list } + end + + with_them do + let(:json_response) { high_risk_response } + + it 'succeeds' do + expect(response.public_send(method)).to eq expected_value + end + end + end + + context 'when response does not have the correct data' do + where(:method, :expected_value) do + :custom_score | 0 + :global_score | 0 + :risk_band | 'Unavailable' + :session_id | 'Unavailable' + :risk_category | 'Unavailable' + :global_telltale_list | 'Unavailable' + :custom_telltale_list | 'Unavailable' + end + + with_them do + let(:json_response) { Gitlab::Json.parse("{}") } + + it 'succeeds' do + expect(response.public_send(method)).to eq expected_value + end + end + end + end +end diff --git a/ee/spec/services/arkose/user_verification_service_spec.rb b/ee/spec/services/arkose/user_verification_service_spec.rb index e5c901aad6cdac87f945be42142e10b5fda2c3a1..fbd4d9475e0ca4ce7cc040932cdbe7bceef3e008 100644 --- a/ee/spec/services/arkose/user_verification_service_spec.rb +++ b/ee/spec/services/arkose/user_verification_service_spec.rb @@ -91,7 +91,7 @@ context 'when the session is allowlisted' do let(:arkose_ec_response) do json = Gitlab::Json.parse(File.read(Rails.root.join('ee/spec/fixtures/arkose/successfully_solved_ec_response.json'))) - json['session_details']['telltale_list'].push(Arkose::UserVerificationService::ALLOWLIST_TELLTALE) + json['session_details']['telltale_list'].push(Arkose::VerifyResponse::ALLOWLIST_TELLTALE) json end