diff --git a/app/controllers/concerns/verifies_with_email.rb b/app/controllers/concerns/verifies_with_email.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0dcc95e30e73dbcfc1e99df6f9a14a20fcec690c
--- /dev/null
+++ b/app/controllers/concerns/verifies_with_email.rb
@@ -0,0 +1,194 @@
+# frozen_string_literal: true
+
+# == VerifiesWithEmail
+#
+# Controller concern to handle verification by email
+module VerifiesWithEmail
+ extend ActiveSupport::Concern
+ include ActionView::Helpers::DateHelper
+
+ TOKEN_LENGTH = 6
+ TOKEN_VALID_FOR_MINUTES = 60
+
+ included do
+ prepend_before_action :verify_with_email, only: :create, unless: -> { two_factor_enabled? }
+ end
+
+ def verify_with_email
+ return unless user = find_user || find_verification_user
+
+ if session[:verification_user_id] && token = verification_params[:verification_token].presence
+ # The verification token is submitted, verify it
+ verify_token(user, token)
+ elsif require_email_verification_enabled?
+ # Limit the amount of password guesses, since we now display the email verification page
+ # when the password is correct, which could be a giveaway when brute-forced.
+ return render_sign_in_rate_limited if check_rate_limit!(:user_sign_in, scope: user) { true }
+
+ if user.valid_password?(user_params[:password])
+ # The user has logged in successfully.
+ if user.unlock_token
+ # Prompt for the token if it already has been set
+ prompt_for_email_verification(user)
+ elsif user.access_locked? || !AuthenticationEvent.initial_login_or_known_ip_address?(user, request.ip)
+ # require email verification if:
+ # - their account has been locked because of too many failed login attempts, or
+ # - they have logged in before, but never from the current ip address
+ send_verification_instructions(user)
+ prompt_for_email_verification(user)
+ end
+ end
+ end
+ end
+
+ def resend_verification_code
+ return unless user = find_verification_user
+
+ send_verification_instructions(user)
+ prompt_for_email_verification(user)
+ end
+
+ def successful_verification
+ session.delete(:verification_user_id)
+ @redirect_url = after_sign_in_path_for(current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+
+ render layout: 'minimal'
+ end
+
+ private
+
+ def find_verification_user
+ return unless session[:verification_user_id]
+
+ User.find_by_id(session[:verification_user_id])
+ end
+
+ # After successful verification and calling sign_in, devise redirects the
+ # user to this path. Override it to show the successful verified page.
+ def after_sign_in_path_for(resource)
+ if action_name == 'create' && session[:verification_user_id]
+ return users_successful_verification_path
+ end
+
+ super
+ end
+
+ def send_verification_instructions(user)
+ return if send_rate_limited?(user)
+
+ raw_token, encrypted_token = generate_token
+ user.unlock_token = encrypted_token
+ user.lock_access!({ send_instructions: false })
+ send_verification_instructions_email(user, raw_token)
+ end
+
+ def send_verification_instructions_email(user, token)
+ return unless user.can?(:receive_notifications)
+
+ Notify.verification_instructions_email(
+ user.id,
+ token: token,
+ expires_in: TOKEN_VALID_FOR_MINUTES).deliver_later
+
+ log_verification(user, :instructions_sent)
+ end
+
+ def verify_token(user, token)
+ return handle_verification_failure(user, :rate_limited) if verification_rate_limited?(user)
+ return handle_verification_failure(user, :invalid) unless valid_token?(user, token)
+ return handle_verification_failure(user, :expired) if expired_token?(user)
+
+ handle_verification_success(user)
+ end
+
+ def generate_token
+ raw_token = SecureRandom.random_number(10**TOKEN_LENGTH).to_s.rjust(TOKEN_LENGTH, '0')
+ encrypted_token = digest_token(raw_token)
+ [raw_token, encrypted_token]
+ end
+
+ def digest_token(token)
+ Devise.token_generator.digest(User, :unlock_token, token)
+ end
+
+ def render_sign_in_rate_limited
+ message = s_('IdentityVerification|Maximum login attempts exceeded. '\
+ 'Wait %{interval} and try again.') % { interval: user_sign_in_interval }
+ redirect_to new_user_session_path, alert: message
+ end
+
+ def user_sign_in_interval
+ interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[:user_sign_in][:interval]
+ distance_of_time_in_words(interval_in_seconds)
+ end
+
+ def verification_rate_limited?(user)
+ Gitlab::ApplicationRateLimiter.throttled?(:email_verification, scope: user.unlock_token)
+ end
+
+ def send_rate_limited?(user)
+ Gitlab::ApplicationRateLimiter.throttled?(:email_verification_code_send, scope: user)
+ end
+
+ def expired_token?(user)
+ user.locked_at < (Time.current - TOKEN_VALID_FOR_MINUTES.minutes)
+ end
+
+ def valid_token?(user, token)
+ user.unlock_token == digest_token(token)
+ end
+
+ def handle_verification_failure(user, reason)
+ message = case reason
+ when :rate_limited
+ s_("IdentityVerification|You've reached the maximum amount of tries. "\
+ 'Wait %{interval} or resend a new code and try again.') % { interval: email_verification_interval }
+ when :expired
+ s_('IdentityVerification|The code has expired. Resend a new code and try again.')
+ when :invalid
+ s_('IdentityVerification|The code is incorrect. Enter it again, or resend a new code.')
+ end
+
+ user.errors.add(:base, message)
+ log_verification(user, :failed_attempt, reason)
+
+ prompt_for_email_verification(user)
+ end
+
+ def email_verification_interval
+ interval_in_seconds = Gitlab::ApplicationRateLimiter.rate_limits[:email_verification][:interval]
+ distance_of_time_in_words(interval_in_seconds)
+ end
+
+ def handle_verification_success(user)
+ user.unlock_access!
+ log_verification(user, :successful)
+
+ sign_in(user)
+ end
+
+ def prompt_for_email_verification(user)
+ session[:verification_user_id] = user.id
+ self.resource = user
+
+ render 'devise/sessions/email_verification'
+ end
+
+ def verification_params
+ params.require(:user).permit(:verification_token)
+ end
+
+ def log_verification(user, event, reason = nil)
+ Gitlab::AppLogger.info(
+ message: 'Email Verification',
+ event: event.to_s.titlecase,
+ username: user.username,
+ ip: request.ip,
+ reason: reason.to_s
+ )
+ end
+
+ def require_email_verification_enabled?
+ Feature.enabled?(:require_email_verification)
+ end
+end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 9000e9c39dec2c0a781f3386c4764cfbd3ae0e68..fc51ee47bd2a3e737426606cc32ccaf737278732 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -11,6 +11,7 @@ class SessionsController < Devise::SessionsController
include Gitlab::Utils::StrongMemoize
include OneTrustCSP
include BizibleCSP
+ include VerifiesWithEmail
skip_before_action :check_two_factor_requirement, only: [:destroy]
skip_before_action :check_password_expiration, only: [:destroy]
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index c23d905a0089d018d9248597f180f57d27b0834d..dc4e2e9709ae1c5574e3006c9a1410d065170523 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -276,6 +276,14 @@ def instance_access_request_link(user, format: nil)
end
end
+ def link_start(url)
+ ''.html_safe % { url: url }
+ end
+
+ def link_end
+ ''.html_safe
+ end
+
def contact_your_administrator_text
_('Please contact your administrator with any questions.')
end
diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb
index f0389000eb392df056b7e8863c50123bd0d82771..129180d1ccfc54e0a959c4afa2974dbde439cb96 100644
--- a/app/helpers/sessions_helper.rb
+++ b/app/helpers/sessions_helper.rb
@@ -39,4 +39,16 @@ def set_session_time(expiry_s)
# 2. https://github.com/redis-store/redis-store/blob/3acfa95f4eb6260c714fdb00a3d84be8eedc13b2/lib/redis/store/ttl.rb#L32
request.env['rack.session.options'][:expire_after] = expiry_s
end
+
+ def send_rate_limited?(user)
+ Gitlab::ApplicationRateLimiter.peek(:email_verification_code_send, scope: user)
+ end
+
+ def obfuscated_email(email)
+ regex = ::Gitlab::UntrustedRegexp.new('^(..?)(.*)(@.?)(.*)(\..*)$')
+ match = regex.match(email)
+ return email unless match
+
+ match[1] + '*' * match[2].length + match[3] + '*' * match[4].length + match[5]
+ end
end
diff --git a/app/mailers/emails/identity_verification.rb b/app/mailers/emails/identity_verification.rb
new file mode 100644
index 0000000000000000000000000000000000000000..2fc8cae06fe484d2baeeb59fd03c58e6ad1cde55
--- /dev/null
+++ b/app/mailers/emails/identity_verification.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Emails
+ module IdentityVerification
+ def verification_instructions_email(user_id, token:, expires_in:)
+ @token = token
+ @expires_in_minutes = expires_in
+ @password_link = edit_profile_password_url
+ @two_fa_link = help_page_url('user/profile/account/two_factor_authentication')
+
+ user = User.find(user_id)
+ email_with_layout(to: user.email, subject: s_('IdentityVerification|Verify your identity'))
+ end
+ end
+end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index b70ce1d365502f6f3958f758b1e79798e956e84f..ed7681e595fd2f6921e33677e0699206271524a3 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -23,6 +23,7 @@ class Notify < ApplicationMailer
include Emails::ServiceDesk
include Emails::InProductMarketing
include Emails::AdminNotification
+ include Emails::IdentityVerification
helper TimeboxesHelper
helper MergeRequestsHelper
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index 074aec54b1057cff3f7d36af892fe2604caf339e..be8d96012cc54cf53b395070952f5cef3f74863d 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -213,6 +213,10 @@ def user_auto_banned_namespace_email
::Notify.user_auto_banned_email(user.id, user.id, max_project_downloads: 5, within_seconds: 600, group: group).message
end
+ def verification_instructions_email
+ Notify.verification_instructions_email(user.id, token: '123456', expires_in: 60).message
+ end
+
private
def project
diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb
index 1e822629ba150a140d2ca988761ca7e675992ae3..0ed197f32dff3c93c25726ea18c84c9b3a760c35 100644
--- a/app/models/authentication_event.rb
+++ b/app/models/authentication_event.rb
@@ -25,4 +25,9 @@ class AuthenticationEvent < ApplicationRecord
def self.providers
STATIC_PROVIDERS | Devise.omniauth_providers.map(&:to_s)
end
+
+ def self.initial_login_or_known_ip_address?(user, ip_address)
+ !where(user_id: user).exists? ||
+ where(user_id: user, ip_address: ip_address).success.exists?
+ end
end
diff --git a/app/models/concerns/require_email_verification.rb b/app/models/concerns/require_email_verification.rb
new file mode 100644
index 0000000000000000000000000000000000000000..eb00a9647a347a365fd2cf7374f89e0cc28fcaa9
--- /dev/null
+++ b/app/models/concerns/require_email_verification.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+# == Require Email Verification module
+#
+# Contains functionality to handle email verification
+module RequireEmailVerification
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ # This value is twice the amount we want it to be, because due to a bug in the devise-two-factor
+ # gem every failed login attempt increments the value of failed_attempts by 2 instead of 1.
+ # See: https://github.com/tinfoil/devise-two-factor/issues/127
+ MAXIMUM_ATTEMPTS = 3 * 2
+ UNLOCK_IN = 24.hours
+
+ included do
+ # Virtual attribute for the email verification token form
+ attr_accessor :verification_token
+ end
+
+ # When overridden, do not send Devise unlock instructions when locking access.
+ def lock_access!(opts = {})
+ return super unless override_devise_lockable?
+
+ super({ send_instructions: false })
+ end
+
+ protected
+
+ # We cannot override the class methods `maximum_attempts` and `unlock_in`, because we want to
+ # check for 2FA being enabled on the instance. So instead override the Devise Lockable methods
+ # where those values are used.
+ def attempts_exceeded?
+ return super unless override_devise_lockable?
+
+ failed_attempts >= MAXIMUM_ATTEMPTS
+ end
+
+ def lock_expired?
+ return super unless override_devise_lockable?
+
+ locked_at && locked_at < UNLOCK_IN.ago
+ end
+
+ private
+
+ def override_devise_lockable?
+ strong_memoize(:override_devise_lockable) do
+ Feature.enabled?(:require_email_verification) && !two_factor_enabled?
+ end
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index b35036d0af7cc922582171fca9a240c7b0a134e5..20ff796aba07bf9c3cc5c9ebcf23cae95667a952 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -88,6 +88,7 @@ class User < ApplicationRecord
# and should be added after Devise modules are initialized.
include AsyncDeviseEmail
include ForcedEmailConfirmation
+ include RequireEmailVerification
MINIMUM_INACTIVE_DAYS = 90
MINIMUM_DAYS_CREATED = 7
@@ -1899,7 +1900,7 @@ def read_only_attribute?(attribute)
end
# override, from Devise
- def lock_access!
+ def lock_access!(opts = {})
Gitlab::AppLogger.info("Account Locked: username=#{username}")
super
end
diff --git a/app/views/devise/sessions/email_verification.haml b/app/views/devise/sessions/email_verification.haml
new file mode 100644
index 0000000000000000000000000000000000000000..6cafcb941b4970f782d34b853a8a1e4ca5ff1f63
--- /dev/null
+++ b/app/views/devise/sessions/email_verification.haml
@@ -0,0 +1,19 @@
+%div
+ = render 'devise/shared/tab_single', tab_title: s_('IdentityVerification|Help us protect your account')
+ .login-box.gl-p-5
+ .login-body
+ = form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: 'gl-show-field-errors' }) do |f|
+ %p
+ = s_("IdentityVerification|For added security, you'll need to verify your identity. We've sent a verification code to %{email}").html_safe % { email: "#{sanitize(obfuscated_email(resource.email))}".html_safe }
+ %div
+ = f.label :verification_token, s_('IdentityVerification|Verification code')
+ = f.text_field :verification_token, class: 'form-control gl-form-input', required: true, autofocus: true, autocomplete: 'off', title: s_('IdentityVerification|Please enter a valid code'), inputmode: 'numeric', maxlength: 6, pattern: '[0-9]{6}'
+ %p.gl-field-error.gl-mt-2
+ = resource.errors.full_messages.to_sentence
+ .gl-mt-5
+ = f.submit s_('IdentityVerification|Verify code'), class: 'gl-button btn btn-confirm'
+ - unless send_rate_limited?(resource)
+ = link_to s_('IdentityVerification|Resend code'), users_resend_verification_code_path, method: :post, class: 'form-control gl-button btn-link gl-mt-3 gl-mb-0'
+ %p.gl-p-5.gl-text-secondary
+ - support_link_start = ''.html_safe
+ = s_("IdentityVerification|If you've lost access to the email associated to this account or having trouble with the code, %{link_start}here are some other steps you can take.%{link_end}").html_safe % { link_start: support_link_start, link_end: ''.html_safe }
diff --git a/app/views/devise/sessions/successful_verification.haml b/app/views/devise/sessions/successful_verification.haml
new file mode 100644
index 0000000000000000000000000000000000000000..8af80fbdceb5ff091dfe21f0263952b4576c7372
--- /dev/null
+++ b/app/views/devise/sessions/successful_verification.haml
@@ -0,0 +1,11 @@
+= content_for :meta_tags do
+ %meta{ 'http-equiv': 'refresh', content: "3; url=#{@redirect_url}" }
+.gl-text-center.gl-max-w-62.gl-mx-auto
+ .svg-content.svg-80
+ = image_tag 'illustrations/success-sm.svg'
+ %h2
+ = s_('IdentityVerification|Verification successful')
+ %p.gl-pt-2
+ - redirect_url_start = ''.html_safe % { url: @redirect_url }
+ - redirect_url_end = ''.html_safe
+ = html_escape(s_("IdentityVerification|Your account has been successfully verified. You'll be redirected to your account in just a moment or %{redirect_url_start}click here%{redirect_url_end} to refresh.")) % { redirect_url_start: redirect_url_start, redirect_url_end: redirect_url_end }
diff --git a/app/views/notify/verification_instructions_email.html.haml b/app/views/notify/verification_instructions_email.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..63d8d1b2461e03f42865cae8eed761973617d518
--- /dev/null
+++ b/app/views/notify/verification_instructions_email.html.haml
@@ -0,0 +1,12 @@
+%div{ style: 'text-align:center;color:#1F1F1F;line-height:1.25em;max-width:400px;margin:0 auto;' }
+ %h3
+ = s_('IdentityVerification|Help us protect your account')
+ %p{ style: 'font-size:0.9em' }
+ = s_('IdentityVerification|Before you sign in, we need to verify your identity. Enter the following code on the sign-in page.')
+ %div{ style: 'margin:26px 0;width:207px;height:53px;background-color:#F0F0F0;line-height:53px;font-weight:700;font-size:1.5em;color:#303030;' }
+ = @token
+ %p{ style: 'font-size:0.75em' }
+ = s_('IdentityVerification|If you have not recently tried to sign into GitLab, we recommend %{password_link_start}changing your password%{link_end} and %{two_fa_link_start}setting up Two-Factor Authentication%{link_end} to keep your account safe. Your verification code expires after %{expires_in_minutes} minutes.').html_safe % { link_end: link_end,
+ password_link_start: link_start(@password_link),
+ two_fa_link_start: link_start(@two_fa_link),
+ expires_in_minutes: @expires_in_minutes }
diff --git a/app/views/notify/verification_instructions_email.text.erb b/app/views/notify/verification_instructions_email.text.erb
new file mode 100644
index 0000000000000000000000000000000000000000..df507b5db719d91cad576784a550ef7881c2a380
--- /dev/null
+++ b/app/views/notify/verification_instructions_email.text.erb
@@ -0,0 +1,8 @@
+<%= s_('IdentityVerification|Help us protect your account') %>
+
+<%= s_('IdentityVerification|Before you sign in, we need to verify your identity. Enter the following code on the sign-in page.') %>
+
+<%= @token %>
+
+<%= s_('IdentityVerification|If you have not recently tried to sign into GitLab, we recommend changing your password (%{password_link}) and setting up Two-Factor Authentication (%{two_fa_link}) to keep your account safe.') % { password_link: @password_link, two_fa_link: @two_fa_link } %>
+<%= s_('IdentityVerification|Your verification code expires after %{expires_in_minutes} minutes.') % { expires_in_minutes: @expires_in_minutes } %>
diff --git a/config/feature_flags/development/require_email_verification.yml b/config/feature_flags/development/require_email_verification.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e6cb78ffcf7a3903e0df7e70c16f80a11ab8ec98
--- /dev/null
+++ b/config/feature_flags/development/require_email_verification.yml
@@ -0,0 +1,8 @@
+---
+name: require_email_verification
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/86352
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364835
+milestone: "15.2"
+type: development
+group: group::anti-abuse
+default_enabled: false
diff --git a/config/routes/user.rb b/config/routes/user.rb
index ccacf817cc5f53b6afb4c3f33072d19ad58c094b..a5fc53b61d2c564daa862ce5e1c75be0d3c85681 100644
--- a/config/routes/user.rb
+++ b/config/routes/user.rb
@@ -53,6 +53,8 @@ def override_omniauth(provider, controller, path_prefix = '/users/auth')
devise_scope :user do
get '/users/almost_there' => 'confirmations#almost_there'
+ post '/users/resend_verification_code', to: 'sessions#resend_verification_code'
+ get '/users/successful_verification', to: 'sessions#successful_verification'
end
scope '-/users', module: :users do
diff --git a/db/post_migrate/20220601152916_add_user_id_and_ip_address_success_index_to_authentication_events.rb b/db/post_migrate/20220601152916_add_user_id_and_ip_address_success_index_to_authentication_events.rb
new file mode 100644
index 0000000000000000000000000000000000000000..aa860959c20795ed610928f2e00eff9f76ef7f5b
--- /dev/null
+++ b/db/post_migrate/20220601152916_add_user_id_and_ip_address_success_index_to_authentication_events.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class AddUserIdAndIpAddressSuccessIndexToAuthenticationEvents < Gitlab::Database::Migration[2.0]
+ OLD_INDEX_NAME = 'index_authentication_events_on_user_id'
+ NEW_INDEX_NAME = 'index_authentication_events_on_user_and_ip_address_and_result'
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :authentication_events, [:user_id, :ip_address, :result], name: NEW_INDEX_NAME
+ remove_concurrent_index_by_name :authentication_events, OLD_INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :authentication_events, :user_id, name: OLD_INDEX_NAME
+ remove_concurrent_index_by_name :authentication_events, NEW_INDEX_NAME
+ end
+end
diff --git a/db/schema_migrations/20220601152916 b/db/schema_migrations/20220601152916
new file mode 100644
index 0000000000000000000000000000000000000000..4858976aa3a66098e05525096ccfad61bc9c9cbe
--- /dev/null
+++ b/db/schema_migrations/20220601152916
@@ -0,0 +1 @@
+f460407888e289580dec15ea27e19fa5cc2d2116a831105b71b980c617971743
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index e511bd8861824840d7d4f3ac367a31b561b1a656..21880fea0af471eafd417fdee494089c6f368593 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -27282,7 +27282,7 @@ CREATE INDEX index_authentication_events_on_provider ON authentication_events US
CREATE INDEX index_authentication_events_on_provider_user_id_created_at ON authentication_events USING btree (provider, user_id, created_at) WHERE (result = 1);
-CREATE INDEX index_authentication_events_on_user_id ON authentication_events USING btree (user_id);
+CREATE INDEX index_authentication_events_on_user_and_ip_address_and_result ON authentication_events USING btree (user_id, ip_address, result);
CREATE INDEX index_award_emoji_on_awardable_type_and_awardable_id ON award_emoji USING btree (awardable_type, awardable_id);
diff --git a/ee/app/assets/javascripts/pages/sessions/new/index.js b/ee/app/assets/javascripts/pages/sessions/new/index.js
index c3dfc113cc6eaf22dae59ce0478cd2c8db4119ed..744341910141ec501813511f320a4eb81550d3ed 100644
--- a/ee/app/assets/javascripts/pages/sessions/new/index.js
+++ b/ee/app/assets/javascripts/pages/sessions/new/index.js
@@ -1,6 +1,6 @@
import '~/pages/sessions/new/index';
-if (gon.features.arkoseLabsLoginChallenge) {
+if (gon.features?.arkoseLabsLoginChallenge) {
import('ee/arkose_labs')
.then(({ setupArkoseLabs }) => {
setupArkoseLabs();
diff --git a/lib/gitlab/application_rate_limiter.rb b/lib/gitlab/application_rate_limiter.rb
index fd54375bf51363cae596346b134594af30af2e77..0c52ce8aba48dfff7c37f186c9793b99ee1b8e26 100644
--- a/lib/gitlab/application_rate_limiter.rb
+++ b/lib/gitlab/application_rate_limiter.rb
@@ -37,6 +37,7 @@ def rate_limits # rubocop:disable Metrics/AbcSize
users_get_by_id: { threshold: -> { application_settings.users_get_by_id_limit }, interval: 10.minutes },
username_exists: { threshold: 20, interval: 1.minute },
user_sign_up: { threshold: 20, interval: 1.minute },
+ user_sign_in: { threshold: 5, interval: 10.minutes },
profile_resend_email_confirmation: { threshold: 5, interval: 1.minute },
profile_update_username: { threshold: 10, interval: 1.minute },
update_environment_canary_ingress: { threshold: 1, interval: 1.minute },
@@ -46,7 +47,9 @@ def rate_limits # rubocop:disable Metrics/AbcSize
gitlab_shell_operation: { threshold: 600, interval: 1.minute },
pipelines_create: { threshold: -> { application_settings.pipeline_limit_per_project_user_sha }, interval: 1.minute },
temporary_email_failure: { threshold: 50, interval: 1.day },
- project_testing_integration: { threshold: 5, interval: 1.minute }
+ project_testing_integration: { threshold: 5, interval: 1.minute },
+ email_verification: { threshold: 10, interval: 10.minutes },
+ email_verification_code_send: { threshold: 10, interval: 1.hour }
}.freeze
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index da702e4b5e900f53e3a70e8678f4673f5321aa37..3615ce476fae3b776c4cd13138aece6b7c696e12 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -19327,15 +19327,66 @@ msgstr ""
msgid "IdentityVerification|Before you create your group, we need you to verify your identity with a valid payment method. You will not be charged during this step. If we ever need to charge you, we will let you know."
msgstr ""
+msgid "IdentityVerification|Before you sign in, we need to verify your identity. Enter the following code on the sign-in page."
+msgstr ""
+
msgid "IdentityVerification|Create a project"
msgstr ""
+msgid "IdentityVerification|For added security, you'll need to verify your identity. We've sent a verification code to %{email}"
+msgstr ""
+
+msgid "IdentityVerification|Help us protect your account"
+msgstr ""
+
+msgid "IdentityVerification|If you have not recently tried to sign into GitLab, we recommend %{password_link_start}changing your password%{link_end} and %{two_fa_link_start}setting up Two-Factor Authentication%{link_end} to keep your account safe. Your verification code expires after %{expires_in_minutes} minutes."
+msgstr ""
+
+msgid "IdentityVerification|If you have not recently tried to sign into GitLab, we recommend changing your password (%{password_link}) and setting up Two-Factor Authentication (%{two_fa_link}) to keep your account safe."
+msgstr ""
+
+msgid "IdentityVerification|If you've lost access to the email associated to this account or having trouble with the code, %{link_start}here are some other steps you can take.%{link_end}"
+msgstr ""
+
+msgid "IdentityVerification|Maximum login attempts exceeded. Wait %{interval} and try again."
+msgstr ""
+
+msgid "IdentityVerification|Please enter a valid code"
+msgstr ""
+
+msgid "IdentityVerification|Resend code"
+msgstr ""
+
+msgid "IdentityVerification|The code has expired. Resend a new code and try again."
+msgstr ""
+
+msgid "IdentityVerification|The code is incorrect. Enter it again, or resend a new code."
+msgstr ""
+
+msgid "IdentityVerification|Verification code"
+msgstr ""
+
+msgid "IdentityVerification|Verification successful"
+msgstr ""
+
+msgid "IdentityVerification|Verify code"
+msgstr ""
+
msgid "IdentityVerification|Verify your identity"
msgstr ""
msgid "IdentityVerification|You can always verify your account at a later time to create a group."
msgstr ""
+msgid "IdentityVerification|You've reached the maximum amount of tries. Wait %{interval} or resend a new code and try again."
+msgstr ""
+
+msgid "IdentityVerification|Your account has been successfully verified. You'll be redirected to your account in just a moment or %{redirect_url_start}click here%{redirect_url_end} to refresh."
+msgstr ""
+
+msgid "IdentityVerification|Your verification code expires after %{expires_in_minutes} minutes."
+msgstr ""
+
msgid "If any indexed field exceeds this limit, it is truncated to this number of characters. The rest of the content is neither indexed nor searchable. This does not apply to repository and wiki indexing. For unlimited characters, set this to 0."
msgstr ""
diff --git a/spec/features/users/email_verification_on_login_spec.rb b/spec/features/users/email_verification_on_login_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..54eb11ca8576db91939ae5095b541f8d3462b60f
--- /dev/null
+++ b/spec/features/users/email_verification_on_login_spec.rb
@@ -0,0 +1,357 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Email Verification On Login', :clean_gitlab_redis_rate_limiting do
+ include EmailHelpers
+
+ let_it_be(:user) { create(:user) }
+
+ let(:require_email_verification_enabled) { true }
+
+ before do
+ stub_feature_flags(require_email_verification: require_email_verification_enabled)
+ end
+
+ shared_examples 'email verification required' do
+ before do
+ allow(Gitlab::AppLogger).to receive(:info)
+ end
+
+ it 'requires email verification before being able to access GitLab' do
+ perform_enqueued_jobs do
+ # When logging in
+ gitlab_sign_in(user)
+ expect_log_message(message: "Account Locked: username=#{user.username}")
+ expect_log_message('Instructions Sent')
+
+ # Expect the user to be locked and the unlock_token to be set
+ user.reload
+ expect(user.locked_at).not_to be_nil
+ expect(user.unlock_token).not_to be_nil
+
+ # Expect to see the verification form on the login page
+ expect(page).to have_current_path(new_user_session_path)
+ expect(page).to have_content('Help us protect your account')
+
+ # Expect an instructions email to be sent with a code
+ code = expect_instructions_email_and_extract_code
+
+ # Signing in again prompts for the code and doesn't send a new one
+ gitlab_sign_in(user)
+ expect(page).to have_current_path(new_user_session_path)
+ expect(page).to have_content('Help us protect your account')
+
+ # Verify the code
+ verify_code(code)
+ expect_log_message('Successful')
+ expect_log_message(message: "Successful Login: username=#{user.username} "\
+ "ip=127.0.0.1 method=standard admin=false")
+
+ # Expect the user to be unlocked
+ expect_user_to_be_unlocked
+
+ # Expect a confirmation page with a meta refresh tag for 3 seconds to the root
+ expect(page).to have_current_path(users_successful_verification_path)
+ expect(page).to have_content('Verification successful')
+ expect(page).to have_selector("meta[http-equiv='refresh'][content='3; url=#{root_path}']", visible: false)
+ end
+ end
+
+ describe 'resending a new code' do
+ it 'resends a new code' do
+ perform_enqueued_jobs do
+ # When logging in
+ gitlab_sign_in(user)
+
+ # Expect an instructions email to be sent with a code
+ code = expect_instructions_email_and_extract_code
+
+ # Request a new code
+ click_link 'Resend code'
+ expect_log_message('Instructions Sent', 2)
+ new_code = expect_instructions_email_and_extract_code
+
+ # Verify the old code is different from the new code
+ expect(code).not_to eq(new_code)
+ end
+ end
+
+ it 'rate limits resends' do
+ # When logging in
+ gitlab_sign_in(user)
+
+ # It shows a resend button
+ expect(page).to have_link 'Resend code'
+
+ # Resend more than the rate limited amount of times
+ 10.times do
+ click_link 'Resend code'
+ end
+
+ # Expect the link to be gone
+ expect(page).not_to have_link 'Resend code'
+
+ # Wait for 1 hour
+ travel 1.hour
+
+ # Now it's visible again
+ gitlab_sign_in(user)
+ expect(page).to have_link 'Resend code'
+ end
+ end
+
+ describe 'verification errors' do
+ it 'rate limits verifications' do
+ perform_enqueued_jobs do
+ # When logging in
+ gitlab_sign_in(user)
+
+ # Expect an instructions email to be sent with a code
+ code = expect_instructions_email_and_extract_code
+
+ # Verify an invalid token more than the rate limited amount of times
+ 11.times do
+ verify_code('123456')
+ end
+
+ # Expect an error message
+ expect_log_message('Failed Attempt', reason: 'rate_limited')
+ expect(page).to have_content("You've reached the maximum amount of tries. "\
+ 'Wait 10 minutes or resend a new code and try again.')
+
+ # Wait for 10 minutes
+ travel 10.minutes
+
+ # Now it works again
+ verify_code(code)
+ expect_log_message('Successful')
+ end
+ end
+
+ it 'verifies invalid codes' do
+ # When logging in
+ gitlab_sign_in(user)
+
+ # Verify an invalid code
+ verify_code('123456')
+
+ # Expect an error message
+ expect_log_message('Failed Attempt', reason: 'invalid')
+ expect(page).to have_content('The code is incorrect. Enter it again, or resend a new code.')
+ end
+
+ it 'verifies expired codes' do
+ perform_enqueued_jobs do
+ # When logging in
+ gitlab_sign_in(user)
+
+ # Expect an instructions email to be sent with a code
+ code = expect_instructions_email_and_extract_code
+
+ # Wait for the code to expire before verifying
+ travel VerifiesWithEmail::TOKEN_VALID_FOR_MINUTES.minutes + 1.second
+ verify_code(code)
+
+ # Expect an error message
+ expect_log_message('Failed Attempt', reason: 'expired')
+ expect(page).to have_content('The code has expired. Resend a new code and try again.')
+ end
+ end
+ end
+ end
+
+ shared_examples 'no email verification required' do |**login_args|
+ it 'does not lock the user and redirects to the root page after logging in' do
+ gitlab_sign_in(user, **login_args)
+
+ expect_user_to_be_unlocked
+
+ expect(page).to have_current_path(root_path)
+ end
+ end
+
+ shared_examples 'no email verification required when 2fa enabled or ff disabled' do
+ context 'when 2FA is enabled' do
+ let_it_be(:user) { create(:user, :two_factor) }
+
+ it_behaves_like 'no email verification required', two_factor_auth: true
+ end
+
+ context 'when the feature flag is disabled' do
+ let(:require_email_verification_enabled) { false }
+
+ it_behaves_like 'no email verification required'
+ end
+ end
+
+ describe 'when failing to login the maximum allowed number of times' do
+ before do
+ # See comment in RequireEmailVerification::MAXIMUM_ATTEMPTS on why this is divided by 2
+ (RequireEmailVerification::MAXIMUM_ATTEMPTS / 2).times do
+ gitlab_sign_in(user, password: 'wrong_password')
+ end
+ end
+
+ it 'locks the user, but does not set the unlock token', :aggregate_failures do
+ user.reload
+ expect(user.locked_at).not_to be_nil
+ expect(user.unlock_token).to be_nil # The unlock token is only set after logging in with valid credentials
+ expect(user.failed_attempts).to eq(RequireEmailVerification::MAXIMUM_ATTEMPTS)
+ end
+
+ it_behaves_like 'email verification required'
+ it_behaves_like 'no email verification required when 2fa enabled or ff disabled'
+
+ describe 'when waiting for the auto unlock time' do
+ before do
+ travel User::UNLOCK_IN + 1.second
+ end
+
+ it_behaves_like 'no email verification required'
+ end
+ end
+
+ describe 'when no previous authentication event exists' do
+ it_behaves_like 'no email verification required'
+ end
+
+ describe 'when a previous authentication event exists for another ip address' do
+ before do
+ create(:authentication_event, :successful, user: user, ip_address: '1.2.3.4')
+ end
+
+ it_behaves_like 'email verification required'
+ it_behaves_like 'no email verification required when 2fa enabled or ff disabled'
+ end
+
+ describe 'when a previous authentication event exists for the same ip address' do
+ before do
+ create(:authentication_event, :successful, user: user)
+ end
+
+ it_behaves_like 'no email verification required'
+ end
+
+ describe 'rate limiting password guessing' do
+ before do
+ 5.times { gitlab_sign_in(user, password: 'wrong_password') }
+ gitlab_sign_in(user)
+ end
+
+ it 'shows an error message on on the login page' do
+ expect(page).to have_current_path(new_user_session_path)
+ expect(page).to have_content('Maximum login attempts exceeded. Wait 10 minutes and try again.')
+ end
+ end
+
+ describe 'inconsistent states' do
+ context 'when the feature flag is toggled off after being prompted for a verification token' do
+ before do
+ create(:authentication_event, :successful, user: user, ip_address: '1.2.3.4')
+ end
+
+ it 'still accepts the token' do
+ perform_enqueued_jobs do
+ # The user is prompted for a verification code
+ gitlab_sign_in(user)
+ expect(page).to have_content('Help us protect your account')
+ code = expect_instructions_email_and_extract_code
+
+ # We toggle the feature flag off
+ stub_feature_flags(require_email_verification: false)
+
+ # Resending and veryfying the code work as expected
+ click_link 'Resend code'
+ new_code = expect_instructions_email_and_extract_code
+
+ verify_code(code)
+ expect(page).to have_content('The code is incorrect. Enter it again, or resend a new code.')
+
+ travel VerifiesWithEmail::TOKEN_VALID_FOR_MINUTES.minutes + 1.second
+
+ verify_code(new_code)
+ expect(page).to have_content('The code has expired. Resend a new code and try again.')
+
+ click_link 'Resend code'
+ another_code = expect_instructions_email_and_extract_code
+
+ verify_code(another_code)
+ expect_user_to_be_unlocked
+ expect(page).to have_current_path(users_successful_verification_path)
+ end
+ end
+ end
+
+ context 'when the feature flag is toggled on after Devise sent unlock instructions' do
+ let(:require_email_verification_enabled) { false }
+
+ before do
+ perform_enqueued_jobs do
+ (User.maximum_attempts / 2).times do
+ gitlab_sign_in(user, password: 'wrong_password')
+ end
+ end
+ end
+
+ it 'the unlock link still works' do
+ # The user is locked and unlock instructions are sent
+ expect(page).to have_content('Invalid login or password.')
+ user.reload
+ expect(user.locked_at).not_to be_nil
+ expect(user.unlock_token).not_to be_nil
+ mail = find_email_for(user)
+
+ expect(mail.to).to match_array([user.email])
+ expect(mail.subject).to eq('Unlock instructions')
+ unlock_url = mail.body.parts.first.to_s[/http.*/]
+
+ # We toggle the feature flag on
+ stub_feature_flags(require_email_verification: true)
+
+ # Unlocking works as expected
+ visit unlock_url
+ expect_user_to_be_unlocked
+ expect(page).to have_current_path(new_user_session_path)
+ expect(page).to have_content('Your account has been unlocked successfully')
+
+ gitlab_sign_in(user)
+ expect(page).to have_current_path(root_path)
+ end
+ end
+ end
+
+ def expect_user_to_be_unlocked
+ user.reload
+
+ aggregate_failures do
+ expect(user.locked_at).to be_nil
+ expect(user.unlock_token).to be_nil
+ expect(user.failed_attempts).to eq(0)
+ end
+ end
+
+ def expect_instructions_email_and_extract_code
+ mail = find_email_for(user)
+ expect(mail.to).to match_array([user.email])
+ expect(mail.subject).to eq('Verify your identity')
+ code = mail.body.parts.first.to_s[/\d{#{VerifiesWithEmail::TOKEN_LENGTH}}/]
+ reset_delivered_emails!
+ code
+ end
+
+ def verify_code(code)
+ fill_in 'Verification code', with: code
+ click_button 'Verify code'
+ end
+
+ def expect_log_message(event = nil, times = 1, reason: '', message: nil)
+ expect(Gitlab::AppLogger).to have_received(:info)
+ .exactly(times).times
+ .with(message || hash_including(message: 'Email Verification',
+ event: event,
+ username: user.username,
+ ip: '127.0.0.1',
+ reason: reason))
+ end
+end
diff --git a/spec/helpers/sessions_helper_spec.rb b/spec/helpers/sessions_helper_spec.rb
index fd3d7100ba163111b22b8e0552585d28b735dfb0..15424425060319ffadeed2de37e9dac7d35fd088 100644
--- a/spec/helpers/sessions_helper_spec.rb
+++ b/spec/helpers/sessions_helper_spec.rb
@@ -50,4 +50,51 @@
expect(helper.unconfirmed_email?).to be_falsey
end
end
+
+ describe '#send_rate_limited?' do
+ let_it_be(:user) { build(:user) }
+
+ subject { helper.send_rate_limited?(user) }
+
+ before do
+ allow(::Gitlab::ApplicationRateLimiter)
+ .to receive(:peek)
+ .with(:email_verification_code_send, scope: user)
+ .and_return(rate_limited)
+ end
+
+ context 'when rate limited' do
+ let(:rate_limited) { true }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when not rate limited' do
+ let(:rate_limited) { false }
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ describe '#obfuscated_email' do
+ subject { helper.obfuscated_email(email) }
+
+ context 'when an email address is normal length' do
+ let(:email) { 'alex@gitlab.com' }
+
+ it { is_expected.to eq('al**@g*****.com') }
+ end
+
+ context 'when an email address contains multiple top level domains' do
+ let(:email) { 'alex@gl.co.uk' }
+
+ it { is_expected.to eq('al**@g****.uk') }
+ end
+
+ context 'when an email address is very short' do
+ let(:email) { 'a@b' }
+
+ it { is_expected.to eq('a@b') }
+ end
+ end
end
diff --git a/spec/migrations/20220601152916_add_user_id_and_ip_address_success_index_to_authentication_events_spec.rb b/spec/migrations/20220601152916_add_user_id_and_ip_address_success_index_to_authentication_events_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8cb6ab23fef09faaa2a51ea2a0a18dfa1537e271
--- /dev/null
+++ b/spec/migrations/20220601152916_add_user_id_and_ip_address_success_index_to_authentication_events_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe AddUserIdAndIpAddressSuccessIndexToAuthenticationEvents do
+ let(:db) { described_class.new }
+ let(:old_index) { described_class::OLD_INDEX_NAME }
+ let(:new_index) { described_class::NEW_INDEX_NAME }
+
+ it 'correctly migrates up and down' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(db.connection.indexes(:authentication_events).map(&:name)).to include(old_index)
+ expect(db.connection.indexes(:authentication_events).map(&:name)).not_to include(new_index)
+ }
+
+ migration.after -> {
+ expect(db.connection.indexes(:authentication_events).map(&:name)).to include(new_index)
+ expect(db.connection.indexes(:authentication_events).map(&:name)).not_to include(old_index)
+ }
+ end
+ end
+end
diff --git a/spec/models/authentication_event_spec.rb b/spec/models/authentication_event_spec.rb
index 83598fa6765dda03de1d09f564e2f0eeb1e70bec..23e253c2a289bd505fb5620197d51c835a5f8540 100644
--- a/spec/models/authentication_event_spec.rb
+++ b/spec/models/authentication_event_spec.rb
@@ -44,4 +44,31 @@
expect(described_class.providers).to match_array %w(ldapmain google_oauth2 standard two-factor two-factor-via-u2f-device two-factor-via-webauthn-device)
end
end
+
+ describe '.initial_login_or_known_ip_address?' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:ip_address) { '127.0.0.1' }
+
+ subject { described_class.initial_login_or_known_ip_address?(user, ip_address) }
+
+ context 'on first login, when no record exists yet' do
+ it { is_expected.to eq(true) }
+ end
+
+ context 'on second login from the same ip address' do
+ before do
+ create(:authentication_event, :successful, user: user, ip_address: ip_address)
+ end
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'on second login from another ip address' do
+ before do
+ create(:authentication_event, :successful, user: user, ip_address: '1.2.3.4')
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
end
diff --git a/spec/models/concerns/require_email_verification_spec.rb b/spec/models/concerns/require_email_verification_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..66e35563c7fd3645d0901d84d4743fc89b42f8a1
--- /dev/null
+++ b/spec/models/concerns/require_email_verification_spec.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RequireEmailVerification do
+ let_it_be(:model) do
+ Class.new(ApplicationRecord) do
+ self.table_name = 'users'
+
+ devise :lockable
+
+ include RequireEmailVerification
+ end
+ end
+
+ using RSpec::Parameterized::TableSyntax
+
+ where(:feature_flag_enabled, :two_factor_enabled, :overridden) do
+ false | false | false
+ false | true | false
+ true | false | true
+ true | true | false
+ end
+
+ with_them do
+ let(:instance) { model.new }
+
+ before do
+ stub_feature_flags(require_email_verification: feature_flag_enabled)
+ allow(instance).to receive(:two_factor_enabled?).and_return(two_factor_enabled)
+ end
+
+ describe '#lock_access!' do
+ subject { instance.lock_access! }
+
+ before do
+ allow(instance).to receive(:save)
+ end
+
+ it 'sends Devise unlock instructions unless overridden and always sets locked_at' do
+ expect(instance).to receive(:send_unlock_instructions).exactly(overridden ? 0 : 1).times
+
+ expect { subject }.to change { instance.locked_at }.from(nil)
+ end
+ end
+
+ describe '#attempts_exceeded?' do
+ subject { instance.send(:attempts_exceeded?) }
+
+ context 'when failed_attempts is LT overridden amount' do
+ before do
+ instance.failed_attempts = 5
+ end
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when failed_attempts is GTE overridden amount but LT Devise default amount' do
+ before do
+ instance.failed_attempts = 6
+ end
+
+ it { is_expected.to eq(overridden) }
+ end
+
+ context 'when failed_attempts is GTE Devise default amount' do
+ before do
+ instance.failed_attempts = 10
+ end
+
+ it { is_expected.to eq(true) }
+ end
+ end
+
+ describe '#lock_expired?' do
+ subject { instance.send(:lock_expired?) }
+
+ context 'when locked shorter ago than Devise default time' do
+ before do
+ instance.locked_at = 9.minutes.ago
+ end
+
+ it { is_expected.to eq(false) }
+ end
+
+ context 'when locked longer ago than Devise default time but shorter ago than overriden time' do
+ before do
+ instance.locked_at = 11.minutes.ago
+ end
+
+ it { is_expected.to eq(!overridden) }
+ end
+
+ context 'when locked longer ago than overriden time' do
+ before do
+ instance.locked_at = (24.hours + 1.minute).ago
+ end
+
+ it { is_expected.to eq(true) }
+ end
+ end
+ end
+end
diff --git a/spec/requests/verifies_with_email_spec.rb b/spec/requests/verifies_with_email_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5b568c1d3ed53a1b65971009badb0c1f067f3838
--- /dev/null
+++ b/spec/requests/verifies_with_email_spec.rb
@@ -0,0 +1,232 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'VerifiesWithEmail', :clean_gitlab_redis_sessions, :clean_gitlab_redis_rate_limiting do
+ include SessionHelpers
+ include EmailHelpers
+
+ let(:user) { create(:user) }
+
+ shared_examples_for 'send verification instructions' do
+ it 'locks the user' do
+ user.reload
+ expect(user.unlock_token).not_to be_nil
+ expect(user.locked_at).not_to be_nil
+ end
+
+ it 'sends an email' do
+ mail = find_email_for(user)
+ expect(mail.to).to match_array([user.email])
+ expect(mail.subject).to eq('Verify your identity')
+ end
+ end
+
+ shared_examples_for 'prompt for email verification' do
+ it 'sets the verification_user_id session variable and renders the email verification template' do
+ expect(request.session[:verification_user_id]).to eq(user.id)
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template('devise/sessions/email_verification')
+ end
+ end
+
+ describe 'verify_with_email' do
+ context 'when user is locked and a verification_user_id session variable exists' do
+ before do
+ encrypted_token = Devise.token_generator.digest(User, :unlock_token, 'token')
+ user.update!(locked_at: Time.current, unlock_token: encrypted_token)
+ stub_session(verification_user_id: user.id)
+ end
+
+ context 'when rate limited and a verification_token param exists' do
+ before do
+ allow(Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true)
+
+ post(user_session_path(user: { verification_token: 'token' }))
+ end
+
+ it_behaves_like 'prompt for email verification'
+
+ it 'adds a verification error message' do
+ expect(response.body)
+ .to include("You've reached the maximum amount of tries. "\
+ 'Wait 10 minutes or resend a new code and try again.')
+ end
+ end
+
+ context 'when an invalid verification_token param exists' do
+ before do
+ post(user_session_path(user: { verification_token: 'invalid_token' }))
+ end
+
+ it_behaves_like 'prompt for email verification'
+
+ it 'adds a verification error message' do
+ expect(response.body).to include(('The code is incorrect. Enter it again, or resend a new code.'))
+ end
+ end
+
+ context 'when an expired verification_token param exists' do
+ before do
+ user.update!(locked_at: 1.hour.ago)
+ post(user_session_path(user: { verification_token: 'token' }))
+ end
+
+ it_behaves_like 'prompt for email verification'
+
+ it 'adds a verification error message' do
+ expect(response.body).to include(('The code has expired. Resend a new code and try again.'))
+ end
+ end
+
+ context 'when a valid verification_token param exists' do
+ before do
+ post(user_session_path(user: { verification_token: 'token' }))
+ end
+
+ it 'unlocks the user' do
+ user.reload
+ expect(user.unlock_token).to be_nil
+ expect(user.locked_at).to be_nil
+ end
+
+ it 'redirects to the successful verification path' do
+ expect(response).to redirect_to(users_successful_verification_path)
+ end
+ end
+ end
+
+ context 'when signing in with a valid password' do
+ let(:sign_in) { post(user_session_path(user: { login: user.username, password: user.password })) }
+
+ context 'when the feature flag is toggled on' do
+ before do
+ stub_feature_flags(require_email_verification: true)
+ end
+
+ context 'when rate limited' do
+ before do
+ allow(Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true)
+ sign_in
+ end
+
+ it 'redirects to the login form and shows an alert message' do
+ expect(response).to redirect_to(new_user_session_path)
+ expect(flash[:alert]).to eq('Maximum login attempts exceeded. Wait 10 minutes and try again.')
+ end
+ end
+
+ context 'when the user already has an unlock_token set' do
+ before do
+ user.update!(unlock_token: 'token')
+ sign_in
+ end
+
+ it_behaves_like 'prompt for email verification'
+ end
+
+ context 'when the user is already locked' do
+ before do
+ user.update!(locked_at: Time.current)
+ perform_enqueued_jobs { sign_in }
+ end
+
+ it_behaves_like 'send verification instructions'
+ it_behaves_like 'prompt for email verification'
+ end
+
+ context 'when the user is signing in from an unknown ip address' do
+ before do
+ allow(AuthenticationEvent)
+ .to receive(:initial_login_or_known_ip_address?)
+ .and_return(false)
+
+ perform_enqueued_jobs { sign_in }
+ end
+
+ it_behaves_like 'send verification instructions'
+ it_behaves_like 'prompt for email verification'
+ end
+ end
+
+ context 'when the feature flag is toggled off' do
+ before do
+ stub_feature_flags(require_email_verification: false)
+ sign_in
+ end
+
+ it 'redirects to the root path' do
+ expect(response).to redirect_to(root_path)
+ end
+ end
+ end
+ end
+
+ describe 'resend_verification_code' do
+ context 'when no verification_user_id session variable exists' do
+ before do
+ post(users_resend_verification_code_path)
+ end
+
+ it 'returns 204 No Content' do
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(response.body).to be_empty
+ end
+ end
+
+ context 'when a verification_user_id session variable exists' do
+ before do
+ stub_session(verification_user_id: user.id)
+
+ perform_enqueued_jobs do
+ post(users_resend_verification_code_path)
+ end
+ end
+
+ it_behaves_like 'send verification instructions'
+ it_behaves_like 'prompt for email verification'
+ end
+
+ context 'when exceeding the rate limit' do
+ before do
+ allow(Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true)
+
+ stub_session(verification_user_id: user.id)
+
+ perform_enqueued_jobs do
+ post(users_resend_verification_code_path)
+ end
+ end
+
+ it 'does not lock the user' do
+ user.reload
+ expect(user.unlock_token).to be_nil
+ expect(user.locked_at).to be_nil
+ end
+
+ it 'does not send an email' do
+ mail = find_email_for(user)
+ expect(mail).to be_nil
+ end
+
+ it_behaves_like 'prompt for email verification'
+ end
+ end
+
+ describe 'successful_verification' do
+ before do
+ sign_in(user)
+ end
+
+ it 'renders the template and removes the verification_user_id session variable' do
+ stub_session(verification_user_id: user.id)
+
+ get(users_successful_verification_path)
+
+ expect(request.session.has_key?(:verification_user_id)).to eq(false)
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template('successful_verification', layout: 'minimal')
+ expect(response.body).to include(root_path)
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 6bd16edcb72237c14c0f4f0b9c2bacdb5d439713..d01af9961414150ee454c9371806a8b85121431b 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -316,6 +316,10 @@
# most cases. We do test the CAPTCHA flow in the appropriate specs.
stub_feature_flags(arkose_labs_login_challenge: false)
+ # Specs should not require email verification by default, this makes the sign-in flow simpler in
+ # most cases. We do test the email verification flow in the appropriate specs.
+ stub_feature_flags(require_email_verification: false)
+
allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged)
else
unstub_all_feature_flags