Browse Source

Fixes #4595 - 2FA: Authenticator App

Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Florian Liebe <fl@zammad.com>
Co-authored-by: Mantas Masalskis <mm@zammad.com>
Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
Co-authored-by: Tobias Schäfer <ts@zammad.com>
Co-authored-by: Vladimir Sheremet <vs@zammad.com>
Florian Liebe 1 year ago
parent
commit
54f06204fd

+ 1 - 1
.gitleaks.toml

@@ -15,4 +15,4 @@ paths = [
   '''^tmp/''',
 ]
 regexTarget = "line"
-regexes = []
+regexes = []

+ 3 - 0
Gemfile

@@ -93,6 +93,9 @@ end
 gem 'doorkeeper'
 gem 'oauth2'
 
+# authentication - two factor
+gem 'rotp', require: false
+
 # authentication - third party
 gem 'omniauth-rails_csrf_protection'
 

+ 2 - 0
Gemfile.lock

@@ -449,6 +449,7 @@ GEM
     redis (4.8.1)
     regexp_parser (2.8.0)
     rexml (3.2.5)
+    rotp (6.2.2)
     rspec-core (3.12.2)
       rspec-support (~> 3.12.0)
     rspec-expectations (3.12.3)
@@ -710,6 +711,7 @@ DEPENDENCIES
   rails-controller-testing
   rchardet (>= 1.8.0)
   redis (>= 3, < 5)
+  rotp
   rspec-rails
   rspec-retry
   rszr

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

@@ -133,6 +133,11 @@ Copyright: Faruk Ates (https://twitter.com/KuraFire)
            Richard Herrera (https://twitter.com/doctyper)
 License: MIT license & BSD license
 -----------------------------------------------------------------------------
+qrcodegen.js
+Source: https://www.nayuki.io/page/qr-code-generator-library
+Copyright: 2022, Project Nayuki
+License: MIT license
+-----------------------------------------------------------------------------
 rangy.js
 Source: https://github.com/timdown/rangy
 Copyright: 2015, Tim Down

+ 28 - 0
app/assets/javascripts/app/controllers/_application_controller/after_auth_modal.coffee

@@ -0,0 +1,28 @@
+class App.ControllerAfterAuthModal extends App.ControllerModal
+  includeForm: false
+  data: {}
+  logoutOnCancel: true
+  backdrop: 'static'
+  keyboard: false
+  buttonClose: false
+  buttonSubmit: false
+  buttonCancel: __('Cancel')
+
+  onCancel: (e) ->
+    if @logoutOnCancel
+      App.Auth.logout()
+
+  fetchAfterAuth: ->
+    @ajax(
+      id:      'after_auth'
+      type:    'GET'
+      url:     "#{@apiPath}/users/after_auth"
+      success: (after_auth) ->
+        App.Config.set('after_auth', after_auth)
+
+        return if _.isEmpty(after_auth)
+
+        new App['AfterAuth' + after_auth.type](
+          data: after_auth.data
+        )
+    )

+ 12 - 10
app/assets/javascripts/app/controllers/_application_controller/full_page.coffee

@@ -7,14 +7,16 @@ class App.ControllerFullPage extends App.Controller
   replaceWith: (localElement) =>
     @appEl.find('>').not(".#{@className}").remove() if @className
     @appEl.find('>').filter(".#{@className}").remove() if @forceRender
-    @el = $(localElement)
     container = @appEl.find('>').filter(".#{@className}")
-    if !container.get(0)
-      @el.addClass(@className)
-      @appEl.append(@el)
-      @delegateEvents(@events)
-      @refreshElements()
-      @el.on('remove', @releaseController)
-      @el.on('remove', @release)
-    else
-      container.html(@el.children())
+
+    if container.get(0)
+      @el = container
+      return container.html($(localElement).children())
+
+    @el = $(localElement)
+    @el.addClass(@className)
+    @appEl.append(@el)
+    @delegateEvents(@events)
+    @refreshElements()
+    @el.on('remove', @releaseController)
+    @el.on('remove', @release)

+ 1 - 1
app/assets/javascripts/app/controllers/_application_controller/tabs.coffee

@@ -42,7 +42,7 @@ class App.ControllerTabs extends App.Controller
         params.target = tab.target
         params.el = @$("##{tab.target}")
         @controllerList ||= []
-        @controllerList.push new tab.controller(_.extend(@originParams, params))
+        @controllerList.push new tab.controller(_.extend({}, @originParams, params))
 
     # check if tabs need to be show / cant' use .tab(), because tabs are note shown (only one tab exists)
     if @tabs.length <= 1

+ 4 - 4
app/assets/javascripts/app/controllers/_manage/security.coffee

@@ -6,10 +6,10 @@ class Security extends App.ControllerTabs
 
     @title __('Security'), true
     @tabs = [
-      { name: __('Base'),                     'target': 'base',             controller: App.SettingsArea, params: { area: 'Security::Base' } }
-      { name: __('Password'),                 'target': 'password',         controller: App.SettingsArea, params: { area: 'Security::Password' } }
-      #{ name: __('Authentication'),           'target': 'auth',            controller: App.SettingsArea, params: { area: 'Security::Authentication' } }
-      { name: __('Third-party Applications'), 'target': 'third_party_auth', controller: App.SettingsArea, params: { area: 'Security::ThirdPartyAuthentication' } }
+      { name: __('Base'),                      target: 'base',             controller: App.SettingsArea, params: { area: 'Security::Base' } }
+      { name: __('Password'),                  target: 'password',         controller: App.SettingsArea, params: { area: 'Security::Password' } }
+      { name: __('Two-factor Authentication'), target: 'two_factor_auth',  controller: App.SettingsArea, params: { area: 'Security::TwoFactorAuthentication', subtitle: __('Two-factor Authentication Methods') } }
+      { name: __('Third-party Applications'),  target: 'third_party_auth', controller: App.SettingsArea, params: { area: 'Security::ThirdPartyAuthentication' } }
     ]
     @render()
 

+ 14 - 0
app/assets/javascripts/app/controllers/_plugin/after_auth.coffee

@@ -0,0 +1,14 @@
+class AfterAuth extends App.Controller
+  constructor: ->
+    super
+
+    return if !@authenticateCheck()
+
+    after_auth = App.Config.get('after_auth')
+    return if _.isEmpty(after_auth)
+
+    new App['AfterAuth' + after_auth.type](
+      data: after_auth.data
+    )
+
+App.Config.set('after_auth', AfterAuth, 'Plugins')

+ 118 - 6
app/assets/javascripts/app/controllers/_profile/password.coffee

@@ -1,17 +1,62 @@
 class ProfilePassword extends App.ControllerSubContent
   @requiredPermission: 'user_preferences.password'
-  header: __('Password')
+  header: __('Password & Authentication')
   events:
     'submit form': 'update'
+    'click [data-type="setup"]':  'twoFactorMethodSetup'
+    'click [data-type="remove"]': 'twoFactorMethodRemove'
 
   constructor: ->
     super
-    @render()
 
-  render: =>
+    @controllerBind('config_update', (data) =>
+      return if data.name isnt 'two_factor_authentication_method_authenticator_app'
+
+      @preRender()
+    )
+
+    @preRender()
+
+  preRender: =>
+    if !@allowsTwoFactor()
+      @render()
+      return
+
+    @load()
+
+    @listenTo App.User.current(), 'two_factor_changed', =>
+      @load()
+
+  load: =>
+    @startLoading()
+
+    @ajax(
+      id:   'profile_two_factor'
+      type: 'GET'
+      url:  @apiPath + "/users/#{App.User.current().id}/two_factor_enabled_methods"
+      processData: true
+      success: (data, status, xhr) =>
+        @stopLoading()
+
+        @render(data)
+      error: (xhr) =>
+        @stopLoading()
+    )
+
+  allowsChangePassword: ->
+    App.Config.get('user_show_password_login') || @permissionCheck('admin.*')
+
+  allowsTwoFactor: ->
+    App.Config.get('two_factor_authentication_method_authenticator_app')
+
+  render: (twoFactorMethods) =>
 
     # item
-    html = $( App.view('profile/password')() )
+    html = $( App.view('profile/password')(
+      allowsChangePassword: @allowsChangePassword(),
+      allowsTwoFactor:      @allowsTwoFactor(),
+      twoFactorMethods:     @transformTwoFactorMethods(twoFactorMethods)
+    ) )
 
     configure_attributes = [
       { name: 'password_old', display: __('Current password'), tag: 'input', type: 'password', limit: 100, null: false, class: 'input', single: true  },
@@ -84,13 +129,80 @@ class ProfilePassword extends App.ControllerSubContent
 
     @formEnable( @$('form') )
 
+  transformTwoFactorMethods: (data) ->
+    return [] if _.isEmpty(data)
+
+    for elem in data
+      elem.details = App.TwoFactorMethods.methodByKey(elem.method) || {}
+
+      if elem.configured
+        elem.active_icon_class = 'checkmark'
+        elem.active_icon_parent_class = 'is-done'
+      else
+        elem.active_icon_class = 'small-dot'
+
+    _.sortBy data, (elem) -> elem.details.order
+
+  twoFactorMethodSetup: (e) ->
+    e.preventDefault()
+
+    key    = e.currentTarget.closest('tr').dataset.twoFactorKey
+    method = App.TwoFactorMethods.methodByKey(key)
+
+    new App["TwoFactorConfigurationMethod#{method.identifier}"](
+      container: @el.closest('.content')
+      successCallback: @load
+    )
+
+  twoFactorMethodRemove: (e) =>
+    e.preventDefault()
+
+    key     = e.currentTarget.closest('tr').dataset.twoFactorKey
+    method  = App.TwoFactorMethods.methodByKey(key)
+
+    new App.ControllerConfirm(
+      head: __('Are you sure?')
+      message: App.i18n.translateContent('Two-factor authentication method "%s" will be removed.', App.i18n.translateContent(method.label))
+      container: @el.closest('.content')
+      small: true
+      callback: =>
+        @ajax(
+          id:   'profile_two_factor_removal'
+          type: 'DELETE'
+          url:  @apiPath + "/users/#{App.User.current().id}/two_factor_remove_method"
+          processData: true
+          data: JSON.stringify(
+            method: key
+          )
+          success: (data, status, xhr) =>
+            @notify
+              type:      'success'
+              msg:       App.i18n.translateContent('Two-factor authentication method was removed.')
+              removeAll: true
+
+            @load()
+          error: (xhr, statusText) =>
+            data = JSON.parse(xhr.responseText)
+
+            message = data?.error || __('Could not remove two-factor authentication method')
+
+            @notify
+              type:      'error'
+              msg:       App.i18n.translateContent(message)
+              removeAll: true
+        )
+    )
+
 App.Config.set('Password', {
   prio: 2000,
-  name: __('Password'),
+  name: __('Password & Authentication'),
   parent: '#profile',
   target: '#profile/password',
   controller: ProfilePassword,
   permission: (controller) ->
-    return false if !App.Config.get('user_show_password_login') && !controller.permissionCheck('admin.*')
+    canChangePassword = App.Config.get('user_show_password_login') || controller.permissionCheck('admin.*')
+    twoFactorEnabled  = App.Config.get('two_factor_authentication_method_authenticator_app')
+
+    return false if !canChangePassword && !twoFactorEnabled
     return controller.permissionCheck('user_preferences.password')
 }, 'NavBarProfile')

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