Add email-based MFA with phased rollout support
What does this MR do and why?
Add email-based MFA with phased rollout support
- Add
send_otp_with_email
method to generate and send OTP codes - Implement
require_email_based_otp?
logic with feature flag control- Add
email_based_mfa
feature flag for granular rollout control
- Add
- Maintain existing behavior for locked account and untrusted IPs
- Add specs, with refactor to separate email OTP from account lock scenarios
After this MR:
- Email-based OTP is not enabled for anyone. No end user impact should be observed by merging this MR.
- However, if the FF was enabled and
user.require_email_otp_after
was set to the past, they will need to perform Email-based OTP on every subsequent sign in
Closes https://gitlab.com/gitlab-org/gitlab/-/issues/554382+.
Part of https://gitlab.com/groups/gitlab-org/-/epics/18304+.
References
Reading the Architecture Design is a great place to start: https://internal.gitlab.com/handbook/security/product_security/mandatory_mfa/architecture_design/
However, note the design shows the end-state. Other features, like soft-rollout banners + skippable prompt, user preferences, etc are coming later. See https://gitlab.com/groups/gitlab-org/-/epics/18304+
Screenshots or screen recordings
The UI doesn't change - it reuses the existing VerifiesWithEmail
UI that people see after their account is locked, or when they log in from a new IP.
The logic does change, though. In this video you see:
- When a user signs in with a username and & password, they are prompted to enter an emailed code. (Note this user has the FF enabled &
email_otp_required_after
set in the past) - Invalid codes don't work
- Old codes don't work
- You can resend codes
How to set up and validate locally
- Check out the branch
git checkout BRANCH_NAME
- In a rails console:
user = User.find_by(username: ...) Feature.enable(:email_based_mfa, user)
- Sign in. Note that you are not required to enter an email-based OTP.
- In a rails console:
user.update(email_otp_required_after: 10.minutes.from_now)
- Sign out & in. Note that you are not required to enter an email-based OTP.
- In a rails console:
user.update(email_otp_required_after: Time.zone.now)
- Sign out & in. Note that you are required to enter an email-based OTP.
- Navigate to
https://gdk.test:3443/rails/letter_opener
- Enter an invalid code. Note it is incorrect.
- Enter the code from the email. Note you are signed in.
- See below for other variations to try in this state
- In a rails console:
Feature.disable(:email_based_mfa, user) # or Feature.disable(:email_based_mfa)
- Sign out & in. Note that you are not required to enter an email-based OTP.
What this demonstrates:
- We can turn the feature on and off for individual users using the feature flag
- When the feature flag is enabled, we can controll the rollout based on a timestamp.
It does not demonstrate the "soft rollout" features we'll be adding in subsequent MRs.
Variations
Resending to the same address:
- Enable the feature flag, and set
required_after
to the past. - Sign in.
- Click "Resend".
- Note that you get another code.
- Note that the old code does not work.
- Note that the new code does work.
Resending to a new address:
- (If needed) Add a secondary email address, either via the UI or with
Email.create(user: user, email: "newaddress@example.com", confirmed_at: Time.zone.now)
- Sign out.
- Sign in.
- Click "Send to a secondary email address"
- Note that THE DIFFERENT EMAIL gets another code. Not the user's primary email.
- Note that the old code, previously delivered to the primary email, does not work.
- Note that the new code does work.
Signing in when locked - this shows that the existing behavior remains unaffected:
- Lock the user:
user.lock_access!
- Sign in.
- Retrieve and enter the code.
Rate limiting - we inherit the existing rate limiting features of VerifiesWithEmail
. Feel free to try them out.
Known issues
- Non-blocking. It is known, and pre-existing, that an email will not be sent to locked users who were locked > 24hrs ago. That is being tracked here: Users aren't sent an unlock token under certain... (#560080)
- If a user loses access to all their email addresses, they will be locked out. See https://internal.gitlab.com/handbook/security/product_security/mandatory_mfa/architecture_design/#denial-of-service-due-to-inaccessible-email
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.
-
(After first backend review has passed, to save AppSec time) You have confirmed that if this MR contains changes to processing or storing of credentials or tokens, authorization, and authentication methods, or other items described in the security review guidelines, you have added the security label and you have @-mentioned @gitlab-com/gl-security/appsec
.
Things for reviewers to check
These are risks I am confident I've addressed, but are worthwhile verifying:
- Check this MR does not break the lock & unlock functionality.
- Check that, if this were enabled for a user, it doesn't break the sign in flow.
- Check that this doesn't impact how "real" 2FA works (
AuthenticatesWithTwoFactor
controls that, and is out of scope)