Commit 38d31ac2 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'feat/2fa-for-admin-mode' into 'master'

feat: 2FA for admin mode

Closes #35079

See merge request gitlab-org/gitlab!22281
parents 2bfb2985 030f4fc1
# 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,6 +10,7 @@
= provider_image_tag(provider)
%span
= label_for_provider(provider)
- unless defined?(hide_remember_me) && hide_remember_me
%fieldset.remember-me
%label
= check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox'
......
#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
......@@ -7481,6 +7481,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 ""
......@@ -7520,6 +7523,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 ""
......@@ -21098,6 +21104,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