diff --git a/Gemfile b/Gemfile index 6dc10459974154e2d4f1c4904dc5c32ec3c25e27..796dcb66b7fd7a4c73363d265c75937ea2d6879d 100644 --- a/Gemfile +++ b/Gemfile @@ -740,3 +740,5 @@ gem 'gitlab-sdk', '~> 0.3.0', feature_category: :application_instrumentation gem 'openbao_client', path: 'gems/openbao_client' # rubocop:todo Gemfile/MissingFeatureCategory gem 'paper_trail', '~> 15.0' # rubocop:todo Gemfile/MissingFeatureCategory + +gem "i18n_data", "~> 0.13.1", feature_category: :system_access diff --git a/Gemfile.lock b/Gemfile.lock index ab893542da251845e0a94469dae0b461a4c52e88..a0009c4ca5b3140154b45533bc3c07ccf037e49d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2130,6 +2130,7 @@ DEPENDENCIES html-pipeline (~> 2.14.3) html2text httparty (~> 0.21.0) + i18n_data (~> 0.13.1) icalendar (~> 2.10.1) influxdb-client (~> 3.1) invisible_captcha (~> 2.1.0) diff --git a/Gemfile.next.lock b/Gemfile.next.lock index d4492ce46a9ff04a2b361bc1451f55860b6b90f3..e9897a21151ef581cf7dd0e1f03c3bf6fa7f01e9 100644 --- a/Gemfile.next.lock +++ b/Gemfile.next.lock @@ -2157,6 +2157,7 @@ DEPENDENCIES html-pipeline (~> 2.14.3) html2text httparty (~> 0.21.0) + i18n_data (~> 0.13.1) icalendar (~> 2.10.1) influxdb-client (~> 3.1) invisible_captcha (~> 2.1.0) diff --git a/app/controllers/concerns/known_sign_in.rb b/app/controllers/concerns/known_sign_in.rb index 997f26fa95906781cc6feb85a24b4f21dc469e53..6bbdde19f232618a425a7b79ef071647ac6e0a8d 100644 --- a/app/controllers/concerns/known_sign_in.rb +++ b/app/controllers/concerns/known_sign_in.rb @@ -46,6 +46,12 @@ def known_ip_addresses end def notify_user - current_user.notification_service.unknown_sign_in(current_user, request.remote_ip, current_user.current_sign_in_at) + request_info = Gitlab::Auth::VisitorLocation.new(request) + current_user.notification_service.unknown_sign_in( + current_user, + request.remote_ip, + current_user.current_sign_in_at, + request_info + ) end end diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 8a0d1ea2638b6ffa1b9a306b39cb10ea1972850d..9f8baf8502fba191a9bb0858c645163810578125 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -147,9 +147,11 @@ def ssh_key_expiring_soon_email(user, fingerprints) mail_with_locale(to: @user.notification_email_or_default, subject: subject(_("Your SSH key is expiring soon."))) end - def unknown_sign_in_email(user, ip, time) + def unknown_sign_in_email(user, ip, time, request_info = {}) @user = user @ip = ip + @city = request_info[:city] + @country = request_info[:country] @time = time @target_url = edit_user_settings_password_url diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 43b357a75d5d9af1e35e34116fde32f371d5f3ec..f2e1e59d4815941bffc564251ba4fd657b4ee891 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -217,7 +217,7 @@ def remote_mirror_update_failed_email end def unknown_sign_in_email - Notify.unknown_sign_in_email(user, '127.0.0.1', Time.current).message + Notify.unknown_sign_in_email(user, '127.0.0.1', Time.current, country: 'Germany', city: 'Frankfurt').message end def two_factor_otp_attempt_failed_email diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index eeba7274d25deaaa5abdeba35c96d1fd745594db..60151466ba0e8ddabe457718d43816bbebdc2274 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -137,7 +137,7 @@ def ssh_key_expiring_soon(user, fingerprints) # Notify a user when a previously unknown IP or device is used to # sign in to their account - def unknown_sign_in(user, ip, time) + def unknown_sign_in(user, ip, time, request_info) return unless user.can?(:receive_notifications) mailer.unknown_sign_in_email(user, ip, time).deliver_later diff --git a/app/views/notify/unknown_sign_in_email.html.haml b/app/views/notify/unknown_sign_in_email.html.haml index 0e90b0568f0e0f55af805e396f73c37d07e7c386..b3fd312462f69e4e461ff2570dcf30e661a1d275 100644 --- a/app/views/notify/unknown_sign_in_email.html.haml +++ b/app/views/notify/unknown_sign_in_email.html.haml @@ -1,5 +1,5 @@ - default_font = "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" -- default_style = "#{default_font}font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" +- default_style = "#{default_font}font-size:15px;line-height:1.4;color:#626168;font-weight:300;padding:14px 0;margin:0;" - spacer_style = "#{default_font};height:18px;font-size:18px;line-height:18px;" %tr.alert @@ -33,6 +33,13 @@ %td{ style: "#{default_style}color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %span.muted{ style: "color:#333333;text-decoration:none;" } = @ip + - if @city && @country + %tr + %td{ style: "#{default_style}border-top:1px solid #ededed;" } + = _('Location') + %td{ style: "#{default_style}color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } + %span.muted{ style: "color:#333333;text-decoration:none;" } + = @city + ", " + @country %tr %td{ style: "#{default_style}border-top:1px solid #ededed;" } = _('Time') diff --git a/app/views/notify/unknown_sign_in_email.text.haml b/app/views/notify/unknown_sign_in_email.text.haml index a926673a9cf65c23220f22da182a0ff478df678f..7a9087ff4a073024d43c5edfe4a7ec8cd069dc5e 100644 --- a/app/views/notify/unknown_sign_in_email.text.haml +++ b/app/views/notify/unknown_sign_in_email.text.haml @@ -1,6 +1,9 @@ = _('Hi %{user_name} (%{user_username})!') % { user_name: sanitize_name(@user.name), user_username: @user.username } -= _('A sign-in to your account has been made from the following IP address: %{ip}') % { ip: @ip } +- if @city && @country + = _('A sign-in to your account has been made from the following IP address: %{ip} (%{city}, %{country})') % { ip: @ip, city: @city, country: @country } +- else + = _('A sign-in to your account has been made from the following IP address: %{ip}') % { ip: @ip } = _('If you recently signed in and recognize the IP address, you may disregard this email.') = _('If you did not recently sign in, you should immediately change your password: %{password_link}.') % { password_link: help_page_url('user/profile/user_passwords.md', anchor: 'change-your-password') } diff --git a/lib/gitlab/auth/visitor_location.rb b/lib/gitlab/auth/visitor_location.rb new file mode 100644 index 0000000000000000000000000000000000000000..27d8174301abbe12057c575d2d9b3ae7641e8327 --- /dev/null +++ b/lib/gitlab/auth/visitor_location.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Takes in an incoming request and extracts contextual information such as location. +# +# Information about country and city of the request is taken from its headers set by Cloudflare WAF. +# See: https://developers.cloudflare.com/rules/transform/managed-transforms/reference/#add-visitor-location-headers +# +module Gitlab + module Auth + class VisitorLocation + attr_reader :request + + HEADERS = { + country: 'Cf-Ipcountry', + city: 'Cf-Ipcity' + }.freeze + + # @param [ActionDispatch::Request] request + def initialize(request) + @request = request + end + + def country + code = request.headers[HEADERS[:country]] # 2-letter country code, e.g. "JP" for Japan + # If country name is not known for local language, default to English. Or just display country code + I18nData.countries(I18n.locale)[code] || I18nData.countries[code] || code + end + + def city + request.headers[HEADERS[:city]] + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6c1c0c53d08b99c237dc897808072e3e7db9e5d1..1ba00cc1837dfd870779736c68a4e56f2f66d7a2 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2124,6 +2124,9 @@ msgstr "" msgid "A sign-in to your account has been made from the following IP address: %{ip}" msgstr "" +msgid "A sign-in to your account has been made from the following IP address: %{ip} (%{city}, %{country})" +msgstr "" + msgid "A template for starting a new TYPO3 project" msgstr "" diff --git a/spec/lib/gitlab/auth/visitor_location_spec.rb b/spec/lib/gitlab/auth/visitor_location_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..f24e1888f49e55c5799bd991ad155ca9806bb522 --- /dev/null +++ b/spec/lib/gitlab/auth/visitor_location_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Auth::VisitorLocation, feature_category: :system_access do + let(:country_code) { 'DE' } + let(:country) { 'Germany' } + let(:city) { 'Frankfurt' } + let(:headers) { { "Cf-Ipcountry" => country_code, "Cf-Ipcity" => city } } + let(:request) { instance_double(ActionDispatch::Request, headers: headers) } + + subject(:request_info) { described_class.new(request) } + + it 'returns country and city' do + expect(request_info.country).to eq(country) + expect(request_info.city).to eq(city) + end + + context 'when country code not recognized' do + let(:country_code) { 'UNKNOWN' } + + it 'returns country code' do + expect(request_info.country).to eq(country_code) + end + end + + context 'when locale is not default' do + before do + I18n.locale = :de + end + + it 'returns localized country name' do + expect(request_info.country).to eq('Deutschland') + end + end + + context 'when location headers are not set' do + let(:headers) { {} } + + it 'cannot determine country and city' do + expect(request_info.country).to be_nil + expect(request_info.city).to be_nil + end + end +end diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index e7e23a0d2753bccd04a0f44f4e8c39d659be3ddf..357ee7e02768b209d64bcacd3b0d20fffaa182be 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -443,10 +443,12 @@ end describe 'user unknown sign in email' do - let_it_be(:user) { create(:user) } - let_it_be(:ip) { '169.0.0.1' } - let_it_be(:current_time) { Time.current } - let_it_be(:email) { Notify.unknown_sign_in_email(user, ip, current_time) } + let(:user) { create(:user) } + let(:ip) { '169.0.0.1' } + let(:current_time) { Time.current } + let(:country) { 'Germany' } + let(:city) { 'Frankfurt' } + let(:email) { Notify.unknown_sign_in_email(user, ip, current_time, country: country, city: city) } subject { email } @@ -487,6 +489,19 @@ is_expected.to have_body_text help_page_url('user/profile/account/two_factor_authentication.md') end + it 'shows location information' do + is_expected.to have_body_text _('Location') + is_expected.to have_body_text country + is_expected.to have_body_text city + end + + context 'when no location information was given' do + let(:country) { nil } + let(:city) { nil } + + it { is_expected.not_to have_body_text _('Location') } + end + context 'when two factor authentication is enabled' do let(:user) { create(:user, :two_factor) } diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 1b47c73e9d3da54ff250afec00260fdfcfdede33..5118dc4f682497ad9e9aee1d6d118a4d1d23addf 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -652,11 +652,14 @@ end describe '#unknown_sign_in' do - let_it_be(:user) { create(:user) } - let_it_be(:ip) { '127.0.0.1' } - let_it_be(:time) { Time.current } + let(:user) { create(:user) } + let(:ip) { '127.0.0.1' } + let(:country) { 'Germany' } + let(:city) { 'Frankfurt' } + let(:request_info) { Struct.new(:country, :city).new(country, city) } + let(:time) { Time.current } - subject { notification.unknown_sign_in(user, ip, time) } + subject { notification.unknown_sign_in(user, ip, time, request_info) } it 'sends email to the user' do expect { subject }.to have_enqueued_email(user, ip, time, mail: 'unknown_sign_in_email')