Commit 3cb69f0c authored by Rémy Coutable's avatar Rémy Coutable

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
parents fc809d68 cdf7a6c2
......@@ -23,6 +23,7 @@ v 8.9.0 (unreleased)
- Fix issues filter when ordering by milestone
- Todos will display target state if issuable target is 'Closed' or 'Merged'
- Fix bug when sorting issues by milestone due date and filtering by two or more labels
- Add support for using Yubikeys (U2F) for two-factor authentication
- Link to blank group icon doesn't throw a 404 anymore
- Remove 'main language' feature
- Pipelines can be canceled only when there are running builds
......
......@@ -45,9 +45,10 @@ gem 'akismet', '~> 2.0'
gem 'devise-two-factor', '~> 3.0.0'
gem 'rqrcode-rails3', '~> 0.1.7'
gem 'attr_encrypted', '~> 3.0.0'
gem 'u2f', '~> 0.2.1'
# Browser detection
gem "browser", '~> 1.0.0'
gem "browser", '~> 2.0.3'
# Extracting information from a git repository
# Provide access to Gitlab::Git library
......
......@@ -92,7 +92,7 @@ GEM
sass (~> 3.0)
slim (>= 1.3.6, < 4.0)
terminal-table (~> 1.4)
browser (1.0.1)
browser (2.0.3)
builder (3.2.2)
bullet (5.0.0)
activesupport (>= 3.0.0)
......@@ -747,6 +747,7 @@ GEM
simple_oauth (~> 0.1.4)
tzinfo (1.2.2)
thread_safe (~> 0.1)
u2f (0.2.1)
uglifier (2.7.2)
execjs (>= 0.3.0)
json (>= 1.8.0)
......@@ -814,7 +815,7 @@ DEPENDENCIES
binding_of_caller (~> 0.7.2)
bootstrap-sass (~> 3.3.0)
brakeman (~> 3.2.0)
browser (~> 1.0.0)
browser (~> 2.0.3)
bullet
bundler-audit
byebug
......@@ -963,6 +964,7 @@ DEPENDENCIES
thin (~> 1.6.1)
tinder (~> 1.10.0)
turbolinks (~> 2.5.0)
u2f (~> 0.2.1)
uglifier (~> 2.7.2)
underscore-rails (~> 1.8.0)
unf (~> 0.1.4)
......@@ -975,4 +977,4 @@ DEPENDENCIES
wikicloth (= 0.8.1)
BUNDLED WITH
1.12.4
1.12.5
......@@ -56,9 +56,11 @@
#= require_directory ./commit
#= require_directory ./extensions
#= require_directory ./lib
#= require_directory ./u2f
#= require_directory .
#= require fuzzaldrin-plus
#= require cropper
#= require u2f
window.slugify = (text) ->
text.replace(/[^-a-zA-Z0-9]+/g, '_').toLowerCase()
......
# Authenticate U2F (universal 2nd factor) devices for users to authenticate with.
#
# State Flow #1: setup -> in_progress -> authenticated -> POST to server
# State Flow #2: setup -> in_progress -> error -> setup
class @U2FAuthenticate
constructor: (@container, u2fParams) ->
@appId = u2fParams.app_id
@challenges = u2fParams.challenges
@signRequests = u2fParams.sign_requests
start: () =>
if U2FUtil.isU2FSupported()
@renderSetup()
else
@renderNotSupported()
authenticate: () =>
u2f.sign(@appId, @challenges, @signRequests, (response) =>
if response.errorCode
error = new U2FError(response.errorCode)
@renderError(error);
else
@renderAuthenticated(JSON.stringify(response))
, 10)
#############
# Rendering #
#############
templates: {
"notSupported": "#js-authenticate-u2f-not-supported",
"setup": '#js-authenticate-u2f-setup',
"inProgress": '#js-authenticate-u2f-in-progress',
"error": '#js-authenticate-u2f-error',
"authenticated": '#js-authenticate-u2f-authenticated'
}
renderTemplate: (name, params) =>
templateString = $(@templates[name]).html()
template = _.template(templateString)
@container.html(template(params))
renderSetup: () =>
@renderTemplate('setup')
@container.find('#js-login-u2f-device').on('click', @renderInProgress)
renderInProgress: () =>
@renderTemplate('inProgress')
@authenticate()
renderError: (error) =>
@renderTemplate('error', {error_message: error.message()})
@container.find('#js-u2f-try-again').on('click', @renderSetup)
renderAuthenticated: (deviceResponse) =>
@renderTemplate('authenticated')
# Prefer to do this instead of interpolating using Underscore templates
# because of JSON escaping issues.
@container.find("#js-device-response").val(deviceResponse)
renderNotSupported: () =>
@renderTemplate('notSupported')
class @U2FError
constructor: (@errorCode) ->
@httpsDisabled = (window.location.protocol isnt 'https:')
console.error("U2F Error Code: #{@errorCode}")
message: () =>
switch
when (@errorCode is u2f.ErrorCodes.BAD_REQUEST and @httpsDisabled)
"U2F only works with HTTPS-enabled websites. Contact your administrator for more details."
when @errorCode is u2f.ErrorCodes.DEVICE_INELIGIBLE
"This device has already been registered with us."
else
"There was a problem communicating with your device."
# Register U2F (universal 2nd factor) devices for users to authenticate with.
#
# State Flow #1: setup -> in_progress -> registered -> POST to server
# State Flow #2: setup -> in_progress -> error -> setup
class @U2FRegister
constructor: (@container, u2fParams) ->
@appId = u2fParams.app_id
@registerRequests = u2fParams.register_requests
@signRequests = u2fParams.sign_requests
start: () =>
if U2FUtil.isU2FSupported()
@renderSetup()
else
@renderNotSupported()
register: () =>
u2f.register(@appId, @registerRequests, @signRequests, (response) =>
if response.errorCode
error = new U2FError(response.errorCode)
@renderError(error);
else
@renderRegistered(JSON.stringify(response))
, 10)
#############
# Rendering #
#############
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'
}
renderTemplate: (name, params) =>
templateString = $(@templates[name]).html()
template = _.template(templateString)
@container.html(template(params))
renderSetup: () =>
@renderTemplate('setup')
@container.find('#js-setup-u2f-device').on('click', @renderInProgress)
renderInProgress: () =>
@renderTemplate('inProgress')
@register()
renderError: (error) =>
@renderTemplate('error', {error_message: error.message()})
@container.find('#js-u2f-try-again').on('click', @renderSetup)
renderRegistered: (deviceResponse) =>
@renderTemplate('registered')
# Prefer to do this instead of interpolating using Underscore templates
# because of JSON escaping issues.
@container.find("#js-device-response").val(deviceResponse)
renderNotSupported: () =>
@renderTemplate('notSupported')
# Helper class for U2F (universal 2nd factor) device registration and authentication.
class @U2FUtil
@isU2FSupported: ->
if @testMode
true
else
gon.u2f.browser_supports_u2f
@enableTestMode: ->
@testMode = true
<% if Rails.env.test? %>
U2FUtil.enableTestMode();
<% end %>
......@@ -182,8 +182,8 @@ class ApplicationController < ActionController::Base
end
def check_2fa_requirement
if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled && !skip_two_factor?
redirect_to new_profile_two_factor_auth_path
if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor?
redirect_to profile_two_factor_auth_path
end
end
......@@ -342,6 +342,10 @@ class ApplicationController < ActionController::Base
session[:skip_tfa] && session[:skip_tfa] > Time.current
end
def browser_supports_u2f?
browser.chrome? && browser.version.to_i >= 41 && !browser.device.mobile?
end
def redirect_to_home_page_url?
# If user is not signed-in and tries to access root_path - redirect him to landing page
# Don't redirect to the default URL to prevent endless redirections
......@@ -355,6 +359,13 @@ class ApplicationController < ActionController::Base
current_user.nil? && root_path == request.path
end
# U2F (universal 2nd factor) devices need a unique identifier for the application
# to perform authentication.
# https://developers.yubico.com/U2F/App_ID.html
def u2f_app_id
request.base_url
end
private
def set_default_sort
......
......@@ -24,7 +24,64 @@ module AuthenticatesWithTwoFactor
# Returns nil
def prompt_for_two_factor(user)
session[:otp_user_id] = user.id
setup_u2f_authentication(user)
render 'devise/sessions/two_factor'
end
def authenticate_with_two_factor
user = self.resource = find_user
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)
elsif user && user.valid_password?(user_params[:password])
prompt_for_two_factor(user)
end
end
private
def authenticate_with_two_factor_via_otp(user)
if valid_otp_attempt?(user)
# Remove any lingering user data from login
session.delete(:otp_user_id)
remember_me(user) if user_params[:remember_me] == '1'
sign_in(user)
else
flash.now[:alert] = 'Invalid two-factor code.'
render :two_factor
end
end
# 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[:challenges])
# Remove any lingering user data from login
session.delete(:otp_user_id)
session.delete(:challenges)
sign_in(user)
else
flash.now[:alert] = 'Authentication via U2F device failed.'
prompt_for_two_factor(user)
end
end
# Setup in preparation of communication with a U2F (universal 2nd factor) device
# Actual communication is performed using a Javascript API
def setup_u2f_authentication(user)
key_handles = user.u2f_registrations.pluck(:key_handle)
u2f = U2F::U2F.new(u2f_app_id)
render 'devise/sessions/two_factor' and return
if key_handles.present?
sign_requests = u2f.authentication_requests(key_handles)
challenges = sign_requests.map(&:challenge)
session[:challenges] = challenges
gon.push(u2f: { challenges: challenges, app_id: u2f_app_id,
sign_requests: sign_requests,
browser_supports_u2f: browser_supports_u2f? })
end
end
end
class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
skip_before_action :check_2fa_requirement
def new
def show
unless current_user.otp_secret
current_user.otp_secret = User.generate_otp_secret(32)
end
......@@ -12,21 +12,22 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
current_user.save! if current_user.changed?
if two_factor_authentication_required?
if two_factor_authentication_required? && !current_user.two_factor_enabled?
if two_factor_grace_period_expired?
flash.now[:alert] = 'You must enable Two-factor Authentication for your account.'
flash.now[:alert] = 'You must enable Two-Factor Authentication for your account.'
else
grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
flash.now[:alert] = "You must enable Two-factor Authentication for your account before #{l(grace_period_deadline)}."
flash.now[:alert] = "You must enable Two-Factor Authentication for your account before #{l(grace_period_deadline)}."
end
end
@qr_code = build_qr_code
setup_u2f_registration
end
def create
if current_user.validate_and_consume_otp!(params[:pin_code])
current_user.two_factor_enabled = true
current_user.otp_required_for_login = true
@codes = current_user.generate_otp_backup_codes!
current_user.save!
......@@ -34,8 +35,23 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
else
@error = 'Invalid pin code'
@qr_code = build_qr_code
setup_u2f_registration
render 'show'
end
end
# 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, params[:device_response], session[:challenges])
render 'new'
if @u2f_registration.persisted?
session.delete(:challenges)
redirect_to profile_account_path, notice: "Your U2F device was registered!"
else
@qr_code = build_qr_code
setup_u2f_registration
render :show
end
end
......@@ -70,4 +86,21 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
def issuer_host
Gitlab.config.gitlab.host
end
# Setup in preparation of communication with a U2F (universal 2nd factor) device
# Actual communication is performed using a Javascript API
def setup_u2f_registration
@u2f_registration ||= U2fRegistration.new
@registration_key_handles = current_user.u2f_registrations.pluck(:key_handle)
u2f = U2F::U2F.new(u2f_app_id)
registration_requests = u2f.registration_requests
sign_requests = u2f.authentication_requests(@registration_key_handles)
session[:challenges] = registration_requests.map(&:challenge)
gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id,
register_requests: registration_requests,
sign_requests: sign_requests,
browser_supports_u2f: browser_supports_u2f? })
end
end
......@@ -30,8 +30,7 @@ class SessionsController < Devise::SessionsController
resource.update_attributes(reset_password_token: nil,
reset_password_sent_at: nil)
end
authenticated_with = user_params[:otp_attempt] ? "two-factor" : "standard"
log_audit_event(current_user, with: authenticated_with)
log_audit_event(current_user, with: authentication_method)
end
end
......@@ -54,7 +53,7 @@ class SessionsController < Devise::SessionsController
end
def user_params
params.require(:user).permit(:login, :password, :remember_me, :otp_attempt)
params.require(:user).permit(:login, :password, :remember_me, :otp_attempt, :device_response)
end
def find_user
......@@ -89,27 +88,6 @@ class SessionsController < Devise::SessionsController
find_user.try(:two_factor_enabled?)
end
def authenticate_with_two_factor
user = self.resource = find_user
if user_params[:otp_attempt].present? && session[:otp_user_id]
if valid_otp_attempt?(user)
# Remove any lingering user data from login
session.delete(:otp_user_id)
remember_me(user) if user_params[:remember_me] == '1'
sign_in(user) and return
else
flash.now[:alert] = 'Invalid two-factor code.'
render :two_factor and return
end
else
if user && user.valid_password?(user_params[:password])
prompt_for_two_factor(user)
end
end
end
def auto_sign_in_with_provider
provider = Gitlab.config.omniauth.auto_sign_in_with_provider
return unless provider.present?
......@@ -138,4 +116,14 @@ class SessionsController < Devise::SessionsController
def load_recaptcha
Gitlab::Recaptcha.load_configurations!
end
def authentication_method
if user_params[:otp_attempt]
"two-factor"
elsif user_params[:device_response]
"two-factor-via-u2f-device"
else
"standard"
end
end
end
......@@ -66,7 +66,7 @@ module AuthHelper
def two_factor_skippable?
current_application_settings.require_two_factor_authentication &&
!current_user.two_factor_enabled &&
!current_user.two_factor_enabled? &&
current_application_settings.two_factor_grace_period &&
!two_factor_grace_period_expired?
end
......
# Registration information for U2F (universal 2nd factor) devices, like Yubikeys
class U2fRegistration < ActiveRecord::Base
belongs_to :user
def self.register(user, app_id, json_response, challenges)
u2f = U2F::U2F.new(app_id)
registration = self.new
begin
response = U2F::RegisterResponse.load_from_json(json_response)
registration_data = u2f.register!(challenges, response)
registration.update(certificate: registration_data.certificate,
key_handle: registration_data.key_handle,
public_key: registration_data.public_key,
counter: registration_data.counter,
user: user)
rescue JSON::ParserError, NoMethodError, ArgumentError
registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.')
rescue U2F::Error => e
registration.errors.add(:base, e.message)
end
registration
end
def self.authenticate(user, app_id, json_response, challenges)
response = U2F::SignResponse.load_from_json(json_response)
registration = user.u2f_registrations.find_by_key_handle(response.key_handle)
u2f = U2F::U2F.new(app_id)
if registration
u2f.authenticate!(challenges, response, Base64.decode64(registration.public_key), registration.counter)
registration.update(counter: response.counter)
true
end
rescue JSON::ParserError, NoMethodError, ArgumentError, U2F::Error
false
end
end
......@@ -27,7 +27,6 @@ class User < ActiveRecord::Base
devise :two_factor_authenticatable,
otp_secret_encryption_key: Gitlab::Application.config.secret_key_base
alias_attribute :two_factor_enabled, :otp_required_for_login
devise :two_factor_backupable, otp_number_of_backup_codes: 10
serialize :otp_backup_codes, JSON
......@@ -51,6 +50,7 @@ class User < ActiveRecord::Base
has_many :keys, dependent: :destroy
has_many :emails, dependent: :destroy
has_many :identities, dependent: :destroy, autosave: true
has_many :u2f_registrations, dependent: :destroy
# Groups
has_many :members, dependent: :destroy
......@@ -175,8 +175,16 @@ class User < ActiveRecord::Base
scope :active, -> { with_state(:active) }
scope :not_in_project, ->(project) { project.users.present? ? where("id not in (:ids)", ids: project.users.map(&:id) ) : all }
scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members)') }
scope :with_two_factor, -> { where(two_factor_enabled: true) }
scope :without_two_factor, -> { where(two_factor_enabled: false) }
def self.with_two_factor
joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id").
where("u2f.id IS NOT NULL OR otp_required_for_login = ?", true).distinct(arel_table[:id])
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 otp_required_for_login = ?", false)
end
#
# Class methods
......@@ -323,14 +331,29 @@ class User < ActiveRecord::Base
end
def disable_two_factor!
update_attributes(
two_factor_enabled: false,
encrypted_otp_secret: nil,
encrypted_otp_secret_iv: nil,
encrypted_otp_secret_salt: nil,
otp_grace_period_started_at: nil,
otp_backup_codes: nil
)
transaction do
update_attributes(
otp_required_for_login: false,
encrypted_otp_secret: nil,
encrypted_otp_secret_iv: nil,
encrypted_otp_secret_salt: nil,
otp_grace_period_started_at: nil,
otp_backup_codes: nil
)
self.u2f_registrations.destroy_all
end
end
def two_factor_enabled?
two_factor_otp_enabled? || two_factor_u2f_enabled?
end
def two_factor_otp_enabled?
self.otp_required_for_login?
end
def two_factor_u2f_enabled?
self.u2f_registrations.exists?
end
def namespace_uniq
......
%div
.login-box
.login-heading
%h3 Two-factor Authentication
%h3 Two-Factor Authentication
.login-body
= form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
= f.hidden_field :remember_me, value: params[resource_name][:remember_me]
= f.text_field :otp_attempt, class: 'form-control', placeholder: 'Two-factor Authentication code', required: true, autofocus: true, autocomplete: 'off'
%p.help-block.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-save"
- if @user.two_factor_otp_enabled?
%h5 Authenticate via Two-Factor App
= form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
= f.hidden_field :remember_me, value: params[resource_name][:remember_me]
= f.text_field :otp_attempt, class: 'form-control', placeholder: 'Two-Factor Authentication code', required: true, autofocus: true, autocomplete: 'off'
%p.help-block.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-save"
- if @user.two_factor_u2f_enabled?
%hr
= render "u2f/authenticate"
......@@ -24,7 +24,7 @@
%td Show/hide this dialog
%tr
%td.shortcut
- if browser.mac?
- if browser.platform.mac?
.key &#8984; shift p
- else
.key ctrl shift p
......
......@@ -35,8 +35,6 @@
= csrf_meta_tags
= include_gon
- unless browser.safari?
%meta{name: 'referrer', content: 'origin-when-cross-origin'}
%meta{name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1'}
......
......@@ -2,6 +2,8 @@
%html{ lang: "en"}
= render "layouts/head"
%body{class: "#{user_application_theme}", 'data-page' => body_data_page}
= Gon::Base.render_data
-# Ideally this would be inside the head, but turbolinks only evaluates page-specific JS in the body.
= yield :scripts_body_top
......
......@@ -2,6 +2,7 @@
%html{ lang: "en"}
= render "layouts/head"
%body.ui_charcoal.login-page.application.navless
= Gon::Base.render_data
= render "layouts/header/empty"
= render "layouts/broadcast"
.container.navless-container
......
......@@ -2,6 +2,7 @@
%html{ lang: "en"}
= render "layouts/head"
%body.ui_charcoal.login-page.application.navless
= Gon::Base.render_data
= render "layouts/header/empty"
= render "layouts/broadcast"
.container.navless-container
......
......@@ -2,6 +2,7 @@
%html{ lang: "en"}
= render "layouts/head"
%body{class: "#{user_application_theme} application navless"}
= Gon::Base.render_data
= render "layouts/header/empty"
.container.navless-container
= render "layouts/flash"
......
......@@ -11,7 +11,7 @@
%p
Your private token is used to access application resources without authentication.
.col-lg-9
= form_for @user, url: reset_private_token_profile_path, method: :put, html: {class: "private-token"} do |f|
= form_for @user, url: reset_private_token_profile_path, method: :put, html: { class: "private-token" } do |f|
%p.cgray
- if current_user.private_token
= label_tag "token", "Private token", class: "label-light"
......@@ -29,21 +29,22 @@
.row.prepend-top-default
.col-lg-3.profile-settings-sidebar
%h4.prepend-top-0
Two-factor Authentication
Two-Factor Authentication
%p
Increase your account's security by enabling two-factor authentication (2FA).
Increase your account's security by enabling Two-Factor Authentication (2FA).
.col-lg-9
%p
Status: #{current_user.two_factor_enabled? ? 'enabled' : 'disabled'}
- if !current_user.two_factor_enabled?
%p
Download the Google Authenticator application from App Store for iOS or Google Play for Android and scan this code.
More information is available in the #{link_to('documentation', help_page_path('profile', 'two_factor_authentication'))}.
.append-bottom-10
= link_to 'Enable two-factor authentication', new_profile_two_factor_auth_path, class: 'btn btn-success'
Status: #{current_user.two_factor_enabled? ? 'Enabled' : 'Disabled'}
- if current_user.two_factor_enabled?
= link_to 'Manage Two-Factor Authentication', profile_two_factor_auth_path, class: 'btn btn-info'
= link_to 'Disable', profile_two_factor_auth_path,
method: :delete,
data: { confirm: "Are you sure? This will invalidate your registered applications and U2F devices." },
class: 'btn btn-danger'
- else
= link_to 'Disable Two-factor Authentication', profile_two_factor_auth_path, method: :delete, class: 'btn btn-danger',
data: { confirm: 'Are you sure?' }
.append-bottom-10
= link_to 'Enable Two-Factor Authentication', profile_two_factor_auth_path, class: 'btn btn-success'
%hr
- if button_based_providers.any?
.row.prepend-top-default
......
- page_title 'Two-factor Authentication', 'Account'
.row.prepend-top-default
.col-lg-3
%h4.prepend-top-0
Two-factor Authentication (2FA)
%p
Increase your account's security by enabling two-factor authentication (2FA).
.col-lg-9
%p
Download the Google Authenticator application from App Store for iOS or Google Play for Android and scan this code.
More information is available in the #{link_to('documentation', help_page_path('profile', 'two_factor_authentication'))}.
.row.append-bottom-10
.col-md-3
= raw @qr_code
.col-md-9
.account-well
%p.prepend-top-0.append-bottom-0
Can't scan the code?
%p.prepend-top-0.append-bottom-0
To add the entry manually, provide the following details to the application on your phone.
%p.prepend-top-0.append-bottom-0
Account:
= current_user.email
%p.prepend-top-0.append-bottom-0
Key:
= current_user.otp_secret.scan(/.{4}/).join(' ')
%p.two-factor-new-manual-content
Time based: Yes
= form_tag profile_two_factor_auth_path, method: :post do |f|
- if @error
.alert.alert-danger
= @error
.form-group
= label_tag :pin_code, nil, class: "label-light"
= text_field_tag :pin_code, nil, class: "form-control", required: true
.prepend-top-default
= submit_tag 'Enable two-factor authentication', class: 'btn btn-success'
= link_to 'Configure it later', skip_profile_two_factor_auth_path, :method => :patch, class: 'btn btn-cancel' if two_factor_skippable?
- page_title 'Two-Factor Authentication', 'Account'
- header_title "Two-Factor Authentication", profile_two_factor_auth_path
.row.prepend-top-default
.col-lg-3
%h4.prepend-top-0
Register Two-Factor Authentication App
%p
Use an app on your mobile device to enable two-factor authentication (2FA).
.col-lg-9
- if current_user.two_factor_otp_enabled?
= icon "check inverse", base: "circle", class: "text-success", text: "You've already enabled two-factor authentication using mobile authenticator applications. You can disable it from your account settings page."
- else
%p
Download the Google Authenticator application from App Store or Google Play Store and scan this code.
More information is available in the #{link_to('documentation', help_page_path('profile', 'two_factor_authentication'))}.
.row.append-bottom-10
.col-md-3
= raw @qr_code
.col-md-9
.account-well
%p.prepend-top-0.append-bottom-0
Can't scan the code?
%p.prepend-top-0.append-bottom-0
To add the entry manually, provide the following details to the application on your phone.
%p.prepend-top-0.append-bottom-0
Account:
= current_user.email
%p.prepend-top-0.append-bottom-0
Key:
= current_user.otp_secret.scan(/.{4}/).join(' ')
%p.two-factor-new-manual-content
Time based: Yes
= form_tag profile_two_factor_auth_path, method: :post do |f|
- if @error
.alert.alert-danger
= @error
.form-group
= label_tag :pin_code, nil, class: "label-light"
= text_field_tag :pin_code, nil, class: "form-control", required: true
.prepend-top-default
= submit_tag 'Register with Two-Factor App', class: 'btn btn-success'
%hr
.row.prepend-top-default
.col-lg-3
%h4.prepend-top-0
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, it's recommended that you set up a
two-factor authentication app as well as a U2F device so you'll always be able to log in
using an unsupported browser.
.col-lg-9
%p
- if @registration_key_handles.present?
= icon "check inverse", base: "circle", class: "text-success", text: "You have #{pluralize(@registration_key_handles.size, 'U2F device')} registered with GitLab."
- if @u2f_registration.errors.present?
= form_errors(@u2f_registration)
= render "u2f/register"
- if two_factor_skippable?
:javascript
var button = "<a class='btn btn-xs btn-warning pull-right' data-method='patch' href='#{skip_profile_two_factor_auth_path}'>Configure it later</a>";
$(".flash-alert").append(button);
#js-authenticate-u2f
%script#js-authenticate-u2f-not-supported{ type: "text/template" }
%p Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer).
%script#js-authenticate-u2f-setup{ type: "text/template" }
%div
%p Insert your security key (if you haven't already), and press the button below.
%a.btn.btn-info#js-login-u2f-device{ href: 'javascript:void(0)' } Login Via U2F Device
%script#js-authenticate-u2f-in-progress{ type: "text/template" }
%p Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.
%script#js-authenticate-u2f-error{ type: "text/template" }
%div
%p <%= error_message %>
%a.btn.btn-warning#js-u2f-try-again Try again?
%script#js-authenticate-u2f-authenticated{ type: "text/template" }
%div
%p We heard back from your U2F device. Click this button to authenticate with the GitLab server.
= form_tag(new_user_session_path, method: :post) do |f|
= hidden_field_tag 'user[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
= submit_tag "Authenticate via U2F Device", class: "btn btn-success"
:javascript
var u2fAuthenticate = new U2FAuthenticate($("#js-authenticate-u2f"), gon.u2f);
u2fAuthenticate.start();
#js-register-u2f
%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" }
.row.append-bottom-10
.col-md-3
%a#js-setup-u2f-device.btn.btn-info{ href: 'javascript:void(0)' } Setup New U2F Device
.col-md-9
%p Your U2F device needs to be set up. Plug it in (if not already) and click the button on the left.
%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 %>
%a.btn.btn-warning#js-u2f-try-again Try again?
%script#js-register-u2f-registered{ type: "text/template" }
%div.row.append-bottom-10
%p Your device was successfully set up! Click this button to register with the GitLab server.
= form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do
= hidden_field_tag :device_response, nil, class: 'form-control', required: true, id: "js-device-response"
= submit_tag "Register U2F Device", class: "btn btn-success"
:javascript
var u2fRegister = new U2FRegister($("#js-register-u2f"), gon.u2f);
u2fRegister.start();
......@@ -343,8 +343,9 @@ Rails.application.routes.draw do
resources :keys
resources :emails, only: [:index, :create, :destroy]
resource :avatar, only: [:destroy]
resource :two_factor_auth, only: [:new, :create, :destroy] do
resource :two_factor_auth, only: [:show, :create, :destroy] do
member do
post :create_u2f
post :codes
patch :skip
end
......
class CreateU2fRegistrations < ActiveRecord::Migration
def change
create_table :u2f_registrations do |t|
t.text :certificate
t.string :key_handle, index: true
t.string :public_key
t.integer :counter
t.references :user, index: true, foreign_key: true
t.timestamps null: false
end
end
end
......@@ -12,7 +12,6 @@
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20160530150109) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
enable_extension "pg_trgm"
......@@ -940,6 +939,19 @@ ActiveRecord::Schema.define(version: 20160530150109) do
add_index "todos", ["target_type", "target_id"], name: "index_todos_on_target_type_and_target_id", using: :btree
add_index "todos", ["user_id"], name: "index_todos_on_user_id", using: :btree
create_table "u2f_registrations", force: :cascade do |t|
t.text "certificate"
t.string "key_handle"
t.string "public_key"
t.integer "counter"
t.integer "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index "u2f_registrations", ["key_handle"], name: "index_u2f_registrations_on_key_handle", using: :btree
add_index "u2f_registrations", ["user_id"], name: "index_u2f_registrations_on_user_id", using: :btree
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
......@@ -1047,4 +1059,5 @@ ActiveRecord::Schema.define(version: 20160530150109) do
add_index "web_hooks", ["created_at", "id"], name: "index_web_hooks_on_created_at_and_id", using: :btree
add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree
add_foreign_key "u2f_registrations", "users"
end
......@@ -8,12 +8,27 @@ your phone.
By enabling 2FA, the only way someone other than you can log into your account
is to know your username and password *and* have access to your phone.
#### Note
> **Note:**
When you enable 2FA, don't forget to back up your recovery codes. For your safety, if you
lose your codes for GitLab.com, we can't disable or recover them.
In addition to a phone application, GitLab supports U2F (universal 2nd factor) devices as
the second factor of authentication. Once enabled, in addition to supplying your username and
password to login, you'll be prompted to activate your U2F device (usually by pressing
a button on it), and it will perform secure authentication on your behalf.
> **Note:** Support for U2F devices was added in version 8.8
The U2F workflow is only supported by Google Chrome at this point, so we _strongly_ recommend
that you set up both methods of two-factor authentication, so you can still access your account
from other browsers.
> **Note:** GitLab officially only supports [Yubikey] U2F devices.
## Enabling 2FA
### Enable 2FA via mobile application
**In GitLab:**
1. Log in to your GitLab account.
......@@ -38,9 +53,26 @@ lose your codes for GitLab.com, we can't disable or recover them.
1. Click **Submit**.
If the pin you entered was correct, you'll see a message indicating that
Two-factor Authentication has been enabled, and you'll be presented with a list
Two-Factor Authentication has been enabled, and you'll be presented with a list
of recovery codes.
### Enable 2FA via U2F device
**In GitLab:**
1. Log in to your GitLab account.
1. Go to your **Profile Settings**.
1. Go to **Account**.
1. Click **Enable Two-Factor Authentication**.
1. Plug in your U2F device.
1. Click on **Setup New U2F Device**.
1. A light will start blinking on your device. Activate it by pressing its button.
You will see a message indicating that your device was successfully set up.
Click on **Register U2F Device** to complete the process.
![Two-Factor U2F Setup](2fa_u2f_register.png)
## Recovery Codes
Should you ever lose access to your phone, you can use one of the ten provided
......@@ -51,21 +83,39 @@ account.
If you lose the recovery codes or just want to generate new ones, you can do so
from the **Profile Settings** > **Account** page where you first enabled 2FA.
> **Note:** Recovery codes are not generated for U2F devices.
## Logging in with 2FA Enabled
Logging in with 2FA enabled is only slightly different than a normal login.
Enter your username and password credentials as you normally would, and you'll
be presented with a second prompt for an authentication code. Enter the pin from
your phone's application or a recovery code to log in.
be presented with a second prompt, depending on which type of 2FA you've enabled.
### Log in via mobile application
Enter the pin from your phone's application or a recovery code to log in.
![Two-factor authentication on sign in](2fa_auth.png)
![Two-Factor Authentication on sign in via OTP](2fa_auth.png)
### Log in via U2F device
1. Click **Login via U2F Device**
1. A light will start blinking on your device. Activate it by pressing its button.
You will see a message indicating that your device responded to the authentication request.
Click on **Authenticate via U2F Device** to complete the process.
![Two-Factor Authentication on sign in via U2F device](2fa_u2f_authenticate.png)
## Disabling 2FA
1. Log in to your GitLab account.
1. Go to your **Profile Settings**.
1. Go to **Account**.
1. Click **Disable Two-factor Authentication**.
1. Click **Disable**, under **Two-Factor Authentication**.
This will clear all your two-factor authentication registrations, including mobile
applications and U2F devices.
## Note to GitLab administrators
......@@ -74,3 +124,4 @@ You need to take special care to that 2FA keeps working after
[Google Authenticator]: https://support.google.com/accounts/answer/1066447?hl=en
[FreeOTP]: https://fedorahosted.org/freeotp/
[YubiKey]: https://www.yubico.com/products/yubikey-hardware/
......@@ -30,7 +30,7 @@ module API
expose :identities, using: Entities::Identity
expose :can_create_group?, as: :can_create_group
expose :can_create_project?, as: :can_create_project
expose :two_factor_enabled
expose :two_factor_enabled?, as: :two_factor_enabled
expose :external
end
......
......@@ -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
......
......@@ -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
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
......@@ -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'
......
......@@ -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)
......
......@@ -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
......
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
= render partial: "u2f/authenticate", locals: { new_user_session_path: "/users/sign_in" }
= render partial: "u2f/register", locals: { create_u2f_profile_two_factor_auth_path: '/profile/two_factor_auth/create_u2f' }
#= 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")
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)
#= 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!")
......@@ -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) }
......
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
This diff is collapsed.
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