summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTimothy Andrew <mail@timothyandrew.net>2016-06-06 10:20:39 +0530
committerTimothy Andrew <mail@timothyandrew.net>2016-06-06 12:50:31 +0530
commit86b07caa599a7f064e9077770b1a87c670d7607c (patch)
tree21717ae881f78fddccab7de6fcb817dcdfc1da65
parent128549f10beb406333fa23c1693750c06ff7bc4a (diff)
downloadgitlab-ce-86b07caa599a7f064e9077770b1a87c670d7607c.tar.gz
Implement authentication (login) using a U2F device.
- Move the `authenticate_with_two_factor` method from `ApplicationController` to the `AuthenticatesWithTwoFactor` module, where it should be.
-rw-r--r--app/assets/javascripts/u2f/authenticate.js.coffee63
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb59
-rw-r--r--app/controllers/sessions_controller.rb23
-rw-r--r--app/views/devise/sessions/two_factor.html.haml21
-rw-r--r--app/views/u2f/_authenticate.html.haml28
-rw-r--r--spec/features/login_spec.rb26
-rw-r--r--spec/javascripts/fixtures/u2f/authenticate.html.haml1
-rw-r--r--spec/javascripts/u2f/authenticate_spec.coffee52
8 files changed, 230 insertions, 43 deletions
diff --git a/app/assets/javascripts/u2f/authenticate.js.coffee b/app/assets/javascripts/u2f/authenticate.js.coffee
new file mode 100644
index 00000000000..6deb902c8de
--- /dev/null
+++ b/app/assets/javascripts/u2f/authenticate.js.coffee
@@ -0,0 +1,63 @@
+# 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')
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index d5918a7af3b..998b8adc411 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -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
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index d68c2a708e3..1c19c1cc1a8 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -54,7 +54,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 +89,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?
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index fd5937a45ce..9d04db2c45e 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -1,11 +1,18 @@
%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"
diff --git a/app/views/u2f/_authenticate.html.haml b/app/views/u2f/_authenticate.html.haml
new file mode 100644
index 00000000000..75fb0e303ad
--- /dev/null
+++ b/app/views/u2f/_authenticate.html.haml
@@ -0,0 +1,28 @@
+#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();
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/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/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")