Commit 86b07caa authored by Timothy Andrew's avatar Timothy Andrew

Implement authentication (login) using a U2F device.

- Move the `authenticate_with_two_factor` method from
  `ApplicationController` to the `AuthenticatesWithTwoFactor` module,
  where it should be.
parent 128549f1
# 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')
...@@ -24,7 +24,64 @@ module AuthenticatesWithTwoFactor ...@@ -24,7 +24,64 @@ module AuthenticatesWithTwoFactor
# Returns nil # Returns nil
def prompt_for_two_factor(user) def prompt_for_two_factor(user)
session[:otp_user_id] = user.id 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
render 'devise/sessions/two_factor' and return # 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)
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
end end
...@@ -54,7 +54,7 @@ class SessionsController < Devise::SessionsController ...@@ -54,7 +54,7 @@ class SessionsController < Devise::SessionsController
end end
def user_params 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 end
def find_user def find_user
...@@ -89,27 +89,6 @@ class SessionsController < Devise::SessionsController ...@@ -89,27 +89,6 @@ class SessionsController < Devise::SessionsController
find_user.try(:two_factor_enabled?) find_user.try(:two_factor_enabled?)
end 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 def auto_sign_in_with_provider
provider = Gitlab.config.omniauth.auto_sign_in_with_provider provider = Gitlab.config.omniauth.auto_sign_in_with_provider
return unless provider.present? return unless provider.present?
......
%div %div
.login-box .login-box
.login-heading .login-heading
%h3 Two-factor Authentication %h3 Two-Factor Authentication
.login-body .login-body
- 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| = 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.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' = 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. %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 .prepend-top-20
= f.submit "Verify code", class: "btn btn-save" = f.submit "Verify code", class: "btn btn-save"
- if @user.two_factor_u2f_enabled?
%hr
= render "u2f/authenticate"
#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();
...@@ -33,11 +33,11 @@ feature 'Login', feature: true do ...@@ -33,11 +33,11 @@ feature 'Login', feature: true do
before do before do
login_with(user, remember: true) login_with(user, remember: true)
expect(page).to have_content('Two-factor Authentication') expect(page).to have_content('Two-Factor Authentication')
end end
def enter_code(code) def enter_code(code)
fill_in 'Two-factor Authentication code', with: code fill_in 'Two-Factor Authentication code', with: code
click_button 'Verify code' click_button 'Verify code'
end end
...@@ -143,12 +143,12 @@ feature 'Login', feature: true do ...@@ -143,12 +143,12 @@ feature 'Login', feature: true do
context 'within the grace period' do context 'within the grace period' do
it 'redirects to two-factor configuration page' do it 'redirects to two-factor configuration page' do
expect(current_path).to eq new_profile_two_factor_auth_path 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') expect(page).to have_content('You must enable Two-Factor Authentication for your account before')
end end
it 'disallows skipping two-factor configuration' do it 'allows skipping two-factor configuration', js: true do
expect(current_path).to eq new_profile_two_factor_auth_path expect(current_path).to eq profile_two_factor_auth_path
click_link 'Configure it later' click_link 'Configure it later'
expect(current_path).to eq root_path expect(current_path).to eq root_path
...@@ -159,26 +159,26 @@ feature 'Login', feature: true do ...@@ -159,26 +159,26 @@ feature 'Login', feature: true do
let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) } let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) }
it 'redirects to two-factor configuration page' do it 'redirects to two-factor configuration page' do
expect(current_path).to eq new_profile_two_factor_auth_path expect(current_path).to eq profile_two_factor_auth_path
expect(page).to have_content('You must enable Two-factor Authentication for your account.') expect(page).to have_content('You must enable Two-Factor Authentication for your account.')
end end
it 'disallows skipping two-factor configuration' do it 'disallows skipping two-factor configuration', js: true do
expect(current_path).to eq new_profile_two_factor_auth_path expect(current_path).to eq profile_two_factor_auth_path
expect(page).not_to have_link('Configure it later') expect(page).not_to have_link('Configure it later')
end end
end end
end end
context 'without grace pariod defined' do context 'without grace period defined' do
before(:each) do before(:each) do
stub_application_setting(two_factor_grace_period: 0) stub_application_setting(two_factor_grace_period: 0)
login_with(user) login_with(user)
end end
it 'redirects to two-factor configuration page' do it 'redirects to two-factor configuration page' do
expect(current_path).to eq new_profile_two_factor_auth_path expect(current_path).to eq profile_two_factor_auth_path
expect(page).to have_content('You must enable Two-factor Authentication for your account.') expect(page).to have_content('You must enable Two-Factor Authentication for your account.')
end end
end end
end end
......
= render partial: "u2f/authenticate", locals: { new_user_session_path: "/users/sign_in" }
#= 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")
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