Commit 17d955b0 authored by Drew Blessing's avatar Drew Blessing Committed by Drew Blessing

Send email notification for unknown sign ins

When a user successfully signs in from a previously unknown
ip address, send an email notification.
This will allow GitLab to proactively alert users of potentially
malicious and unauthorized sign-ins due to brute force password
attacks or otherwise compromised user passwords.
parent 0eb7e145
# frozen_string_literal: true
module KnownSignIn
include Gitlab::Utils::StrongMemoize
private
def verify_known_sign_in
return unless current_user
notify_user unless known_remote_ip?
end
def known_remote_ip?
known_ip_addresses.include?(request.remote_ip)
end
def sessions
strong_memoize(:session) do
ActiveSession.list(current_user).reject(&:is_impersonated)
end
end
def known_ip_addresses
[current_user.last_sign_in_ip, sessions.map(&:ip_address)].flatten
end
def notify_user
current_user.notification_service.unknown_sign_in(current_user, request.remote_ip)
end
end
...@@ -6,6 +6,9 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController ...@@ -6,6 +6,9 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include Devise::Controllers::Rememberable include Devise::Controllers::Rememberable
include AuthHelper include AuthHelper
include InitializesCurrentUserMode include InitializesCurrentUserMode
include KnownSignIn
after_action :verify_known_sign_in
protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true
......
...@@ -7,6 +7,7 @@ class SessionsController < Devise::SessionsController ...@@ -7,6 +7,7 @@ class SessionsController < Devise::SessionsController
include Recaptcha::ClientHelper include Recaptcha::ClientHelper
include Recaptcha::Verify include Recaptcha::Verify
include RendersLdapServers include RendersLdapServers
include KnownSignIn
skip_before_action :check_two_factor_requirement, only: [:destroy] skip_before_action :check_two_factor_requirement, only: [:destroy]
# replaced with :require_no_authentication_without_flash # replaced with :require_no_authentication_without_flash
...@@ -27,6 +28,7 @@ class SessionsController < Devise::SessionsController ...@@ -27,6 +28,7 @@ class SessionsController < Devise::SessionsController
before_action :frontend_tracking_data, only: [:new] before_action :frontend_tracking_data, only: [:new]
after_action :log_failed_login, if: :action_new_and_failed_login? after_action :log_failed_login, if: :action_new_and_failed_login?
after_action :verify_known_sign_in, only: [:create]
helper_method :captcha_enabled?, :captcha_on_login_required? helper_method :captcha_enabled?, :captcha_on_login_required?
......
...@@ -44,6 +44,16 @@ module Emails ...@@ -44,6 +44,16 @@ module Emails
mail(to: @user.notification_email, subject: subject(_("Your Personal Access Tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire })) mail(to: @user.notification_email, subject: subject(_("Your Personal Access Tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire }))
end end
end end
def unknown_sign_in_email(user, ip)
@user = user
@ip = ip
@target_url = edit_profile_password_url
Gitlab::I18n.with_locale(@user.preferred_language) do
mail(to: @user.notification_email, subject: subject(_("Unknown sign-in from new location")))
end
end
end end
end end
......
...@@ -161,6 +161,10 @@ class NotifyPreview < ActionMailer::Preview ...@@ -161,6 +161,10 @@ class NotifyPreview < ActionMailer::Preview
Notify.remote_mirror_update_failed_email(remote_mirror.id, user.id).message Notify.remote_mirror_update_failed_email(remote_mirror.id, user.id).message
end end
def unknown_sign_in_email
Notify.unknown_sign_in_email(user, '127.0.0.1').message
end
private private
def project def project
......
...@@ -66,6 +66,14 @@ class NotificationService ...@@ -66,6 +66,14 @@ class NotificationService
mailer.access_token_about_to_expire_email(user).deliver_later mailer.access_token_about_to_expire_email(user).deliver_later
end end
# Notify a user when a previously unknown IP or device is used to
# sign in to their account
def unknown_sign_in(user, ip)
return unless user.can?(:receive_notifications)
mailer.unknown_sign_in_email(user, ip).deliver_later
end
# When create an issue we should send an email to: # When create an issue we should send an email to:
# #
# * issue assignee if their notification level is not Disabled # * issue assignee if their notification level is not Disabled
......
%p
= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
%p
= _('A sign-in to your account has been made from the following IP address: %{ip}.') % { ip: @ip }
%p
- password_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://docs.gitlab.com/ee/user/profile/#changing-your-password' }
= _('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 %{password_link_start}change your password%{password_link_end}.').html_safe % { password_link_start: password_link_start, password_link_end: '</a>'.html_safe }
= _('Passwords should be unique and not used for any other sites or services.')
- unless @user.two_factor_enabled?
%p
- mfa_link_start = '<a href="https://docs.gitlab.com/ee/user/profile/account/two_factor_authentication.html" target="_blank">'.html_safe
= _('To further protect your account, consider configuring a %{mfa_link_start}two-factor authentication%{mfa_link_end} method.').html_safe % { mfa_link_start: mfa_link_start, mfa_link_end: '</a>'.html_safe }
= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
= _('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: 'https://docs.gitlab.com/ee/user/profile/#changing-your-password' }
= _('Passwords should be unique and not used for any other sites or services.')
- unless @user.two_factor_enabled?
= _('To further protect your account, consider configuring a two-factor authentication method: %{mfa_link}.') % { mfa_link: 'https://docs.gitlab.com/ee/user/profile/account/two_factor_authentication.html' }
---
title: Send email notification for unknown sign-ins
merge_request: 29741
author:
type: added
...@@ -17,6 +17,11 @@ There are several ways to create users on GitLab. See the [creating users docume ...@@ -17,6 +17,11 @@ There are several ways to create users on GitLab. See the [creating users docume
There are several ways to sign into your GitLab account. There are several ways to sign into your GitLab account.
See the [authentication topic](../../topics/authentication/index.md) for more details. See the [authentication topic](../../topics/authentication/index.md) for more details.
### Unknown sign-in
GitLab will notify you if a sign-in occurs that is from an unknown IP address.
See [Unknown Sign-In Notification](unknown_sign_in_notification.md) for more details.
## User profile ## User profile
To access your profile: To access your profile:
...@@ -44,6 +49,7 @@ To access your profile settings: ...@@ -44,6 +49,7 @@ To access your profile settings:
From there, you can: From there, you can:
- Update your personal information - Update your personal information
- Change your [password](#changing-your-password)
- Set a [custom status](#current-status) for your profile - Set a [custom status](#current-status) for your profile
- Manage your [commit email](#commit-email) for your profile - Manage your [commit email](#commit-email) for your profile
- Manage [2FA](account/two_factor_authentication.md) - Manage [2FA](account/two_factor_authentication.md)
...@@ -60,6 +66,18 @@ From there, you can: ...@@ -60,6 +66,18 @@ From there, you can:
- [View your active sessions](active_sessions.md) and revoke any of them if necessary - [View your active sessions](active_sessions.md) and revoke any of them if necessary
- Access your audit log, a security log of important events involving your account - Access your audit log, a security log of important events involving your account
## Changing your password
1. Navigate to your [profile's](#profile-settings) **Settings > Password**.
1. Enter your current password in the 'Current password' field.
1. Enter your desired new password twice, once in the 'New password' field and
once in the 'Password confirmation' field.
1. Click the 'Save password' button.
If you don't know your current password, select the 'I forgot my password' link.
![Change your password](./img/change_password_v13_0.png)
## Changing your username ## Changing your username
Your `username` is a unique [`namespace`](../group/index.md#namespaces) Your `username` is a unique [`namespace`](../group/index.md#namespaces)
......
# Email notification for unknown sign-ins
When a user successfully signs in from a previously unknown IP address,
GitLab notifies the user by email. In this way, GitLab proactively alerts users of potentially
malicious or unauthorized sign-ins.
There are two methods used to identify a known sign-in:
- Last sign-in IP: The current sign-in IP address is checked against the last sign-in
IP address.
- Current active sessions: If the user has an existing active session from the
same IP address. See [Active Sessions](active_sessions.md).
## Example email
![Unknown sign in email](./img/unknown_sign_in_email_v13_0.png)
...@@ -916,6 +916,12 @@ msgstr "" ...@@ -916,6 +916,12 @@ msgstr ""
msgid "A secure token that identifies an external storage request." msgid "A secure token that identifies an external storage request."
msgstr "" 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}."
msgstr ""
msgid "A subscription will trigger a new pipeline on the default branch of this project when a pipeline successfully completes for a new tag on the %{default_branch_docs} of the subscribed project." msgid "A subscription will trigger a new pipeline on the default branch of this project when a pipeline successfully completes for a new tag on the %{default_branch_docs} of the subscribed project."
msgstr "" msgstr ""
...@@ -11151,12 +11157,21 @@ msgstr "" ...@@ -11151,12 +11157,21 @@ msgstr ""
msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}" msgid "If using GitHub, you’ll see pipeline statuses on GitHub for your commits and pull requests. %{more_info_link}"
msgstr "" msgstr ""
msgid "If you did not recently sign in, you should immediately %{password_link_start}change your password%{password_link_end}."
msgstr ""
msgid "If you did not recently sign in, you should immediately change your password: %{password_link}."
msgstr ""
msgid "If you lose your recovery codes you can generate new ones, invalidating all previous codes." msgid "If you lose your recovery codes you can generate new ones, invalidating all previous codes."
msgstr "" msgstr ""
msgid "If you reach 100%% storage capacity, you will not be able to: %{base_message}" msgid "If you reach 100%% storage capacity, you will not be able to: %{base_message}"
msgstr "" msgstr ""
msgid "If you recently signed in and recognize the IP address, you may disregard this email."
msgstr ""
msgid "If your HTTP repository is not publicly accessible, add your credentials." msgid "If your HTTP repository is not publicly accessible, add your credentials."
msgstr "" msgstr ""
...@@ -14884,6 +14899,9 @@ msgstr "" ...@@ -14884,6 +14899,9 @@ msgstr ""
msgid "Password was successfully updated. Please login with it" msgid "Password was successfully updated. Please login with it"
msgstr "" msgstr ""
msgid "Passwords should be unique and not used for any other sites or services."
msgstr ""
msgid "Past due" msgid "Past due"
msgstr "" msgstr ""
...@@ -22083,6 +22101,12 @@ msgstr "" ...@@ -22083,6 +22101,12 @@ msgstr ""
msgid "To enable it and see User Cohorts, visit %{application_settings_link_start}application settings%{application_settings_link_end}." msgid "To enable it and see User Cohorts, visit %{application_settings_link_start}application settings%{application_settings_link_end}."
msgstr "" msgstr ""
msgid "To further protect your account, consider configuring a %{mfa_link_start}two-factor authentication%{mfa_link_end} method."
msgstr ""
msgid "To further protect your account, consider configuring a two-factor authentication method: %{mfa_link}."
msgstr ""
msgid "To get started you enter your FogBugz URL and login information below. In the next steps, you'll be able to map users and select the projects you want to import." msgid "To get started you enter your FogBugz URL and login information below. In the next steps, you'll be able to map users and select the projects you want to import."
msgstr "" msgstr ""
...@@ -22605,6 +22629,9 @@ msgstr "" ...@@ -22605,6 +22629,9 @@ msgstr ""
msgid "Unknown response text" msgid "Unknown response text"
msgstr "" msgstr ""
msgid "Unknown sign-in from new location"
msgstr ""
msgid "Unlimited" msgid "Unlimited"
msgstr "" msgstr ""
......
...@@ -144,6 +144,10 @@ describe OmniauthCallbacksController, type: :controller, do_not_mock_admin_mode: ...@@ -144,6 +144,10 @@ describe OmniauthCallbacksController, type: :controller, do_not_mock_admin_mode:
let(:extern_uid) { 'my-uid' } let(:extern_uid) { 'my-uid' }
let(:provider) { :github } let(:provider) { :github }
it_behaves_like 'known sign in' do
let(:post_action) { post provider }
end
it 'allows sign in' do it 'allows sign in' do
post provider post provider
...@@ -287,6 +291,11 @@ describe OmniauthCallbacksController, type: :controller, do_not_mock_admin_mode: ...@@ -287,6 +291,11 @@ describe OmniauthCallbacksController, type: :controller, do_not_mock_admin_mode:
request.env['omniauth.auth'] = Rails.application.env_config['omniauth.auth'] request.env['omniauth.auth'] = Rails.application.env_config['omniauth.auth']
end end
it_behaves_like 'known sign in' do
let(:user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'saml') }
let(:post_action) { post :saml, params: { SAMLResponse: mock_saml_response } }
end
context 'sign up' do context 'sign up' do
before do before do
user.destroy user.destroy
......
...@@ -99,6 +99,11 @@ describe SessionsController do ...@@ -99,6 +99,11 @@ describe SessionsController do
set_devise_mapping(context: @request) set_devise_mapping(context: @request)
end end
it_behaves_like 'known sign in' do
let(:user) { create(:user) }
let(:post_action) { post(:create, params: { user: { login: user.username, password: user.password } }) }
end
context 'when using standard authentications' do context 'when using standard authentications' do
context 'invalid password' do context 'invalid password' do
it 'does not authenticate user' do it 'does not authenticate user' do
......
...@@ -156,4 +156,44 @@ describe Emails::Profile do ...@@ -156,4 +156,44 @@ describe Emails::Profile do
it { expect { Notify.access_token_about_to_expire_email('foo') }.not_to raise_error } it { expect { Notify.access_token_about_to_expire_email('foo') }.not_to raise_error }
end end
end end
describe 'user unknown sign in email' do
let_it_be(:user) { create(:user) }
let_it_be(:ip) { '169.0.0.1' }
subject { Notify.unknown_sign_in_email(user, ip) }
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
it_behaves_like 'a user cannot unsubscribe through footer link'
it 'is sent to the user' do
expect(subject).to deliver_to user.email
end
it 'has the correct subject' do
expect(subject).to have_subject /^Unknown sign-in from new location$/
end
it 'mentions the unknown sign-in IP' do
expect(subject).to have_body_text /A sign-in to your account has been made from the following IP address: #{ip}./
end
it 'includes a link to the change password page' do
expect(subject).to have_body_text /#{edit_profile_password_path}/
end
it 'mentions two factor authentication when two factor is not enabled' do
expect(subject).to have_body_text /two-factor authentication/
end
context 'when two factor authentication is enabled' do
it 'does not mention two factor authentication' do
two_factor_user = create(:user, :two_factor)
expect( Notify.unknown_sign_in_email(two_factor_user, ip) )
.not_to have_body_text /two-factor authentication/
end
end
end
end end
...@@ -240,6 +240,17 @@ describe NotificationService, :mailer do ...@@ -240,6 +240,17 @@ describe NotificationService, :mailer do
end end
end end
describe '#unknown_sign_in' do
let_it_be(:user) { create(:user) }
let_it_be(:ip) { '127.0.0.1' }
subject { notification.unknown_sign_in(user, ip) }
it 'sends email to the user' do
expect { subject }.to have_enqueued_email(user, ip, mail: 'unknown_sign_in_email')
end
end
describe 'Notes' do describe 'Notes' do
context 'issue note' do context 'issue note' do
let(:project) { create(:project, :private) } let(:project) { create(:project, :private) }
......
# frozen_string_literal: true
RSpec.shared_examples 'known sign in' do
def stub_remote_ip(ip)
request.remote_ip = ip
end
def stub_user_ip(ip)
user.update!(current_sign_in_ip: ip)
end
context 'with a valid post' do
context 'when remote IP does not match user last sign in IP' do
before do
stub_user_ip('127.0.0.1')
stub_remote_ip('169.0.0.1')
end
it 'notifies the user' do
expect_next_instance_of(NotificationService) do |instance|
expect(instance).to receive(:unknown_sign_in)
end
post_action
end
end
context 'when remote IP matches an active session' do
before do
existing_sessions = ActiveSession.session_ids_for_user(user.id)
existing_sessions.each { |sessions| ActiveSession.destroy(user, sessions) }
stub_user_ip('169.0.0.1')
stub_remote_ip('127.0.0.1')
ActiveSession.set(user, request)
end
it 'does not notify the user' do
expect_any_instance_of(NotificationService).not_to receive(:unknown_sign_in)
post_action
end
end
context 'when remote IP address matches last sign in IP' do
before do
stub_user_ip('127.0.0.1')
stub_remote_ip('127.0.0.1')
end
it 'does not notify the user' do
expect_any_instance_of(NotificationService).not_to receive(:unknown_sign_in)
post_action
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment