Commit 030f4fc1 authored by Diego Louzán's avatar Diego Louzán Committed by Kushal Pandya

2FA support for admin mode feature

Users that have 2FA setup must use it before activating admin mode
parent 465acee6
# frozen_string_literal: true
module Authenticates2FAForAdminMode
extend ActiveSupport::Concern
included do
include AuthenticatesWithTwoFactor
end
def admin_mode_prompt_for_two_factor(user)
return handle_locked_user(user) unless user.can?(:log_in)
session[:otp_user_id] = user.id
setup_u2f_authentication(user)
render 'admin/sessions/two_factor', layout: 'application'
end
def admin_mode_authenticate_with_two_factor
user = current_user
return handle_locked_user(user) unless user.can?(:log_in)
if user_params[:otp_attempt].present? && session[:otp_user_id]
admin_mode_authenticate_with_two_factor_via_otp(user)
elsif user_params[:device_response].present? && session[:otp_user_id]
admin_mode_authenticate_with_two_factor_via_u2f(user)
elsif user && user.valid_password?(user_params[:password])
admin_mode_prompt_for_two_factor(user)
else
invalid_login_redirect
end
end
def admin_mode_authenticate_with_two_factor_via_otp(user)
if valid_otp_attempt?(user)
# Remove any lingering user data from login
session.delete(:otp_user_id)
user.save!
# The admin user has successfully passed 2fa, enable admin mode ignoring password
enable_admin_mode
else
user.increment_failed_attempts!
Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=OTP")
flash.now[:alert] = _('Invalid two-factor code.')
admin_mode_prompt_for_two_factor(user)
end
end
def admin_mode_authenticate_with_two_factor_via_u2f(user)
if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
# Remove any lingering user data from login
session.delete(:otp_user_id)
session.delete(:challenge)
# The admin user has successfully passed 2fa, enable admin mode ignoring password
enable_admin_mode
else
user.increment_failed_attempts!
Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=U2F")
flash.now[:alert] = _('Authentication via U2F device failed.')
admin_mode_prompt_for_two_factor(user)
end
end
private
def enable_admin_mode
if current_user_mode.enable_admin_mode!(skip_password_validation: true)
redirect_to redirect_path, notice: _('Admin mode enabled')
else
invalid_login_redirect
end
end
def invalid_login_redirect
flash.now[:alert] = _('Invalid login or password')
render :new
end
end
# frozen_string_literal: true
class Admin::SessionsController < ApplicationController
include Authenticates2FAForAdminMode
include InternalRedirect
before_action :user_is_admin!
......@@ -15,7 +16,9 @@ class Admin::SessionsController < ApplicationController
end
def create
if current_user_mode.enable_admin_mode!(password: params[:password])
if two_factor_enabled_for_user?
admin_mode_authenticate_with_two_factor
elsif current_user_mode.enable_admin_mode!(password: user_params[:password])
redirect_to redirect_path, notice: _('Admin mode enabled')
else
flash.now[:alert] = _('Invalid login or password')
......@@ -37,6 +40,10 @@ class Admin::SessionsController < ApplicationController
render_404 unless current_user&.admin?
end
def two_factor_enabled_for_user?
current_user&.two_factor_enabled?
end
def redirect_path
redirect_to_path = safe_redirect_path(stored_location_for(:redirect)) || safe_redirect_path_for_url(request.referer)
......@@ -51,4 +58,13 @@ class Admin::SessionsController < ApplicationController
def excluded_redirect_paths
[new_admin_session_path, admin_session_path]
end
def user_params
params.fetch(:user, {}).permit(:password, :otp_attempt, :device_response)
end
def valid_otp_attempt?(user)
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
end
end
......@@ -3,8 +3,6 @@
# == AuthenticatesWithTwoFactor
#
# Controller concern to handle two-factor authentication
#
# Upon inclusion, skips `require_no_authentication` on `:create`.
module AuthenticatesWithTwoFactor
extend ActiveSupport::Concern
......
......@@ -2,6 +2,7 @@
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include AuthenticatesWithTwoFactor
include Authenticates2FAForAdminMode
include Devise::Controllers::Rememberable
include AuthHelper
include InitializesCurrentUserMode
......@@ -97,7 +98,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
log_audit_event(current_user, with: oauth['provider'])
if Feature.enabled?(:user_mode_in_session)
return admin_mode_flow if current_user_mode.admin_mode_requested?
return admin_mode_flow(auth_module::User) if current_user_mode.admin_mode_requested?
end
identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session)
......@@ -245,13 +246,19 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
end
def admin_mode_flow
if omniauth_identity_matches_current_user?
def admin_mode_flow(auth_user_class)
auth_user = build_auth_user(auth_user_class)
return fail_admin_mode_invalid_credentials unless omniauth_identity_matches_current_user?
if current_user.two_factor_enabled? && !auth_user.bypass_two_factor?
admin_mode_prompt_for_two_factor(current_user)
else
# Can only reach here if the omniauth identity matches current user
# and current_user is an admin that requested admin mode
current_user_mode.enable_admin_mode!(skip_password_validation: true)
redirect_to stored_location_for(:redirect) || admin_root_path, notice: _('Admin mode enabled')
else
fail_admin_mode_invalid_credentials
end
end
......
= form_tag(admin_session_path, method: :post, html: { class: 'new_user gl-show-field-errors', 'aria-live': 'assertive'}) do
.form-group
= label_tag :password, _('Password'), class: 'label-bold'
= password_field_tag :password, nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
= label_tag :user_password, _('Password'), class: 'label-bold'
= password_field_tag 'user[password]', nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
.submit-container.move-submit-down
= submit_tag _('Enter Admin Mode'), class: 'btn btn-success', data: { qa_selector: 'enter_admin_mode_button' }
%ul.nav-links.new-session-tabs.nav-tabs.nav{ role: 'tablist' }
%li.nav-item{ role: 'presentation' }
%a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= _('Enter Admin Mode')
%a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= tab_title
= form_tag(admin_session_path, { method: :post, class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if current_user.two_factor_u2f_enabled?}" }) do
.form-group
= label_tag :user_otp_attempt, _('Two-Factor Authentication code')
= text_field_tag 'user[otp_attempt]', nil, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', title: _('This field is required.')
%p.form-text.text-muted.hint
= _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.")
.submit-container.move-submit-down
= submit_tag 'Verify code', class: 'btn btn-success'
#js-authenticate-u2f
%a.btn.btn-block.btn-info#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code")
%script#js-authenticate-u2f-in-progress{ type: "text/template" }
%p= _("Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.")
-# haml-lint:disable NoPlainNodes
%script#js-authenticate-u2f-error{ type: "text/template" }
%div
%p <%= error_message %> (#{_("error code:")} <%= error_code %>)
%a.btn.btn-block.btn-warning#js-u2f-try-again= _("Try again?")
%script#js-authenticate-u2f-authenticated{ type: "text/template" }
%div
%p= _("We heard back from your U2F device. You have been authenticated.")
= form_tag(admin_session_path, method: :post, id: 'js-login-u2f-form') do |f|
= hidden_field_tag 'user[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
......@@ -2,10 +2,10 @@
- page_title _('Enter Admin Mode')
.row.justify-content-center
.col-6.new-session-forms-container
.col-md-5.new-session-forms-container
.login-page
#signin-container
= render 'admin/sessions/tabs_normal'
= render 'admin/sessions/tabs_normal', tab_title: _('Enter Admin Mode')
.tab-content
- if !current_user.require_password_creation_for_web?
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
......@@ -14,7 +14,7 @@
- if omniauth_enabled? && button_based_providers_enabled?
.clearfix
= render 'devise/shared/omniauth_box'
= render 'devise/shared/omniauth_box', hide_remember_me: true
-# Show a message if none of the mechanisms above are enabled
- if current_user.require_password_creation_for_web? && !omniauth_enabled?
......
- @hide_breadcrumbs = true
- page_title _('Enter 2FA for Admin Mode')
.row.justify-content-center
.col-md-5.new-session-forms-container
.login-page
#signin-container
= render 'admin/sessions/tabs_normal', tab_title: _('Enter Admin Mode')
.tab-content
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
.login-body
- if current_user.two_factor_otp_enabled?
= render 'admin/sessions/two_factor_otp'
- if current_user.two_factor_u2f_enabled?
= render 'admin/sessions/two_factor_u2f'
......@@ -10,8 +10,9 @@
= provider_image_tag(provider)
%span
= label_for_provider(provider)
%fieldset.remember-me
%label
= check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox'
%span
Remember me
- unless defined?(hide_remember_me) && hide_remember_me
%fieldset.remember-me
%label
= check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox'
%span
Remember me
#js-authenticate-u2f
%a.btn.btn-block.btn-info#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code")
-# haml-lint:disable InlineJavaScript
%script#js-authenticate-u2f-in-progress{ type: "text/template" }
%p= _("Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.")
......
---
title: Add 2FA support to admin mode feature
merge_request: 22281
author: Diego Louzán
type: added
......@@ -7501,6 +7501,9 @@ msgstr ""
msgid "Ensure your %{linkStart}environment is part of the deploy stage%{linkEnd} of your CI pipeline to track deployments to your cluster."
msgstr ""
msgid "Enter 2FA for Admin Mode"
msgstr ""
msgid "Enter Admin Mode"
msgstr ""
......@@ -7540,6 +7543,9 @@ msgstr ""
msgid "Enter one or more user ID separated by commas"
msgstr ""
msgid "Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes."
msgstr ""
msgid "Enter the issue description"
msgstr ""
......@@ -21055,6 +21061,9 @@ msgstr ""
msgid "Two-Factor Authentication"
msgstr ""
msgid "Two-Factor Authentication code"
msgstr ""
msgid "Two-factor Authentication"
msgstr ""
......
......@@ -68,7 +68,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do
# triggering the auth form will request admin mode
get :new
post :create, params: { password: user.password }
post :create, params: { user: { password: user.password } }
expect(response).to redirect_to admin_root_path
expect(controller.current_user_mode.admin_mode?).to be(true)
......@@ -82,7 +82,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do
# triggering the auth form will request admin mode
get :new
post :create, params: { password: '' }
post :create, params: { user: { password: '' } }
expect(response).to render_template :new
expect(controller.current_user_mode.admin_mode?).to be(false)
......@@ -95,7 +95,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do
# do not trigger the auth form
post :create, params: { password: user.password }
post :create, params: { user: { password: user.password } }
expect(response).to redirect_to(new_admin_session_path)
expect(controller.current_user_mode.admin_mode?).to be(false)
......@@ -110,12 +110,118 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do
get :new
Timecop.freeze(Gitlab::Auth::CurrentUserMode::ADMIN_MODE_REQUESTED_GRACE_PERIOD.from_now) do
post :create, params: { password: user.password }
post :create, params: { user: { password: user.password } }
expect(response).to redirect_to(new_admin_session_path)
expect(controller.current_user_mode.admin_mode?).to be(false)
end
end
context 'when using two-factor authentication via OTP' do
let(:user) { create(:admin, :two_factor) }
def authenticate_2fa(user_params)
post(:create, params: { user: user_params }, session: { otp_user_id: user.id })
end
it 'requests two factor after a valid password is provided' do
expect(controller.current_user_mode.admin_mode?).to be(false)
# triggering the auth form will request admin mode
get :new
post :create, params: { user: { password: user.password } }
expect(response).to render_template('admin/sessions/two_factor')
expect(controller.current_user_mode.admin_mode?).to be(false)
end
it 'can login with valid otp' do
expect(controller.current_user_mode.admin_mode?).to be(false)
controller.store_location_for(:redirect, admin_root_path)
controller.current_user_mode.request_admin_mode!
authenticate_2fa(otp_attempt: user.current_otp)
expect(response).to redirect_to admin_root_path
expect(controller.current_user_mode.admin_mode?).to be(true)
end
it 'cannot login with invalid otp' do
expect(controller.current_user_mode.admin_mode?).to be(false)
controller.current_user_mode.request_admin_mode!
authenticate_2fa(otp_attempt: 'invalid')
expect(response).to render_template('admin/sessions/two_factor')
expect(controller.current_user_mode.admin_mode?).to be(false)
end
context 'with password authentication disabled' do
before do
stub_application_setting(password_authentication_enabled_for_web: false)
end
it 'allows 2FA stage of non-password login' do
expect(controller.current_user_mode.admin_mode?).to be(false)
controller.store_location_for(:redirect, admin_root_path)
controller.current_user_mode.request_admin_mode!
authenticate_2fa(otp_attempt: user.current_otp)
expect(response).to redirect_to admin_root_path
expect(controller.current_user_mode.admin_mode?).to be(true)
end
end
end
context 'when using two-factor authentication via U2F' do
let(:user) { create(:admin, :two_factor_via_u2f) }
def authenticate_2fa_u2f(user_params)
post(:create, params: { user: user_params }, session: { otp_user_id: user.id })
end
it 'requests two factor after a valid password is provided' do
expect(controller.current_user_mode.admin_mode?).to be(false)
# triggering the auth form will request admin mode
get :new
post :create, params: { user: { password: user.password } }
expect(response).to render_template('admin/sessions/two_factor')
expect(controller.current_user_mode.admin_mode?).to be(false)
end
it 'can login with valid auth' do
allow(U2fRegistration).to receive(:authenticate).and_return(true)
expect(controller.current_user_mode.admin_mode?).to be(false)
controller.store_location_for(:redirect, admin_root_path)
controller.current_user_mode.request_admin_mode!
authenticate_2fa_u2f(login: user.username, device_response: '{}')
expect(response).to redirect_to admin_root_path
expect(controller.current_user_mode.admin_mode?).to be(true)
end
it 'cannot login with invalid auth' do
allow(U2fRegistration).to receive(:authenticate).and_return(false)
expect(controller.current_user_mode.admin_mode?).to be(false)
controller.current_user_mode.request_admin_mode!
authenticate_2fa_u2f(login: user.username, device_response: '{}')
expect(response).to render_template('admin/sessions/two_factor')
expect(controller.current_user_mode.admin_mode?).to be(false)
end
end
end
end
......@@ -136,7 +242,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do
expect(controller.current_user_mode.admin_mode?).to be(false)
get :new
post :create, params: { password: user.password }
post :create, params: { user: { password: user.password } }
expect(controller.current_user_mode.admin_mode?).to be(true)
post :destroy
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Admin Mode Login', :clean_gitlab_redis_shared_state, :do_not_mock_admin_mode do
include TermsHelper
include UserLoginHelper
describe 'with two-factor authentication', :js do
def enter_code(code)
fill_in 'user_otp_attempt', with: code
click_button 'Verify code'
end
context 'with valid username/password' do
let(:user) { create(:admin, :two_factor) }
context 'using one-time code' do
it 'blocks login if we reuse the same code immediately' do
gitlab_sign_in(user, remember: true)
expect(page).to have_content('Two-Factor Authentication')
repeated_otp = user.current_otp
enter_code(repeated_otp)
gitlab_enable_admin_mode_sign_in(user)
expect(page).to have_content('Two-Factor Authentication')
enter_code(repeated_otp)
expect(current_path).to eq admin_session_path
expect(page).to have_content('Invalid two-factor code')
end
context 'not re-using codes' do
before do
gitlab_sign_in(user, remember: true)
expect(page).to have_content('Two-Factor Authentication')
enter_code(user.current_otp)
gitlab_enable_admin_mode_sign_in(user)
expect(page).to have_content('Two-Factor Authentication')
end
it 'allows login with valid code' do
# Cannot reuse the TOTP
Timecop.travel(30.seconds.from_now) do
enter_code(user.current_otp)
expect(current_path).to eq admin_root_path
expect(page).to have_content('Admin mode enabled')
end
end
it 'blocks login with invalid code' do
# Cannot reuse the TOTP
Timecop.travel(30.seconds.from_now) do
enter_code('foo')
expect(page).to have_content('Invalid two-factor code')
end
end
it 'allows login with invalid code, then valid code' do
# Cannot reuse the TOTP
Timecop.travel(30.seconds.from_now) do
enter_code('foo')
expect(page).to have_content('Invalid two-factor code')
enter_code(user.current_otp)
expect(current_path).to eq admin_root_path
expect(page).to have_content('Admin mode enabled')
end
end
context 'using backup code' do
let(:codes) { user.generate_otp_backup_codes! }
before do
expect(codes.size).to eq 10
# Ensure the generated codes get saved
user.save
end
context 'with valid code' do
it 'allows login' do
enter_code(codes.sample)
expect(current_path).to eq admin_root_path
expect(page).to have_content('Admin mode enabled')
end
it 'invalidates the used code' do
expect { enter_code(codes.sample) }
.to change { user.reload.otp_backup_codes.size }.by(-1)
end
end
context 'with invalid code' do
it 'blocks login' do
code = codes.sample
expect(user.invalidate_otp_backup_code!(code)).to eq true
user.save!
expect(user.reload.otp_backup_codes.size).to eq 9
enter_code(code)
expect(page).to have_content('Invalid two-factor code.')
end
end
end
end
end
context 'when logging in via omniauth' do
let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml')}
let(:mock_saml_response) do
File.read('spec/fixtures/authentication/saml_response.xml')
end
before do
stub_omniauth_saml_config(enabled: true, auto_link_saml_user: true, allow_single_sign_on: ['saml'],
providers: [mock_saml_config_with_upstream_two_factor_authn_contexts])
end
context 'when authn_context is worth two factors' do
let(:mock_saml_response) do
File.read('spec/fixtures/authentication/saml_response.xml')
.gsub('urn:oasis:names:tc:SAML:2.0:ac:classes:Password',
'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS')
end
it 'signs user in without prompting for second factor' do
sign_in_using_saml!
expect(page).not_to have_content('Two-Factor Authentication')
enable_admin_mode_using_saml!
expect(page).not_to have_content('Two-Factor Authentication')
expect(current_path).to eq admin_root_path
expect(page).to have_content('Admin mode enabled')
end
end
context 'when two factor authentication is required' do
it 'shows 2FA prompt after omniauth login' do
sign_in_using_saml!
expect(page).to have_content('Two-Factor Authentication')
enter_code(user.current_otp)
enable_admin_mode_using_saml!
expect(page).to have_content('Two-Factor Authentication')
# Cannot reuse the TOTP
Timecop.travel(30.seconds.from_now) do
enter_code(user.current_otp)
expect(current_path).to eq admin_root_path
expect(page).to have_content('Admin mode enabled')
end
end
end
def sign_in_using_saml!
gitlab_sign_in_via('saml', user, 'my-uid', mock_saml_response)
end
def enable_admin_mode_using_saml!
gitlab_enable_admin_mode_sign_in_via('saml', user, 'my-uid', mock_saml_response)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Admin Mode Logout', :js, :clean_gitlab_redis_shared_state, :do_not_mock_admin_mode do
include TermsHelper
include UserLoginHelper
let(:user) { create(:admin) }
before do
gitlab_sign_in(user)
gitlab_enable_admin_mode_sign_in(user)
visit admin_root_path
end
it 'disable removes admin mode and redirects to root page' do
gitlab_disable_admin_mode
expect(current_path).to eq root_path
expect(page).to have_link(href: new_admin_session_path)
end
it 'disable shows flash notice' do
gitlab_disable_admin_mode
expect(page).to have_selector('.flash-notice')
end
context 'on a read-only instance' do
before do
allow(Gitlab::Database).to receive(:read_only?).and_return(true)
end
it 'disable removes admin mode and redirects to root page' do
gitlab_disable_admin_mode
expect(current_path).to eq root_path
expect(page).to have_link(href: new_admin_session_path)
end
end
end
......@@ -45,7 +45,7 @@ describe 'Admin mode', :clean_gitlab_redis_shared_state, :do_not_mock_admin_mode
it 'can enter admin mode' do
visit new_admin_session_path
fill_in 'password', with: admin.password
fill_in 'user_password', with: admin.password
click_button 'Enter Admin Mode'
......@@ -60,7 +60,7 @@ describe 'Admin mode', :clean_gitlab_redis_shared_state, :do_not_mock_admin_mode
it 'can enter admin mode' do
visit new_admin_session_path
fill_in 'password', with: admin.password
fill_in 'user_password', with: admin.password
click_button 'Enter Admin Mode'
......
......@@ -51,7 +51,7 @@ module LoginHelpers
def gitlab_enable_admin_mode_sign_in(user)
visit new_admin_session_path
fill_in 'password', with: user.password
fill_in 'user_password', with: user.password
click_button 'Enter Admin Mode'
end
......@@ -62,6 +62,12 @@ module LoginHelpers
click_link provider
end
def gitlab_enable_admin_mode_sign_in_via(provider, user, uid, saml_response = nil)
mock_auth_hash_with_saml_xml(provider, uid, user.email, saml_response)
visit new_admin_session_path
click_link provider
end
# Requires Javascript driver.
def gitlab_sign_out
find(".header-user-dropdown-toggle").click
......@@ -71,6 +77,11 @@ module LoginHelpers
expect(page).to have_button('Sign in')
end
# Requires Javascript driver.
def gitlab_disable_admin_mode
click_on 'Leave Admin Mode'
end
private
# Private: Login as the specified user
......
......@@ -15,7 +15,7 @@ describe 'admin/sessions/new.html.haml' do
render
expect(rendered).to have_css('#login-pane.active')
expect(rendered).to have_selector('input[name="password"]')
expect(rendered).to have_selector('input[name="user[password]"]')
end
it 'warns authentication not possible if password not set' do
......
# frozen_string_literal: true
require 'spec_helper'
describe 'admin/sessions/two_factor.html.haml' do
before do
allow(view).to receive(:current_user).and_return(user)
end
context 'user has no two factor auth' do
let(:user) { create(:admin) }
it 'shows tab' do
render
expect(rendered).to have_no_field('user[otp_attempt]')
expect(rendered).to have_no_field('user[device_response]')
end
end
context 'user has otp active' do
let(:user) { create(:admin, :two_factor) }
it 'shows enter otp form' do
render
expect(rendered).to have_css('#login-pane.active')
expect(rendered).to have_field('user[otp_attempt]')
end
end
context 'user has u2f active' do
let(:user) { create(:admin, :two_factor_via_u2f) }
it 'shows enter u2f form' do
render
expect(rendered).to have_css('#js-login-2fa-device.btn')
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