Browse Source

Fixes #4616 - Two-factor authentication (2FA): Security Keys

Co-authored-by: Florian Liebe <fl@zammad.com>
Co-authored-by: Vladimir Sheremet <vs@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Florian Liebe 1 year ago
parent
commit
e3d3463ead

+ 1 - 0
Gemfile

@@ -95,6 +95,7 @@ gem 'oauth2'
 
 
 # authentication - two factor
 # authentication - two factor
 gem 'rotp', require: false
 gem 'rotp', require: false
+gem 'webauthn', require: false
 
 
 # authentication - third party
 # authentication - third party
 gem 'omniauth-rails_csrf_protection'
 gem 'omniauth-rails_csrf_protection'

+ 25 - 0
Gemfile.lock

@@ -97,12 +97,15 @@ GEM
       activerecord (>= 4.2)
       activerecord (>= 4.2)
     addressable (2.8.4)
     addressable (2.8.4)
       public_suffix (>= 2.0.2, < 6.0)
       public_suffix (>= 2.0.2, < 6.0)
+    android_key_attestation (0.3.0)
     argon2 (2.2.0)
     argon2 (2.2.0)
       ffi (~> 1.15)
       ffi (~> 1.15)
       ffi-compiler (~> 1.0)
       ffi-compiler (~> 1.0)
     ast (2.4.2)
     ast (2.4.2)
     autoprefixer-rails (10.4.13.0)
     autoprefixer-rails (10.4.13.0)
       execjs (~> 2)
       execjs (~> 2)
+    awrence (1.2.1)
+    bindata (2.4.15)
     binding_of_caller (1.0.0)
     binding_of_caller (1.0.0)
       debug_inspector (>= 0.0.1)
       debug_inspector (>= 0.0.1)
     biz (1.8.2)
     biz (1.8.2)
@@ -125,6 +128,7 @@ GEM
       rack-test (>= 0.6.3)
       rack-test (>= 0.6.3)
       regexp_parser (>= 1.5, < 3.0)
       regexp_parser (>= 1.5, < 3.0)
       xpath (~> 3.2)
       xpath (~> 3.2)
+    cbor (0.5.9.6)
     childprocess (4.1.0)
     childprocess (4.1.0)
     chunky_png (1.4.0)
     chunky_png (1.4.0)
     clavius (1.0.4)
     clavius (1.0.4)
@@ -141,6 +145,9 @@ GEM
     composite_primary_keys (13.0.7)
     composite_primary_keys (13.0.7)
       activerecord (~> 6.1.0)
       activerecord (~> 6.1.0)
     concurrent-ruby (1.2.2)
     concurrent-ruby (1.2.2)
+    cose (1.3.0)
+      cbor (~> 0.5.9)
+      openssl-signature_algorithm (~> 1.0)
     crack (0.4.5)
     crack (0.4.5)
       rexml
       rexml
     crass (1.0.6)
     crass (1.0.6)
@@ -369,6 +376,8 @@ GEM
       omniauth-oauth (~> 1.1)
       omniauth-oauth (~> 1.1)
       rack
       rack
     openssl (3.1.0)
     openssl (3.1.0)
+    openssl-signature_algorithm (1.3.0)
+      openssl (> 2.0)
     overcommit (0.60.0)
     overcommit (0.60.0)
       childprocess (>= 0.6.3, < 5)
       childprocess (>= 0.6.3, < 5)
       iniparse (~> 1.4)
       iniparse (~> 1.4)
@@ -513,6 +522,8 @@ GEM
     ruby2_keywords (0.0.5)
     ruby2_keywords (0.0.5)
     rubyntlm (0.6.3)
     rubyntlm (0.6.3)
     rubyzip (2.3.2)
     rubyzip (2.3.2)
+    safety_net_attestation (0.4.0)
+      jwt (~> 2.0)
     sassc (2.4.0)
     sassc (2.4.0)
       ffi (~> 1.9)
       ffi (~> 1.9)
     sassc-rails (2.1.2)
     sassc-rails (2.1.2)
@@ -566,6 +577,10 @@ GEM
     time (0.2.2)
     time (0.2.2)
       date
       date
     timeout (0.3.2)
     timeout (0.3.2)
+    tpm-key_attestation (0.12.0)
+      bindata (~> 2.4)
+      openssl (> 2.0)
+      openssl-signature_algorithm (~> 1.0)
     twilio-ruby (6.0.1)
     twilio-ruby (6.0.1)
       faraday (>= 0.9, < 3.0)
       faraday (>= 0.9, < 3.0)
       jwt (>= 1.5, < 3.0)
       jwt (>= 1.5, < 3.0)
@@ -604,6 +619,15 @@ GEM
       dry-cli (>= 0.7, < 2)
       dry-cli (>= 0.7, < 2)
       rack-proxy (~> 0.6, >= 0.6.1)
       rack-proxy (~> 0.6, >= 0.6.1)
       zeitwerk (~> 2.2)
       zeitwerk (~> 2.2)
+    webauthn (3.0.0)
+      android_key_attestation (~> 0.3.0)
+      awrence (~> 1.1)
+      bindata (~> 2.4)
+      cbor (~> 0.5.9)
+      cose (~> 1.1)
+      openssl (>= 2.2)
+      safety_net_attestation (~> 0.4.0)
+      tpm-key_attestation (~> 0.12.0)
     webmock (3.18.1)
     webmock (3.18.1)
       addressable (>= 2.8.0)
       addressable (>= 2.8.0)
       crack (>= 0.3.2)
       crack (>= 0.3.2)
@@ -743,6 +767,7 @@ DEPENDENCIES
   vcr
   vcr
   viewpoint
   viewpoint
   vite_rails
   vite_rails
+  webauthn
   webmock
   webmock
   write_xlsx
   write_xlsx
   zendesk_api
   zendesk_api

+ 5 - 0
LICENSE-3RD-PARTY.txt

@@ -173,6 +173,11 @@ Source: http://spinejs.com
 Copyright: 2011 Alex MacCaw <info@eribium.org>
 Copyright: 2011 Alex MacCaw <info@eribium.org>
 License: MIT license
 License: MIT license
 -----------------------------------------------------------------------------
 -----------------------------------------------------------------------------
+webauthn-json
+Source: https://github.com/github/webauthn-json
+Copyright: 2019 GitHub, Inc.
+License: MIT license
+-----------------------------------------------------------------------------
 word_filter.js
 word_filter.js
 Source: https://gist.github.com/sbrin/6801034
 Source: https://gist.github.com/sbrin/6801034
 Copyright: 2015, sbrin - https://github.com/sbrin
 Copyright: 2015, sbrin - https://github.com/sbrin

+ 38 - 4
app/assets/javascripts/app/controllers/_profile/password.coffee

@@ -3,14 +3,16 @@ class ProfilePassword extends App.ControllerSubContent
   header: __('Password & Authentication')
   header: __('Password & Authentication')
   events:
   events:
     'submit form': 'update'
     'submit form': 'update'
-    'click [data-type="setup"]':  'twoFactorMethodSetup'
-    'click [data-type="remove"]': 'twoFactorMethodRemove'
+    'click [data-type="setup"]':   'twoFactorMethodSetup'
+    'click [data-type="edit"]':    'twoFactorMethodSetup'
+    'click [data-type="remove"]':  'twoFactorMethodRemove'
+    'click [data-type="default"]': 'twoFactorMethodDefault'
 
 
   constructor: ->
   constructor: ->
     super
     super
 
 
     @controllerBind('config_update', (data) =>
     @controllerBind('config_update', (data) =>
-      return if data.name isnt 'two_factor_authentication_method_authenticator_app'
+      return if not /^two_factor_authentication_method_/.test(data.name)
 
 
       @preRender()
       @preRender()
     )
     )
@@ -47,7 +49,10 @@ class ProfilePassword extends App.ControllerSubContent
     App.Config.get('user_show_password_login') || @permissionCheck('admin.*')
     App.Config.get('user_show_password_login') || @permissionCheck('admin.*')
 
 
   allowsTwoFactor: ->
   allowsTwoFactor: ->
-    App.Config.get('two_factor_authentication_method_authenticator_app')
+    _.some(
+      App.Config.all(),
+      (state, setting) -> /^two_factor_authentication_method_/.test(setting) and state
+    )
 
 
   render: (data = {}) =>
   render: (data = {}) =>
 
 
@@ -196,6 +201,35 @@ class ProfilePassword extends App.ControllerSubContent
         )
         )
     )
     )
 
 
+  twoFactorMethodDefault: (e) =>
+    e.preventDefault()
+
+    @ajax(
+      id:   'profile_two_factor_default_authentication_method'
+      type: 'POST'
+      url:  @apiPath + '/users/two_factor_default_authentication_method'
+      processData: true
+      data: JSON.stringify(
+        method: e.currentTarget.closest('tr').dataset.twoFactorKey
+      )
+      success: (data, status, xhr) =>
+        @notify
+          type:      'success'
+          msg:       App.i18n.translateContent('Two-factor authentication method was set as default.')
+          removeAll: true
+
+        @load()
+      error: (xhr, statusText) =>
+        data = JSON.parse(xhr.responseText)
+
+        message = data?.error || __('Could not set two-factor authentication method as default')
+
+        @notify
+          type:      'error'
+          msg:       App.i18n.translateContent(message)
+          removeAll: true
+    )
+
 App.Config.set('Password', {
 App.Config.set('Password', {
   prio: 2000,
   prio: 2000,
   name: __('Password & Authentication'),
   name: __('Password & Authentication'),

+ 10 - 6
app/assets/javascripts/app/controllers/login.coffee

@@ -121,9 +121,7 @@ class Login extends App.ControllerFullPage
       method:       method
       method:       method
     )
     )
 
 
-    methodLoginElements = methodLogin.render(
-      errorMessage: data.errorMessage
-    )
+    methodLoginElements = methodLogin.render()
 
 
     @el.find('.js-form').html   methodLoginElements.form
     @el.find('.js-form').html   methodLoginElements.form
     @el.find('.js-footer').html methodLoginElements.footer
     @el.find('.js-footer').html methodLoginElements.footer
@@ -138,6 +136,7 @@ class Login extends App.ControllerFullPage
       (elem) => _.includes(@twoFactorAvailableMethods, elem.key))
       (elem) => _.includes(@twoFactorAvailableMethods, elem.key))
 
 
     @el.find('.js-form').html App.view('widget/two_factor_login/try_another_method')(
     @el.find('.js-form').html App.view('widget/two_factor_login/try_another_method')(
+      defaultTwoFactorMethod:    @defaultTwoFactorMethod
       twoFactorMethods:          methodsToShow
       twoFactorMethods:          methodsToShow
       twoFactorHasRecoveryCodes: @twoFactorHasRecoveryCodes
       twoFactorHasRecoveryCodes: @twoFactorHasRecoveryCodes
     )
     )
@@ -176,12 +175,16 @@ class Login extends App.ControllerFullPage
     errorMessage = App.i18n.translateContent(details.error || 'Could not process your request')
     errorMessage = App.i18n.translateContent(details.error || 'Could not process your request')
 
 
     if config = details.two_factor_required
     if config = details.two_factor_required
+      @defaultTwoFactorMethod          = config.default_two_factor_authentication_method
       @twoFactorAvailableMethods       = config.available_two_factor_authentication_methods
       @twoFactorAvailableMethods       = config.available_two_factor_authentication_methods
       @twoFactorHasRecoveryCodes       = config.recovery_codes_available
       @twoFactorHasRecoveryCodes       = config.recovery_codes_available
       @twoFactorAvailableAnotherMethod = config.available_two_factor_authentication_methods.length > 1 || (config.recovery_codes_available && config.available_two_factor_authentication_methods.length > 0)
       @twoFactorAvailableAnotherMethod = config.available_two_factor_authentication_methods.length > 1 || (config.recovery_codes_available && config.available_two_factor_authentication_methods.length > 0)
 
 
+
+
       @renderTwoFactor(
       @renderTwoFactor(
-        twoFactorMethod: config.default_two_factor_authentication_method
+        twoFactorMethod:           @defaultTwoFactorMethod
+        twoFactorAvailableMethods: @twoFactorAvailableMethods
       )
       )
 
 
       return
       return
@@ -211,10 +214,11 @@ class Login extends App.ControllerFullPage
   clickedAnotherTwoFactor: (e) ->
   clickedAnotherTwoFactor: (e) ->
     @preventDefaultAndStopPropagation(e)
     @preventDefaultAndStopPropagation(e)
 
 
-    newMethod = e.target.closest('[data-method]').dataset['method']
+    newMethod = e.target.dataset['method']
 
 
     @renderTwoFactor(
     @renderTwoFactor(
-      twoFactorMethod: newMethod
+      twoFactorMethod:           newMethod
+      twoFactorAvailableMethods: @twoFactorAvailableMethods
     )
     )
 
 
   goToMobile: (e) ->
   goToMobile: (e) ->

+ 3 - 1
app/assets/javascripts/app/controllers/user.coffee

@@ -152,7 +152,9 @@ class User extends App.ControllerSubContent
           icon: 'two-factor'
           icon: 'two-factor'
           class: 'create js-manageTwoFactor'
           class: 'create js-manageTwoFactor'
           available: (user) ->
           available: (user) ->
-            user.two_factor_configured
+            if user.preferences && user.preferences.two_factor_authentication && user.preferences.two_factor_authentication.default
+              return true
+            return false
           callback: (id) ->
           callback: (id) ->
             user = App.User.find(id)
             user = App.User.find(id)
             return if !user
             return if !user

+ 3 - 0
app/assets/javascripts/app/controllers/widget/two_factor_configuration/method/security_keys.coffee

@@ -0,0 +1,3 @@
+class App.TwoFactorConfigurationMethodSecurityKeys extends App.TwoFactorConfigurationMethod
+  methodModalClass: ->
+    App.TwoFactorConfigurationModalSecurityKeys

+ 1 - 1
app/assets/javascripts/app/controllers/widget/two_factor_configuration/modal.coffee

@@ -12,7 +12,7 @@ class App.TwoFactorConfigurationModal extends App.ControllerModal
 
 
   closeWithFade: =>
   closeWithFade: =>
     @el.addClass('fade')
     @el.addClass('fade')
-    $('.modal-backdrop').addClass('fade')
+    @el.closest('.modal-backdrop').addClass('fade')
     @close()
     @close()
 
 
   nextModalClass: ->
   nextModalClass: ->

+ 2 - 2
app/assets/javascripts/app/controllers/widget/two_factor_configuration/modal/authenticator_app.coffee

@@ -56,9 +56,9 @@ class App.TwoFactorConfigurationModalAuthenticatorApp extends App.TwoFactorConfi
 
 
   fetchInitialConfiguration: (callback) =>
   fetchInitialConfiguration: (callback) =>
     @ajax(
     @ajax(
-      id:      'two_factor_authentication_method_configuration'
+      id:      'two_factor_authentication_method_initiate_configuration'
       type:    'GET'
       type:    'GET'
-      url:     "#{@apiPath}/users/two_factor_authentication_method_configuration/#{@method.key}"
+      url:     "#{@apiPath}/users/two_factor_authentication_method_initiate_configuration/#{@method.key}"
       success: callback
       success: callback
     )
     )
 
 

+ 236 - 0
app/assets/javascripts/app/controllers/widget/two_factor_configuration/modal/security_keys.coffee

@@ -0,0 +1,236 @@
+class App.TwoFactorConfigurationModalSecurityKeys extends App.TwoFactorConfigurationModal
+  buttonSubmit: __('Set Up')
+  buttonClass: 'btn--success'
+  head: __('Security Keys')
+
+  content: ->
+    false
+
+  render: ->
+    super
+
+    $('.modal .js-loading').removeClass('hide')
+    $('.modal .js-submit').prop('disabled', true)
+
+    callback = (data) =>
+      @config      = data?.configuration or {}
+      @credentials = @config?.credentials or []
+
+      content = $(App.view('widget/two_factor_configuration/security_keys/index')())
+
+      $('.modal .js-loading').addClass('hide')
+      $('.modal-body').html(content)
+      $('.modal .js-submit').prop('disabled', false)
+
+      return if not @credentials.length
+
+      # Show the table with the keys only if there is at least one configured.
+      $('.modal-body').find('.js-table').html(@renderTable().el)
+
+    @fetchExistingSecurityKeys(callback)
+
+  fetchExistingSecurityKeys: (callback) =>
+    @ajax(
+      id:      'two_factor_authentication_method_configuration'
+      type:    'GET'
+      url:     "#{@apiPath}/users/two_factor_authentication_method_configuration/security_keys"
+      success: callback
+      error:   callback
+    )
+
+  renderTable: =>
+    new App.ControllerTable(
+      customActions: [
+        name: 'remove'
+        display: __('Remove')
+        icon: 'trash'
+        class: 'btn--danger'
+        callback: @removeSecurityKey
+      ]
+      overview: ['nickname', 'created_at']
+      attribute_list: [
+        { name: 'nickname',   display: __('Name'),       type: 'text' },
+        { name: 'created_at', display: __('Created at'), tag:  'datetime' },
+      ]
+      objects: _.map(@credentials, (credential) ->
+        _.extend(credential,
+          id: credential.external_id
+        )
+      )
+      pagerEnabled: false
+    )
+
+  confirmRemoval: (id) =>
+    credential = @credentials.find((credential) -> credential.external_id is id)
+
+    new App.ControllerConfirm(
+      head: __('Are you sure?')
+      message: App.i18n.translatePlain('Security key "%s" will be removed.', credential.nickname)
+      container: @el.closest('.content')
+      small: true
+      callback: =>
+        @removeSecurityKey(id)
+    )
+
+  removeSecurityKey: (id) =>
+    newConfiguration             = _.extend({}, @config)
+    newConfiguration.credentials = _.filter(@credentials, (credential) -> credential.external_id isnt id)
+
+    data =
+      configuration: _.extend({}, @config,
+        credentials: _.filter(@credentials, (credential) -> credential.external_id isnt id)
+      )
+
+    # Remove the complete configuration if it's the last key.
+    data.configuration = null if not data.configuration.credentials.length
+
+    @ajax(
+      id:          'two_factor_authentication_method_configuration'
+      type:        'PUT'
+      url:         "#{@apiPath}/users/two_factor_authentication_method_configuration/security_keys"
+      data:        JSON.stringify(data)
+      processData: true
+      success: =>
+        # Refresh the table in the password profile screen.
+        @successCallback()
+
+        @render()
+    )
+
+  nextModalClass: ->
+    App.TwoFactorConfigurationModalSecurityKeyConfig
+
+  onSubmit: (e) ->
+
+    # Pass the modal options to the next modal instance.
+    @next(
+      container: @container
+      successCallback: @successCallback
+    )
+
+    # We are not calling `super`, since we do not want to call success callback yet.
+
+class App.TwoFactorConfigurationModalSecurityKeyConfig extends App.TwoFactorConfigurationModal
+  buttonSubmit: __('Next')
+  buttonClass: 'btn--primary'
+  head: __('Security Key')
+
+  content: ->
+    false
+
+  render: ->
+    super
+
+    configure_attributes = [
+        { name: 'nickname', display: __('Name for this security key'), tag: 'input', type: 'text', limit: 255, null: false, class: 'input' }
+      ]
+
+    @payloadForm = new App.ControllerForm(
+      elReplace: $('.modal-body')
+      model:     { configure_attributes: configure_attributes }
+    )
+
+  nextModalClass: ->
+    App.TwoFactorConfigurationModalSecurityKeyRegister
+
+  onSubmit: (e) ->
+    params = @formParam(e.target)
+
+    errors = @payloadForm.validate(params)
+    if !_.isEmpty(errors)
+      @formValidate(form: e.target, errors: errors)
+      return false
+
+    # Pass the modal options to the next modal instance.
+    @next(
+      container: @container
+      nickname: params.nickname
+      successCallback: @successCallback
+    )
+
+    # We are not calling `super`, since we do not want to call success callback yet.
+
+class App.TwoFactorConfigurationModalSecurityKeyRegister extends App.TwoFactorConfigurationModal
+  buttonSubmit: __('Retry')
+  buttonClass: 'btn--primary hidden'
+  head: __('Security Key')
+
+  constructor: ->
+    @method = App.Config.get('TwoFactorMethods').SecurityKeys
+
+    super
+
+  content: ->
+    false
+
+  render: ->
+    super
+
+    $('.modal .js-loading').removeClass('hide')
+
+    callback = (data) =>
+      @config = data.configuration
+
+      content = $(App.view('widget/two_factor_configuration/security_keys/register')())
+
+      $('.modal .js-loading').addClass('hide')
+      $('.modal-body').html(content)
+
+      @onSubmit()
+
+    @fetchInitialConfiguration(callback)
+
+  fetchInitialConfiguration: (callback) =>
+    @ajax(
+      id:      'two_factor_authentication_method_initiate_configuration'
+      type:    'GET'
+      url:     "#{@apiPath}/users/two_factor_authentication_method_initiate_configuration/#{@method.key}"
+      success: callback
+    )
+
+  showError: (message = __('Security key set up failed.')) =>
+    @el.find('.main').hide()
+    @showAlert(message)
+    @el.find('.js-submit').removeClass('hidden')
+
+  hideError: =>
+    @el.find('.js-submit').addClass('hidden')
+    @clearAlerts()
+    @el.find('.main').show()
+
+  onSubmit: =>
+    @hideError()
+
+    if not window.isSecureContext
+      @showError(__('The application is not running in a secure context.'))
+      return
+
+    webauthnJSON
+      .create(publicKey: @config)
+      .then((publicKeyCredential) =>
+        data = JSON.stringify(
+          method: @method.key
+          payload:
+            credential: publicKeyCredential
+            challenge:  @config.challenge
+          configuration: _.extend({}, @config, { nickname: @nickname, type: 'registration' })
+        )
+
+        @ajax
+          id: 'two_factor_verify_configuration'
+          type: 'POST'
+          url: "#{@apiPath}/users/two_factor_verify_configuration"
+          data: data
+          processData: true
+          success: (data, status, xhr) =>
+            if data?.verified
+              @finalizeConfigurationWizard(data)
+              return
+
+            App.Log.error('TwoFactorConfigurationModalSecurityKeyRegister', data, status)
+            @showError()
+        )
+      .catch((e) =>
+        App.Log.error('TwoFactorConfigurationModalSecurityKeyRegister', e)
+        @showError()
+      )

Some files were not shown because too many files changed in this diff