summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorRémy Coutable <remy@rymai.me>2016-06-06 09:40:49 +0000
committerRémy Coutable <remy@rymai.me>2016-06-06 09:40:49 +0000
commit3cb69f0c0b0049426e6abad0914812a9eef87b04 (patch)
tree213dfa68ae204871cada77962ae7921a9ecf9e50 /spec
parentfc809d689a03e69c581c1bb8ed0cf246953a7c08 (diff)
parentcdf7a6c2ded729174a5e099b2fc255ee61f0cc79 (diff)
downloadgitlab-ce-3cb69f0c0b0049426e6abad0914812a9eef87b04.tar.gz
Merge branch '15337-yubikey-support' into 'master'
Allow a U2F Device to be the Second Factor for Authentication Parent Issue: #15337 ## TODO - [ ] #15337 (!3905) FIDO/U2F 2FA using Yubikey - [x] Order a Yubikey? - [x] Do some reading to figure out what all this stuff means - [x] Look through the existing MR - [x] Browser support? - [x] Implementation - [x] User can register 2FA using their U2H device instead of authenticator - [x] Barebones flow - [x] Save the registration in the database - [x] Authentication flow - [x] First try after login/server start doesn't work - [x] User can log in using their U2F device - [x] Allow setting up authenticator if U2F is already set up (or vice versa) - [x] Change `two_factor_auths/new` to `show` - [x] `sign_requests` during registration? (Registering a device that has already been registered) - [x] 2FA skippable flow? - [x] Enforced 2FA flow (grace period?) - [x] Move the "Configure it Later" button to the right place - [x] Don't allow registration when the yubikey isn't plugged in - [x] Polish authentication flow - [x] Login should only show the 2FA method that's enabled - [x] Message to say that u2f only works on chrome, and it's recommended to enable otp as well. - [x] Index for key_handle - [x] Server-side errors while registering/logging in - [x] Handle non-chrome browsers - [x] Try to authenticate with a key that hasn't been registered (shouldn't work) - [x] Try the same key for multiple user accounts (should work) - [x] Fix existing tests - [x] Make sure CI is green - [x] Add tests - [x] Figure out how to fake the Yubikey - [x] Teaspoon tests for the React components - [x] Each device can only be registered once per user - [x] Feature specs - [x] Regular flows - [x] Test error cases - [x] Refactoring - [x] Refactor App ID - [x] Clean up the `show` action - [x] Annotate methods with definition of U2F - [x] Changelog - [x] Fix merge conflicts - [x] Verify flows - [x] Authenticator + no U2F - [x] U2F + no authenticator - [x] U2F + authenticator - [x] U2F + authenticator -> disable 2FA - [x] 2FA required with different grace periods - [x] Screenshots for MR - [x] Augment the [help docs](http://localhost:3000/help/profile/two_factor_authentication) - [x] Assign to endboss - [x] Ask for feedback on UI/UX - [x] Ask for feedback on copy - [x] Wait for review/merge - [x] Fix merge conflicts - [x] Wait for CI to pass - [x] Implement review comments/suggestions - [x] Move `TwoFactorAuthController#create_u2f` to a service - [x] Extra space before `Base64` in `u2f_registration` model - [x] Move `with/without_two_factor` scopes to class methods - [x] In `profiles/accounts/show`, add spaces at `{` and `}` - [x] Remove blank lines in `profiles/two_factor_auths/show` - [x] Fix typo in doc. "(universal 2nd factor )" - [x] Add "Added in 8.8" to doc - [x] In the doc, use 'Enable 2FA via mobile application' instead of 'Via Mobile Application' - [x] In the doc, use 'Enable 2FA via U2F device' instead of 'Via U2F Device - [x] Use "Two-Factor Authentication" everywhere - [x] Use `#icon` wrapper instead of `fa_stacked_icon` - [x] Check if `string` is enough for `key_handle` and `public_key` - [x] Separate `exercise` and `verify` phases of test (u2f_spec) - [x] Assert that `user_without_2fa` is _not_ in results (with_two_factor) - [x] Remove rubocop exception - [x] Refactor call to `User.with_two_factor.count` to not include `.length` - [x] Add a note that makes the "Disable" button/feature obvious - [x] Remove i18n - [x] Test in Firefox with addon (+ create new issue for support) - [x] Remove React - [x] Rewrite registration - [x] Switch underscore template to default style - [x] Rewrite authentication - [x] Move `register` haml to `u2f` dir - [x] Remove instance variables - [x] Fix tests - [x] Read SCSS guidelines - [x] Address @connorshea's comments regarding text style - [x] Make sure all classes and IDs are in line (add `js-` prefixes) - [x] Register - [x] Authenticate - [x] Refactoring? - [x] Include non-minifed version of bowser - [x] Audit log - [x] Look at the `browser` gem (and don't use bowser) - [x] Error message when on HTTP? - [x] Test on Mobile - [x] Fix merge conflicts - [x] Retest all flows - [x] Back to Rémy for review - [x] Make sure CI is green - [x] Wait for merge / more feedback - [x] Implement @rymai's changes - [x] JS/Coffeescript variables should be lowerCamelCase - [x] Spaces before/after `}` and `{` in HAML (and elsewhere) - [x] Rails view helpers in u2f HAML - [x] `%div.row.append-bottom-10` - [x] Wrap line in `without_two_factor` scope - [x] Exception-less flow in `U2F::CreateService` - [x] Fix merge conflicts - [x] Move service to model class method - [x] Fix teaspoon specs - [x] Address @rymai's suggestions about error handing - [x] Javascript error constants - [x] Fix merge conflicts - [x] One final review - [x] Test "registration with errors" flow - [x] Assign to Remy - [x] Wait for replies from @jschatz1 - [x] Address @rymai's comments - [x] Omit `%div` - [x] Scope `$.find` globally - [x] Replace `find('#element-id).click` with `click_on('Element Text') - [x] Rebase master + conflicts - [x] Look at https://news.ycombinator.com/item?id=11690774 - [x] Address @connorshea's comment regarding HTTPS on localhost - [x] Final sanity check - [x] Wait for [CI to pass](https://gitlab.com/gitlab-org/gitlab-ce/commit/c84179ad233529c33ee6ba8491cfea862c6cd864/builds) - [x] Address @rymai's next round of comments - [x] Interpolate `true` and `false` in DB scopes - [x] Why have `Gon::Base.render_data` thrice? - [x] `user_spec` should have correct spacing - [x] Use `arel_table[:id]` instead of `users.id` - [x] URL helper in `app/views/profiles/two_factor_auths/show.html.haml` - [x] Remove polyfill change - [x] Wait for [CI to pass](https://gitlab.com/gitlab-org/gitlab-ce/commit/0123ab8/builds) - [x] Address @jschatz1's comments - [x] Use `on('click', ...)` instead of `click(...)` - [x] Use `is` and `isnt` in coffeescript - [x] Use `and` and `or` in coffeescript - [x] Add `Gon::Base.render_data` to `devise_empty` (and other base layouts) - [x] Wait for [CI to pass](https://gitlab.com/gitlab-org/gitlab-ce/commit/401916397336174c582be3d3004a072f845d4b5f/builds) - [x] Wait for [build](https://gitlab.com/gitlab-org/gitlab-ce/commit/75955710ef9a5f0dcee04e8617028c0e3ea5bf50/builds) to pass - [x] Fix merge conflicts - [x] Inspect diff / workflow - [x] Assign back to @rymai - [x] Make sure [ci](https://gitlab.com/gitlab-org/gitlab-ce/commit/2c6316b29a9276ef44c7b4b39363a611bf5973a6/builds) has passed - [x] Fix merge conflicts (probably introduced by [devise upgrade](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4216) - [x] Wait for [CI](https://gitlab.com/gitlab-org/gitlab-ce/commit/a5ef48b7aa63d0d9e45b41643043b57208eaab9f/builds) to pass - [x] Respond to @rymai's comments - [x] Use `elsif` - [x] Check if we need `and return` - [x] Only fetch key handles from the DB - [x] No annotations to models? - [x] Align hash keys in model - [x] Wait for [build](https://gitlab.com/gitlab-org/gitlab-ce/commit/e0ef504734e7f14813c73bbb79f5c5f6fae3248c/builds) to pass - [ ] Wait for merge ## Screenshots ![Screenshot_2016-05-03_09.53.04](/uploads/1af3f277efa488dc107d36e6b4b07ca4/Screenshot_2016-05-03_09.53.04.png) ![Screenshot_2016-05-03_10.19.53](/uploads/2bfc67dfb96c0e005cce033d8b456813/Screenshot_2016-05-03_10.19.53.png) ![Screenshot_2016-05-03_10.19.56](/uploads/e912abedd5b1d07d7185cee9f204c5ff/Screenshot_2016-05-03_10.19.56.png) ![Screenshot_2016-05-03_10.20.04](/uploads/9350d5c98823d1f3d4e59517dfb8910a/Screenshot_2016-05-03_10.20.04.png) ![Screenshot_2016-05-03_10.31.15](/uploads/84473dc263e0643311a39006e649035f/Screenshot_2016-05-03_10.31.15.png) ![Screenshot_2016-05-03_10.31.22](/uploads/13ce43e0d7a565000af29984667eeb08/Screenshot_2016-05-03_10.31.22.png) ![Screenshot_2016-05-03_10.31.37](/uploads/b90fbb40dbf9bbd73af324f48ffdc948/Screenshot_2016-05-03_10.31.37.png) ![Screenshot_2016-05-03_10.36.48](/uploads/41a0fbc493c6fefeafd922b3ddf2a25e/Screenshot_2016-05-03_10.36.48.png) See merge request !3905
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/profiles/two_factor_auths_controller_spec.rb14
-rw-r--r--spec/controllers/sessions_controller_spec.rb26
-rw-r--r--spec/factories/u2f_registrations.rb8
-rw-r--r--spec/factories/users.rb14
-rw-r--r--spec/features/admin/admin_users_spec.rb10
-rw-r--r--spec/features/login_spec.rb26
-rw-r--r--spec/features/u2f_spec.rb239
-rw-r--r--spec/javascripts/fixtures/u2f/authenticate.html.haml1
-rw-r--r--spec/javascripts/fixtures/u2f/register.html.haml1
-rw-r--r--spec/javascripts/u2f/authenticate_spec.coffee52
-rw-r--r--spec/javascripts/u2f/mock_u2f_device.js.coffee15
-rw-r--r--spec/javascripts/u2f/register_spec.js.coffee57
-rw-r--r--spec/models/user_spec.rb60
-rw-r--r--spec/support/fake_u2f_device.rb36
14 files changed, 532 insertions, 27 deletions
diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb
index 4fb1473c2d2..d08d0018b35 100644
--- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb
+++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb
@@ -8,21 +8,21 @@ describe Profiles::TwoFactorAuthsController do
allow(subject).to receive(:current_user).and_return(user)
end
- describe 'GET new' do
+ describe 'GET show' do
let(:user) { create(:user) }
it 'generates otp_secret for user' do
expect(User).to receive(:generate_otp_secret).with(32).and_return('secret').once
- get :new
- get :new # Second hit shouldn't re-generate it
+ get :show
+ get :show # Second hit shouldn't re-generate it
end
it 'assigns qr_code' do
code = double('qr code')
expect(subject).to receive(:build_qr_code).and_return(code)
- get :new
+ get :show
expect(assigns[:qr_code]).to eq code
end
end
@@ -40,7 +40,7 @@ describe Profiles::TwoFactorAuthsController do
expect(user).to receive(:validate_and_consume_otp!).with(pin).and_return(true)
end
- it 'sets two_factor_enabled' do
+ it 'enables 2fa for the user' do
go
user.reload
@@ -79,9 +79,9 @@ describe Profiles::TwoFactorAuthsController do
expect(assigns[:qr_code]).to eq code
end
- it 'renders new' do
+ it 'renders show' do
go
- expect(response).to render_template(:new)
+ expect(response).to render_template(:show)
end
end
end
diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb
index 5dc8724fb50..4e9bfb0c69b 100644
--- a/spec/controllers/sessions_controller_spec.rb
+++ b/spec/controllers/sessions_controller_spec.rb
@@ -25,10 +25,15 @@ describe SessionsController do
expect(response).to set_flash.to /Signed in successfully/
expect(subject.current_user). to eq user
end
+
+ it "creates an audit log record" do
+ expect { post(:create, user: { login: user.username, password: user.password }) }.to change { SecurityEvent.count }.by(1)
+ expect(SecurityEvent.last.details[:with]).to eq("standard")
+ end
end
end
- context 'when using two-factor authentication' do
+ context 'when using two-factor authentication via OTP' do
let(:user) { create(:user, :two_factor) }
def authenticate_2fa(user_params)
@@ -117,6 +122,25 @@ describe SessionsController do
end
end
end
+
+ it "creates an audit log record" do
+ expect { authenticate_2fa(login: user.username, otp_attempt: user.current_otp) }.to change { SecurityEvent.count }.by(1)
+ expect(SecurityEvent.last.details[:with]).to eq("two-factor")
+ end
+ end
+
+ context 'when using two-factor authentication via U2F device' do
+ let(:user) { create(:user, :two_factor) }
+
+ def authenticate_2fa_u2f(user_params)
+ post(:create, { user: user_params }, { otp_user_id: user.id })
+ end
+
+ it "creates an audit log record" do
+ allow(U2fRegistration).to receive(:authenticate).and_return(true)
+ expect { authenticate_2fa_u2f(login: user.username, device_response: "{}") }.to change { SecurityEvent.count }.by(1)
+ expect(SecurityEvent.last.details[:with]).to eq("two-factor-via-u2f-device")
+ end
end
end
end
diff --git a/spec/factories/u2f_registrations.rb b/spec/factories/u2f_registrations.rb
new file mode 100644
index 00000000000..df92b079581
--- /dev/null
+++ b/spec/factories/u2f_registrations.rb
@@ -0,0 +1,8 @@
+FactoryGirl.define do
+ factory :u2f_registration do
+ certificate { FFaker::BaconIpsum.characters(728) }
+ key_handle { FFaker::BaconIpsum.characters(86) }
+ public_key { FFaker::BaconIpsum.characters(88) }
+ counter 0
+ end
+end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index a9b2148bd2a..c6f7869516e 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -15,14 +15,26 @@ FactoryGirl.define do
end
trait :two_factor do
+ two_factor_via_otp
+ end
+
+ trait :two_factor_via_otp do
before(:create) do |user|
- user.two_factor_enabled = true
+ user.otp_required_for_login = true
user.otp_secret = User.generate_otp_secret(32)
user.otp_grace_period_started_at = Time.now
user.generate_otp_backup_codes!
end
end
+ trait :two_factor_via_u2f do
+ transient { registrations_count 5 }
+
+ after(:create) do |user, evaluator|
+ create_list(:u2f_registration, evaluator.registrations_count, user: user)
+ end
+ end
+
factory :omniauth_user do
transient do
extern_uid '123456'
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index 96621843b30..b72ad405479 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -19,7 +19,7 @@ describe "Admin::Users", feature: true do
describe 'Two-factor Authentication filters' do
it 'counts users who have enabled 2FA' do
- create(:user, two_factor_enabled: true)
+ create(:user, :two_factor)
visit admin_users_path
@@ -29,7 +29,7 @@ describe "Admin::Users", feature: true do
end
it 'filters by users who have enabled 2FA' do
- user = create(:user, two_factor_enabled: true)
+ user = create(:user, :two_factor)
visit admin_users_path
click_link '2FA Enabled'
@@ -38,7 +38,7 @@ describe "Admin::Users", feature: true do
end
it 'counts users who have not enabled 2FA' do
- create(:user, two_factor_enabled: false)
+ create(:user)
visit admin_users_path
@@ -48,7 +48,7 @@ describe "Admin::Users", feature: true do
end
it 'filters by users who have not enabled 2FA' do
- user = create(:user, two_factor_enabled: false)
+ user = create(:user)
visit admin_users_path
click_link '2FA Disabled'
@@ -173,7 +173,7 @@ describe "Admin::Users", feature: true do
describe 'Two-factor Authentication status' do
it 'shows when enabled' do
- @user.update_attribute(:two_factor_enabled, true)
+ @user.update_attribute(:otp_required_for_login, true)
visit admin_user_path(@user)
diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb
index c1b178c3b6c..72b5ff231f7 100644
--- a/spec/features/login_spec.rb
+++ b/spec/features/login_spec.rb
@@ -33,11 +33,11 @@ feature 'Login', feature: true do
before do
login_with(user, remember: true)
- expect(page).to have_content('Two-factor Authentication')
+ expect(page).to have_content('Two-Factor Authentication')
end
def enter_code(code)
- fill_in 'Two-factor Authentication code', with: code
+ fill_in 'Two-Factor Authentication code', with: code
click_button 'Verify code'
end
@@ -143,12 +143,12 @@ feature 'Login', feature: true do
context 'within the grace period' do
it 'redirects to two-factor configuration page' do
- expect(current_path).to eq new_profile_two_factor_auth_path
- expect(page).to have_content('You must enable Two-factor Authentication for your account before')
+ expect(current_path).to eq profile_two_factor_auth_path
+ expect(page).to have_content('You must enable Two-Factor Authentication for your account before')
end
- it 'disallows skipping two-factor configuration' do
- expect(current_path).to eq new_profile_two_factor_auth_path
+ it 'allows skipping two-factor configuration', js: true do
+ expect(current_path).to eq profile_two_factor_auth_path
click_link 'Configure it later'
expect(current_path).to eq root_path
@@ -159,26 +159,26 @@ feature 'Login', feature: true do
let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) }
it 'redirects to two-factor configuration page' do
- expect(current_path).to eq new_profile_two_factor_auth_path
- expect(page).to have_content('You must enable Two-factor Authentication for your account.')
+ expect(current_path).to eq profile_two_factor_auth_path
+ expect(page).to have_content('You must enable Two-Factor Authentication for your account.')
end
- it 'disallows skipping two-factor configuration' do
- expect(current_path).to eq new_profile_two_factor_auth_path
+ it 'disallows skipping two-factor configuration', js: true do
+ expect(current_path).to eq profile_two_factor_auth_path
expect(page).not_to have_link('Configure it later')
end
end
end
- context 'without grace pariod defined' do
+ context 'without grace period defined' do
before(:each) do
stub_application_setting(two_factor_grace_period: 0)
login_with(user)
end
it 'redirects to two-factor configuration page' do
- expect(current_path).to eq new_profile_two_factor_auth_path
- expect(page).to have_content('You must enable Two-factor Authentication for your account.')
+ expect(current_path).to eq profile_two_factor_auth_path
+ expect(page).to have_content('You must enable Two-Factor Authentication for your account.')
end
end
end
diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb
new file mode 100644
index 00000000000..366a90228b1
--- /dev/null
+++ b/spec/features/u2f_spec.rb
@@ -0,0 +1,239 @@
+require 'spec_helper'
+
+feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: true, js: true do
+ def register_u2f_device(u2f_device = nil)
+ u2f_device ||= FakeU2fDevice.new(page)
+ u2f_device.respond_to_u2f_registration
+ click_on 'Setup New U2F Device'
+ expect(page).to have_content('Your device was successfully set up')
+ click_on 'Register U2F Device'
+ u2f_device
+ end
+
+ describe "registration" do
+ let(:user) { create(:user) }
+ before { login_as(user) }
+
+ describe 'when 2FA via OTP is disabled' do
+ it 'allows registering a new device' do
+ visit profile_account_path
+ click_on 'Enable Two-Factor Authentication'
+
+ register_u2f_device
+
+ expect(page.body).to match('Your U2F device was registered')
+ end
+
+ it 'allows registering more than one device' do
+ visit profile_account_path
+
+ # First device
+ click_on 'Enable Two-Factor Authentication'
+ register_u2f_device
+ expect(page.body).to match('Your U2F device was registered')
+
+ # Second device
+ click_on 'Manage Two-Factor Authentication'
+ register_u2f_device
+ expect(page.body).to match('Your U2F device was registered')
+ click_on 'Manage Two-Factor Authentication'
+
+ expect(page.body).to match('You have 2 U2F devices registered')
+ end
+ end
+
+ describe 'when 2FA via OTP is enabled' do
+ before { user.update_attributes(otp_required_for_login: true) }
+
+ it 'allows registering a new device' do
+ visit profile_account_path
+ click_on 'Manage Two-Factor Authentication'
+ expect(page.body).to match("You've already enabled two-factor authentication using mobile")
+
+ register_u2f_device
+
+ expect(page.body).to match('Your U2F device was registered')
+ end
+
+ it 'allows registering more than one device' do
+ visit profile_account_path
+
+ # First device
+ click_on 'Manage Two-Factor Authentication'
+ register_u2f_device
+ expect(page.body).to match('Your U2F device was registered')
+
+ # Second device
+ click_on 'Manage Two-Factor Authentication'
+ register_u2f_device
+ expect(page.body).to match('Your U2F device was registered')
+
+ click_on 'Manage Two-Factor Authentication'
+ expect(page.body).to match('You have 2 U2F devices registered')
+ end
+ end
+
+ it 'allows the same device to be registered for multiple users' do
+ # First user
+ visit profile_account_path
+ click_on 'Enable Two-Factor Authentication'
+ u2f_device = register_u2f_device
+ expect(page.body).to match('Your U2F device was registered')
+ logout
+
+ # Second user
+ login_as(:user)
+ visit profile_account_path
+ click_on 'Enable Two-Factor Authentication'
+ register_u2f_device(u2f_device)
+ expect(page.body).to match('Your U2F device was registered')
+
+ expect(U2fRegistration.count).to eq(2)
+ end
+
+ context "when there are form errors" do
+ it "doesn't register the device if there are errors" do
+ visit profile_account_path
+ click_on 'Enable Two-Factor Authentication'
+
+ # Have the "u2f device" respond with bad data
+ page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };")
+ click_on 'Setup New U2F Device'
+ expect(page).to have_content('Your device was successfully set up')
+ click_on 'Register U2F Device'
+
+ expect(U2fRegistration.count).to eq(0)
+ expect(page.body).to match("The form contains the following error")
+ expect(page.body).to match("did not send a valid JSON response")
+ end
+
+ it "allows retrying registration" do
+ visit profile_account_path
+ click_on 'Enable Two-Factor Authentication'
+
+ # Failed registration
+ page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };")
+ click_on 'Setup New U2F Device'
+ expect(page).to have_content('Your device was successfully set up')
+ click_on 'Register U2F Device'
+ expect(page.body).to match("The form contains the following error")
+
+ # Successful registration
+ register_u2f_device
+
+ expect(page.body).to match('Your U2F device was registered')
+ expect(U2fRegistration.count).to eq(1)
+ end
+ end
+ end
+
+ describe "authentication" do
+ let(:user) { create(:user) }
+
+ before do
+ # Register and logout
+ login_as(user)
+ visit profile_account_path
+ click_on 'Enable Two-Factor Authentication'
+ @u2f_device = register_u2f_device
+ logout
+ end
+
+ describe "when 2FA via OTP is disabled" do
+ it "allows logging in with the U2F device" do
+ login_with(user)
+
+ @u2f_device.respond_to_u2f_authentication
+ click_on "Login Via U2F Device"
+ expect(page.body).to match('We heard back from your U2F device')
+ click_on "Authenticate via U2F Device"
+
+ expect(page.body).to match('Signed in successfully')
+ end
+ end
+
+ describe "when 2FA via OTP is enabled" do
+ it "allows logging in with the U2F device" do
+ user.update_attributes(otp_required_for_login: true)
+ login_with(user)
+
+ @u2f_device.respond_to_u2f_authentication
+ click_on "Login Via U2F Device"
+ expect(page.body).to match('We heard back from your U2F device')
+ click_on "Authenticate via U2F Device"
+
+ expect(page.body).to match('Signed in successfully')
+ end
+ end
+
+ describe "when a given U2F device has already been registered by another user" do
+ describe "but not the current user" do
+ it "does not allow logging in with that particular device" do
+ # Register current user with the different U2F device
+ current_user = login_as(:user)
+ visit profile_account_path
+ click_on 'Enable Two-Factor Authentication'
+ register_u2f_device
+ logout
+
+ # Try authenticating user with the old U2F device
+ login_as(current_user)
+ @u2f_device.respond_to_u2f_authentication
+ click_on "Login Via U2F Device"
+ expect(page.body).to match('We heard back from your U2F device')
+ click_on "Authenticate via U2F Device"
+
+ expect(page.body).to match('Authentication via U2F device failed')
+ end
+ end
+
+ describe "and also the current user" do
+ it "allows logging in with that particular device" do
+ # Register current user with the same U2F device
+ current_user = login_as(:user)
+ visit profile_account_path
+ click_on 'Enable Two-Factor Authentication'
+ register_u2f_device(@u2f_device)
+ logout
+
+ # Try authenticating user with the same U2F device
+ login_as(current_user)
+ @u2f_device.respond_to_u2f_authentication
+ click_on "Login Via U2F Device"
+ expect(page.body).to match('We heard back from your U2F device')
+ click_on "Authenticate via U2F Device"
+
+ expect(page.body).to match('Signed in successfully')
+ end
+ end
+ end
+
+ describe "when a given U2F device has not been registered" do
+ it "does not allow logging in with that particular device" do
+ unregistered_device = FakeU2fDevice.new(page)
+ login_as(user)
+ unregistered_device.respond_to_u2f_authentication
+ click_on "Login Via U2F Device"
+ expect(page.body).to match('We heard back from your U2F device')
+ click_on "Authenticate via U2F Device"
+
+ expect(page.body).to match('Authentication via U2F device failed')
+ end
+ end
+ end
+
+ describe "when two-factor authentication is disabled" do
+ let(:user) { create(:user) }
+
+ before do
+ login_as(user)
+ visit profile_account_path
+ click_on 'Enable Two-Factor Authentication'
+ register_u2f_device
+ end
+
+ it "deletes u2f registrations" do
+ expect { click_on "Disable" }.to change { U2fRegistration.count }.from(1).to(0)
+ end
+ end
+end
diff --git a/spec/javascripts/fixtures/u2f/authenticate.html.haml b/spec/javascripts/fixtures/u2f/authenticate.html.haml
new file mode 100644
index 00000000000..859e79a6c9e
--- /dev/null
+++ b/spec/javascripts/fixtures/u2f/authenticate.html.haml
@@ -0,0 +1 @@
+= render partial: "u2f/authenticate", locals: { new_user_session_path: "/users/sign_in" }
diff --git a/spec/javascripts/fixtures/u2f/register.html.haml b/spec/javascripts/fixtures/u2f/register.html.haml
new file mode 100644
index 00000000000..393c0613fd3
--- /dev/null
+++ b/spec/javascripts/fixtures/u2f/register.html.haml
@@ -0,0 +1 @@
+= render partial: "u2f/register", locals: { create_u2f_profile_two_factor_auth_path: '/profile/two_factor_auth/create_u2f' }
diff --git a/spec/javascripts/u2f/authenticate_spec.coffee b/spec/javascripts/u2f/authenticate_spec.coffee
new file mode 100644
index 00000000000..e8a2892d678
--- /dev/null
+++ b/spec/javascripts/u2f/authenticate_spec.coffee
@@ -0,0 +1,52 @@
+#= require u2f/authenticate
+#= require u2f/util
+#= require u2f/error
+#= require u2f
+#= require ./mock_u2f_device
+
+describe 'U2FAuthenticate', ->
+ U2FUtil.enableTestMode()
+ fixture.load('u2f/authenticate')
+
+ beforeEach ->
+ @u2fDevice = new MockU2FDevice
+ @container = $("#js-authenticate-u2f")
+ @component = new U2FAuthenticate(@container, {}, "token")
+ @component.start()
+
+ it 'allows authenticating via a U2F device', ->
+ setupButton = @container.find("#js-login-u2f-device")
+ setupMessage = @container.find("p")
+ expect(setupMessage.text()).toContain('Insert your security key')
+ expect(setupButton.text()).toBe('Login Via U2F Device')
+ setupButton.trigger('click')
+
+ inProgressMessage = @container.find("p")
+ expect(inProgressMessage.text()).toContain("Trying to communicate with your device")
+
+ @u2fDevice.respondToAuthenticateRequest({deviceData: "this is data from the device"})
+ authenticatedMessage = @container.find("p")
+ deviceResponse = @container.find('#js-device-response')
+ expect(authenticatedMessage.text()).toContain("Click this button to authenticate with the GitLab server")
+ expect(deviceResponse.val()).toBe('{"deviceData":"this is data from the device"}')
+
+ describe "errors", ->
+ it "displays an error message", ->
+ setupButton = @container.find("#js-login-u2f-device")
+ setupButton.trigger('click')
+ @u2fDevice.respondToAuthenticateRequest({errorCode: "error!"})
+ errorMessage = @container.find("p")
+ expect(errorMessage.text()).toContain("There was a problem communicating with your device")
+
+ it "allows retrying authentication after an error", ->
+ setupButton = @container.find("#js-login-u2f-device")
+ setupButton.trigger('click')
+ @u2fDevice.respondToAuthenticateRequest({errorCode: "error!"})
+ retryButton = @container.find("#js-u2f-try-again")
+ retryButton.trigger('click')
+
+ setupButton = @container.find("#js-login-u2f-device")
+ setupButton.trigger('click')
+ @u2fDevice.respondToAuthenticateRequest({deviceData: "this is data from the device"})
+ authenticatedMessage = @container.find("p")
+ expect(authenticatedMessage.text()).toContain("Click this button to authenticate with the GitLab server")
diff --git a/spec/javascripts/u2f/mock_u2f_device.js.coffee b/spec/javascripts/u2f/mock_u2f_device.js.coffee
new file mode 100644
index 00000000000..97ed0e83a0e
--- /dev/null
+++ b/spec/javascripts/u2f/mock_u2f_device.js.coffee
@@ -0,0 +1,15 @@
+class @MockU2FDevice
+ constructor: () ->
+ window.u2f ||= {}
+
+ window.u2f.register = (appId, registerRequests, signRequests, callback) =>
+ @registerCallback = callback
+
+ window.u2f.sign = (appId, challenges, signRequests, callback) =>
+ @authenticateCallback = callback
+
+ respondToRegisterRequest: (params) =>
+ @registerCallback(params)
+
+ respondToAuthenticateRequest: (params) =>
+ @authenticateCallback(params)
diff --git a/spec/javascripts/u2f/register_spec.js.coffee b/spec/javascripts/u2f/register_spec.js.coffee
new file mode 100644
index 00000000000..0858abeca1a
--- /dev/null
+++ b/spec/javascripts/u2f/register_spec.js.coffee
@@ -0,0 +1,57 @@
+#= require u2f/register
+#= require u2f/util
+#= require u2f/error
+#= require u2f
+#= require ./mock_u2f_device
+
+describe 'U2FRegister', ->
+ U2FUtil.enableTestMode()
+ fixture.load('u2f/register')
+
+ beforeEach ->
+ @u2fDevice = new MockU2FDevice
+ @container = $("#js-register-u2f")
+ @component = new U2FRegister(@container, $("#js-register-u2f-templates"), {}, "token")
+ @component.start()
+
+ it 'allows registering a U2F device', ->
+ setupButton = @container.find("#js-setup-u2f-device")
+ expect(setupButton.text()).toBe('Setup New U2F Device')
+ setupButton.trigger('click')
+
+ inProgressMessage = @container.children("p")
+ expect(inProgressMessage.text()).toContain("Trying to communicate with your device")
+
+ @u2fDevice.respondToRegisterRequest({deviceData: "this is data from the device"})
+ registeredMessage = @container.find('p')
+ deviceResponse = @container.find('#js-device-response')
+ expect(registeredMessage.text()).toContain("Your device was successfully set up!")
+ expect(deviceResponse.val()).toBe('{"deviceData":"this is data from the device"}')
+
+ describe "errors", ->
+ it "doesn't allow the same device to be registered twice (for the same user", ->
+ setupButton = @container.find("#js-setup-u2f-device")
+ setupButton.trigger('click')
+ @u2fDevice.respondToRegisterRequest({errorCode: 4})
+ errorMessage = @container.find("p")
+ expect(errorMessage.text()).toContain("already been registered with us")
+
+ it "displays an error message for other errors", ->
+ setupButton = @container.find("#js-setup-u2f-device")
+ setupButton.trigger('click')
+ @u2fDevice.respondToRegisterRequest({errorCode: "error!"})
+ errorMessage = @container.find("p")
+ expect(errorMessage.text()).toContain("There was a problem communicating with your device")
+
+ it "allows retrying registration after an error", ->
+ setupButton = @container.find("#js-setup-u2f-device")
+ setupButton.trigger('click')
+ @u2fDevice.respondToRegisterRequest({errorCode: "error!"})
+ retryButton = @container.find("#U2FTryAgain")
+ retryButton.trigger('click')
+
+ setupButton = @container.find("#js-setup-u2f-device")
+ setupButton.trigger('click')
+ @u2fDevice.respondToRegisterRequest({deviceData: "this is data from the device"})
+ registeredMessage = @container.find("p")
+ expect(registeredMessage.text()).toContain("Your device was successfully set up!")
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 528a79bf221..6ea8bf9bbe1 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -121,6 +121,66 @@ describe User, models: true do
end
end
+ describe "scopes" do
+ describe ".with_two_factor" do
+ it "returns users with 2fa enabled via OTP" do
+ user_with_2fa = create(:user, :two_factor_via_otp)
+ user_without_2fa = create(:user)
+ users_with_two_factor = User.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
+
+ 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 = User.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
+
+ 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 = User.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)
+ end
+ end
+
+ describe ".without_two_factor" do
+ it "excludes users with 2fa enabled via OTP" do
+ user_with_2fa = create(:user, :two_factor_via_otp)
+ user_without_2fa = create(:user)
+ users_without_two_factor = User.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
+
+ 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 = User.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
+
+ 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 = User.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
+
describe "Respond to" do
it { is_expected.to respond_to(:is_admin?) }
it { is_expected.to respond_to(:name) }
diff --git a/spec/support/fake_u2f_device.rb b/spec/support/fake_u2f_device.rb
new file mode 100644
index 00000000000..553fe9f1fbc
--- /dev/null
+++ b/spec/support/fake_u2f_device.rb
@@ -0,0 +1,36 @@
+class FakeU2fDevice
+ def initialize(page)
+ @page = page
+ end
+
+ def respond_to_u2f_registration
+ app_id = @page.evaluate_script('gon.u2f.app_id')
+ challenges = @page.evaluate_script('gon.u2f.challenges')
+
+ json_response = u2f_device(app_id).register_response(challenges[0])
+
+ @page.execute_script("
+ u2f.register = function(appId, registerRequests, signRequests, callback) {
+ callback(#{json_response});
+ };
+ ")
+ end
+
+ def respond_to_u2f_authentication
+ app_id = @page.evaluate_script('gon.u2f.app_id')
+ challenges = @page.evaluate_script('gon.u2f.challenges')
+ json_response = u2f_device(app_id).sign_response(challenges[0])
+
+ @page.execute_script("
+ u2f.sign = function(appId, challenges, signRequests, callback) {
+ callback(#{json_response});
+ };
+ ")
+ end
+
+ private
+
+ def u2f_device(app_id)
+ @u2f_device ||= U2F::FakeU2F.new(app_id)
+ end
+end