Skip to content

Users aren't sent an unlock token under certain conditions if they log in > 24 hours after being locked

Everyone can contribute. Help move this issue forward while earning points, leveling up and collecting rewards.

There is a bug and/or docs discrepancy in how we handle locked accounts, in a very edge case way:

  • After 24 hours accounts will unlock automatically IF AND ONLY IF they did not attempt to sign in with a correct username and password within that 24 hour window
  • Within 24 hours, if a user signs in with the correct username and password their account will no longer automatically unlock. They MUST complete the email verification flow.
    • However, once they've been sent a code, after 24 hours, users aren't sent an unlock token when they sign in. They need to click resend 😅

This only affects .com, and any self-managed instance where Gitlab::CurrentSettings.require_email_verification_on_account_locked is true.

What's the issue?

Docs:

If 2FA is not enabled user accounts are locked after three failed sign-in attempts within 24 hours. Accounts remain locked until:

  • The next successful sign-in, at which point the user must verify their identity with a code sent to their email.
  • GitLab Support verifies the identity of the user and manually unlocks the account.

- https://docs.gitlab.com/security/unlock_user/#gitlabcom-users

  def verify_email(user)
    return true unless requires_verify_email?(user)

    # If they've received too many codes already, we won't send more
    unless send_rate_limited?(user)

      # If access is locked but there's no unlock_token, or the token has
      # expired, send a new one
      if user.access_locked?

        if !user.unlock_token || unlock_token_expired?(user) # rubocop:disable Style/IfUnlessModifier -- This is easier to read
          lock_and_send_verification_instructions(user)
        end
      # If they're not already locked but from a new IP, lock and send a
      # code
      elsif !trusted_ip_address?(user)
        lock_and_send_verification_instructions(
          user,
          reason: 'sign in from untrusted IP address'
        )
      end
    end

    # At this point they have a non-expired token in their email inbox.
    # Prompt for them to enter it.
    prompt_for_email_verification(user)
  end

Devise code:

      # Verifies whether a user is locked or not.
      def access_locked?
        locked_at && !lock_expired?
      end

Our code:

module RequireEmailVerification
  extend ActiveSupport::Concern
  include Gitlab::Utils::StrongMemoize

  MAXIMUM_ATTEMPTS = 3
  UNLOCK_IN = 24.hours

  def lock_expired?
    return super unless override_devise_lockable?

    locked_at && locked_at < UNLOCK_IN.ago
  end

  def override_devise_lockable?
    ::Gitlab::CurrentSettings.require_email_verification_on_account_locked &&
      !two_factor_enabled? &&
      identities.none? &&
      Feature.disabled?(:skip_require_email_verification, self, type: :ops)
  end

User#access_locked? will returned false after 24 hours. This is inconsistent with the docs. ⚠️

However, if the user had attempted to log in with the correct password, they can get in to a state where access_locked? is false, but User#unlock_token will be truthy. They'll remain locked until they complete email verification. This is consistent with the docs, but perhaps needs to change. See this comment. They'll also need to hit "Resend" as VerifiesWithEmail#verify_email won't send them a fresh code when access_locked? is false.

What's the fix?

  1. Clarify the behavior of account locks at different points in time (see this comment)
  2. Update the docs to reflect the intended behavior
  3. Ensure the specs / code reflects the intended behavior
  4. Note that this area of the code is being changed at time of writing (See https://gitlab.com/groups/gitlab-org/-/epics/18304).
Edited by Nick Malcolm