diff options
author | Rémy Coutable <remy@rymai.me> | 2016-06-06 09:40:49 +0000 |
---|---|---|
committer | Rémy Coutable <remy@rymai.me> | 2016-06-06 09:40:49 +0000 |
commit | 3cb69f0c0b0049426e6abad0914812a9eef87b04 (patch) | |
tree | 213dfa68ae204871cada77962ae7921a9ecf9e50 /spec | |
parent | fc809d689a03e69c581c1bb8ed0cf246953a7c08 (diff) | |
parent | cdf7a6c2ded729174a5e099b2fc255ee61f0cc79 (diff) | |
download | gitlab-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.rb | 14 | ||||
-rw-r--r-- | spec/controllers/sessions_controller_spec.rb | 26 | ||||
-rw-r--r-- | spec/factories/u2f_registrations.rb | 8 | ||||
-rw-r--r-- | spec/factories/users.rb | 14 | ||||
-rw-r--r-- | spec/features/admin/admin_users_spec.rb | 10 | ||||
-rw-r--r-- | spec/features/login_spec.rb | 26 | ||||
-rw-r--r-- | spec/features/u2f_spec.rb | 239 | ||||
-rw-r--r-- | spec/javascripts/fixtures/u2f/authenticate.html.haml | 1 | ||||
-rw-r--r-- | spec/javascripts/fixtures/u2f/register.html.haml | 1 | ||||
-rw-r--r-- | spec/javascripts/u2f/authenticate_spec.coffee | 52 | ||||
-rw-r--r-- | spec/javascripts/u2f/mock_u2f_device.js.coffee | 15 | ||||
-rw-r--r-- | spec/javascripts/u2f/register_spec.js.coffee | 57 | ||||
-rw-r--r-- | spec/models/user_spec.rb | 60 | ||||
-rw-r--r-- | spec/support/fake_u2f_device.rb | 36 |
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 |