diff --git a/.gitignore b/.gitignore index d2424706337..c088cfd8a79 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ Vagrantfile !/cert/*.crt.example /config/application.yml /config/aws.yml +/geo_data/* /keys/*.key.enc !/keys/*.key.enc.example /keys/equifax_rsa diff --git a/.reek.yml b/.reek.yml index faf5dba8de9..ebfc222574d 100644 --- a/.reek.yml +++ b/.reek.yml @@ -128,10 +128,13 @@ detectors: - TwoFactorLoginOptionsPresenter UncommunicativeMethodName: exclude: + - Deploy::Activate#download_application_yml_from_s3 + - Deploy::Activate#download_geocoding_database_from_s3 - PhoneConfirmationFlow - render_401 - SessionDecorator#registration_bullet_1 - ServiceProviderSessionDecorator#registration_bullet_1 + UncommunicativeModuleName: exclude: - X509 diff --git a/Gemfile b/Gemfile index 7e0fd1263d9..324b2aa639d 100644 --- a/Gemfile +++ b/Gemfile @@ -27,6 +27,7 @@ gem 'identity-hostdata', github: '18F/identity-hostdata', branch: 'master' gem 'json-jwt' gem 'local_time' gem 'lograge' +gem 'maxminddb' gem 'net-sftp' gem 'newrelic_rpm' gem 'pg' diff --git a/Gemfile.lock b/Gemfile.lock index 05ff58229b1..356e8cf6d19 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -347,6 +347,7 @@ GEM systemu (~> 2.6.2) mail (2.7.1) mini_mime (>= 0.1.1) + maxminddb (0.1.22) memory_profiler (0.9.11) method_source (0.9.2) mime-types (3.2.2) @@ -710,6 +711,7 @@ DEPENDENCIES lexisnexis! local_time lograge + maxminddb net-sftp newrelic_rpm overcommit diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 0216afbab51..11b93aaef94 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -13,6 +13,8 @@ def show personal_key: flash[:personal_key], decorated_user: current_user.decorate ) + + @login_presenter = LoginPresenter.new(user: current_user) end private diff --git a/app/presenters/login_presenter.rb b/app/presenters/login_presenter.rb new file mode 100644 index 00000000000..9b7095bbee4 --- /dev/null +++ b/app/presenters/login_presenter.rb @@ -0,0 +1,59 @@ +class LoginPresenter + include ActionView::Helpers::DateHelper + + def initialize(user:) + @user = user + end + + def current_sign_in_location_and_ip + I18n.t('account.index.sign_in_location_and_ip', location: current_location, ip: current_ip) + end + + def last_sign_in_location_and_ip + I18n.t('account.index.sign_in_location_and_ip', location: last_location, ip: last_ip) + end + + def current_timestamp + timestamp = user.current_sign_in_at || Time.zone.now + I18n.t( + 'account.index.sign_in_timestamp', + timestamp: time_ago_in_words( + timestamp, highest_measures: 2, two_words_connector: two_words_connector + ) + ) + end + + def last_timestamp + timestamp = user.last_sign_in_at || Time.zone.now + I18n.t( + 'account.index.sign_in_timestamp', + timestamp: time_ago_in_words( + timestamp, highest_measures: 2, two_words_connector: two_words_connector + ) + ) + end + + private + + attr_reader :user + + def current_location + IpGeocoder.new(current_ip).location + end + + def last_location + IpGeocoder.new(last_ip).location + end + + def current_ip + user.current_sign_in_ip + end + + def last_ip + user.last_sign_in_ip + end + + def two_words_connector + " #{I18n.t('datetime.dotiw.two_words_connector')} " + end +end diff --git a/app/services/ip_geocoder.rb b/app/services/ip_geocoder.rb new file mode 100644 index 00000000000..69fb112d6a4 --- /dev/null +++ b/app/services/ip_geocoder.rb @@ -0,0 +1,42 @@ +class IpGeocoder + def initialize(ip) + @ip = ip + end + + def location + geocoded_location&.language = I18n.locale + + return city_and_state if both_city_and_state_present? + return country if country.present? + + I18n.t('account.index.unknown_location') + end + + private + + attr_reader :ip + + def city_and_state + "#{city}, #{state}" + end + + def both_city_and_state_present? + city.present? && state.present? + end + + def city + geocoded_location&.city + end + + def state + geocoded_location&.state_code + end + + def country + geocoded_location&.country + end + + def geocoded_location + @geocoded_location ||= Geocoder.search(ip).first + end +end diff --git a/app/views/accounts/_account_item.html.slim b/app/views/accounts/_account_item.html.slim index f88880673e7..bfd4e4f635e 100644 --- a/app/views/accounts/_account_item.html.slim +++ b/app/views/accounts/_account_item.html.slim @@ -7,5 +7,5 @@ .col.col-4.right-align - if local_assigns.key? :path = render action, path: path, name: name - - else + - elsif local_assigns.key? :action = render action diff --git a/app/views/accounts/_current_sign_in.html.slim b/app/views/accounts/_current_sign_in.html.slim new file mode 100644 index 00000000000..5ec766431e8 --- /dev/null +++ b/app/views/accounts/_current_sign_in.html.slim @@ -0,0 +1,6 @@ +.p2.clearfix.border-top + .clearfix.mxn1 + .sm-col.sm-col-6.px1 + .bold = presenter.current_sign_in_location_and_ip + .sm-col.sm-col-6.px1.sm-right-align + = presenter.current_timestamp diff --git a/app/views/accounts/_last_sign_in.html.slim b/app/views/accounts/_last_sign_in.html.slim new file mode 100644 index 00000000000..357aee8ce99 --- /dev/null +++ b/app/views/accounts/_last_sign_in.html.slim @@ -0,0 +1,6 @@ +.p2.clearfix.border-top + .clearfix.mxn1 + .sm-col.sm-col-6.px1 + .bold = presenter.last_sign_in_location_and_ip + .sm-col.sm-col-6.px1.sm-right-align + = presenter.last_timestamp diff --git a/app/views/accounts/show.html.slim b/app/views/accounts/show.html.slim index 5dfde7d798e..5c542b0dd49 100644 --- a/app/views/accounts/show.html.slim +++ b/app/views/accounts/show.html.slim @@ -74,6 +74,13 @@ h1.hide = t('titles.account') - @view_model.recent_events.each do |event| = render event.event_partial, event: event +.mb3.profile-info-box + .bg-lightest-blue.pb1.pt1.px2.h6.caps.clearfix + = t('headings.account.login_history') + = image_tag asset_url('history.svg'), width: 12, class: 'ml1' + = render 'accounts/current_sign_in', presenter: @login_presenter + = render 'accounts/last_sign_in', presenter: @login_presenter + .mb3.profile-info-box .bg-lightest-blue.pb1.pt1.px2.h6.caps.clearfix = t('headings.account.account_management') diff --git a/bin/setup b/bin/setup index a02271c8925..fbeacf7e1c4 100755 --- a/bin/setup +++ b/bin/setup @@ -35,6 +35,11 @@ Dir.chdir APP_ROOT do run "test -L certs/saml.crt || cp certs/saml.crt.example certs/saml.crt" run "test -L certs/saml2018.crt || cp certs/saml2018.crt.example certs/saml2018.crt" + puts "== Copying GeoLite2 City database ==" + run "test -L geo_data/GeoLite2-City.mmdb || mkdir geo_data && cd geo_data && " \ + "curl http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz | tar xvz" + run "mv geo_data/GeoLite2-City_20181120/GeoLite2-City.mmdb geo_data/GeoLite2-City.mmdb" + if ARGV.shift == "--docker" then run 'docker-compose build' run 'docker-compose run --rm web yarn install' diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index e9eb1f52038..8e24811dab9 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -97,6 +97,7 @@ ignore_unused: - 'devise.mailer.confirmation_instructions.subject' - 'devise.mailer.reset_password_instructions.subject' - 'devise.sessions.signed_in' + - 'datetime.dotiw.two_words_connector' - 'service_providers.*' - 'two_factor_authentication.invalid_otp' - 'two_factor_authentication.invalid_personal_key' diff --git a/config/initializers/geocoder.rb b/config/initializers/geocoder.rb new file mode 100644 index 00000000000..4d09553bdbc --- /dev/null +++ b/config/initializers/geocoder.rb @@ -0,0 +1,6 @@ +Geocoder.configure( + ip_lookup: :geoip2, + geoip2: { + file: Rails.root.join('geo_data', 'GeoLite2-City.mmdb'), + } +) diff --git a/config/locales/account/en.yml b/config/locales/account/en.yml index da29c9b4249..16300822c91 100644 --- a/config/locales/account/en.yml +++ b/config/locales/account/en.yml @@ -21,7 +21,10 @@ en: reactivation: instructions: Your profile was recently deactivated due to a password reset. link: Reactivate your profile now. + sign_in_location_and_ip: 'From %{location} (IP address: %{ip})' + sign_in_timestamp: "%{timestamp} ago" ssn: Social Security Number + unknown_location: unknown location verification: instructions: Your account requires a secret code to be verified. reactivate_button: Enter the code you received via US mail diff --git a/config/locales/account/es.yml b/config/locales/account/es.yml index 3407a9bc55e..2fd852e29c8 100644 --- a/config/locales/account/es.yml +++ b/config/locales/account/es.yml @@ -21,7 +21,10 @@ es: reactivation: instructions: Su perfil ha sido desactivado debido a un cambio de contraseña. link: Reactive su perfil ahora. + sign_in_location_and_ip: 'Desde %{location} (Dirección IP: %{ip})' + sign_in_timestamp: Hace %{timestamp} ssn: Número de Seguro Social + unknown_location: ubicación desconocida verification: instructions: Su cuenta requiere que un código secreto sea verificado. reactivate_button: Ingrese el código que recibió por correo postal. diff --git a/config/locales/account/fr.yml b/config/locales/account/fr.yml index fff3e00bca9..e313b0e8714 100644 --- a/config/locales/account/fr.yml +++ b/config/locales/account/fr.yml @@ -23,7 +23,10 @@ fr: de mot passe. Vous pouvez utiliser votre clé personnelle pour réactiver votre profil. link: Réactivez votre profil maintenant. + sign_in_location_and_ip: "%{location} (Adresse IP: %{ip})" + sign_in_timestamp: Il y a %{timestamp} ssn: Numéro d'assurance sociale + unknown_location: lieu inconnu verification: instructions: Votre compte requiert la vérification d'un code secret. reactivate_button: Entrez le code que vous avez reçu par la poste diff --git a/config/locales/dotiw/en.yml b/config/locales/dotiw/en.yml new file mode 100644 index 00000000000..f5c277acf76 --- /dev/null +++ b/config/locales/dotiw/en.yml @@ -0,0 +1,5 @@ +--- +en: + datetime: + dotiw: + two_words_connector: and diff --git a/config/locales/dotiw/es.yml b/config/locales/dotiw/es.yml new file mode 100644 index 00000000000..0c407bd25c2 --- /dev/null +++ b/config/locales/dotiw/es.yml @@ -0,0 +1,5 @@ +--- +es: + datetime: + dotiw: + two_words_connector: y diff --git a/config/locales/dotiw/fr.yml b/config/locales/dotiw/fr.yml new file mode 100644 index 00000000000..d435f590725 --- /dev/null +++ b/config/locales/dotiw/fr.yml @@ -0,0 +1,5 @@ +--- +fr: + datetime: + dotiw: + two_words_connector: et diff --git a/config/locales/headings/en.yml b/config/locales/headings/en.yml index 9f733dfd293..62c26156c5b 100644 --- a/config/locales/headings/en.yml +++ b/config/locales/headings/en.yml @@ -5,6 +5,7 @@ en: account_history: Account history account_management: Account Management connected_apps: Applications + login_history: Login history login_info: Your account profile_info: Profile information reactivate: Reactivate your account diff --git a/config/locales/headings/es.yml b/config/locales/headings/es.yml index 92a0f466cfd..6b346b5b369 100644 --- a/config/locales/headings/es.yml +++ b/config/locales/headings/es.yml @@ -5,6 +5,7 @@ es: account_history: Historial de cuenta account_management: Manejo de cuenta connected_apps: Aplicaciones + login_history: Historial de sesión login_info: Su cuenta profile_info: Información de perfil reactivate: Reactive su cuenta diff --git a/config/locales/headings/fr.yml b/config/locales/headings/fr.yml index ad9ae7794ce..179ba2d77bc 100644 --- a/config/locales/headings/fr.yml +++ b/config/locales/headings/fr.yml @@ -5,6 +5,7 @@ fr: account_history: Historique du compte account_management: Gestion de compte connected_apps: Applications + login_history: Historique de connexion login_info: Votre compte profile_info: Information du profil reactivate: Réactivez votre compte diff --git a/lib/deploy/activate.rb b/lib/deploy/activate.rb index a7e710269b7..e4ecdcb1cc2 100644 --- a/lib/deploy/activate.rb +++ b/lib/deploy/activate.rb @@ -13,16 +13,49 @@ def initialize(logger: default_logger, s3_client: nil) end def run + download_application_yml_from_s3 + deep_merge_s3_data_with_example_application_yml + set_proper_file_permissions_for_application_yml + + download_geocoding_database_from_s3 + set_proper_file_permissions_for_geolocation_db + end + + private + + def download_application_yml_from_s3 LoginGov::Hostdata.s3(logger: logger, s3_client: s3_client).download_configs( '/%s/idp/v1/application.yml' => env_yaml_path ) + end + def deep_merge_s3_data_with_example_application_yml File.open(result_yaml_path, 'w') { |file| file.puts YAML.dump(application_config) } + end + def set_proper_file_permissions_for_application_yml FileUtils.chmod(0o640, [env_yaml_path, result_yaml_path]) end - private + def download_geocoding_database_from_s3 + ec2_region = ec2_data.region + + LoginGov::Hostdata::S3.new( + bucket: "login-gov.secrets.#{ec2_data.account_id}-#{ec2_region}", + env: nil, + region: ec2_region, + logger: logger, + s3_client: s3_client + ).download_configs('/common/GeoLite2-City.mmdb' => geolocation_db_path) + end + + def ec2_data + @ec2_data ||= LoginGov::Hostdata::EC2.load + end + + def set_proper_file_permissions_for_geolocation_db + FileUtils.chmod(0o640, geolocation_db_path) + end def default_logger logger = Logger.new(STDOUT) @@ -49,5 +82,9 @@ def example_application_yaml_path def result_yaml_path File.join(root, 'config/application.yml') end + + def geolocation_db_path + File.join(root, 'geo_data/GeoLite2-City.mmdb') + end end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index fecc5585a22..de5ca14acfe 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -76,4 +76,8 @@ example.run Capybara.use_default_driver end + + config.before(:each, type: :feature) do + allow_any_instance_of(Geocoder::Result::Test).to receive(:language=) + end end diff --git a/spec/support/geocoder_stubs.rb b/spec/support/geocoder_stubs.rb new file mode 100644 index 00000000000..4a5a58728dc --- /dev/null +++ b/spec/support/geocoder_stubs.rb @@ -0,0 +1,41 @@ +Geocoder.configure(ip_lookup: :test) + +Geocoder::Lookup::Test.add_stub( + '1.2.3.4', [ + { + 'city' => 'foo', + 'country' => 'United States', + 'state_code' => '', + }, + ] +) + +Geocoder::Lookup::Test.add_stub( + '159.142.31.80', [ + { + 'city' => 'Arlington', + 'country' => 'United States', + 'state_code' => 'VA', + }, + ] +) + +Geocoder::Lookup::Test.add_stub( + '4.3.2.1', [ + { + 'city' => '', + 'country' => '', + 'state_code' => '', + }, + ] +) + +Geocoder::Lookup::Test.add_stub( + '127.0.0.1', [ + { + 'city' => '', + 'country' => 'United States', + 'state_code' => '', + }, + ] +) diff --git a/spec/views/accounts/show.html.slim_spec.rb b/spec/views/accounts/show.html.slim_spec.rb index be4c5c10cde..d9b13a118fe 100644 --- a/spec/views/accounts/show.html.slim_spec.rb +++ b/spec/views/accounts/show.html.slim_spec.rb @@ -11,6 +11,7 @@ :view_model, AccountShow.new(decrypted_pii: nil, personal_key: nil, decorated_user: decorated_user) ) + assign(:login_presenter, LoginPresenter.new(user: user)) end context 'user is not TOTP enabled' do @@ -190,4 +191,55 @@ end end end + + describe 'sign in timestamps and IP addresses' do + before do + current_sign_in_at = Time.zone.now - 5.seconds + last_sign_in_at = Time.zone.now - 5.seconds + user = build( + :user, + :signed_up, + :with_email, + current_sign_in_at: current_sign_in_at, + last_sign_in_at: last_sign_in_at, + current_sign_in_ip: '1.2.3.4', + last_sign_in_ip: '159.142.31.80' + ) + allow(view).to receive(:current_user).and_return(user) + assign(:login_presenter, LoginPresenter.new(user: user)) + allow_any_instance_of(Geocoder::Result::Test).to receive(:language=) + end + + it 'uses distance of time in words for timestamp' do + render + + expect(rendered).to have_content 'seconds ago' + end + + it 'only shows the country if city and state not geocoded from IP address' do + render + + expect(rendered).to have_content 'From United States (IP address: 1.2.3.4)' + end + + it 'shows city and state when geocoded from IP address' do + render + + expect(rendered).to have_content 'From Arlington, VA (IP address: 159.142.31.80)' + end + + it 'shows unknown location when IP address cannot be geocoded' do + user = build( + :user, + :signed_up, + :with_email, + current_sign_in_ip: '4.3.2.1' + ) + assign(:login_presenter, LoginPresenter.new(user: user)) + + render + + expect(rendered).to have_content 'From unknown location (IP address: 4.3.2.1)' + end + end end