diff options
49 files changed, 1889 insertions, 146 deletions
diff --git a/CHANGELOG b/CHANGELOG index ecce18af066..72fe32a01a3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 1771b919b60..5f1dbd431e4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 18c1aa0d4e2..a76b111bf03 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -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() 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/assets/javascripts/u2f/error.js.coffee b/app/assets/javascripts/u2f/error.js.coffee new file mode 100644 index 00000000000..1a2fc3e757f --- /dev/null +++ b/app/assets/javascripts/u2f/error.js.coffee @@ -0,0 +1,13 @@ +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." diff --git a/app/assets/javascripts/u2f/register.js.coffee b/app/assets/javascripts/u2f/register.js.coffee new file mode 100644 index 00000000000..74472cfa120 --- /dev/null +++ b/app/assets/javascripts/u2f/register.js.coffee @@ -0,0 +1,63 @@ +# 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') diff --git a/app/assets/javascripts/u2f/util.js.coffee.erb b/app/assets/javascripts/u2f/util.js.coffee.erb new file mode 100644 index 00000000000..d59341c38b9 --- /dev/null +++ b/app/assets/javascripts/u2f/util.js.coffee.erb @@ -0,0 +1,15 @@ +# 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 %> diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c28d1ca9e3b..62f63701799 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 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/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index 8f83fdd02bc..6a358fdcc05 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -1,7 +1,7 @@  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 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index d68c2a708e3..f6eedb1773c 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -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 diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index b05fa0a14d6..cd4d778e508 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -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 diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb new file mode 100644 index 00000000000..00b19686d48 --- /dev/null +++ b/app/models/u2f_registration.rb @@ -0,0 +1,40 @@ +# 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 diff --git a/app/models/user.rb b/app/models/user.rb index bbc88f7e38a..e0987e07e1f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 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/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index 70e88da7aae..01648047ce2 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -24,7 +24,7 @@                  %td Show/hide this dialog                %tr                  %td.shortcut -                  - if browser.mac? +                  - if browser.platform.mac?                      .key ⌘ shift p                    - else                      .key ctrl shift p diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index b30fb0a5da9..e0ed657919e 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -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'} diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index e4d1c773d03..2b86b289bbe 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -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 diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index f08cb0a5428..3d28eec84ef 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -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 diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 7c061dd531f..6bd427b02ac 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -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 diff --git a/app/views/layouts/errors.html.haml b/app/views/layouts/errors.html.haml index 915acc4612e..7fbe065df00 100644 --- a/app/views/layouts/errors.html.haml +++ b/app/views/layouts/errors.html.haml @@ -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" diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 01ac8161945..3d2a245ecbd 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -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 diff --git a/app/views/profiles/two_factor_auths/new.html.haml b/app/views/profiles/two_factor_auths/new.html.haml deleted file mode 100644 index 69fc81cb45c..00000000000 --- a/app/views/profiles/two_factor_auths/new.html.haml +++ /dev/null @@ -1,39 +0,0 @@ -- 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? diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml new file mode 100644 index 00000000000..ce76cb73c9c --- /dev/null +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -0,0 +1,69 @@ +- 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); + 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/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml new file mode 100644 index 00000000000..46af591fc43 --- /dev/null +++ b/app/views/u2f/_register.html.haml @@ -0,0 +1,31 @@ +#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(); diff --git a/config/routes.rb b/config/routes.rb index 1fc7985136b..27ab79d68f5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20160425045124_create_u2f_registrations.rb b/db/migrate/20160425045124_create_u2f_registrations.rb new file mode 100644 index 00000000000..93bdd9de2eb --- /dev/null +++ b/db/migrate/20160425045124_create_u2f_registrations.rb @@ -0,0 +1,13 @@ +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 diff --git a/db/schema.rb b/db/schema.rb index 28902119615..9b991f347a9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/doc/profile/2fa_u2f_authenticate.png b/doc/profile/2fa_u2f_authenticate.png Binary files differnew file mode 100644 index 00000000000..b9138ff60db --- /dev/null +++ b/doc/profile/2fa_u2f_authenticate.png diff --git a/doc/profile/2fa_u2f_register.png b/doc/profile/2fa_u2f_register.png Binary files differnew file mode 100644 index 00000000000..15b3683ef73 --- /dev/null +++ b/doc/profile/2fa_u2f_register.png diff --git a/doc/profile/two_factor_authentication.md b/doc/profile/two_factor_authentication.md index a0e23c1586c..82505b13401 100644 --- a/doc/profile/two_factor_authentication.md +++ b/doc/profile/two_factor_authentication.md @@ -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. + + +  ## 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. - + + +### 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. + +  ## 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/ diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 1a996846e9d..66c138eb902 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -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 diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb index 4fb1473c2d2..d08d0018b35 100644 --- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb +++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb @@ -8,21 +8,21 @@ describe Profiles::TwoFactorAuthsController do      allow(subject).to receive(:current_user).and_return(user)    end -  describe 'GET new' do +  describe 'GET show' do      let(:user) { create(:user) }      it 'generates otp_secret for user' do        expect(User).to receive(:generate_otp_secret).with(32).and_return('secret').once -      get :new -      get :new # Second hit shouldn't re-generate it +      get :show +      get :show # Second hit shouldn't re-generate it      end      it 'assigns qr_code' do        code = double('qr code')        expect(subject).to receive(:build_qr_code).and_return(code) -      get :new +      get :show        expect(assigns[:qr_code]).to eq code      end    end @@ -40,7 +40,7 @@ describe Profiles::TwoFactorAuthsController do          expect(user).to receive(:validate_and_consume_otp!).with(pin).and_return(true)        end -      it 'sets two_factor_enabled' do +      it 'enables 2fa for the user' do          go          user.reload @@ -79,9 +79,9 @@ describe Profiles::TwoFactorAuthsController do          expect(assigns[:qr_code]).to eq code        end -      it 'renders new' do +      it 'renders show' do          go -        expect(response).to render_template(:new) +        expect(response).to render_template(:show)        end      end    end diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index 5dc8724fb50..4e9bfb0c69b 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -25,10 +25,15 @@ describe SessionsController do            expect(response).to set_flash.to /Signed in successfully/            expect(subject.current_user). to eq user          end + +        it "creates an audit log record" do +          expect { post(:create, user: { login: user.username, password: user.password }) }.to change { SecurityEvent.count }.by(1) +          expect(SecurityEvent.last.details[:with]).to eq("standard") +        end        end      end -    context 'when using two-factor authentication' do +    context 'when using two-factor authentication via OTP' do        let(:user) { create(:user, :two_factor) }        def authenticate_2fa(user_params) @@ -117,6 +122,25 @@ describe SessionsController do            end          end        end + +      it "creates an audit log record" do +        expect { authenticate_2fa(login: user.username, otp_attempt: user.current_otp) }.to change { SecurityEvent.count }.by(1) +        expect(SecurityEvent.last.details[:with]).to eq("two-factor") +      end +    end + +    context 'when using two-factor authentication via U2F device' do +      let(:user) { create(:user, :two_factor) } + +      def authenticate_2fa_u2f(user_params) +        post(:create, { user: user_params }, { otp_user_id: user.id }) +      end + +      it "creates an audit log record" do +        allow(U2fRegistration).to receive(:authenticate).and_return(true) +        expect { authenticate_2fa_u2f(login: user.username, device_response: "{}") }.to change { SecurityEvent.count }.by(1) +        expect(SecurityEvent.last.details[:with]).to eq("two-factor-via-u2f-device") +      end      end    end  end diff --git a/spec/factories/u2f_registrations.rb b/spec/factories/u2f_registrations.rb new file mode 100644 index 00000000000..df92b079581 --- /dev/null +++ b/spec/factories/u2f_registrations.rb @@ -0,0 +1,8 @@ +FactoryGirl.define do +  factory :u2f_registration do +    certificate { FFaker::BaconIpsum.characters(728) } +    key_handle { FFaker::BaconIpsum.characters(86) } +    public_key { FFaker::BaconIpsum.characters(88) } +    counter 0 +  end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index a9b2148bd2a..c6f7869516e 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -15,14 +15,26 @@ FactoryGirl.define do      end      trait :two_factor do +      two_factor_via_otp +    end + +    trait :two_factor_via_otp do        before(:create) do |user| -        user.two_factor_enabled = true +        user.otp_required_for_login = true          user.otp_secret = User.generate_otp_secret(32)          user.otp_grace_period_started_at = Time.now          user.generate_otp_backup_codes!        end      end +    trait :two_factor_via_u2f do +      transient { registrations_count 5 } + +      after(:create) do |user, evaluator| +        create_list(:u2f_registration, evaluator.registrations_count, user: user) +      end +    end +      factory :omniauth_user do        transient do          extern_uid '123456' diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index 96621843b30..b72ad405479 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -19,7 +19,7 @@ describe "Admin::Users", feature: true  do      describe 'Two-factor Authentication filters' do        it 'counts users who have enabled 2FA' do -        create(:user, two_factor_enabled: true) +        create(:user, :two_factor)          visit admin_users_path @@ -29,7 +29,7 @@ describe "Admin::Users", feature: true  do        end        it 'filters by users who have enabled 2FA' do -        user = create(:user, two_factor_enabled: true) +        user = create(:user, :two_factor)          visit admin_users_path          click_link '2FA Enabled' @@ -38,7 +38,7 @@ describe "Admin::Users", feature: true  do        end        it 'counts users who have not enabled 2FA' do -        create(:user, two_factor_enabled: false) +        create(:user)          visit admin_users_path @@ -48,7 +48,7 @@ describe "Admin::Users", feature: true  do        end        it 'filters by users who have not enabled 2FA' do -        user = create(:user, two_factor_enabled: false) +        user = create(:user)          visit admin_users_path          click_link '2FA Disabled' @@ -173,7 +173,7 @@ describe "Admin::Users", feature: true  do      describe 'Two-factor Authentication status' do        it 'shows when enabled' do -        @user.update_attribute(:two_factor_enabled, true) +        @user.update_attribute(:otp_required_for_login, true)          visit admin_user_path(@user) diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb index c1b178c3b6c..72b5ff231f7 100644 --- a/spec/features/login_spec.rb +++ b/spec/features/login_spec.rb @@ -33,11 +33,11 @@ feature 'Login', feature: true do        before do          login_with(user, remember: true) -        expect(page).to have_content('Two-factor Authentication') +        expect(page).to have_content('Two-Factor Authentication')        end        def enter_code(code) -        fill_in 'Two-factor Authentication code', with: code +        fill_in 'Two-Factor Authentication code', with: code          click_button 'Verify code'        end @@ -143,12 +143,12 @@ feature 'Login', feature: true do        context 'within the grace period' do          it 'redirects to two-factor configuration page' do -          expect(current_path).to eq new_profile_two_factor_auth_path -          expect(page).to have_content('You must enable Two-factor Authentication for your account before') +          expect(current_path).to eq profile_two_factor_auth_path +          expect(page).to have_content('You must enable Two-Factor Authentication for your account before')          end -        it 'disallows skipping two-factor configuration' do -          expect(current_path).to eq new_profile_two_factor_auth_path +        it 'allows skipping two-factor configuration', js: true do +          expect(current_path).to eq profile_two_factor_auth_path            click_link 'Configure it later'            expect(current_path).to eq root_path @@ -159,26 +159,26 @@ feature 'Login', feature: true do          let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) }          it 'redirects to two-factor configuration page' do -          expect(current_path).to eq new_profile_two_factor_auth_path -          expect(page).to have_content('You must enable Two-factor Authentication for your account.') +          expect(current_path).to eq profile_two_factor_auth_path +          expect(page).to have_content('You must enable Two-Factor Authentication for your account.')          end -        it 'disallows skipping two-factor configuration' do -          expect(current_path).to eq new_profile_two_factor_auth_path +        it 'disallows skipping two-factor configuration', js: true do +          expect(current_path).to eq profile_two_factor_auth_path            expect(page).not_to have_link('Configure it later')          end        end      end -    context 'without grace pariod defined' do +    context 'without grace period defined' do        before(:each) do          stub_application_setting(two_factor_grace_period: 0)          login_with(user)        end        it 'redirects to two-factor configuration page' do -        expect(current_path).to eq new_profile_two_factor_auth_path -        expect(page).to have_content('You must enable Two-factor Authentication for your account.') +        expect(current_path).to eq profile_two_factor_auth_path +        expect(page).to have_content('You must enable Two-Factor Authentication for your account.')        end      end    end diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb new file mode 100644 index 00000000000..366a90228b1 --- /dev/null +++ b/spec/features/u2f_spec.rb @@ -0,0 +1,239 @@ +require 'spec_helper' + +feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: true, js: true do +  def register_u2f_device(u2f_device = nil) +    u2f_device ||= FakeU2fDevice.new(page) +    u2f_device.respond_to_u2f_registration +    click_on 'Setup New U2F Device' +    expect(page).to have_content('Your device was successfully set up') +    click_on 'Register U2F Device' +    u2f_device +  end + +  describe "registration" do +    let(:user) { create(:user) } +    before { login_as(user) } + +    describe 'when 2FA via OTP is disabled' do +      it 'allows registering a new device' do +        visit profile_account_path +        click_on 'Enable Two-Factor Authentication' + +        register_u2f_device + +        expect(page.body).to match('Your U2F device was registered') +      end + +      it 'allows registering more than one device' do +        visit profile_account_path + +        # First device +        click_on 'Enable Two-Factor Authentication' +        register_u2f_device +        expect(page.body).to match('Your U2F device was registered') + +        # Second device +        click_on 'Manage Two-Factor Authentication' +        register_u2f_device +        expect(page.body).to match('Your U2F device was registered') +        click_on 'Manage Two-Factor Authentication' + +        expect(page.body).to match('You have 2 U2F devices registered') +      end +    end + +    describe 'when 2FA via OTP is enabled' do +      before { user.update_attributes(otp_required_for_login: true) } + +      it 'allows registering a new device' do +        visit profile_account_path +        click_on 'Manage Two-Factor Authentication' +        expect(page.body).to match("You've already enabled two-factor authentication using mobile") + +        register_u2f_device + +        expect(page.body).to match('Your U2F device was registered') +      end + +      it 'allows registering more than one device' do +        visit profile_account_path + +        # First device +        click_on 'Manage Two-Factor Authentication' +        register_u2f_device +        expect(page.body).to match('Your U2F device was registered') + +        # Second device +        click_on 'Manage Two-Factor Authentication' +        register_u2f_device +        expect(page.body).to match('Your U2F device was registered') + +        click_on 'Manage Two-Factor Authentication' +        expect(page.body).to match('You have 2 U2F devices registered') +      end +    end + +    it 'allows the same device to be registered for multiple users' do +      # First user +      visit profile_account_path +      click_on 'Enable Two-Factor Authentication' +      u2f_device = register_u2f_device +      expect(page.body).to match('Your U2F device was registered') +      logout + +      # Second user +      login_as(:user) +      visit profile_account_path +      click_on 'Enable Two-Factor Authentication' +      register_u2f_device(u2f_device) +      expect(page.body).to match('Your U2F device was registered') + +      expect(U2fRegistration.count).to eq(2) +    end + +    context "when there are form errors" do +      it "doesn't register the device if there are errors" do +        visit profile_account_path +        click_on 'Enable Two-Factor Authentication' + +        # Have the "u2f device" respond with bad data +        page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };") +        click_on 'Setup New U2F Device' +        expect(page).to have_content('Your device was successfully set up') +        click_on 'Register U2F Device' + +        expect(U2fRegistration.count).to eq(0) +        expect(page.body).to match("The form contains the following error") +        expect(page.body).to match("did not send a valid JSON response") +      end + +      it "allows retrying registration" do +        visit profile_account_path +        click_on 'Enable Two-Factor Authentication' + +        # Failed registration +        page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };") +        click_on 'Setup New U2F Device' +        expect(page).to have_content('Your device was successfully set up') +        click_on 'Register U2F Device' +        expect(page.body).to match("The form contains the following error") + +        # Successful registration +        register_u2f_device + +        expect(page.body).to match('Your U2F device was registered') +        expect(U2fRegistration.count).to eq(1) +      end +    end +  end + +  describe "authentication" do +    let(:user) { create(:user) } + +    before do +      # Register and logout +      login_as(user) +      visit profile_account_path +      click_on 'Enable Two-Factor Authentication' +      @u2f_device = register_u2f_device +      logout +    end + +    describe "when 2FA via OTP is disabled" do +      it "allows logging in with the U2F device" do +        login_with(user) + +        @u2f_device.respond_to_u2f_authentication +        click_on "Login Via U2F Device" +        expect(page.body).to match('We heard back from your U2F device') +        click_on "Authenticate via U2F Device" + +        expect(page.body).to match('Signed in successfully') +      end +    end + +    describe "when 2FA via OTP is enabled" do +      it "allows logging in with the U2F device" do +        user.update_attributes(otp_required_for_login: true) +        login_with(user) + +        @u2f_device.respond_to_u2f_authentication +        click_on "Login Via U2F Device" +        expect(page.body).to match('We heard back from your U2F device') +        click_on "Authenticate via U2F Device" + +        expect(page.body).to match('Signed in successfully') +      end +    end + +    describe "when a given U2F device has already been registered by another user" do +      describe "but not the current user" do +        it "does not allow logging in with that particular device" do +          # Register current user with the different U2F device +          current_user = login_as(:user) +          visit profile_account_path +          click_on 'Enable Two-Factor Authentication' +          register_u2f_device +          logout + +          # Try authenticating user with the old U2F device +          login_as(current_user) +          @u2f_device.respond_to_u2f_authentication +          click_on "Login Via U2F Device" +          expect(page.body).to match('We heard back from your U2F device') +          click_on "Authenticate via U2F Device" + +          expect(page.body).to match('Authentication via U2F device failed') +        end +      end + +      describe "and also the current user" do +        it "allows logging in with that particular device" do +          # Register current user with the same U2F device +          current_user = login_as(:user) +          visit profile_account_path +          click_on 'Enable Two-Factor Authentication' +          register_u2f_device(@u2f_device) +          logout + +          # Try authenticating user with the same U2F device +          login_as(current_user) +          @u2f_device.respond_to_u2f_authentication +          click_on "Login Via U2F Device" +          expect(page.body).to match('We heard back from your U2F device') +          click_on "Authenticate via U2F Device" + +          expect(page.body).to match('Signed in successfully') +        end +      end +    end + +    describe "when a given U2F device has not been registered" do +      it "does not allow logging in with that particular device" do +        unregistered_device = FakeU2fDevice.new(page) +        login_as(user) +        unregistered_device.respond_to_u2f_authentication +        click_on "Login Via U2F Device" +        expect(page.body).to match('We heard back from your U2F device') +        click_on "Authenticate via U2F Device" + +        expect(page.body).to match('Authentication via U2F device failed') +      end +    end +  end + +  describe "when two-factor authentication is disabled" do +    let(:user) { create(:user) } + +    before do +      login_as(user) +      visit profile_account_path +      click_on 'Enable Two-Factor Authentication' +      register_u2f_device +    end + +    it "deletes u2f registrations" do +      expect { click_on "Disable" }.to change { U2fRegistration.count }.from(1).to(0) +    end +  end +end diff --git a/spec/javascripts/fixtures/u2f/authenticate.html.haml b/spec/javascripts/fixtures/u2f/authenticate.html.haml new file mode 100644 index 00000000000..859e79a6c9e --- /dev/null +++ b/spec/javascripts/fixtures/u2f/authenticate.html.haml @@ -0,0 +1 @@ += render partial: "u2f/authenticate", locals: { new_user_session_path: "/users/sign_in" } diff --git a/spec/javascripts/fixtures/u2f/register.html.haml b/spec/javascripts/fixtures/u2f/register.html.haml new file mode 100644 index 00000000000..393c0613fd3 --- /dev/null +++ b/spec/javascripts/fixtures/u2f/register.html.haml @@ -0,0 +1 @@ += render partial: "u2f/register", locals: { create_u2f_profile_two_factor_auth_path: '/profile/two_factor_auth/create_u2f' } diff --git a/spec/javascripts/u2f/authenticate_spec.coffee b/spec/javascripts/u2f/authenticate_spec.coffee new file mode 100644 index 00000000000..e8a2892d678 --- /dev/null +++ b/spec/javascripts/u2f/authenticate_spec.coffee @@ -0,0 +1,52 @@ +#= require u2f/authenticate +#= require u2f/util +#= require u2f/error +#= require u2f +#= require ./mock_u2f_device + +describe 'U2FAuthenticate', -> +  U2FUtil.enableTestMode() +  fixture.load('u2f/authenticate') + +  beforeEach -> +    @u2fDevice = new MockU2FDevice +    @container = $("#js-authenticate-u2f") +    @component = new U2FAuthenticate(@container, {}, "token") +    @component.start() + +  it 'allows authenticating via a U2F device', -> +    setupButton = @container.find("#js-login-u2f-device") +    setupMessage = @container.find("p") +    expect(setupMessage.text()).toContain('Insert your security key') +    expect(setupButton.text()).toBe('Login Via U2F Device') +    setupButton.trigger('click') + +    inProgressMessage = @container.find("p") +    expect(inProgressMessage.text()).toContain("Trying to communicate with your device") + +    @u2fDevice.respondToAuthenticateRequest({deviceData: "this is data from the device"}) +    authenticatedMessage = @container.find("p") +    deviceResponse = @container.find('#js-device-response') +    expect(authenticatedMessage.text()).toContain("Click this button to authenticate with the GitLab server") +    expect(deviceResponse.val()).toBe('{"deviceData":"this is data from the device"}') + +  describe "errors", -> +    it "displays an error message", -> +      setupButton = @container.find("#js-login-u2f-device") +      setupButton.trigger('click') +      @u2fDevice.respondToAuthenticateRequest({errorCode: "error!"}) +      errorMessage = @container.find("p") +      expect(errorMessage.text()).toContain("There was a problem communicating with your device") + +    it "allows retrying authentication after an error", -> +      setupButton = @container.find("#js-login-u2f-device") +      setupButton.trigger('click') +      @u2fDevice.respondToAuthenticateRequest({errorCode: "error!"}) +      retryButton = @container.find("#js-u2f-try-again") +      retryButton.trigger('click') + +      setupButton = @container.find("#js-login-u2f-device") +      setupButton.trigger('click') +      @u2fDevice.respondToAuthenticateRequest({deviceData: "this is data from the device"}) +      authenticatedMessage = @container.find("p") +      expect(authenticatedMessage.text()).toContain("Click this button to authenticate with the GitLab server") diff --git a/spec/javascripts/u2f/mock_u2f_device.js.coffee b/spec/javascripts/u2f/mock_u2f_device.js.coffee new file mode 100644 index 00000000000..97ed0e83a0e --- /dev/null +++ b/spec/javascripts/u2f/mock_u2f_device.js.coffee @@ -0,0 +1,15 @@ +class @MockU2FDevice +  constructor: () -> +    window.u2f ||= {} + +    window.u2f.register = (appId, registerRequests, signRequests, callback) => +      @registerCallback = callback + +    window.u2f.sign = (appId, challenges, signRequests, callback) => +      @authenticateCallback = callback + +  respondToRegisterRequest: (params) => +    @registerCallback(params) + +  respondToAuthenticateRequest: (params) => +    @authenticateCallback(params) diff --git a/spec/javascripts/u2f/register_spec.js.coffee b/spec/javascripts/u2f/register_spec.js.coffee new file mode 100644 index 00000000000..0858abeca1a --- /dev/null +++ b/spec/javascripts/u2f/register_spec.js.coffee @@ -0,0 +1,57 @@ +#= require u2f/register +#= require u2f/util +#= require u2f/error +#= require u2f +#= require ./mock_u2f_device + +describe 'U2FRegister', -> +  U2FUtil.enableTestMode() +  fixture.load('u2f/register') + +  beforeEach -> +    @u2fDevice = new MockU2FDevice +    @container = $("#js-register-u2f") +    @component = new U2FRegister(@container, $("#js-register-u2f-templates"), {}, "token") +    @component.start() + +  it 'allows registering a U2F device', -> +    setupButton = @container.find("#js-setup-u2f-device") +    expect(setupButton.text()).toBe('Setup New U2F Device') +    setupButton.trigger('click') + +    inProgressMessage = @container.children("p") +    expect(inProgressMessage.text()).toContain("Trying to communicate with your device") + +    @u2fDevice.respondToRegisterRequest({deviceData: "this is data from the device"}) +    registeredMessage = @container.find('p') +    deviceResponse = @container.find('#js-device-response') +    expect(registeredMessage.text()).toContain("Your device was successfully set up!") +    expect(deviceResponse.val()).toBe('{"deviceData":"this is data from the device"}') + +  describe "errors", -> +    it "doesn't allow the same device to be registered twice (for the same user", -> +      setupButton = @container.find("#js-setup-u2f-device") +      setupButton.trigger('click') +      @u2fDevice.respondToRegisterRequest({errorCode: 4}) +      errorMessage = @container.find("p") +      expect(errorMessage.text()).toContain("already been registered with us") + +    it "displays an error message for other errors", -> +      setupButton = @container.find("#js-setup-u2f-device") +      setupButton.trigger('click') +      @u2fDevice.respondToRegisterRequest({errorCode: "error!"}) +      errorMessage = @container.find("p") +      expect(errorMessage.text()).toContain("There was a problem communicating with your device") + +    it "allows retrying registration after an error", -> +      setupButton = @container.find("#js-setup-u2f-device") +      setupButton.trigger('click') +      @u2fDevice.respondToRegisterRequest({errorCode: "error!"}) +      retryButton = @container.find("#U2FTryAgain") +      retryButton.trigger('click') + +      setupButton = @container.find("#js-setup-u2f-device") +      setupButton.trigger('click') +      @u2fDevice.respondToRegisterRequest({deviceData: "this is data from the device"}) +      registeredMessage = @container.find("p") +      expect(registeredMessage.text()).toContain("Your device was successfully set up!") diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 528a79bf221..6ea8bf9bbe1 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -121,6 +121,66 @@ describe User, models: true do      end    end +  describe "scopes" do +    describe ".with_two_factor" do +      it "returns users with 2fa enabled via OTP" do +        user_with_2fa = create(:user, :two_factor_via_otp) +        user_without_2fa = create(:user) +        users_with_two_factor = User.with_two_factor.pluck(:id) + +        expect(users_with_two_factor).to include(user_with_2fa.id) +        expect(users_with_two_factor).not_to include(user_without_2fa.id) +      end + +      it "returns users with 2fa enabled via U2F" do +        user_with_2fa = create(:user, :two_factor_via_u2f) +        user_without_2fa = create(:user) +        users_with_two_factor = User.with_two_factor.pluck(:id) + +        expect(users_with_two_factor).to include(user_with_2fa.id) +        expect(users_with_two_factor).not_to include(user_without_2fa.id) +      end + +      it "returns users with 2fa enabled via OTP and U2F" do +        user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f) +        user_without_2fa = create(:user) +        users_with_two_factor = User.with_two_factor.pluck(:id) + +        expect(users_with_two_factor).to eq([user_with_2fa.id]) +        expect(users_with_two_factor).not_to include(user_without_2fa.id) +      end +    end + +    describe ".without_two_factor" do +      it "excludes users with 2fa enabled via OTP" do +        user_with_2fa = create(:user, :two_factor_via_otp) +        user_without_2fa = create(:user) +        users_without_two_factor = User.without_two_factor.pluck(:id) + +        expect(users_without_two_factor).to include(user_without_2fa.id) +        expect(users_without_two_factor).not_to include(user_with_2fa.id) +      end + +      it "excludes users with 2fa enabled via U2F" do +        user_with_2fa = create(:user, :two_factor_via_u2f) +        user_without_2fa = create(:user) +        users_without_two_factor = User.without_two_factor.pluck(:id) + +        expect(users_without_two_factor).to include(user_without_2fa.id) +        expect(users_without_two_factor).not_to include(user_with_2fa.id) +      end + +      it "excludes users with 2fa enabled via OTP and U2F" do +        user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f) +        user_without_2fa = create(:user) +        users_without_two_factor = User.without_two_factor.pluck(:id) + +        expect(users_without_two_factor).to include(user_without_2fa.id) +        expect(users_without_two_factor).not_to include(user_with_2fa.id) +      end +    end +  end +    describe "Respond to" do      it { is_expected.to respond_to(:is_admin?) }      it { is_expected.to respond_to(:name) } diff --git a/spec/support/fake_u2f_device.rb b/spec/support/fake_u2f_device.rb new file mode 100644 index 00000000000..553fe9f1fbc --- /dev/null +++ b/spec/support/fake_u2f_device.rb @@ -0,0 +1,36 @@ +class FakeU2fDevice +  def initialize(page) +    @page = page +  end +   +  def respond_to_u2f_registration +    app_id = @page.evaluate_script('gon.u2f.app_id') +    challenges = @page.evaluate_script('gon.u2f.challenges') + +    json_response = u2f_device(app_id).register_response(challenges[0]) + +    @page.execute_script(" +    u2f.register = function(appId, registerRequests, signRequests, callback) { +      callback(#{json_response}); +    }; +    ") +  end + +  def respond_to_u2f_authentication +    app_id = @page.evaluate_script('gon.u2f.app_id') +    challenges = @page.evaluate_script('gon.u2f.challenges') +    json_response = u2f_device(app_id).sign_response(challenges[0]) + +    @page.execute_script(" +    u2f.sign = function(appId, challenges, signRequests, callback) { +      callback(#{json_response}); +    }; +    ") +  end + +  private + +  def u2f_device(app_id) +    @u2f_device ||= U2F::FakeU2F.new(app_id) +  end +end diff --git a/vendor/assets/javascripts/u2f.js b/vendor/assets/javascripts/u2f.js new file mode 100644 index 00000000000..e666b136051 --- /dev/null +++ b/vendor/assets/javascripts/u2f.js @@ -0,0 +1,748 @@ +//Copyright 2014-2015 Google Inc. All rights reserved. + +//Use of this source code is governed by a BSD-style +//license that can be found in the LICENSE file or at +//https://developers.google.com/open-source/licenses/bsd + +/** + * @fileoverview The U2F api. + */ +'use strict'; + + +/** + * Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + +/** + * FIDO U2F Javascript API Version + * @number + */ +var js_api_version; + +/** + * The U2F extension id + * @const {string} + */ +// The Chrome packaged app extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the package Chrome app and does not require installing the U2F Chrome extension. +u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; +// The U2F Chrome extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the U2F Chrome extension to authenticate. +// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; + + +/** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { +    'U2F_REGISTER_REQUEST': 'u2f_register_request', +    'U2F_REGISTER_RESPONSE': 'u2f_register_response', +    'U2F_SIGN_REQUEST': 'u2f_sign_request', +    'U2F_SIGN_RESPONSE': 'u2f_sign_response', +    'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request', +    'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response' +}; + + +/** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { +    'OK': 0, +    'OTHER_ERROR': 1, +    'BAD_REQUEST': 2, +    'CONFIGURATION_UNSUPPORTED': 3, +    'DEVICE_INELIGIBLE': 4, +    'TIMEOUT': 5 +}; + + +/** + * A message for registration requests + * @typedef {{ + *   type: u2f.MessageTypes, + *   appId: ?string, + *   timeoutSeconds: ?number, + *   requestId: ?number + * }} + */ +u2f.U2fRequest; + + +/** + * A message for registration responses + * @typedef {{ + *   type: u2f.MessageTypes, + *   responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + *   requestId: ?number + * }} + */ +u2f.U2fResponse; + + +/** + * An error object for responses + * @typedef {{ + *   errorCode: u2f.ErrorCodes, + *   errorMessage: ?string + * }} + */ +u2f.Error; + +/** + * Data object for a single sign request. + * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}} + */ +u2f.Transport; + + +/** + * Data object for a single sign request. + * @typedef {Array<u2f.Transport>} + */ +u2f.Transports; + +/** + * Data object for a single sign request. + * @typedef {{ + *   version: string, + *   challenge: string, + *   keyHandle: string, + *   appId: string + * }} + */ +u2f.SignRequest; + + +/** + * Data object for a sign response. + * @typedef {{ + *   keyHandle: string, + *   signatureData: string, + *   clientData: string + * }} + */ +u2f.SignResponse; + + +/** + * Data object for a registration request. + * @typedef {{ + *   version: string, + *   challenge: string + * }} + */ +u2f.RegisterRequest; + + +/** + * Data object for a registration response. + * @typedef {{ + *   version: string, + *   keyHandle: string, + *   transports: Transports, + *   appId: string + * }} + */ +u2f.RegisterResponse; + + +/** + * Data object for a registered key. + * @typedef {{ + *   version: string, + *   keyHandle: string, + *   transports: ?Transports, + *   appId: ?string + * }} + */ +u2f.RegisteredKey; + + +/** + * Data object for a get API register response. + * @typedef {{ + *   js_api_version: number + * }} + */ +u2f.GetJsApiVersionResponse; + + +//Low level MessagePort API support + +/** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function(callback) { +    if (typeof chrome != 'undefined' && chrome.runtime) { +        // The actual message here does not matter, but we need to get a reply +        // for the callback to run. Thus, send an empty signature request +        // in order to get a failure response. +        var msg = { +            type: u2f.MessageTypes.U2F_SIGN_REQUEST, +            signRequests: [] +        }; +        chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { +            if (!chrome.runtime.lastError) { +                // We are on a whitelisted origin and can talk directly +                // with the extension. +                u2f.getChromeRuntimePort_(callback); +            } else { +                // chrome.runtime was available, but we couldn't message +                // the extension directly, use iframe +                u2f.getIframePort_(callback); +            } +        }); +    } else if (u2f.isAndroidChrome_()) { +        u2f.getAuthenticatorPort_(callback); +    } else if (u2f.isIosChrome_()) { +        u2f.getIosPort_(callback); +    } else { +        // chrome.runtime was not available at all, which is normal +        // when this origin doesn't have access to any extensions. +        u2f.getIframePort_(callback); +    } +}; + +/** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function() { +    var userAgent = navigator.userAgent; +    return userAgent.indexOf('Chrome') != -1 && +        userAgent.indexOf('Android') != -1; +}; + +/** + * Detect chrome running on iOS based on the browser's platform. + * @private + */ +u2f.isIosChrome_ = function() { +    return $.inArray(navigator.platform, ["iPhone", "iPad", "iPod"]) > -1; +}; + +/** + * Connects directly to the extension via chrome.runtime.connect. + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function(callback) { +    var port = chrome.runtime.connect(u2f.EXTENSION_ID, +        {'includeTlsChannelId': true}); +    setTimeout(function() { +        callback(new u2f.WrappedChromeRuntimePort_(port)); +    }, 0); +}; + +/** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function(callback) { +    setTimeout(function() { +        callback(new u2f.WrappedAuthenticatorPort_()); +    }, 0); +}; + +/** + * Return a 'port' abstraction to the iOS client app. + * @param {function(u2f.WrappedIosPort_)} callback + * @private + */ +u2f.getIosPort_ = function(callback) { +    setTimeout(function() { +        callback(new u2f.WrappedIosPort_()); +    }, 0); +}; + +/** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function(port) { +    this.port_ = port; +}; + +/** + * Format and return a sign request compliant with the JS API version supported by the extension. + * @param {Array<u2f.SignRequest>} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatSignRequest_ = +    function(appId, challenge, registeredKeys, timeoutSeconds, reqId) { +        if (js_api_version === undefined || js_api_version < 1.1) { +            // Adapt request to the 1.0 JS API +            var signRequests = []; +            for (var i = 0; i < registeredKeys.length; i++) { +                signRequests[i] = { +                    version: registeredKeys[i].version, +                    challenge: challenge, +                    keyHandle: registeredKeys[i].keyHandle, +                    appId: appId +                }; +            } +            return { +                type: u2f.MessageTypes.U2F_SIGN_REQUEST, +                signRequests: signRequests, +                timeoutSeconds: timeoutSeconds, +                requestId: reqId +            }; +        } +        // JS 1.1 API +        return { +            type: u2f.MessageTypes.U2F_SIGN_REQUEST, +            appId: appId, +            challenge: challenge, +            registeredKeys: registeredKeys, +            timeoutSeconds: timeoutSeconds, +            requestId: reqId +        }; +    }; + +/** + * Format and return a register request compliant with the JS API version supported by the extension.. + * @param {Array<u2f.SignRequest>} signRequests + * @param {Array<u2f.RegisterRequest>} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatRegisterRequest_ = +    function(appId, registeredKeys, registerRequests, timeoutSeconds, reqId) { +        if (js_api_version === undefined || js_api_version < 1.1) { +            // Adapt request to the 1.0 JS API +            for (var i = 0; i < registerRequests.length; i++) { +                registerRequests[i].appId = appId; +            } +            var signRequests = []; +            for (var i = 0; i < registeredKeys.length; i++) { +                signRequests[i] = { +                    version: registeredKeys[i].version, +                    challenge: registerRequests[0], +                    keyHandle: registeredKeys[i].keyHandle, +                    appId: appId +                }; +            } +            return { +                type: u2f.MessageTypes.U2F_REGISTER_REQUEST, +                signRequests: signRequests, +                registerRequests: registerRequests, +                timeoutSeconds: timeoutSeconds, +                requestId: reqId +            }; +        } +        // JS 1.1 API +        return { +            type: u2f.MessageTypes.U2F_REGISTER_REQUEST, +            appId: appId, +            registerRequests: registerRequests, +            registeredKeys: registeredKeys, +            timeoutSeconds: timeoutSeconds, +            requestId: reqId +        }; +    }; + + +/** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { +    this.port_.postMessage(message); +}; + + +/** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = +    function(eventName, handler) { +        var name = eventName.toLowerCase(); +        if (name == 'message' || name == 'onmessage') { +            this.port_.onMessage.addListener(function(message) { +                // Emulate a minimal MessageEvent object +                handler({'data': message}); +            }); +        } else { +            console.error('WrappedChromeRuntimePort only supports onMessage'); +        } +    }; + +/** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function() { +    this.requestId_ = -1; +    this.requestObject_ = null; +} + +/** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { +    var intentUrl = +        u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + +        ';S.request=' + encodeURIComponent(JSON.stringify(message)) + +        ';end'; +    document.location = intentUrl; +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() { +    return "WrappedAuthenticatorPort_"; +}; + + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function(eventName, handler) { +    var name = eventName.toLowerCase(); +    if (name == 'message') { +        var self = this; +        /* Register a callback to that executes when +         * chrome injects the response. */ +        window.addEventListener( +            'message', self.onRequestUpdate_.bind(self, handler), false); +    } else { +        console.error('WrappedAuthenticatorPort only supports message'); +    } +}; + +/** + * Callback invoked  when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = +    function(callback, message) { +        var messageObject = JSON.parse(message.data); +        var intentUrl = messageObject['intentURL']; + +        var errorCode = messageObject['errorCode']; +        var responseObject = null; +        if (messageObject.hasOwnProperty('data')) { +            responseObject = /** @type {Object} */ ( +                JSON.parse(messageObject['data'])); +        } + +        callback({'data': responseObject}); +    }; + +/** + * Base URL for intents to Authenticator. + * @const + * @private + */ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = +    'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; + +/** + * Wrap the iOS client app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedIosPort_ = function() {}; + +/** + * Launch the iOS client app request + * @param {Object} message + */ +u2f.WrappedIosPort_.prototype.postMessage = function(message) { +    var str = JSON.stringify(message); +    var url = "u2f://auth?" + encodeURI(str); +    location.replace(url); +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedIosPort_.prototype.getPortType = function() { +    return "WrappedIosPort_"; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) { +    var name = eventName.toLowerCase(); +    if (name !== 'message') { +        console.error('WrappedIosPort only supports message'); +    } +}; + +/** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function(callback) { +    // Create the iframe +    var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; +    var iframe = document.createElement('iframe'); +    iframe.src = iframeOrigin + '/u2f-comms.html'; +    iframe.setAttribute('style', 'display:none'); +    document.body.appendChild(iframe); + +    var channel = new MessageChannel(); +    var ready = function(message) { +        if (message.data == 'ready') { +            channel.port1.removeEventListener('message', ready); +            callback(channel.port1); +        } else { +            console.error('First event on iframe port was not "ready"'); +        } +    }; +    channel.port1.addEventListener('message', ready); +    channel.port1.start(); + +    iframe.addEventListener('load', function() { +        // Deliver the port to the iframe and initialize +        iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); +    }); +}; + + +//High-level JS API + +/** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + +/** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + +/** + * Callbacks waiting for a port + * @type {Array<function((MessagePort|u2f.WrappedChromeRuntimePort_))>} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.<number,(function((u2f.Error|u2f.RegisterResponse)) + *                       |function((u2f.Error|u2f.SignResponse)))>} + * @private + */ +u2f.callbackMap_ = {}; + +/** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function(callback) { +    if (u2f.port_) { +        callback(u2f.port_); +    } else { +        if (u2f.waitingForPort_.length == 0) { +            u2f.getMessagePort(function(port) { +                u2f.port_ = port; +                u2f.port_.addEventListener('message', +                    /** @type {function(Event)} */ (u2f.responseHandler_)); + +                // Careful, here be async callbacks. Maybe. +                while (u2f.waitingForPort_.length) +                    u2f.waitingForPort_.shift()(u2f.port_); +            }); +        } +        u2f.waitingForPort_.push(callback); +    } +}; + +/** + * Handles response messages from the extension. + * @param {MessageEvent.<u2f.Response>} message + * @private + */ +u2f.responseHandler_ = function(message) { +    var response = message.data; +    var reqId = response['requestId']; +    if (!reqId || !u2f.callbackMap_[reqId]) { +        console.error('Unknown or missing requestId in response.'); +        return; +    } +    var cb = u2f.callbackMap_[reqId]; +    delete u2f.callbackMap_[reqId]; +    cb(response['responseData']); +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the sign request. + * @param {string=} appId + * @param {string=} challenge + * @param {Array<u2f.RegisteredKey>} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { +    if (js_api_version === undefined) { +        // Send a message to get the extension to JS API version, then send the actual sign request. +        u2f.getApiVersion( +            function (response) { +                js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; +                console.log("Extension JS API Version: ", js_api_version); +                u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); +            }); +    } else { +        // We know the JS API version. Send the actual sign request in the supported API version. +        u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); +    } +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * @param {string=} appId + * @param {string=} challenge + * @param {Array<u2f.RegisteredKey>} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendSignRequest = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { +    u2f.getPortSingleton_(function(port) { +        var reqId = ++u2f.reqCounter_; +        u2f.callbackMap_[reqId] = callback; +        var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? +            opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); +        var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId); +        port.postMessage(req); +    }); +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the register request. + * @param {string=} appId + * @param {Array<u2f.RegisterRequest>} registerRequests + * @param {Array<u2f.RegisteredKey>} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { +    if (js_api_version === undefined) { +        // Send a message to get the extension to JS API version, then send the actual register request. +        u2f.getApiVersion( +            function (response) { +                js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version']; +                console.log("Extension JS API Version: ", js_api_version); +                u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, +                    callback, opt_timeoutSeconds); +            }); +    } else { +        // We know the JS API version. Send the actual register request in the supported API version. +        u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, +            callback, opt_timeoutSeconds); +    } +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {string=} appId + * @param {Array<u2f.RegisterRequest>} registerRequests + * @param {Array<u2f.RegisteredKey>} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendRegisterRequest = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { +    u2f.getPortSingleton_(function(port) { +        var reqId = ++u2f.reqCounter_; +        u2f.callbackMap_[reqId] = callback; +        var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? +            opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); +        var req = u2f.formatRegisterRequest_( +            appId, registeredKeys, registerRequests, timeoutSeconds, reqId); +        port.postMessage(req); +    }); +}; + + +/** + * Dispatches a message to the extension to find out the supported + * JS API version. + * If the user is on a mobile phone and is thus using Google Authenticator instead + * of the Chrome extension, don't send the request and simply return 0. + * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.getApiVersion = function(callback, opt_timeoutSeconds) { +    u2f.getPortSingleton_(function(port) { +        // If we are using Android Google Authenticator or iOS client app, +        // do not fire an intent to ask which JS API version to use. +        if (port.getPortType) { +            var apiVersion; +            switch (port.getPortType()) { +                case 'WrappedIosPort_': +                case 'WrappedAuthenticatorPort_': +                    apiVersion = 1.1; +                    break; + +                default: +                    apiVersion = 0; +                    break; +            } +            callback({ 'js_api_version': apiVersion }); +            return; +        } +        var reqId = ++u2f.reqCounter_; +        u2f.callbackMap_[reqId] = callback; +        var req = { +            type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, +            timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? +                opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), +            requestId: reqId +        }; +        port.postMessage(req); +    }); +};
\ No newline at end of file  | 
