Skip to content

Commit

Permalink
Fixes #4595 - 2FA: Authenticator App
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
8 people committed May 19, 2023
1 parent bec0ce8 commit 54f0620
Show file tree
Hide file tree
Showing 148 changed files with 5,716 additions and 607 deletions.
2 changes: 1 addition & 1 deletion .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ paths = [
'''^tmp/''',
]
regexTarget = "line"
regexes = []
regexes = []
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ end
gem 'doorkeeper'
gem 'oauth2'

# authentication - two factor
gem 'rotp', require: false

# authentication - third party
gem 'omniauth-rails_csrf_protection'

Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -710,6 +711,7 @@ DEPENDENCIES
rails-controller-testing
rchardet (>= 1.8.0)
redis (>= 3, < 5)
rotp
rspec-rails
rspec-retry
rszr
Expand Down
5 changes: 5 additions & 0 deletions LICENSE-3RD-PARTY.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
14 changes: 14 additions & 0 deletions app/assets/javascripts/app/controllers/_plugin/after_auth.coffee
Original file line number Diff line number Diff line change
@@ -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')
124 changes: 118 additions & 6 deletions app/assets/javascripts/app/controllers/_profile/password.coffee
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down Expand Up @@ -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')
6 changes: 6 additions & 0 deletions app/assets/javascripts/app/controllers/_settings/area.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ class App.SettingsArea extends App.Controller
)

elements = []

if @subtitle
subtitle = $('<h2/>')
subtitle.append(App.i18n.translateContent(@subtitle))
elements.push subtitle

for setting in settings
if setting.preferences.hidden isnt true
if setting.preferences.controller && App[setting.preferences.controller]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
class App.AfterAuthTwoFactorConfiguration extends App.ControllerAfterAuthModal
head: __('Set up two-factor authentication')
buttonCancel: __('Cancel & Sign out')
buttonSubmit: false

events:
'click .js-configuration-method': 'selectConfigurationMethod'

constructor: (params) ->

# Remove the fade transition if requested.
if params.noFadeTransition
params.className = 'modal'

super(params)

content: ->
content = $(App.view('after_auth/two_factor_configuration')())

@fetchAvailableMethods()

content

fetchAvailableMethods: ->
# If user clicks cancel & sign out, modal may try to re-render during logout
# Since current user is no longer avaialble, it would throw a javascript error
return if !App.User.current()

@ajax(
id: 'two_factor_enabled_methods'
type: 'GET'
url: "#{@apiPath}/users/#{App.User.current().id}/two_factor_enabled_methods"
success: @renderAvailableMethods
)

renderAvailableMethods: (data, status, xhr) =>
methodButtons = $(App.view('after_auth/two_factor_configuration/method_buttons')(
enabledMethods: @transformTwoFactorMethods(data)
))

@$('.two-factor-auth-method-buttons').html(methodButtons)

transformTwoFactorMethods: (data) ->
return [] if _.isEmpty(data)

iteratee = (memo, item) ->
method = App.TwoFactorMethods.methodByKey(item.method)

return memo if !method

memo.push(_.extend(
{},
method,
disabled: item.configured
))

memo

_.reduce(data, iteratee, [])

closeWithoutFade: =>
@el.removeClass('fade')
@close()

selectConfigurationMethod: (e) =>
e.preventDefault()

@closeWithoutFade()

configurationMethod = $(e.currentTarget).data('method')

return if _.isEmpty(configurationMethod)

new App['TwoFactorConfigurationMethod' + configurationMethod](
mode: 'after_auth'
successCallback: @fetchAfterAuth
)
1 change: 1 addition & 0 deletions app/assets/javascripts/app/controllers/dashboard.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class App.Dashboard extends App.Controller
@html localEl

mayBeClues: =>
return if @Config.get('after_auth')
return if !@clueAccess
return if !@shown
return if @Config.get('switch_back_to_possible')
Expand Down
Loading

0 comments on commit 54f0620

Please sign in to comment.