Commit a8c7a277 authored by Imre Farkas's avatar Imre Farkas

Merge branch '22506-webauthn-step-1' into 'master'

Add support for WebAuthn behind feature flag

See merge request gitlab-org/gitlab!26692
parents 8bc389c8 a326729a
......@@ -63,6 +63,8 @@ linters:
- "app/views/admin/users/new.html.haml"
- "app/views/admin/users/projects.html.haml"
- "app/views/admin/users/show.html.haml"
- 'app/views/authentication/_authenticate.html.haml'
- 'app/views/authentication/_register.html.haml'
- "app/views/clusters/clusters/_cluster.html.haml"
- "app/views/clusters/clusters/new.html.haml"
- "app/views/dashboard/milestones/index.html.haml"
......@@ -311,8 +313,6 @@ linters:
- "app/views/shared/web_hooks/_form.html.haml"
- "app/views/shared/web_hooks/_hook.html.haml"
- "app/views/shared/wikis/_pages_wiki_page.html.haml"
- "app/views/u2f/_authenticate.html.haml"
- "app/views/u2f/_register.html.haml"
- "app/views/users/_deletion_guidance.html.haml"
- "ee/app/views/admin/_namespace_plan_info.html.haml"
- "ee/app/views/admin/application_settings/_templates.html.haml"
......
......@@ -512,3 +512,5 @@ gem 'json_schemer', '~> 0.2.12'
gem 'oj', '~> 3.10.6'
gem 'multi_json', '~> 1.14.1'
gem 'yajl-ruby', '~> 1.4.1', require: 'yajl'
gem 'webauthn', '~> 2.3'
......@@ -73,6 +73,7 @@ GEM
public_suffix (>= 2.0.2, < 5.0)
aes_key_wrap (1.0.1)
akismet (3.0.0)
android_key_attestation (0.3.0)
apollo_upload_server (2.0.2)
graphql (>= 1.8)
rails (>= 4.2)
......@@ -93,6 +94,7 @@ GEM
encryptor (~> 3.0.0)
attr_required (1.0.1)
awesome_print (1.8.0)
awrence (1.1.1)
aws-eventstream (1.1.0)
aws-partitions (1.345.0)
aws-sdk-cloudformation (1.41.0)
......@@ -167,6 +169,7 @@ GEM
activemodel (>= 4.0.0)
activesupport (>= 4.0.0)
mime-types (>= 1.16)
cbor (0.5.9.6)
character_set (1.4.0)
charlock_holmes (0.7.6)
childprocess (3.0.0)
......@@ -189,6 +192,9 @@ GEM
contracts (0.11.0)
cork (0.3.0)
colored2 (~> 3.1)
cose (1.0.0)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 0.4.0)
countries (3.0.0)
i18n_data (~> 0.8.0)
sixarm_ruby_unaccent (~> 1.1)
......@@ -802,6 +808,8 @@ GEM
validate_email
validate_url
webfinger (>= 1.0.1)
openssl (2.2.0)
openssl-signature_algorithm (0.4.0)
opentracing (0.5.0)
optimist (3.0.1)
org-ruby (0.9.12)
......@@ -1026,6 +1034,8 @@ GEM
rubyzip (2.0.0)
rugged (0.28.4.1)
safe_yaml (1.0.4)
safety_net_attestation (0.4.0)
jwt (~> 2.0)
sanitize (5.2.1)
crass (~> 1.0.2)
nokogiri (>= 1.8.0)
......@@ -1050,6 +1060,7 @@ GEM
scss_lint (0.56.0)
rake (>= 0.9, < 13)
sass (~> 3.5.3)
securecompare (1.0.0)
seed-fu (2.3.7)
activerecord (>= 3.1)
activesupport (>= 3.1)
......@@ -1135,6 +1146,9 @@ GEM
parslet (~> 1.8.0)
toml-rb (1.0.0)
citrus (~> 3.0, > 3.0)
tpm-key_attestation (0.9.0)
bindata (~> 2.4)
openssl-signature_algorithm (~> 0.4.0)
truncato (0.7.11)
htmlentities (~> 4.3.1)
nokogiri (>= 1.7.0, <= 2.0)
......@@ -1186,6 +1200,16 @@ GEM
vmstat (2.3.0)
warden (1.2.8)
rack (>= 2.0.6)
webauthn (2.3.0)
android_key_attestation (~> 0.3.0)
awrence (~> 1.1)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.0)
openssl (~> 2.0)
safety_net_attestation (~> 0.4.0)
securecompare (~> 1.0)
tpm-key_attestation (~> 0.9.0)
webfinger (1.1.0)
activesupport
httpclient (>= 2.4)
......@@ -1472,6 +1496,7 @@ DEPENDENCIES
validates_hostname (~> 1.0.10)
version_sorter (~> 2.2.4)
vmstat (~> 2.3.0)
webauthn (~> 2.3)
webmock (~> 3.5.1)
webpack-rails (~> 0.9.10)
wikicloth (= 0.8.1)
......
import $ from 'jquery';
import initU2F from './u2f';
import initWebauthn from './webauthn';
import U2FRegister from './u2f/register';
import WebAuthnRegister from './webauthn/register';
export const mount2faAuthentication = () => {
// Soon this will conditionally mount a webauthn app (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26692)
initU2F();
if (gon.webauthn) {
initWebauthn();
} else {
initU2F();
}
};
export const mount2faRegistration = () => {
// Soon this will conditionally mount a webauthn app (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26692)
const u2fRegister = new U2FRegister($('#js-register-u2f'), gon.u2f);
u2fRegister.start();
if (gon.webauthn) {
const webauthnRegister = new WebAuthnRegister($('#js-register-token-2fa'), gon.webauthn);
webauthnRegister.start();
} else {
const u2fRegister = new U2FRegister($('#js-register-token-2fa'), gon.u2f);
u2fRegister.start();
}
};
......@@ -40,7 +40,6 @@ export default class U2FAuthenticate {
this.signRequests = u2fParams.sign_requests.map(request => omit(request, 'challenge'));
this.templates = {
setup: '#js-authenticate-token-2fa-setup',
inProgress: '#js-authenticate-token-2fa-in-progress',
error: '#js-authenticate-token-2fa-error',
authenticated: '#js-authenticate-token-2fa-authenticated',
......@@ -86,7 +85,7 @@ export default class U2FAuthenticate {
renderError(error) {
this.renderTemplate('error', {
error_message: error.message(),
error_code: error.errorCode,
error_name: error.errorCode,
});
return this.container.find('#js-token-2fa-try-again').on('click', this.renderInProgress);
}
......
import $ from 'jquery';
import { template as lodashTemplate } from 'lodash';
import { __ } from '~/locale';
import importU2FLibrary from './util';
import U2FError from './error';
......@@ -24,11 +25,10 @@ export default class U2FRegister {
this.signRequests = u2fParams.sign_requests;
this.templates = {
notSupported: '#js-register-u2f-not-supported',
setup: '#js-register-u2f-setup',
inProgress: '#js-register-u2f-in-progress',
error: '#js-register-u2f-error',
registered: '#js-register-u2f-registered',
message: '#js-register-2fa-message',
setup: '#js-register-token-2fa-setup',
error: '#js-register-token-2fa-error',
registered: '#js-register-token-2fa-registered',
};
}
......@@ -65,18 +65,22 @@ export default class U2FRegister {
renderSetup() {
this.renderTemplate('setup');
return this.container.find('#js-setup-u2f-device').on('click', this.renderInProgress);
return this.container.find('#js-setup-token-2fa-device').on('click', this.renderInProgress);
}
renderInProgress() {
this.renderTemplate('inProgress');
this.renderTemplate('message', {
message: __(
'Trying to communicate with your device. Plug it in (if needed) and press the button on the device now.',
),
});
return this.register();
}
renderError(error) {
this.renderTemplate('error', {
error_message: error.message(),
error_code: error.errorCode,
error_name: error.errorCode,
});
return this.container.find('#js-token-2fa-try-again').on('click', this.renderSetup);
}
......@@ -89,6 +93,10 @@ export default class U2FRegister {
}
renderNotSupported() {
return this.renderTemplate('notSupported');
return this.renderTemplate('message', {
message: __(
"Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer).",
),
});
}
}
import WebAuthnError from './error';
import WebAuthnFlow from './flow';
import { supported, convertGetParams, convertGetResponse } from './util';
// Authenticate WebAuthn devices for users to authenticate with.
//
// State Flow #1: setup -> in_progress -> authenticated -> POST to server
// State Flow #2: setup -> in_progress -> error -> setup
export default class WebAuthnAuthenticate {
constructor(container, form, webauthnParams, fallbackButton, fallbackUI) {
this.container = container;
this.webauthnParams = convertGetParams(JSON.parse(webauthnParams.options));
this.renderInProgress = this.renderInProgress.bind(this);
this.form = form;
this.fallbackButton = fallbackButton;
this.fallbackUI = fallbackUI;
if (this.fallbackButton) {
this.fallbackButton.addEventListener('click', this.switchToFallbackUI.bind(this));
}
this.flow = new WebAuthnFlow(container, {
inProgress: '#js-authenticate-token-2fa-in-progress',
error: '#js-authenticate-token-2fa-error',
authenticated: '#js-authenticate-token-2fa-authenticated',
});
this.container.on('click', '#js-token-2fa-try-again', this.renderInProgress);
}
start() {
if (!supported()) {
this.switchToFallbackUI();
} else {
this.renderInProgress();
}
}
authenticate() {
navigator.credentials
.get({ publicKey: this.webauthnParams })
.then(resp => {
const convertedResponse = convertGetResponse(resp);
this.renderAuthenticated(JSON.stringify(convertedResponse));
})
.catch(err => {
this.flow.renderError(new WebAuthnError(err, 'authenticate'));
});
}
renderInProgress() {
this.flow.renderTemplate('inProgress');
this.authenticate();
}
renderAuthenticated(deviceResponse) {
this.flow.renderTemplate('authenticated');
const container = this.container[0];
container.querySelector('#js-device-response').value = deviceResponse;
container.querySelector(this.form).submit();
this.fallbackButton.classList.add('hidden');
}
switchToFallbackUI() {
this.fallbackButton.classList.add('hidden');
this.container[0].classList.add('hidden');
this.fallbackUI.classList.remove('hidden');
}
}
import { __ } from '~/locale';
import { isHTTPS, FLOW_AUTHENTICATE, FLOW_REGISTER } from './util';
export default class WebAuthnError {
constructor(error, flowType) {
this.error = error;
this.errorName = error.name || 'UnknownError';
this.message = this.message.bind(this);
this.httpsDisabled = !isHTTPS();
this.flowType = flowType;
}
message() {
if (this.errorName === 'NotSupportedError') {
return __('Your device is not compatible with GitLab. Please try another device');
} else if (this.errorName === 'InvalidStateError' && this.flowType === FLOW_AUTHENTICATE) {
return __('This device has not been registered with us.');
} else if (this.errorName === 'InvalidStateError' && this.flowType === FLOW_REGISTER) {
return __('This device has already been registered with us.');
} else if (this.errorName === 'SecurityError' && this.httpsDisabled) {
return __(
'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.',
);
}
return __('There was a problem communicating with your device.');
}
}
import { template } from 'lodash';
/**
* Generic abstraction for WebAuthnFlows, especially for register / authenticate
*/
export default class WebAuthnFlow {
constructor(container, templates) {
this.container = container;
this.templates = templates;
}
renderTemplate(name, params) {
const templateString = document.querySelector(this.templates[name]).innerHTML;
const compiledTemplate = template(templateString);
this.container.html(compiledTemplate(params));
}
renderError(error) {
this.renderTemplate('error', {
error_message: error.message(),
error_name: error.errorName,
});
}
}
import $ from 'jquery';
import WebAuthnAuthenticate from './authenticate';
export default () => {
const webauthnAuthenticate = new WebAuthnAuthenticate(
$('#js-authenticate-token-2fa'),
'#js-login-token-2fa-form',
gon.webauthn,
document.querySelector('#js-login-2fa-device'),
document.querySelector('.js-2fa-form'),
);
webauthnAuthenticate.start();
};
import { __ } from '~/locale';
import WebAuthnError from './error';
import WebAuthnFlow from './flow';
import { supported, isHTTPS, convertCreateParams, convertCreateResponse } from './util';
// Register WebAuthn devices for users to authenticate with.
//
// State Flow #1: setup -> in_progress -> registered -> POST to server
// State Flow #2: setup -> in_progress -> error -> setup
export default class WebAuthnRegister {
constructor(container, webauthnParams) {
this.container = container;
this.renderInProgress = this.renderInProgress.bind(this);
this.webauthnOptions = convertCreateParams(webauthnParams.options);
this.flow = new WebAuthnFlow(container, {
message: '#js-register-2fa-message',
setup: '#js-register-token-2fa-setup',
error: '#js-register-token-2fa-error',
registered: '#js-register-token-2fa-registered',
});
this.container.on('click', '#js-token-2fa-try-again', this.renderInProgress);
}
start() {
if (!supported()) {
// we show a special error message when the user visits the site
// using a non-ssl connection as this makes WebAuthn unavailable in
// any case, regardless of the used browser
this.renderNotSupported(!isHTTPS());
} else {
this.renderSetup();
}
}
register() {
navigator.credentials
.create({
publicKey: this.webauthnOptions,
})
.then(cred => this.renderRegistered(JSON.stringify(convertCreateResponse(cred))))
.catch(err => this.flow.renderError(new WebAuthnError(err, 'register')));
}
renderSetup() {
this.flow.renderTemplate('setup');
this.container.find('#js-setup-token-2fa-device').on('click', this.renderInProgress);
}
renderInProgress() {
this.flow.renderTemplate('message', {
message: __(
'Trying to communicate with your device. Plug it in (if needed) and press the button on the device now.',
),
});
return this.register();
}
renderRegistered(deviceResponse) {
this.flow.renderTemplate('registered');
// Prefer to do this instead of interpolating using Underscore templates
// because of JSON escaping issues.
this.container.find('#js-device-response').val(deviceResponse);
}
renderNotSupported(noHttps) {
const message = noHttps
? __(
'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.',
)
: __(
"Your browser doesn't support WebAuthn. Please use a supported browser, e.g. Chrome (67+) or Firefox (60+).",
);
this.flow.renderTemplate('message', { message });
}
}
export function supported() {
return Boolean(
navigator.credentials &&
navigator.credentials.create &&
navigator.credentials.get &&
window.PublicKeyCredential,
);
}
export function isHTTPS() {
return window.location.protocol.startsWith('https');
}
export const FLOW_AUTHENTICATE = 'authenticate';
export const FLOW_REGISTER = 'register';
// adapted from https://stackoverflow.com/a/21797381/8204697
function base64ToBuffer(base64) {
const binaryString = window.atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i += 1) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
// adapted from https://stackoverflow.com/a/9458996/8204697
function bufferToBase64(buffer) {
if (typeof buffer === 'string') {
return buffer;
}
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
/**
* Returns a copy of the given object with the id property converted to buffer
*
* @param {Object} param
*/
function convertIdToBuffer({ id, ...rest }) {
return {
...rest,
id: base64ToBuffer(id),
};
}
/**
* Returns a copy of the given array with all `id`s of the items converted to buffer
*
* @param {Array} items
*/
function convertIdsToBuffer(items) {
return items.map(convertIdToBuffer);
}
/**
* Returns an object with keys of the given props, and values from the given object converted to base64
*
* @param {String} obj
* @param {Array} props
*/
function convertPropertiesToBase64(obj, props) {
return props.reduce(
(acc, property) => Object.assign(acc, { [property]: bufferToBase64(obj[property]) }),
{},
);
}
export function convertGetParams({ allowCredentials, challenge, ...rest }) {
return {
...rest,
...(allowCredentials ? { allowCredentials: convertIdsToBuffer(allowCredentials) } : {}),
challenge: base64ToBuffer(challenge),
};
}
export function convertGetResponse(webauthnResponse) {
return {
type: webauthnResponse.type,
id: webauthnResponse.id,
rawId: bufferToBase64(webauthnResponse.rawId),
response: convertPropertiesToBase64(webauthnResponse.response, [
'clientDataJSON',
'authenticatorData',
'signature',
'userHandle',
]),
clientExtensionResults: webauthnResponse.getClientExtensionResults(),
};
}
export function convertCreateParams({ challenge, user, excludeCredentials, ...rest }) {
return {
...rest,
challenge: base64ToBuffer(challenge),
user: convertIdToBuffer(user),
...(excludeCredentials ? { excludeCredentials: convertIdsToBuffer(excludeCredentials) } : {}),
};
}
export function convertCreateResponse(webauthnResponse) {
return {
type: webauthnResponse.type,
id: webauthnResponse.id,
rawId: bufferToBase64(webauthnResponse.rawId),
clientExtensionResults: webauthnResponse.getClientExtensionResults(),
response: convertPropertiesToBase64(webauthnResponse.response, [
'clientDataJSON',
'attestationObject',
]),
};
}
......@@ -257,7 +257,8 @@
}
}
table.u2f-registrations {
table.u2f-registrations,
.webauthn-registrations {
th:not(:last-child),
td:not(:last-child) {
border-right: solid 1px transparent;
......
......@@ -11,7 +11,13 @@ module Authenticates2FAForAdminMode
return handle_locked_user(user) unless user.can?(:log_in)
session[:otp_user_id] = user.id
setup_u2f_authentication(user)
push_frontend_feature_flag(:webauthn)
if user.two_factor_webauthn_enabled?
setup_webauthn_authentication(user)
else
setup_u2f_authentication(user)
end
render 'admin/sessions/two_factor', layout: 'application'
end
......@@ -24,7 +30,11 @@ module Authenticates2FAForAdminMode
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)
if user.two_factor_webauthn_enabled?
admin_mode_authenticate_with_two_factor_via_webauthn(user)
else
admin_mode_authenticate_with_two_factor_via_u2f(user)
end
elsif user && user.valid_password?(user_params[:password])
admin_mode_prompt_for_two_factor(user)
else
......@@ -52,18 +62,17 @@ module Authenticates2FAForAdminMode
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
admin_handle_two_factor_success
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_handle_two_factor_failure(user, 'U2F')
end
end
admin_mode_prompt_for_two_factor(user)
def admin_mode_authenticate_with_two_factor_via_webauthn(user)
if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute
admin_handle_two_factor_success
else
admin_handle_two_factor_failure(user, 'WebAuthn')
end
end
......@@ -81,4 +90,21 @@ module Authenticates2FAForAdminMode
flash.now[:alert] = _('Invalid login or password')
render :new
end
def admin_handle_two_factor_success
# 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
end
def admin_handle_two_factor_failure(user, method)
user.increment_failed_attempts!
Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}")
flash.now[:alert] = _('Authentication via %{method} device failed.') % { method: method }
admin_mode_prompt_for_two_factor(user)
end
end
......@@ -23,8 +23,14 @@ module AuthenticatesWithTwoFactor
session[:otp_user_id] = user.id
session[:user_updated_at] = user.updated_at
push_frontend_feature_flag(:webauthn)
if user.two_factor_webauthn_enabled?
setup_webauthn_authentication(user)
else
setup_u2f_authentication(user)
end
setup_u2f_authentication(user)
render 'devise/sessions/two_factor'
end
......@@ -46,7 +52,11 @@ module AuthenticatesWithTwoFactor
if user_params[:otp_attempt].present? && session[:otp_user_id]
authenticate_with_two_factor_via_otp(user)
elsif user_params[:device_response].present? && session[:otp_user_id]
authenticate_with_two_factor_via_u2f(user)
if user.two_factor_webauthn_enabled?
authenticate_with_two_factor_via_webauthn(user)
else
authenticate_with_two_factor_via_u2f(user)
end
elsif user && user.valid_password?(user_params[:password])
prompt_for_two_factor(user)
end
......@@ -89,16 +99,17 @@ module AuthenticatesWithTwoFactor
# Authenticate using the response from a U2F (universal 2nd factor) device
def 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
clear_two_factor_attempt!
handle_two_factor_success(user)
else
handle_two_factor_failure(user, 'U2F')
end
end
remember_me(user) if user_params[:remember_me] == '1'
sign_in(user, message: :two_factor_authenticated, event: :authentication)
def authenticate_with_two_factor_via_webauthn(user)
if Webauthn::AuthenticateService.new(user, user_params[:device_response], session[:challenge]).execute
handle_two_factor_success(user)
else
user.increment_failed_attempts!
Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=U2F")
flash.now[:alert] = _('Authentication via U2F device failed.')
prompt_for_two_factor(user)
handle_two_factor_failure(user, 'WebAuthn')
end
end
......@@ -116,8 +127,39 @@ module AuthenticatesWithTwoFactor
sign_requests: sign_requests })
end
end
def setup_webauthn_authentication(user)
if user.webauthn_registrations.present?
webauthn_registration_ids = user.webauthn_registrations.pluck(:credential_xid)
get_options = WebAuthn::Credential.options_for_get(allow: webauthn_registration_ids,
user_verification: 'discouraged',
extensions: { appid: WebAuthn.configuration.origin })
session[:credentialRequestOptions] = get_options
session[:challenge] = get_options.challenge
gon.push(webauthn: { options: get_options.to_json })
end
end
# rubocop: enable CodeReuse/ActiveRecord
def handle_two_factor_success(user)
# Remove any lingering user data from login
clear_two_factor_attempt!
remember_me(user) if user_params[:remember_me] == '1'
sign_in(user, message: :two_factor_authenticated, event: :authentication)
end
def handle_two_factor_failure(user, method)
user.increment_failed_attempts!
Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=#{method}")
flash.now[:alert] = _('Authentication via %{method} device failed.') % { method: method }
prompt_for_two_factor(user)
end
def handle_changed_user(user)
clear_two_factor_attempt!
......
......@@ -2,6 +2,9 @@
class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
skip_before_action :check_two_factor_requirement
before_action do
push_frontend_feature_flag(:webauthn)
end
def show
unless current_user.two_factor_enabled?
......@@ -33,7 +36,12 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
@qr_code = build_qr_code
@account_string = account_string
setup_u2f_registration
if Feature.enabled?(:webauthn)
setup_webauthn_registration
else
setup_u2f_registration
end
end
def create
......@@ -48,7 +56,13 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
else
@error = _('Invalid pin code')
@qr_code = build_qr_code
setup_u2f_registration
if Feature.enabled?(:webauthn)
setup_webauthn_registration
else
setup_u2f_registration
end
render 'show'
end
end
......@@ -56,7 +70,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
# A U2F (universal 2nd factor) device's information is stored after successful
# registration, which is then used while 2FA authentication is taking place.
def create_u2f
@u2f_registration = U2fRegistration.register(current_user, u2f_app_id, u2f_registration_params, session[:challenges])
@u2f_registration = U2fRegistration.register(current_user, u2f_app_id, device_registration_params, session[:challenges])
if @u2f_registration.persisted?
session.delete(:challenges)
......@@ -68,6 +82,21 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
end
end
def create_webauthn
@webauthn_registration = Webauthn::RegisterService.new(current_user, device_registration_params, session[:challenge]).execute
if @webauthn_registration.persisted?
session.delete(:challenge)
redirect_to profile_two_factor_auth_path, notice: s_("Your WebAuthn device was registered!")
else
@qr_code = build_qr_code
setup_webauthn_registration
render :show
end
end
def codes
Users::UpdateService.new(current_user, user: current_user).execute! do |user|
@codes = user.generate_otp_backup_codes!
......@@ -112,11 +141,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
# Actual communication is performed using a Javascript API
def setup_u2f_registration
@u2f_registration ||= U2fRegistration.new
@u2f_registrations = current_user.u2f_registrations
@registrations = u2f_registrations
u2f = U2F::U2F.new(u2f_app_id)
registration_requests = u2f.registration_requests
sign_requests = u2f.authentication_requests(@u2f_registrations.map(&:key_handle))
sign_requests = u2f.authentication_requests(current_user.u2f_registrations.map(&:key_handle))
session[:challenges] = registration_requests.map(&:challenge)
gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id,
......@@ -124,8 +153,53 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
sign_requests: sign_requests })
end
def u2f_registration_params
params.require(:u2f_registration).permit(:device_response, :name)
def device_registration_params
params.require(:device_registration).permit(:device_response, :name)
end
def setup_webauthn_registration
@registrations = webauthn_registrations
@webauthn_registration ||= WebauthnRegistration.new
unless current_user.webauthn_xid
current_user.user_detail.update!(webauthn_xid: WebAuthn.generate_user_id)
end
options = webauthn_options
session[:challenge] = options.challenge
gon.push(webauthn: { options: options, app_id: u2f_app_id })
end
# Adds delete path to u2f registrations
# to reduce logic in view template
def u2f_registrations
current_user.u2f_registrations.map do |u2f_registration|
{
name: u2f_registration.name,
created_at: u2f_registration.created_at,
delete_path: profile_u2f_registration_path(u2f_registration)
}
end
end
def webauthn_registrations
current_user.webauthn_registrations.map do |webauthn_registration|
{
name: webauthn_registration.name,
created_at: webauthn_registration.created_at,
delete_path: profile_webauthn_registration_path(webauthn_registration)
}
end
end
def webauthn_options
WebAuthn::Credential.options_for_create(
user: { id: current_user.webauthn_xid, name: current_user.username },
exclude: current_user.webauthn_registrations.map { |c| c.credential_xid },
authenticator_selection: { user_verification: 'discouraged' },
rp: { name: 'GitLab' }
)
end
def groups_notification(groups)
......@@ -133,6 +207,6 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
leave_group_links = groups.map { |group| view_context.link_to (s_("leave %{group_name}") % { group_name: group.full_name }), leave_group_members_path(group), remote: false, method: :delete}.to_sentence
s_(%{The group settings for %{group_links} require you to enable Two-Factor Authentication for your account. You can %{leave_group_links}.})
.html_safe % { group_links: group_links.html_safe, leave_group_links: leave_group_links.html_safe }
.html_safe % { group_links: group_links.html_safe, leave_group_links: leave_group_links.html_safe }
end
end
# frozen_string_literal: true
class Profiles::WebauthnRegistrationsController < Profiles::ApplicationController
def destroy
webauthn_registration = current_user.webauthn_registrations.find(params[:id])
webauthn_registration.destroy
redirect_to profile_two_factor_auth_path, status: :found, notice: _("Successfully deleted WebAuthn device.")
end
end
......@@ -6,6 +6,9 @@ class ProfilesController < Profiles::ApplicationController
before_action :user
before_action :authorize_change_username!, only: :update_username
skip_before_action :require_email, only: [:show, :update]
before_action do
push_frontend_feature_flag(:webauthn)
end
def show
end
......
......@@ -29,6 +29,9 @@ class SessionsController < Devise::SessionsController
before_action :save_failed_login, if: :action_new_and_failed_login?
before_action :load_recaptcha
before_action :set_invite_params, only: [:new]
before_action do
push_frontend_feature_flag(:webauthn)
end
after_action :log_failed_login, if: :action_new_and_failed_login?
after_action :verify_known_sign_in, only: [:create]
......@@ -293,7 +296,9 @@ class SessionsController < Devise::SessionsController
def authentication_method
if user_params[:otp_attempt]
"two-factor"
elsif user_params[:device_response]
elsif user_params[:device_response] && Feature.enabled?(:webauthn)
"two-factor-via-webauthn-device"
elsif user_params[:device_response] && !Feature.enabled?(:webauthn)
"two-factor-via-u2f-device"
else
"standard"
......
......@@ -12,6 +12,7 @@ class MembersPreloader
ActiveRecord::Associations::Preloader.new.preload(members, :source)
ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :status)
ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :u2f_registrations)
ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :webauthn_registrations)
end
end
......
......@@ -113,6 +113,7 @@ class User < ApplicationRecord
has_many :personal_access_tokens, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :identities, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent
has_many :u2f_registrations, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :webauthn_registrations
has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :user_synced_attributes_metadata, autosave: true
has_one :aws_role, class_name: 'Aws::Role'
......@@ -286,6 +287,7 @@ class User < ApplicationRecord
delegate :path, to: :namespace, allow_nil: true, prefix: true
delegate :job_title, :job_title=, to: :user_detail, allow_nil: true
delegate :bio, :bio=, :bio_html, to: :user_detail, allow_nil: true
delegate :webauthn_xid, :webauthn_xid=, to: :user_detail, allow_nil: true
accepts_nested_attributes_for :user_preference, update_only: true
accepts_nested_attributes_for :user_detail, update_only: true
......@@ -434,14 +436,21 @@ class User < ApplicationRecord
FROM u2f_registrations AS u2f
WHERE u2f.user_id = users.id
) OR users.otp_required_for_login = ?
OR
EXISTS (
SELECT *
FROM webauthn_registrations AS webauthn
WHERE webauthn.user_id = users.id
)
SQL
where(with_u2f_registrations, true)
end
def self.without_two_factor
joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id")
.where("u2f.id IS NULL AND users.otp_required_for_login = ?", false)
joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id
LEFT OUTER JOIN webauthn_registrations AS webauthn ON webauthn.user_id = users.id")
.where("u2f.id IS NULL AND webauthn.id IS NULL AND users.otp_required_for_login = ?", false)
end
#
......@@ -754,11 +763,12 @@ class User < ApplicationRecord
otp_backup_codes: nil
)
self.u2f_registrations.destroy_all # rubocop: disable Cop/DestroyAll
self.webauthn_registrations.destroy_all # rubocop: disable Cop/DestroyAll
end
end
def two_factor_enabled?
two_factor_otp_enabled? || two_factor_u2f_enabled?
two_factor_otp_enabled? || two_factor_webauthn_u2f_enabled?
end
def two_factor_otp_enabled?
......@@ -773,6 +783,16 @@ class User < ApplicationRecord
end
end
def two_factor_webauthn_u2f_enabled?
two_factor_u2f_enabled? || two_factor_webauthn_enabled?
end
def two_factor_webauthn_enabled?
return false unless Feature.enabled?(:webauthn)
(webauthn_registrations.loaded? && webauthn_registrations.any?) || (!webauthn_registrations.loaded? && webauthn_registrations.exists?)
end
def namespace_move_dir_allowed
if namespace&.any_project_has_container_registry_tags?
errors.add(:username, _('cannot be changed if a personal project has container registry tags.'))
......
# frozen_string_literal: true
module Webauthn
class AuthenticateService < BaseService
def initialize(user, device_response, challenge)
@user = user
@device_response = device_response
@challenge = challenge
end
def execute
parsed_device_response = Gitlab::Json.parse(@device_response)
# appid is set for legacy U2F devices, will be used in a future iteration
# rp_id = @app_id
# unless parsed_device_response['clientExtensionResults'] && parsed_device_response['clientExtensionResults']['appid']
# rp_id = URI(@app_id).host
# end
webauthn_credential = WebAuthn::Credential.from_get(parsed_device_response)
encoded_raw_id = Base64.strict_encode64(webauthn_credential.raw_id)
stored_webauthn_credential = @user.webauthn_registrations.find_by_credential_xid(encoded_raw_id)
encoder = WebAuthn.configuration.encoder
if stored_webauthn_credential &&
validate_webauthn_credential(webauthn_credential) &&
verify_webauthn_credential(webauthn_credential, stored_webauthn_credential, @challenge, encoder)
stored_webauthn_credential.update!(counter: webauthn_credential.sign_count)
return true
end
false
rescue JSON::ParserError, WebAuthn::SignCountVerificationError, WebAuthn::Error
false
end
##
# Validates that webauthn_credential is syntactically valid
#
# duplicated from WebAuthn::PublicKeyCredential#verify
# which can't be used here as we need to call WebAuthn::AuthenticatorAssertionResponse#verify instead
# (which is done in #verify_webauthn_credential)
def validate_webauthn_credential(webauthn_credential)
webauthn_credential.type == WebAuthn::TYPE_PUBLIC_KEY &&
webauthn_credential.raw_id && webauthn_credential.id &&
webauthn_credential.raw_id == WebAuthn.standard_encoder.decode(webauthn_credential.id)
end
##
# Verifies that webauthn_credential matches stored_credential with the given challenge
#
def verify_webauthn_credential(webauthn_credential, stored_credential, challenge, encoder)
webauthn_credential.response.verify(
encoder.decode(challenge),
public_key: encoder.decode(stored_credential.public_key),
sign_count: stored_credential.counter)
end
end
end
# frozen_string_literal: true
module Webauthn
class RegisterService < BaseService
def initialize(user, params, challenge)
@user = user
@params = params
@challenge = challenge
end
def execute
registration = WebauthnRegistration.new
begin
webauthn_credential = WebAuthn::Credential.from_create(Gitlab::Json.parse(@params[:device_response]))
webauthn_credential.verify(@challenge)
registration.update(
credential_xid: Base64.strict_encode64(webauthn_credential.raw_id),
public_key: webauthn_credential.public_key,
counter: webauthn_credential.sign_count,
name: @params[:name],
user: @user
)
rescue JSON::ParserError
registration.errors.add(:base, _('Your WebAuthn device did not send a valid JSON response.'))
rescue WebAuthn::Error => e
registration.errors.add(:base, e.message)
end
registration
end
end
end
= 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_tag(admin_session_path, { method: :post, class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if current_user.two_factor_webauthn_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.')
......
......@@ -11,5 +11,5 @@
.login-body
- if current_user.two_factor_otp_enabled?
= render 'admin/sessions/two_factor_otp'
- if current_user.two_factor_u2f_enabled?
= render 'u2f/authenticate', render_remember_me: false, target_path: admin_session_path
- if current_user.two_factor_webauthn_u2f_enabled?
= render 'authentication/authenticate', render_remember_me: false, target_path: admin_session_path
......@@ -6,7 +6,7 @@
%script#js-authenticate-token-2fa-error{ type: "text/template" }
%div
%p <%= error_message %> (#{_("error code:")} <%= error_code %>)
%p <%= error_message %> (<%= error_name %>)
%a.btn.btn-block.btn-warning#js-token-2fa-try-again= _("Try again?")
%script#js-authenticate-token-2fa-authenticated{ type: "text/template" }
......
#js-register-token-2fa
-# haml-lint:disable InlineJavaScript
%script#js-register-2fa-message{ type: "text/template" }
%p <%= message %>
%script#js-register-token-2fa-setup{ type: "text/template" }
- if current_user.two_factor_otp_enabled?
.row.gl-mb-3
.col-md-5
%button#js-setup-token-2fa-device.btn.btn-info= _("Set up new device")
.col-md-7
%p= _("Your device needs to be set up. Plug it in (if needed) and click the button on the left.")
- else
.row.gl-mb-3
.col-md-4
%button#js-setup-token-2fa-device.btn.btn-info.btn-block{ disabled: true }= _("Set up new device")
.col-md-8
%p= _("You need to register a two-factor authentication app before you can set up a device.")
%script#js-register-token-2fa-error{ type: "text/template" }
%div
%p
%span <%= error_message %> (<%= error_name %>)
%a.btn.btn-warning#js-token-2fa-try-again= _("Try again?")
%script#js-register-token-2fa-registered{ type: "text/template" }
.row.gl-mb-3
.col-md-12
%p= _("Your device was successfully set up! Give it a name and register it with the GitLab server.")
= form_tag(target_path, method: :post) do
.row.gl-mb-3
.col-md-3
= text_field_tag 'device_registration[name]', nil, class: 'form-control', placeholder: _("Pick a name")
.col-md-3
= hidden_field_tag 'device_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
= submit_tag _("Register device"), class: "btn btn-success"
......@@ -3,7 +3,7 @@
.login-box
.login-body
- if @user.two_factor_otp_enabled?
= form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_u2f_enabled?}" }) do |f|
= form_for(resource, as: resource_name, url: session_path(resource_name), method: :post, html: { class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if @user.two_factor_webauthn_u2f_enabled?}" }) do |f|
- resource_params = params[resource_name].presence || params
= f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
%div
......@@ -12,6 +12,5 @@
%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.
.prepend-top-20
= f.submit "Verify code", class: "btn btn-success", data: { qa_selector: 'verify_code_button' }
- if @user.two_factor_u2f_enabled?
= render "u2f/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
- if @user.two_factor_webauthn_u2f_enabled?
= render "authentication/authenticate", params: params, resource: resource, resource_name: resource_name, render_remember_me: true, target_path: new_user_session_path
- page_title _('Two-Factor Authentication'), _('Account')
- add_to_breadcrumbs(_('Two-Factor Authentication'), profile_account_path)
- @content_class = "limit-container-width" unless fluid_layout
- webauthn_enabled = Feature.enabled?(:webauthn)
.js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path }
.row.gl-mt-3
......@@ -18,7 +19,7 @@
%div
= link_to _('Disable two-factor authentication'), profile_two_factor_auth_path,
method: :delete,
data: { confirm: _('Are you sure? This will invalidate your registered applications and U2F devices.') },
data: { confirm: webauthn_enabled ? _('Are you sure? This will invalidate your registered applications and U2F / WebAuthn devices.') : _('Are you sure? This will invalidate your registered applications and U2F devices.') },
class: 'btn btn-danger gl-mr-3'
= form_tag codes_profile_two_factor_auth_path, {style: 'display: inline-block', method: :post} do |f|
= submit_tag _('Regenerate recovery codes'), class: 'btn'
......@@ -58,22 +59,35 @@
.row.gl-mt-3
.col-lg-4
%h4.gl-mt-0
= _('Register Universal Two-Factor (U2F) Device')
- if webauthn_enabled
= _('Register WebAuthn Device')
- else
= _('Register Universal Two-Factor (U2F) Device')
%p
= _('Use a hardware device to add the second factor of authentication.')
%p
= _("As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser.")
- if webauthn_enabled
= _("As WebAuthn devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a WebAuthn device. That way you'll always be able to log in - even when you're using an unsupported browser.")
- else
= _("As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser.")
.col-lg-8
- if @u2f_registration.errors.present?
= form_errors(@u2f_registration)
= render "u2f/register"
- registration = webauthn_enabled ? @webauthn_registration : @u2f_registration
- if registration.errors.present?
= form_errors(registration)
- if webauthn_enabled
= render "authentication/register", target_path: create_webauthn_profile_two_factor_auth_path
- else
= render "authentication/register", target_path: create_u2f_profile_two_factor_auth_path
%hr
%h5
= _('U2F Devices (%{length})') % { length: @u2f_registrations.length }
- if webauthn_enabled
= _('WebAuthn Devices (%{length})') % { length: @registrations.length }
- else
= _('U2F Devices (%{length})') % { length: @registrations.length }
- if @u2f_registrations.present?
- if @registrations.present?
.table-responsive
%table.table.table-bordered.u2f-registrations
%colgroup
......@@ -86,12 +100,15 @@
%th= s_('2FADevice|Registered On')
%th
%tbody
- @u2f_registrations.each do |registration|
- @registrations.each do |registration|
%tr
%td= registration.name.presence || html_escape_once(_("&lt;no name set&gt;")).html_safe
%td= registration.created_at.to_date.to_s(:medium)
%td= link_to _('Delete'), profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') }
%td= registration[:name].presence || html_escape_once(_("&lt;no name set&gt;")).html_safe
%td= registration[:created_at].to_date.to_s(:medium)
%td= link_to _('Delete'), registration[:delete_path], method: :delete, class: "btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') }
- else
.settings-message.text-center
= _("You don't have any U2F devices registered yet.")
- if webauthn_enabled
= _("You don't have any WebAuthn devices registered yet.")
- else
= _("You don't have any U2F devices registered yet.")
#js-register-u2f
-# haml-lint:disable InlineJavaScript
%script#js-register-u2f-not-supported{ type: "text/template" }
%p= _("Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer).")
%script#js-register-u2f-setup{ type: "text/template" }
- if current_user.two_factor_otp_enabled?
.row.gl-mb-3
.col-md-4
%button#js-setup-u2f-device.btn.btn-info.btn-block= _("Set up new U2F device")
.col-md-8
%p= _("Your U2F device needs to be set up. Plug it in (if not already) and click the button on the left.")
- else
.row.gl-mb-3
.col-md-4
%button#js-setup-u2f-device.btn.btn-info.btn-block{ disabled: true }= _("Set up new U2F device")
.col-md-8
%p= _("You need to register a two-factor authentication app before you can set up a U2F device.")
%script#js-register-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.")
%script#js-register-u2f-error{ type: "text/template" }
%div
%p
%span <%= error_message %> (#{_("error code:")} <%= error_code %>)
%a.btn.btn-warning#js-token-2fa-try-again= _("Try again?")
%script#js-register-u2f-registered{ type: "text/template" }
.row.gl-mb-3
.col-md-12
%p= _("Your device was successfully set up! Give it a name and register it with the GitLab server.")
= form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do
.row.gl-mb-3
.col-md-3
= text_field_tag 'u2f_registration[name]', nil, class: 'form-control', placeholder: _("Pick a name")
.col-md-3
= hidden_field_tag 'u2f_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
= submit_tag _("Register U2F device"), class: "btn btn-success"
---
title: WebAuthn support (behind feature flag)
merge_request: 26692
author: Jan Beckmann
type: added
WebAuthn.configure do |config|
# This value needs to match `window.location.origin` evaluated by
# the User Agent during registration and authentication ceremonies.
config.origin = Settings.gitlab['base_url']
# Relying Party name for display purposes
# config.rp_name = "Example Inc."
# Optionally configure a client timeout hint, in milliseconds.
# This hint specifies how long the browser should wait for any
# interaction with the user.
# This hint may be overridden by the browser.
# https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-timeout
# config.credential_options_timeout = 120_000
# You can optionally specify a different Relying Party ID
# (https://www.w3.org/TR/webauthn/#relying-party-identifier)
# if it differs from the default one.
#
# In this case the default would be "auth.example.com", but you can set it to
# the suffix "example.com"
#
# config.rp_id = "example.com"
# Configure preferred binary-to-text encoding scheme. This should match the encoding scheme
# used in your client-side (user agent) code before sending the credential to the server.
# Supported values: `:base64url` (default), `:base64` or `false` to disable all encoding.
#
config.encoding = :base64
# Possible values: "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "RS256", "RS384", "RS512", "RS1"
# Default: ["ES256", "PS256", "RS256"]
#
# config.algorithms << "ES384"
end
......@@ -63,9 +63,11 @@ resource :profile, only: [:show, :update] do
post :create_u2f
post :codes
patch :skip
post :create_webauthn
end
end
resources :u2f_registrations, only: [:destroy]
resources :webauthn_registrations, only: [:destroy]
end
end
......@@ -208,6 +208,7 @@ RSpec.describe 'Login' do
let(:user) { create(:user, :two_factor_via_u2f) }
before do
stub_feature_flags(webauthn: false)
mock_group_saml(uid: identity.extern_uid)
end
......@@ -224,6 +225,40 @@ RSpec.describe 'Login' do
expect(current_path).to eq root_path
end
end
context 'with WebAuthn two factor', :js do
let(:user) { create(:user, :two_factor_via_webauthn) }
before do
mock_group_saml(uid: identity.extern_uid)
end
it 'shows WebAuthn prompt after SAML' do
visit sso_group_saml_providers_path(group, token: group.saml_discovery_token)
click_link 'Sign in with Single Sign-On'
# Mock the webauthn procedure to neither reject or resolve, just do nothing
# Using the built-in credentials.get functionality would result in an SecurityError
# as these tests are executed using an IP-adress as effective domain
page.execute_script <<~JS
navigator.credentials.get = function() {
return new Promise((resolve) => {
window.gl.resolveWebauthn = resolve;
});
}
JS
click_link('Try again', href: false)
expect(page).to have_content('Trying to communicate with your device')
expect(page).to have_link('Sign in via 2FA code')
fake_successful_webauthn_authentication
expect(current_path).to eq root_path
end
end
end
describe 'restricted visibility levels' do
......
......@@ -116,6 +116,7 @@ module API
entity = current_user&.admin? ? Entities::UserWithAdmin : Entities::UserBasic
users = users.preload(:identities, :u2f_registrations) if entity == Entities::UserWithAdmin
users = users.preload(:identities, :webauthn_registrations) if entity == Entities::UserWithAdmin
users, options = with_custom_attributes(users, { with: entity, current_user: current_user })
users = users.preload(:user_detail)
......
......@@ -3325,6 +3325,9 @@ msgstr ""
msgid "Are you sure? The device will be signed out of GitLab and all remember me tokens revoked."
msgstr ""
msgid "Are you sure? This will invalidate your registered applications and U2F / WebAuthn devices."
msgstr ""
msgid "Are you sure? This will invalidate your registered applications and U2F devices."
msgstr ""
......@@ -3346,6 +3349,9 @@ msgstr ""
msgid "As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser."
msgstr ""
msgid "As WebAuthn devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a WebAuthn device. That way you'll always be able to log in - even when you're using an unsupported browser."
msgstr ""
msgid "As we continue to build more features for SAST, we'd love your feedback on the SAST configuration feature in %{linkStart}this issue%{linkEnd}."
msgstr ""
......@@ -3575,7 +3581,7 @@ msgstr ""
msgid "Authentication method updated"
msgstr ""
msgid "Authentication via U2F device failed."
msgid "Authentication via %{method} device failed."
msgstr ""
msgid "Author"
......@@ -20495,10 +20501,13 @@ msgstr ""
msgid "Register Two-Factor Authenticator"
msgstr ""
msgid "Register U2F device"
msgid "Register Universal Two-Factor (U2F) Device"
msgstr ""
msgid "Register Universal Two-Factor (U2F) Device"
msgid "Register WebAuthn Device"
msgstr ""
msgid "Register device"
msgstr ""
msgid "Register for GitLab"
......@@ -22667,7 +22676,7 @@ msgstr ""
msgid "Set up assertions/attributes/claims (email, first_name, last_name) and NameID according to %{docsLinkStart}the documentation %{icon}%{docsLinkEnd}"
msgstr ""
msgid "Set up new U2F device"
msgid "Set up new device"
msgstr ""
msgid "Set up new password"
......@@ -24048,6 +24057,9 @@ msgstr ""
msgid "Successfully deleted U2F device."
msgstr ""
msgid "Successfully deleted WebAuthn device."
msgstr ""
msgid "Successfully removed email."
msgstr ""
......@@ -26420,6 +26432,9 @@ msgstr ""
msgid "Try using a different search term to find the file you are looking for."
msgstr ""
msgid "Trying to communicate with your device. Plug it in (if needed) and press the button on the device now."
msgstr ""
msgid "Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now."
msgstr ""
......@@ -27870,6 +27885,12 @@ msgstr ""
msgid "Web terminal"
msgstr ""
msgid "WebAuthn Devices (%{length})"
msgstr ""
msgid "WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details."
msgstr ""
msgid "WebIDE|Merge request"
msgstr ""
......@@ -28559,6 +28580,9 @@ msgstr ""
msgid "You don't have any U2F devices registered yet."
msgstr ""
msgid "You don't have any WebAuthn devices registered yet."
msgstr ""
msgid "You don't have any active chat names."
msgstr ""
......@@ -28685,7 +28709,7 @@ msgstr ""
msgid "You need to be logged in."
msgstr ""
msgid "You need to register a two-factor authentication app before you can set up a U2F device."
msgid "You need to register a two-factor authentication app before you can set up a device."
msgstr ""
msgid "You need to set terms to be enforced"
......@@ -28868,10 +28892,13 @@ msgstr ""
msgid "Your U2F device did not send a valid JSON response."
msgstr ""
msgid "Your U2F device needs to be set up. Plug it in (if not already) and click the button on the left."
msgid "Your U2F device was registered!"
msgstr ""
msgid "Your WebAuthn device did not send a valid JSON response."
msgstr ""
msgid "Your U2F device was registered!"
msgid "Your WebAuthn device was registered!"
msgstr ""
msgid "Your access request to the %{source_type} has been withdrawn."
......@@ -28895,6 +28922,9 @@ msgstr ""
msgid "Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer)."
msgstr ""
msgid "Your browser doesn't support WebAuthn. Please use a supported browser, e.g. Chrome (67+) or Firefox (60+)."
msgstr ""
msgid "Your changes can be committed to %{branch_name} because a merge request is open."
msgstr ""
......@@ -28931,6 +28961,12 @@ msgstr ""
msgid "Your deployment services will be broken, you will need to manually fix the services after renaming."
msgstr ""
msgid "Your device is not compatible with GitLab. Please try another device"
msgstr ""
msgid "Your device needs to be set up. Plug it in (if needed) and click the button on the left."
msgstr ""
msgid "Your device was successfully set up! Give it a name and register it with the GitLab server."
msgstr ""
......@@ -29482,9 +29518,6 @@ msgstr ""
msgid "error"
msgstr ""
msgid "error code:"
msgstr ""
msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
msgstr ""
......
......@@ -220,10 +220,8 @@ RSpec.describe Admin::SessionsController, :do_not_mock_admin_mode do
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)
shared_examples 'when using two-factor authentication via hardware device' do
def authenticate_2fa(user_params)
post(:create, params: { user: user_params }, session: { otp_user_id: user.id })
end
......@@ -239,14 +237,18 @@ RSpec.describe Admin::SessionsController, :do_not_mock_admin_mode do
end
it 'can login with valid auth' do
# we can stub both without an differentiation between webauthn / u2f
# as these not interfere with each other und this saves us passing aroud
# parameters
allow(U2fRegistration).to receive(:authenticate).and_return(true)
allow_any_instance_of(Webauthn::AuthenticateService).to receive(:execute).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: '{}')
authenticate_2fa(login: user.username, device_response: '{}')
expect(response).to redirect_to admin_root_path
expect(controller.current_user_mode.admin_mode?).to be(true)
......@@ -254,16 +256,33 @@ RSpec.describe Admin::SessionsController, :do_not_mock_admin_mode do
it 'cannot login with invalid auth' do
allow(U2fRegistration).to receive(:authenticate).and_return(false)
allow_any_instance_of(Webauthn::AuthenticateService).to receive(:execute).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: '{}')
authenticate_2fa(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
context 'when using two-factor authentication via U2F' do
it_behaves_like 'when using two-factor authentication via hardware device' do
let(:user) { create(:admin, :two_factor_via_u2f) }
before do
stub_feature_flags(webauthn: false)
end
end
end
context 'when using two-factor authentication via WebAuthn' do
it_behaves_like 'when using two-factor authentication via hardware device' do
let(:user) { create(:admin, :two_factor_via_webauthn) }
end
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Profiles::WebauthnRegistrationsController do
let(:user) { create(:user, :two_factor_via_webauthn) }
before do
sign_in(user)
end
describe '#destroy' do
it 'deletes the given webauthn registration' do
registration_to_delete = user.webauthn_registrations.first
expect { delete :destroy, params: { id: registration_to_delete.id } }.to change { user.webauthn_registrations.count }.by(-1)
expect(response).to be_redirect
end
end
end
......@@ -416,6 +416,10 @@ RSpec.describe SessionsController do
post(:create, params: { user: user_params }, session: { otp_user_id: user.id })
end
before do
stub_feature_flags(webauthn: false)
end
context 'remember_me field' do
it 'sets a remember_user_token cookie when enabled' do
allow(U2fRegistration).to receive(:authenticate).and_return(true)
......
......@@ -81,6 +81,14 @@ FactoryBot.define do
end
end
trait :two_factor_via_webauthn do
transient { registrations_count { 5 } }
after(:create) do |user, evaluator|
create_list(:webauthn_registration, evaluator.registrations_count, user: user)
end
end
trait :readme do
project_view { :readme }
end
......
# frozen_string_literal: true
FactoryBot.define do
factory :webauthn_registration do
credential_xid { SecureRandom.base64(88) }
public_key { SecureRandom.base64(103) }
name { FFaker::BaconIpsum.characters(10) }
counter { 1 }
user
end
end
......@@ -3,22 +3,14 @@
require 'spec_helper'
RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :js do
def manage_two_factor_authentication
click_on 'Manage two-factor authentication'
expect(page).to have_content("Set up new U2F device")
wait_for_requests
end
include Spec::Support::Helpers::Features::TwoFactorHelpers
def register_u2f_device(u2f_device = nil, name: 'My device')
u2f_device ||= FakeU2fDevice.new(page, name)
u2f_device.respond_to_u2f_registration
click_on 'Set up new U2F device'
expect(page).to have_content('Your device was successfully set up')
fill_in "Pick a name", with: name
click_on 'Register U2F device'
u2f_device
before do
stub_feature_flags(webauthn: false)
end
it_behaves_like 'hardware device for 2fa', 'U2F'
describe "registration" do
let(:user) { create(:user) }
......@@ -27,31 +19,7 @@ RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :j
user.update_attribute(:otp_required_for_login, true)
end
describe 'when 2FA via OTP is disabled' do
before do
user.update_attribute(:otp_required_for_login, false)
end
it 'does not allow registering a new device' do
visit profile_account_path
click_on 'Enable two-factor authentication'
expect(page).to have_button('Set up new U2F device', disabled: true)
end
end
describe 'when 2FA via OTP is enabled' do
it 'allows registering a new device with a name' do
visit profile_account_path
manage_two_factor_authentication
expect(page).to have_content("You've already enabled two-factor authentication using one time password authenticators")
u2f_device = register_u2f_device
expect(page).to have_content(u2f_device.name)
expect(page).to have_content('Your U2F device was registered')
end
it 'allows registering more than one device' do
visit profile_account_path
......@@ -68,21 +36,6 @@ RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :j
expect(page).to have_content(second_device.name)
expect(U2fRegistration.count).to eq(2)
end
it 'allows deleting a device' do
visit profile_account_path
manage_two_factor_authentication
expect(page).to have_content("You've already enabled two-factor authentication using one time password authenticators")
first_u2f_device = register_u2f_device
second_u2f_device = register_u2f_device(name: 'My other device')
accept_confirm { click_on "Delete", match: :first }
expect(page).to have_content('Successfully deleted')
expect(page.body).not_to match(first_u2f_device.name)
expect(page).to have_content(second_u2f_device.name)
end
end
it 'allows the same device to be registered for multiple users' do
......@@ -111,9 +64,9 @@ RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :j
# Have the "u2f device" respond with bad data
page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };")
click_on 'Set up new U2F device'
click_on 'Set up new device'
expect(page).to have_content('Your device was successfully set up')
click_on 'Register U2F device'
click_on 'Register device'
expect(U2fRegistration.count).to eq(0)
expect(page).to have_content("The form contains the following error")
......@@ -126,9 +79,9 @@ RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :j
# Failed registration
page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };")
click_on 'Set up new U2F device'
click_on 'Set up new device'
expect(page).to have_content('Your device was successfully set up')
click_on 'Register U2F device'
click_on 'Register device'
expect(page).to have_content("The form contains the following error")
# Successful registration
......@@ -228,12 +181,12 @@ RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :j
user = gitlab_sign_in(:user)
user.update_attribute(:otp_required_for_login, true)
visit profile_two_factor_auth_path
expect(page).to have_content("Your U2F device needs to be set up.")
expect(page).to have_content("Your device needs to be set up.")
first_device = register_u2f_device
# Register second device
visit profile_two_factor_auth_path
expect(page).to have_content("Your U2F device needs to be set up.")
expect(page).to have_content("Your device needs to be set up.")
second_device = register_u2f_device(name: 'My other device')
gitlab_sign_out
......@@ -249,50 +202,4 @@ RSpec.describe 'Using U2F (Universal 2nd Factor) Devices for Authentication', :j
end
end
end
describe 'fallback code authentication' do
let(:user) { create(:user) }
def assert_fallback_ui(page)
expect(page).to have_button('Verify code')
expect(page).to have_css('#user_otp_attempt')
expect(page).not_to have_link('Sign in via 2FA code')
expect(page).not_to have_css('#js-authenticate-token-2fa')
end
before do
# Register and logout
gitlab_sign_in(user)
user.update_attribute(:otp_required_for_login, true)
visit profile_account_path
end
describe 'when no u2f device is registered' do
before do
gitlab_sign_out
gitlab_sign_in(user)
end
it 'shows the fallback otp code UI' do
assert_fallback_ui(page)
end
end
describe 'when a u2f device is registered' do
before do
manage_two_factor_authentication
@u2f_device = register_u2f_device
gitlab_sign_out
gitlab_sign_in(user)
end
it 'provides a button that shows the fallback otp code UI' do
expect(page).to have_link('Sign in via 2FA code')
click_link('Sign in via 2FA code')
assert_fallback_ui(page)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Using WebAuthn Devices for Authentication', :js do
include Spec::Support::Helpers::Features::TwoFactorHelpers
let(:app_id) { "http://#{Capybara.current_session.server.host}:#{Capybara.current_session.server.port}" }
before do
WebAuthn.configuration.origin = app_id
end
it_behaves_like 'hardware device for 2fa', 'WebAuthn'
describe 'registration' do
let(:user) { create(:user) }
before do
gitlab_sign_in(user)
user.update_attribute(:otp_required_for_login, true)
end
describe 'when 2FA via OTP is enabled' do
it 'allows registering more than one device' do
visit profile_account_path
# First device
manage_two_factor_authentication
first_device = register_webauthn_device
expect(page).to have_content('Your WebAuthn device was registered')
# Second device
second_device = register_webauthn_device(name: 'My other device')
expect(page).to have_content('Your WebAuthn device was registered')
expect(page).to have_content(first_device.name)
expect(page).to have_content(second_device.name)
expect(WebauthnRegistration.count).to eq(2)
end
end
it 'allows the same device to be registered for multiple users' do
# First user
visit profile_account_path
manage_two_factor_authentication
webauthn_device = register_webauthn_device
expect(page).to have_content('Your WebAuthn device was registered')
gitlab_sign_out
# Second user
user = gitlab_sign_in(:user)
user.update_attribute(:otp_required_for_login, true)
visit profile_account_path
manage_two_factor_authentication
register_webauthn_device(webauthn_device, name: 'My other device')
expect(page).to have_content('Your WebAuthn device was registered')
expect(WebauthnRegistration.count).to eq(2)
end
context 'when there are form errors' do
let(:mock_register_js) do
<<~JS
const mockResponse = {
type: 'public-key',
id: '',
rawId: '',
response: {
clientDataJSON: '',
attestationObject: '',
},
getClientExtensionResults: () => {},
};
navigator.credentials.create = function(_) {return Promise.resolve(mockResponse);}
JS
end
it "doesn't register the device if there are errors" do
visit profile_account_path
manage_two_factor_authentication
# Have the "webauthn device" respond with bad data
page.execute_script(mock_register_js)
click_on 'Set up new device'
expect(page).to have_content('Your device was successfully set up')
click_on 'Register device'
expect(WebauthnRegistration.count).to eq(0)
expect(page).to have_content('The form contains the following error')
expect(page).to have_content('did not send a valid JSON response')
end
it 'allows retrying registration' do
visit profile_account_path
manage_two_factor_authentication
# Failed registration
page.execute_script(mock_register_js)
click_on 'Set up new device'
expect(page).to have_content('Your device was successfully set up')
click_on 'Register device'
expect(page).to have_content('The form contains the following error')
# Successful registration
register_webauthn_device
expect(page).to have_content('Your WebAuthn device was registered')
expect(WebauthnRegistration.count).to eq(1)
end
end
end
describe 'authentication' do
let(:otp_required_for_login) { true }
let(:user) { create(:user, webauthn_xid: WebAuthn.generate_user_id, otp_required_for_login: otp_required_for_login) }
describe 'when there is only an U2F device' do
let!(:u2f_device) do
fake_device = U2F::FakeU2F.new(app_id) # "Client"
u2f = U2F::U2F.new(app_id) # "Server"
challenges = u2f.registration_requests.map(&:challenge)
device_response = fake_device.register_response(challenges[0])
device_registration_params = { device_response: device_response,
name: 'My device' }
U2fRegistration.register(user, app_id, device_registration_params, challenges)
FakeU2fDevice.new(page, 'My device', fake_device)
end
it 'falls back to U2F' do
gitlab_sign_in(user)
u2f_device.respond_to_u2f_authentication
expect(page).to have_css('.sign-out-link', visible: false)
end
end
describe 'when there is a WebAuthn device' do
let!(:webauthn_device) do
add_webauthn_device(app_id, user)
end
describe 'when 2FA via OTP is disabled' do
let(:otp_required_for_login) { false }
it 'allows logging in with the WebAuthn device' do
gitlab_sign_in(user)
webauthn_device.respond_to_webauthn_authentication
expect(page).to have_css('.sign-out-link', visible: false)
end
end
describe 'when 2FA via OTP is enabled' do
it 'allows logging in with the WebAuthn device' do
gitlab_sign_in(user)
webauthn_device.respond_to_webauthn_authentication
expect(page).to have_css('.sign-out-link', visible: false)
end
end
describe 'when a given WebAuthn device has already been registered by another user' do
describe 'but not the current user' do
let(:other_user) { create(:user, webauthn_xid: WebAuthn.generate_user_id, otp_required_for_login: otp_required_for_login) }
it 'does not allow logging in with that particular device' do
# Register other user with a different WebAuthn device
other_device = add_webauthn_device(app_id, other_user)
# Try authenticating user with the old WebAuthn device
gitlab_sign_in(user)
other_device.respond_to_webauthn_authentication
expect(page).to have_content('Authentication via WebAuthn device failed')
end
end
describe "and also the current user" do
# TODO Uncomment once WebAuthn::FakeClient supports passing credential options
# (especially allow_credentials, as this is needed to specify which credential the
# fake client should use. Currently, the first credential is always used).
# There is an issue open for this: https://github.com/cedarcode/webauthn-ruby/issues/259
it "allows logging in with that particular device" do
pending("support for passing credential options in FakeClient")
# Register current user with the same WebAuthn device
current_user = gitlab_sign_in(:user)
visit profile_account_path
manage_two_factor_authentication
register_webauthn_device(webauthn_device)
gitlab_sign_out
# Try authenticating user with the same WebAuthn device
gitlab_sign_in(current_user)
webauthn_device.respond_to_webauthn_authentication
expect(page).to have_css('.sign-out-link', visible: false)
end
end
end
describe 'when a given WebAuthn device has not been registered' do
it 'does not allow logging in with that particular device' do
unregistered_device = FakeWebauthnDevice.new(page, 'My device')
gitlab_sign_in(user)
unregistered_device.respond_to_webauthn_authentication
expect(page).to have_content('Authentication via WebAuthn device failed')
end
end
describe 'when more than one device has been registered by the same user' do
it 'allows logging in with either device' do
first_device = add_webauthn_device(app_id, user)
second_device = add_webauthn_device(app_id, user)
# Authenticate as both devices
[first_device, second_device].each do |device|
gitlab_sign_in(user)
# register_webauthn_device(device)
device.respond_to_webauthn_authentication
expect(page).to have_css('.sign-out-link', visible: false)
gitlab_sign_out
end
end
end
end
end
end
......@@ -76,7 +76,7 @@ describe('U2FAuthenticate', () => {
describe('errors', () => {
it('displays an error message', () => {
const setupButton = container.find('#js-login-u2f-device');
const setupButton = container.find('#js-login-2fa-device');
setupButton.trigger('click');
u2fDevice.respondToAuthenticateRequest({
errorCode: 'error!',
......@@ -87,14 +87,14 @@ describe('U2FAuthenticate', () => {
});
it('allows retrying authentication after an error', () => {
let setupButton = container.find('#js-login-u2f-device');
let setupButton = container.find('#js-login-2fa-device');
setupButton.trigger('click');
u2fDevice.respondToAuthenticateRequest({
errorCode: 'error!',
});
const retryButton = container.find('#js-token-2fa-try-again');
retryButton.trigger('click');
setupButton = container.find('#js-login-u2f-device');
setupButton = container.find('#js-login-2fa-device');
setupButton.trigger('click');
u2fDevice.respondToAuthenticateRequest({
deviceData: 'this is data from the device',
......
......@@ -13,8 +13,8 @@ describe('U2FRegister', () => {
beforeEach(done => {
loadFixtures('u2f/register.html');
u2fDevice = new MockU2FDevice();
container = $('#js-register-u2f');
component = new U2FRegister(container, $('#js-register-u2f-templates'), {}, 'token');
container = $('#js-register-token-2fa');
component = new U2FRegister(container, {});
component
.start()
.then(done)
......@@ -22,9 +22,9 @@ describe('U2FRegister', () => {
});
it('allows registering a U2F device', () => {
const setupButton = container.find('#js-setup-u2f-device');
const setupButton = container.find('#js-setup-token-2fa-device');
expect(setupButton.text()).toBe('Set up new U2F device');
expect(setupButton.text()).toBe('Set up new device');
setupButton.trigger('click');
const inProgressMessage = container.children('p');
......@@ -41,7 +41,7 @@ describe('U2FRegister', () => {
describe('errors', () => {
it("doesn't allow the same device to be registered twice (for the same user", () => {
const setupButton = container.find('#js-setup-u2f-device');
const setupButton = container.find('#js-setup-token-2fa-device');
setupButton.trigger('click');
u2fDevice.respondToRegisterRequest({
errorCode: 4,
......@@ -52,7 +52,7 @@ describe('U2FRegister', () => {
});
it('displays an error message for other errors', () => {
const setupButton = container.find('#js-setup-u2f-device');
const setupButton = container.find('#js-setup-token-2fa-device');
setupButton.trigger('click');
u2fDevice.respondToRegisterRequest({
errorCode: 'error!',
......@@ -63,14 +63,14 @@ describe('U2FRegister', () => {
});
it('allows retrying registration after an error', () => {
let setupButton = container.find('#js-setup-u2f-device');
let setupButton = container.find('#js-setup-token-2fa-device');
setupButton.trigger('click');
u2fDevice.respondToRegisterRequest({
errorCode: 'error!',
});
const retryButton = container.find('#U2FTryAgain');
const retryButton = container.find('#js-token-2fa-try-again');
retryButton.trigger('click');
setupButton = container.find('#js-setup-u2f-device');
setupButton = container.find('#js-setup-token-2fa-device');
setupButton.trigger('click');
u2fDevice.respondToRegisterRequest({
deviceData: 'this is data from the device',
......
import $ from 'jquery';
import waitForPromises from 'helpers/wait_for_promises';
import WebAuthnAuthenticate from '~/authentication/webauthn/authenticate';
import MockWebAuthnDevice from './mock_webauthn_device';
import { useMockNavigatorCredentials } from './util';
const mockResponse = {
type: 'public-key',
id: '',
rawId: '',
response: { clientDataJSON: '', authenticatorData: '', signature: '', userHandle: '' },
getClientExtensionResults: () => {},
};
describe('WebAuthnAuthenticate', () => {
preloadFixtures('webauthn/authenticate.html');
useMockNavigatorCredentials();
let fallbackElement;
let webAuthnDevice;
let container;
let component;
let submitSpy;
const findDeviceResponseInput = () => container[0].querySelector('#js-device-response');
const findDeviceResponseInputValue = () => findDeviceResponseInput().value;
const findMessage = () => container[0].querySelector('p');
const findRetryButton = () => container[0].querySelector('#js-token-2fa-try-again');
const expectAuthenticated = () => {
expect(container.text()).toMatchInterpolatedText(
'We heard back from your device. You have been authenticated.',
);
expect(findDeviceResponseInputValue()).toBe(JSON.stringify(mockResponse));
expect(submitSpy).toHaveBeenCalled();
};
beforeEach(() => {
loadFixtures('webauthn/authenticate.html');
fallbackElement = document.createElement('div');
fallbackElement.classList.add('js-2fa-form');
webAuthnDevice = new MockWebAuthnDevice();
container = $('#js-authenticate-token-2fa');
component = new WebAuthnAuthenticate(
container,
'#js-login-token-2fa-form',
{
options:
// we need some valid base64 for base64ToBuffer
// so we use "YQ==" = base64("a")
JSON.stringify({
challenge: 'YQ==',
timeout: 120000,
allowCredentials: [
{ type: 'public-key', id: 'YQ==' },
{ type: 'public-key', id: 'YQ==' },
],
userVerification: 'discouraged',
}),
},
document.querySelector('#js-login-2fa-device'),
fallbackElement,
);
submitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit');
});
describe('with webauthn unavailable', () => {
let oldGetCredentials;
beforeEach(() => {
oldGetCredentials = window.navigator.credentials.get;
window.navigator.credentials.get = null;
});
afterEach(() => {
window.navigator.credentials.get = oldGetCredentials;
});
it('falls back to normal 2fa', () => {
component.start();
expect(container.html()).toBe('');
expect(container[0]).toHaveClass('hidden');
expect(fallbackElement).not.toHaveClass('hidden');
});
});
describe('with webauthn available', () => {
beforeEach(() => {
component.start();
});
it('shows in progress', () => {
const inProgressMessage = container.find('p');
expect(inProgressMessage.text()).toMatchInterpolatedText(
"Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.",
);
});
it('allows authenticating via a WebAuthn device', () => {
webAuthnDevice.respondToAuthenticateRequest(mockResponse);
return waitForPromises().then(() => {
expectAuthenticated();
});
});
describe('errors', () => {
beforeEach(() => {
webAuthnDevice.rejectAuthenticateRequest(new DOMException());
return waitForPromises();
});
it('displays an error message', () => {
expect(submitSpy).not.toHaveBeenCalled();
expect(findMessage().textContent).toMatchInterpolatedText(
'There was a problem communicating with your device. (Error)',
);
});
it('allows retrying authentication after an error', () => {
findRetryButton().click();
webAuthnDevice.respondToAuthenticateRequest(mockResponse);
return waitForPromises().then(() => {
expectAuthenticated();
});
});
});
});
});
import WebAuthnError from '~/authentication/webauthn/error';
describe('WebAuthnError', () => {
it.each([
[
'NotSupportedError',
'Your device is not compatible with GitLab. Please try another device',
'authenticate',
],
['InvalidStateError', 'This device has not been registered with us.', 'authenticate'],
['InvalidStateError', 'This device has already been registered with us.', 'register'],
['UnknownError', 'There was a problem communicating with your device.', 'register'],
])('exception %s will have message %s, flow type: %s', (exception, expectedMessage, flowType) => {
expect(new WebAuthnError(new DOMException('', exception), flowType).message()).toEqual(
expectedMessage,
);
});
describe('SecurityError', () => {
const { location } = window;
beforeEach(() => {
delete window.location;
window.location = {};
});
afterEach(() => {
window.location = location;
});
it('returns a descriptive error if https is disabled', () => {
window.location.protocol = 'http:';
const expectedMessage =
'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.';
expect(
new WebAuthnError(new DOMException('', 'SecurityError'), 'authenticate').message(),
).toEqual(expectedMessage);
});
it('returns a generic error if https is enabled', () => {
window.location.protocol = 'https:';
const expectedMessage = 'There was a problem communicating with your device.';
expect(
new WebAuthnError(new DOMException('', 'SecurityError'), 'authenticate').message(),
).toEqual(expectedMessage);
});
});
});
/* eslint-disable no-unused-expressions */
export default class MockWebAuthnDevice {
constructor() {
this.respondToAuthenticateRequest = this.respondToAuthenticateRequest.bind(this);
this.respondToRegisterRequest = this.respondToRegisterRequest.bind(this);
window.navigator.credentials || (window.navigator.credentials = {});
window.navigator.credentials.create = () =>
new Promise((resolve, reject) => {
this.registerCallback = resolve;
this.registerRejectCallback = reject;
});
window.navigator.credentials.get = () =>
new Promise((resolve, reject) => {
this.authenticateCallback = resolve;
this.authenticateRejectCallback = reject;
});
}
respondToRegisterRequest(params) {
return this.registerCallback(params);
}
respondToAuthenticateRequest(params) {
return this.authenticateCallback(params);
}
rejectRegisterRequest(params) {
return this.registerRejectCallback(params);
}
rejectAuthenticateRequest(params) {
return this.authenticateRejectCallback(params);
}
}
import $ from 'jquery';
import waitForPromises from 'helpers/wait_for_promises';
import WebAuthnRegister from '~/authentication/webauthn/register';
import MockWebAuthnDevice from './mock_webauthn_device';
import { useMockNavigatorCredentials } from './util';
describe('WebAuthnRegister', () => {
preloadFixtures('webauthn/register.html');
useMockNavigatorCredentials();
const mockResponse = {
type: 'public-key',
id: '',
rawId: '',
response: {
clientDataJSON: '',
attestationObject: '',
},
getClientExtensionResults: () => {},
};
let webAuthnDevice;
let container;
let component;
beforeEach(() => {
loadFixtures('webauthn/register.html');
webAuthnDevice = new MockWebAuthnDevice();
container = $('#js-register-token-2fa');
component = new WebAuthnRegister(container, {
options: {
rp: '',
user: {
id: '',
name: '',
displayName: '',
},
challenge: '',
pubKeyCredParams: '',
},
});
component.start();
});
const findSetupButton = () => container.find('#js-setup-token-2fa-device');
const findMessage = () => container.find('p');
const findDeviceResponse = () => container.find('#js-device-response');
const findRetryButton = () => container.find('#js-token-2fa-try-again');
it('shows setup button', () => {
expect(findSetupButton().text()).toBe('Set up new device');
});
describe('when unsupported', () => {
const { location, PublicKeyCredential } = window;
beforeEach(() => {
delete window.location;
delete window.credentials;
window.location = {};
window.PublicKeyCredential = undefined;
});
afterEach(() => {
window.location = location;
window.PublicKeyCredential = PublicKeyCredential;
});
it.each`
httpsEnabled | expectedText
${false} | ${'WebAuthn only works with HTTPS-enabled websites'}
${true} | ${'Please use a supported browser, e.g. Chrome (67+) or Firefox'}
`('when https is $httpsEnabled', ({ httpsEnabled, expectedText }) => {
window.location.protocol = httpsEnabled ? 'https:' : 'http:';
component.start();
expect(findMessage().text()).toContain(expectedText);
});
});
describe('when setup', () => {
beforeEach(() => {
findSetupButton().trigger('click');
});
it('shows in progress message', () => {
expect(findMessage().text()).toContain('Trying to communicate with your device');
});
it('registers device', () => {
webAuthnDevice.respondToRegisterRequest(mockResponse);
return waitForPromises().then(() => {
expect(findMessage().text()).toContain('Your device was successfully set up!');
expect(findDeviceResponse().val()).toBe(JSON.stringify(mockResponse));
});
});
it.each`
errorName | expectedText
${'NotSupportedError'} | ${'Your device is not compatible with GitLab'}
${'NotAllowedError'} | ${'There was a problem communicating with your device'}
`('when fails with $errorName', ({ errorName, expectedText }) => {
webAuthnDevice.rejectRegisterRequest(new DOMException('', errorName));
return waitForPromises().then(() => {
expect(findMessage().text()).toContain(expectedText);
expect(findRetryButton().length).toBe(1);
});
});
it('can retry', () => {
webAuthnDevice.respondToRegisterRequest({
errorCode: 'error!',
});
return waitForPromises()
.then(() => {
findRetryButton().click();
expect(findMessage().text()).toContain('Trying to communicate with your device');
webAuthnDevice.respondToRegisterRequest(mockResponse);
return waitForPromises();
})
.then(() => {
expect(findMessage().text()).toContain('Your device was successfully set up!');
expect(findDeviceResponse().val()).toBe(JSON.stringify(mockResponse));
});
});
});
});
export function useMockNavigatorCredentials() {
let oldNavigatorCredentials;
let oldPublicKeyCredential;
beforeEach(() => {
oldNavigatorCredentials = navigator.credentials;
oldPublicKeyCredential = window.PublicKeyCredential;
navigator.credentials = {
get: jest.fn(),
create: jest.fn(),
};
window.PublicKeyCredential = function MockPublicKeyCredential() {};
});
afterEach(() => {
navigator.credentials = oldNavigatorCredentials;
window.PublicKeyCredential = oldPublicKeyCredential;
});
}
......@@ -11,6 +11,10 @@ RSpec.context 'U2F' do
clean_frontend_fixtures('u2f/')
end
before do
stub_feature_flags(webauthn: false)
end
describe SessionsController, '(JavaScript fixtures)', type: :controller do
include DeviseHelpers
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.context 'WebAuthn' do
include JavaScriptFixturesHelpers
let(:user) { create(:user, :two_factor_via_webauthn, otp_secret: 'otpsecret:coolkids') }
before(:all) do
clean_frontend_fixtures('webauthn/')
end
describe SessionsController, '(JavaScript fixtures)', type: :controller do
include DeviseHelpers
render_views
before do
set_devise_mapping(context: @request)
end
it 'webauthn/authenticate.html' do
allow(controller).to receive(:find_user).and_return(user)
post :create, params: { user: { login: user.username, password: user.password } }
expect(response).to be_successful
end
end
describe Profiles::TwoFactorAuthsController, '(JavaScript fixtures)', type: :controller do
render_views
before do
sign_in(user)
allow_next_instance_of(Profiles::TwoFactorAuthsController) do |instance|
allow(instance).to receive(:build_qr_code).and_return('qrcode:blackandwhitesquares')
end
end
it 'webauthn/register.html' do
get :show
expect(response).to be_successful
end
end
end
......@@ -712,30 +712,40 @@ RSpec.describe User do
expect(users_with_two_factor).not_to include(user_without_2fa.id)
end
it "returns users with 2fa enabled via U2F" do
user_with_2fa = create(:user, :two_factor_via_u2f)
user_without_2fa = create(:user)
users_with_two_factor = described_class.with_two_factor.pluck(:id)
shared_examples "returns the right users" do |trait|
it "returns users with 2fa enabled via hardware token" do
user_with_2fa = create(:user, trait)
user_without_2fa = create(:user)
users_with_two_factor = described_class.with_two_factor.pluck(:id)
expect(users_with_two_factor).to include(user_with_2fa.id)
expect(users_with_two_factor).not_to include(user_without_2fa.id)
end
expect(users_with_two_factor).to include(user_with_2fa.id)
expect(users_with_two_factor).not_to include(user_without_2fa.id)
end
it "returns users with 2fa enabled via OTP and U2F" do
user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f)
user_without_2fa = create(:user)
users_with_two_factor = described_class.with_two_factor.pluck(:id)
it "returns users with 2fa enabled via OTP and hardware token" do
user_with_2fa = create(:user, :two_factor_via_otp, trait)
user_without_2fa = create(:user)
users_with_two_factor = described_class.with_two_factor.pluck(:id)
expect(users_with_two_factor).to eq([user_with_2fa.id])
expect(users_with_two_factor).not_to include(user_without_2fa.id)
expect(users_with_two_factor).to eq([user_with_2fa.id])
expect(users_with_two_factor).not_to include(user_without_2fa.id)
end
it 'works with ORDER BY' do
user_with_2fa = create(:user, :two_factor_via_otp, trait)
expect(described_class
.with_two_factor
.reorder_by_name).to eq([user_with_2fa])
end
end
it 'works with ORDER BY' do
user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f)
describe "and U2F" do
it_behaves_like "returns the right users", :two_factor_via_u2f
end
expect(described_class
.with_two_factor
.reorder_by_name).to eq([user_with_2fa])
describe "and WebAuthn" do
it_behaves_like "returns the right users", :two_factor_via_webauthn
end
end
......@@ -749,22 +759,44 @@ RSpec.describe User do
expect(users_without_two_factor).not_to include(user_with_2fa.id)
end
it "excludes users with 2fa enabled via U2F" do
user_with_2fa = create(:user, :two_factor_via_u2f)
user_without_2fa = create(:user)
users_without_two_factor = described_class.without_two_factor.pluck(:id)
describe "and u2f" do
it "excludes users with 2fa enabled via U2F" do
user_with_2fa = create(:user, :two_factor_via_u2f)
user_without_2fa = create(:user)
users_without_two_factor = described_class.without_two_factor.pluck(:id)
expect(users_without_two_factor).to include(user_without_2fa.id)
expect(users_without_two_factor).not_to include(user_with_2fa.id)
expect(users_without_two_factor).to include(user_without_2fa.id)
expect(users_without_two_factor).not_to include(user_with_2fa.id)
end
it "excludes users with 2fa enabled via OTP and U2F" do
user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f)
user_without_2fa = create(:user)
users_without_two_factor = described_class.without_two_factor.pluck(:id)
expect(users_without_two_factor).to include(user_without_2fa.id)
expect(users_without_two_factor).not_to include(user_with_2fa.id)
end
end
it "excludes users with 2fa enabled via OTP and U2F" do
user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f)
user_without_2fa = create(:user)
users_without_two_factor = described_class.without_two_factor.pluck(:id)
describe "and webauthn" do
it "excludes users with 2fa enabled via WebAuthn" do
user_with_2fa = create(:user, :two_factor_via_webauthn)
user_without_2fa = create(:user)
users_without_two_factor = described_class.without_two_factor.pluck(:id)
expect(users_without_two_factor).to include(user_without_2fa.id)
expect(users_without_two_factor).not_to include(user_with_2fa.id)
expect(users_without_two_factor).to include(user_without_2fa.id)
expect(users_without_two_factor).not_to include(user_with_2fa.id)
end
it "excludes users with 2fa enabled via OTP and WebAuthn" do
user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_webauthn)
user_without_2fa = create(:user)
users_without_two_factor = described_class.without_two_factor.pluck(:id)
expect(users_without_two_factor).to include(user_without_2fa.id)
expect(users_without_two_factor).not_to include(user_with_2fa.id)
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
require 'webauthn/fake_client'
RSpec.describe Webauthn::AuthenticateService do
let(:client) { WebAuthn::FakeClient.new(origin) }
let(:user) { create(:user) }
let(:challenge) { Base64.strict_encode64(SecureRandom.random_bytes(32)) }
let(:origin) { 'http://localhost' }
before do
create_result = client.create(challenge: challenge) # rubocop:disable Rails/SaveBang
webauthn_credential = WebAuthn::Credential.from_create(create_result)
registration = WebauthnRegistration.new(credential_xid: Base64.strict_encode64(webauthn_credential.raw_id),
public_key: webauthn_credential.public_key,
counter: 0,
name: 'name',
user_id: user.id)
registration.save!
end
describe '#execute' do
it 'returns true if the response is valid and a matching stored credential is present' do
get_result = client.get(challenge: challenge)
get_result['clientExtensionResults'] = {}
service = Webauthn::AuthenticateService.new(user, get_result.to_json, challenge)
expect(service.execute).to be_truthy
end
it 'returns false if the response is valid but no matching stored credential is present' do
other_client = WebAuthn::FakeClient.new(origin)
other_client.create(challenge: challenge) # rubocop:disable Rails/SaveBang
get_result = other_client.get(challenge: challenge)
get_result['clientExtensionResults'] = {}
service = Webauthn::AuthenticateService.new(user, get_result.to_json, challenge)
expect(service.execute).to be_falsey
end
end
end
# frozen_string_literal: true
require 'spec_helper'
require 'webauthn/fake_client'
RSpec.describe Webauthn::RegisterService do
let(:client) { WebAuthn::FakeClient.new(origin) }
let(:user) { create(:user) }
let(:challenge) { Base64.strict_encode64(SecureRandom.random_bytes(32)) }
let(:origin) { 'http://localhost' }
describe '#execute' do
it 'returns a registration if challenge matches' do
create_result = client.create(challenge: challenge) # rubocop:disable Rails/SaveBang
webauthn_credential = WebAuthn::Credential.from_create(create_result)
params = { device_response: create_result.to_json, name: 'abc' }
service = Webauthn::RegisterService.new(user, params, challenge)
registration = service.execute
expect(registration.credential_xid).to eq(Base64.strict_encode64(webauthn_credential.raw_id))
expect(registration.errors.size).to eq(0)
end
it 'returns an error if challenge does not match' do
create_result = client.create(challenge: Base64.strict_encode64(SecureRandom.random_bytes(16))) # rubocop:disable Rails/SaveBang
params = { device_response: create_result.to_json, name: 'abc' }
service = Webauthn::RegisterService.new(user, params, challenge)
registration = service.execute
expect(registration.errors.size).to eq(1)
end
end
end
......@@ -3,9 +3,10 @@
class FakeU2fDevice
attr_reader :name
def initialize(page, name)
def initialize(page, name, device = nil)
@page = page
@name = name
@u2f_device = device
end
def respond_to_u2f_registration
......
# frozen_string_literal: true
require 'webauthn/fake_client'
class FakeWebauthnDevice
attr_reader :name
def initialize(page, name, device = nil)
@page = page
@name = name
@webauthn_device = device
end
def respond_to_webauthn_registration
app_id = @page.evaluate_script('gon.webauthn.app_id')
challenge = @page.evaluate_script('gon.webauthn.options.challenge')
json_response = webauthn_device(app_id).create(challenge: challenge).to_json # rubocop:disable Rails/SaveBang
@page.execute_script <<~JS
var result = #{json_response};
result.getClientExtensionResults = () => ({});
navigator.credentials.create = function(_) {
return Promise.resolve(result);
};
JS
end
def respond_to_webauthn_authentication
app_id = @page.evaluate_script('JSON.parse(gon.webauthn.options).extensions.appid')
challenge = @page.evaluate_script('JSON.parse(gon.webauthn.options).challenge')
begin
json_response = webauthn_device(app_id).get(challenge: challenge).to_json
rescue RuntimeError
# A runtime error is raised from fake webauthn if no credentials have been registered yet.
# To be able to test non registered devices, credentials are created ad-hoc
webauthn_device(app_id).create # rubocop:disable Rails/SaveBang
json_response = webauthn_device(app_id).get(challenge: challenge).to_json
end
@page.execute_script <<~JS
var result = #{json_response};
result.getClientExtensionResults = () => ({});
navigator.credentials.get = function(_) {
return Promise.resolve(result);
};
JS
@page.click_link('Try again?', href: false)
end
def fake_webauthn_authentication
@page.execute_script <<~JS
const mockResponse = {
type: 'public-key',
id: '',
rawId: '',
response: { clientDataJSON: '', authenticatorData: '', signature: '', userHandle: '' },
getClientExtensionResults: () => {},
};
window.gl.resolveWebauthn(mockResponse);
JS
end
def add_credential(app_id, credential_id, credential_key)
credentials = { URI.parse(app_id).host => { credential_id => { credential_key: credential_key, sign_count: 0 } } }
webauthn_device(app_id).send(:authenticator).instance_variable_set(:@credentials, credentials)
end
private
def webauthn_device(app_id)
@webauthn_device ||= WebAuthn::FakeClient.new(app_id)
end
end
# frozen_string_literal: true
# These helpers allow you to manage and register
# U2F and WebAuthn devices
#
# Usage:
# describe "..." do
# include Spec::Support::Helpers::Features::TwoFactorHelpers
# ...
#
# manage_two_factor_authentication
#
module Spec
module Support
module Helpers
module Features
module TwoFactorHelpers
def manage_two_factor_authentication
click_on 'Manage two-factor authentication'
expect(page).to have_content("Set up new device")
wait_for_requests
end
def register_u2f_device(u2f_device = nil, name: 'My device')
u2f_device ||= FakeU2fDevice.new(page, name)
u2f_device.respond_to_u2f_registration
click_on 'Set up new device'
expect(page).to have_content('Your device was successfully set up')
fill_in "Pick a name", with: name
click_on 'Register device'
u2f_device
end
# Registers webauthn device via UI
def register_webauthn_device(webauthn_device = nil, name: 'My device')
webauthn_device ||= FakeWebauthnDevice.new(page, name)
webauthn_device.respond_to_webauthn_registration
click_on 'Set up new device'
expect(page).to have_content('Your device was successfully set up')
fill_in 'Pick a name', with: name
click_on 'Register device'
webauthn_device
end
# Adds webauthn device directly via database
def add_webauthn_device(app_id, user, fake_device = nil, name: 'My device')
fake_device ||= WebAuthn::FakeClient.new(app_id)
options_for_create = WebAuthn::Credential.options_for_create(
user: { id: user.webauthn_xid, name: user.username },
authenticator_selection: { user_verification: 'discouraged' },
rp: { name: 'GitLab' }
)
challenge = options_for_create.challenge
device_response = fake_device.create(challenge: challenge).to_json # rubocop:disable Rails/SaveBang
device_registration_params = { device_response: device_response,
name: name }
Webauthn::RegisterService.new(
user, device_registration_params, challenge).execute
FakeWebauthnDevice.new(page, name, fake_device)
end
def assert_fallback_ui(page)
expect(page).to have_button('Verify code')
expect(page).to have_css('#user_otp_attempt')
expect(page).not_to have_link('Sign in via 2FA code')
expect(page).not_to have_css("#js-authenticate-token-2fa")
end
end
end
end
end
end
......@@ -111,6 +111,11 @@ module LoginHelpers
FakeU2fDevice.new(page, nil).fake_u2f_authentication
end
def fake_successful_webauthn_authentication
allow_any_instance_of(Webauthn::AuthenticateService).to receive(:execute).and_return(true)
FakeWebauthnDevice.new(page, nil).fake_webauthn_authentication
end
def mock_auth_hash_with_saml_xml(provider, uid, email, saml_response)
response_object = { document: saml_xml(saml_response) }
mock_auth_hash(provider, uid, email, response_object: response_object)
......
# frozen_string_literal: true
RSpec.shared_examples 'hardware device for 2fa' do |device_type|
include Spec::Support::Helpers::Features::TwoFactorHelpers
def register_device(device_type, **kwargs)
case device_type.downcase
when "u2f"
register_u2f_device(**kwargs)
when "webauthn"
register_webauthn_device(**kwargs)
else
raise "Unknown device type #{device_type}"
end
end
describe "registration" do
let(:user) { create(:user) }
before do
gitlab_sign_in(user)
user.update_attribute(:otp_required_for_login, true)
end
describe 'when 2FA via OTP is disabled' do
before do
user.update_attribute(:otp_required_for_login, false)
end
it 'does not allow registering a new device' do
visit profile_account_path
click_on 'Enable two-factor authentication'
expect(page).to have_button("Set up new device", disabled: true)
end
end
describe 'when 2FA via OTP is enabled' do
it 'allows registering a new device with a name' do
visit profile_account_path
manage_two_factor_authentication
expect(page).to have_content("You've already enabled two-factor authentication using one time password authenticators")
device = register_device(device_type)
expect(page).to have_content(device.name)
expect(page).to have_content("Your #{device_type} device was registered")
end
it 'allows deleting a device' do
visit profile_account_path
manage_two_factor_authentication
expect(page).to have_content("You've already enabled two-factor authentication using one time password authenticators")
first_device = register_device(device_type)
second_device = register_device(device_type, name: 'My other device')
expect(page).to have_content(first_device.name)
expect(page).to have_content(second_device.name)
accept_confirm { click_on 'Delete', match: :first }
expect(page).to have_content('Successfully deleted')
expect(page.body).not_to have_content(first_device.name)
expect(page.body).to have_content(second_device.name)
end
end
end
describe 'fallback code authentication' do
let(:user) { create(:user) }
before do
# Register and logout
gitlab_sign_in(user)
user.update_attribute(:otp_required_for_login, true)
visit profile_account_path
end
describe 'when no device is registered' do
before do
gitlab_sign_out
gitlab_sign_in(user)
end
it 'shows the fallback otp code UI' do
assert_fallback_ui(page)
end
end
describe 'when a device is registered' do
before do
manage_two_factor_authentication
register_device(device_type)
gitlab_sign_out
gitlab_sign_in(user)
end
it 'provides a button that shows the fallback otp code UI' do
expect(page).to have_link('Sign in via 2FA code')
click_link('Sign in via 2FA code')
assert_fallback_ui(page)
end
end
end
end
......@@ -32,6 +32,10 @@ RSpec.describe 'admin/sessions/two_factor.html.haml' do
context 'user has u2f active' do
let(:user) { create(:admin, :two_factor_via_u2f) }
before do
stub_feature_flags(webauthn: false)
end
it 'shows enter u2f form' do
render
......
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