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>
This commit is contained in:
Florian Liebe 2023-05-19 16:29:47 +02:00
parent bec0ce8e35
commit 54f06204fd
148 changed files with 5727 additions and 618 deletions

View file

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

View file

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

View file

@ -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

View file

@ -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

View file

@ -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
)
)

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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')

View file

@ -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')

View file

@ -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]

View file

@ -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
)

View file

@ -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')

View file

@ -2,6 +2,8 @@ class Login extends App.ControllerFullPage
events:
'submit #login': 'login'
'click .js-go-to-mobile': 'goToMobile'
'click .js-try-another': 'clickedTryAnotherTwoFactor'
'click .js-select-two-factor-method': 'clickedAnotherTwoFactor'
className: 'login'
constructor: ->
@ -108,6 +110,36 @@ class Login extends App.ControllerFullPage
# scroll to top
@scrollTo()
renderTwoFactor: (data= {}) ->
@twoFactorMethod = data.twoFactorMethod
if availableMethods = data.twoFactorAvailableMethods
@twoFactorAvailableMethods = availableMethods
method = App.TwoFactorMethods.methodByKey(@twoFactorMethod)
@replaceWith App.view("login_two_factor_#{@twoFactorMethod}")(
errorMessage: data.errorMessage
formPayload: @formPayload
logoUrl: @logoUrl()
twoFactorMethodDetails: method
twoFactorAvailableMethods: @twoFactorAvailableMethods
)
@$('[name="secure_code"]').trigger('focus')
# scroll to top
@scrollTo()
renderTwoFactorMethods: ->
methodsToShow = _.filter(App.TwoFactorMethods.sortedMethods(),
(elem) => _.includes(@twoFactorAvailableMethods, elem.key))
@replaceWith App.view('login_two_factor_methods')(
twoFactorMethods: methodsToShow
logoUrl: @logoUrl()
)
login: (e) ->
e.preventDefault()
e.stopPropagation()
@ -116,7 +148,7 @@ class Login extends App.ControllerFullPage
params = @formParam(e.target)
# remember username
@username = params['username']
@formPayload = _.pick params, ['username', 'password', 'remember_me']
# session create with login/password
App.Auth.login(
@ -139,11 +171,24 @@ class Login extends App.ControllerFullPage
errorMessage = App.i18n.translateContent(details.error || 'Could not process your request')
# rerender login page
@render(
username: @username
errorMessage: errorMessage
)
if config = details.two_factor_required
@renderTwoFactor(
twoFactorMethod: config.default_two_factor_method
twoFactorAvailableMethods: config.available_two_factor_methods
)
return
if @twoFactorMethod
@renderTwoFactor(
twoFactorMethod: @twoFactorMethod
errorMessage: errorMessage
)
else
# rerender login page
@render(
errorMessage: errorMessage
)
# login shake
@delay(
@ -151,6 +196,20 @@ class Login extends App.ControllerFullPage
600
)
clickedTryAnotherTwoFactor: (e) ->
@preventDefaultAndStopPropagation(e)
@renderTwoFactorMethods()
clickedAnotherTwoFactor: (e) ->
@preventDefaultAndStopPropagation(e)
newMethod = e.target.closest('p').dataset['method']
@renderTwoFactor(
twoFactorMethod: newMethod
)
goToMobile: (e) ->
@preventDefaultAndStopPropagation(e)

View file

@ -146,6 +146,21 @@ class User extends App.ControllerSubContent
800
)
},
{
name: 'manageTwoFactor'
display: __('Manage Two-Factor Authentication')
icon: 'two-factor'
class: 'create js-manageTwoFactor'
available: (user) ->
user.two_factor_configured
callback: (id) ->
user = App.User.find(id)
return if !user
new App.ControllerManageTwoFactor(
user: user
)
},
{
name: 'delete'
display: __('Delete')

View file

@ -0,0 +1,139 @@
class App.ControllerManageTwoFactor extends App.ControllerModal
buttonClose: true
buttonSubmit: false
head: __('Manage Two-Factor Authentication')
events:
'click .js-remove': 'remove'
'click .js-remove-all': 'removeAll'
constructor: ->
super
@load()
load: =>
@startLoading()
@ajax(
type: 'GET'
url: "#{@apiPath}/users/#{@user.id}/two_factor_enabled_methods"
success: (data, status, xhr) =>
@stopLoading()
@loaded = true
@user_methods = _.map(data, (elem) ->
method = App.TwoFactorMethods.methodByKey(elem.method)
{ name: method.label, value: elem.method }
)
@update()
error: (xhr) =>
@stopLoading()
data = JSON.parse(xhr.responseText)
message = data?.error || __("Could not load user's two-factor authentication configuration")
@showAlert message
)
content: ->
return if !@loaded
view = $(App.view('user/manage_two_factor')())
@controller = new App.ControllerForm(
el: view.find('.js-attributes')
model:
configure_attributes: [
{
name: 'method'
display: __('Remove a configured two-factor authentication method')
tag: 'select',
multiple: false
limit: 100
null: false
nulloption: true
translate: true
options: @user_methods
}
],
autofocus: false
)
view
remove: (e) ->
e.preventDefault()
params = @formParam(e.target)
errors = @controller.validate(params)
if !_.isEmpty(errors)
@formValidate( form: e.target, errors: errors )
return false
@formDisable(e)
@ajax(
type: 'DELETE'
url: "#{@apiPath}/users/#{@user.id}/two_factor_remove_method"
data: JSON.stringify(
method: params.method
)
processData: true,
success: (data, status, xhr) =>
@user.trigger('two_factor_changed')
method = App.TwoFactorMethods.methodByKey(params.method)
@notify
type: 'success'
msg: App.i18n.translateInline("User's two-factor authentication method %s was removed.", method.label)
timeout: 4000
@close()
error: (xhr, statusText) =>
data = JSON.parse(xhr.responseText)
message = data?.error || __("Could not remove user's two-factor authentication method")
@showAlert(message)
@formEnable(e)
)
removeAll: (e) ->
e.preventDefault()
@formDisable(e)
new App.ControllerConfirm(
head: __('Confirmation')
message: __('Are you sure? The user will have to to reconfigure all two-factor authentication methods.')
onCancel: =>
@formEnable(e)
onClose: =>
@formEnable(e)
callback: =>
@ajax(
type: 'DELETE'
url: "#{@apiPath}/users/#{@user.id}/two_factor_remove_all_methods"
success: (data, status, xhr) =>
@user.trigger('two_factor_changed')
@notify
type: 'success'
msg: App.i18n.translateInline("All user's two-factor authentication methods were removed successfully!")
timeout: 4000
@close()
error: (xhr, statusText) =>
data = JSON.parse(xhr.responseText)
message = data?.error || __("Could not remove all user's two-factor authentication methods")
@showAlert(message)
@formEnable(e)
)
)

View file

@ -0,0 +1,49 @@
class App.TwoFactorConfigurationMethod extends App.Controller
passwordCheck: true
constructor: (params) ->
super
modalOptions =
container: params.container
successCallback: params.successCallback
# In after auth mode, prevent the user from canceling the modal,
# and bind the cancel handler to return back to after auth modal.
if params.mode is 'after_auth'
@passwordCheck = false
modalOptions = _.extend(
{},
modalOptions,
backdrop: 'static'
buttonClose: false
buttonCancel: __('Go Back')
keyboard: false
onCancel: ->
new App.AfterAuthTwoFactorConfiguration(
noFadeTransition: true
)
)
# Show password check first, if requested.
if @passwordCheck
return new App.TwoFactorConfigurationModalPasswordCheck(
_.extend(
{},
modalOptions,
nextModalClass: @methodModalClass
)
)
constructor = @methodModalClass()
# Show method set up modal.
new constructor(
_.extend(
{},
modalOptions,
)
)
methodModalClass: ->
throw 'You need to implement methodModalClass() method'

View file

@ -0,0 +1,3 @@
class App.TwoFactorConfigurationMethodAuthenticatorApp extends App.TwoFactorConfigurationMethod
methodModalClass: ->
App.TwoFactorConfigurationModalAuthenticatorApp

View file

@ -0,0 +1,41 @@
class App.TwoFactorConfigurationModal extends App.ControllerModal
buttonClose: true
buttonCancel: true
buttonSubmit: __('Set Up')
buttonClass: 'btn--success'
headPrefix: __('Set up two-factor authentication')
shown: true
className: 'modal' # no automatic fade transitions
render: ->
super
closeWithFade: =>
@el.addClass('fade')
$('.modal-backdrop').addClass('fade')
@close()
nextModalClass: ->
throw 'You need to implement nextModalClass() method'
next: (modalOptions = {}) =>
@close()
constructor = @nextModalClass()
new constructor(_.extend(
{},
modalOptions,
backdrop: @backdrop
buttonClose: @buttonClose
buttonCancel: @buttonCancel
onCancel: @onCancel
))
onSubmit: ->
@notify
type: 'success'
msg: App.i18n.translateContent('Two-factor authentication method was set up successfully.')
removeAll: true
@successCallback() if @successCallback

View file

@ -0,0 +1,115 @@
class App.TwoFactorConfigurationModalAuthenticatorApp extends App.TwoFactorConfigurationModal
buttonSubmit: __('Set Up')
buttonClass: 'btn--success'
head: __('Authenticator App')
constructor: ->
@method = App.Config.get('TwoFactorMethods').AuthenticatorApp
super
content: ->
false
render: ->
super
$('.modal .js-loading').removeClass('hide')
$('.modal .js-submit').prop('disabled', true)
callback = (data) =>
@config = data.configuration
content = $(App.view('widget/two_factor_configuration/authenticator_app')(
config: @config
))
configure_attributes = [
{ name: 'payload', display: __('Security Code'), tag: 'input', type: 'text', limit: 100, null: false, class: 'input', label_class: 'hidden', placeholder: __('Security Code') }
]
@payloadForm = new App.ControllerForm(
el: content.find('.js-payload-form')
model: { configure_attributes: configure_attributes }
)
qr_code_canvas = content.find('.js-qr-code-canvas')
qr_code = qrcodegen.QrCode.encodeText(@config.provisioning_uri, qrcodegen.QrCode.Ecc.MEDIUM)
@drawCanvas(qr_code, 6, 1, 'white', 'black', qr_code_canvas.get(0))
# Toggle authenticator app secret on click.
qr_code_canvas.on('click.authenticator_app', ->
content.find('.js-secret')
.show()
.on('click.authenticator_app', ->
$(@).hide()
)
)
$('.modal .js-loading').addClass('hide')
$('.modal-body').html(content)
$('.modal .js-submit').prop('disabled', false)
$('.modal input[name="payload"]').focus()
@fetchInitialConfiguration(callback)
fetchInitialConfiguration: (callback) =>
@ajax(
id: 'two_factor_method_configuration'
type: 'GET'
url: "#{@apiPath}/users/two_factor_method_configuration/#{@method.key}"
success: callback
)
onSubmit: (e) =>
params = @formParam(e.target)
errors = @payloadForm.validate(params)
if !_.isEmpty(errors)
@formValidate(form: e.target, errors: errors)
return false
data = JSON.stringify(
method: @method.key
payload: params.payload
configuration: @config
)
@formDisable(e)
@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
@closeWithFade()
super # handle success callback in the base class
return
@formValidate( form: e.target, errors:
payload: __('Invalid security code! Please try again with a new code.')
)
@formEnable(e)
# CoffeeScript re-implementation of:
# https://github.com/nayuki/QR-Code-generator/blob/master/typescript-javascript/qrcodegen-input-demo.ts?ts=4#L153
drawCanvas: (qr, scale, border, lightColor, darkColor, canvas) ->
if scale <= 0 or border < 0
# coffeelint: disable=detect_translatable_string
throw new RangeError('Value out of range')
# coffeelint: enable=detect_translatable_string
width = (qr.size + border * 2) * scale
canvas.width = width
canvas.height = width
ctx = canvas.getContext('2d')
for y in [-border..(qr.size + border)]
for x in [-border..(qr.size + border)]
ctx.fillStyle = if qr.getModule(x, y) then darkColor else lightColor
ctx.fillRect (x + border) * scale, (y + border) * scale, scale, scale

View file

@ -0,0 +1,52 @@
class App.TwoFactorConfigurationModalPasswordCheck extends App.TwoFactorConfigurationModal
buttonSubmit: __('Next')
buttonClass: 'btn--primary'
head: __('Password')
content: ->
configure_attributes = [
{ name: 'password', display: __('Password'), tag: 'input', type: 'password', limit: 100, null: false, class: 'input', single: true }
]
@form = new App.ControllerForm(
model: { configure_attributes: configure_attributes }
autofocus: true
)
@form.el
onSubmit: (e) ->
params = @formParam(e.target)
errors = @form.validate(params)
if !_.isEmpty(errors)
@formValidate(form: e.target, errors: errors)
return false
@formDisable(e)
@ajax
id: 'password_check'
type: 'POST'
url: "#{@apiPath}/users/password_check"
data: JSON.stringify(params)
processData: true
success: (data, status, xhr) =>
if data?.success
@close()
# 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.
return
@formValidate( form: e.target, errors:
password: __('Current password is wrong!')
)
@formEnable(e)

View file

@ -129,6 +129,8 @@ class App.Auth
# trigger auth ok with new session data
App.Event.trigger('auth', data.session)
App.Config.set('after_auth', data.after_auth)
# init of i18n
preferences = App.Session.get('preferences')
if preferences && preferences.locale
@ -156,6 +158,8 @@ class App.Auth
App.Session.init()
App.Ajax.abortAll()
App.Config.set('after_auth', null)
# clear all in-memory data of all App.Model's
for model_key, model_object of App
if _.isFunction(model_object.clearInMemory)

View file

@ -0,0 +1,10 @@
class App.TwoFactorMethods
@sortedMethods: ->
all_methods = App.Config.get('TwoFactorMethods')
_.sortBy all_methods, (elem) -> elem.order
@methodByKey: (key) ->
_.findWhere App.Config.get('TwoFactorMethods'), { key: key }

View file

@ -0,0 +1,9 @@
App.Config.set('AuthenticatorApp', {
key: 'authenticator_app'
identifier: 'AuthenticatorApp'
label: __('Authenticator App')
description: __('Get the security code from the authenticator app on your device.')
helpMessage: __('Enter the code from your two-factor authenticator app.')
icon: 'mobile-code'
order: 2000
}, 'TwoFactorMethods')

View file

@ -0,0 +1,865 @@
/*
* QR Code generator library (compiled from TypeScript)
*
* Copyright (c) Project Nayuki. (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
* - The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* - The Software is provided "as is", without warranty of any kind, express or
* implied, including but not limited to the warranties of merchantability,
* fitness for a particular purpose and noninfringement. In no event shall the
* authors or copyright holders be liable for any claim, damages or other
* liability, whether in an action of contract, tort or otherwise, arising from,
* out of or in connection with the Software or the use or other dealings in the
* Software.
*/
"use strict";
var qrcodegen;
(function (qrcodegen) {
/*---- QR Code symbol class ----*/
/*
* A QR Code symbol, which is a type of two-dimension barcode.
* Invented by Denso Wave and described in the ISO/IEC 18004 standard.
* Instances of this class represent an immutable square grid of dark and light cells.
* The class provides static factory functions to create a QR Code from text or binary data.
* The class covers the QR Code Model 2 specification, supporting all versions (sizes)
* from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
*
* Ways to create a QR Code object:
* - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary().
* - Mid level: Custom-make the list of segments and call QrCode.encodeSegments().
* - Low level: Custom-make the array of data codeword bytes (including
* segment headers and final padding, excluding error correction codewords),
* supply the appropriate version number, and call the QrCode() constructor.
* (Note that all ways require supplying the desired error correction level.)
*/
var QrCode = /** @class */ (function () {
/*-- Constructor (low level) and fields --*/
// Creates a new QR Code with the given version number,
// error correction level, data codeword bytes, and mask number.
// This is a low-level API that most users should not use directly.
// A mid-level API is the encodeSegments() function.
function QrCode(
// The version number of this QR Code, which is between 1 and 40 (inclusive).
// This determines the size of this barcode.
version,
// The error correction level used in this QR Code.
errorCorrectionLevel, dataCodewords, msk) {
this.version = version;
this.errorCorrectionLevel = errorCorrectionLevel;
// The modules of this QR Code (false = light, true = dark).
// Immutable after constructor finishes. Accessed through getModule().
this.modules = [];
// Indicates function modules that are not subjected to masking. Discarded when constructor finishes.
this.isFunction = [];
// Check scalar arguments
if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION)
throw new RangeError("Version value out of range");
if (msk < -1 || msk > 7)
throw new RangeError("Mask value out of range");
this.size = version * 4 + 17;
// Initialize both grids to be size*size arrays of Boolean false
var row = [];
for (var i = 0; i < this.size; i++)
row.push(false);
for (var i = 0; i < this.size; i++) {
this.modules.push(row.slice()); // Initially all light
this.isFunction.push(row.slice());
}
// Compute ECC, draw modules
this.drawFunctionPatterns();
var allCodewords = this.addEccAndInterleave(dataCodewords);
this.drawCodewords(allCodewords);
// Do masking
if (msk == -1) { // Automatically choose best mask
var minPenalty = 1000000000;
for (var i = 0; i < 8; i++) {
this.applyMask(i);
this.drawFormatBits(i);
var penalty = this.getPenaltyScore();
if (penalty < minPenalty) {
msk = i;
minPenalty = penalty;
}
this.applyMask(i); // Undoes the mask due to XOR
}
}
assert(0 <= msk && msk <= 7);
this.mask = msk;
this.applyMask(msk); // Apply the final choice of mask
this.drawFormatBits(msk); // Overwrite old format bits
this.isFunction = [];
}
/*-- Static factory functions (high level) --*/
// Returns a QR Code representing the given Unicode text string at the given error correction level.
// As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer
// Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible
// QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the
// ecl argument if it can be done without increasing the version.
QrCode.encodeText = function (text, ecl) {
var segs = qrcodegen.QrSegment.makeSegments(text);
return QrCode.encodeSegments(segs, ecl);
};
// Returns a QR Code representing the given binary data at the given error correction level.
// This function always encodes using the binary segment mode, not any text mode. The maximum number of
// bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output.
// The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version.
QrCode.encodeBinary = function (data, ecl) {
var seg = qrcodegen.QrSegment.makeBytes(data);
return QrCode.encodeSegments([seg], ecl);
};
/*-- Static factory functions (mid level) --*/
// Returns a QR Code representing the given segments with the given encoding parameters.
// The smallest possible QR Code version within the given range is automatically
// chosen for the output. Iff boostEcl is true, then the ECC level of the result
// may be higher than the ecl argument if it can be done without increasing the
// version. The mask number is either between 0 to 7 (inclusive) to force that
// mask, or -1 to automatically choose an appropriate mask (which may be slow).
// This function allows the user to create a custom sequence of segments that switches
// between modes (such as alphanumeric and byte) to encode text in less space.
// This is a mid-level API; the high-level API is encodeText() and encodeBinary().
QrCode.encodeSegments = function (segs, ecl, minVersion, maxVersion, mask, boostEcl) {
if (minVersion === void 0) { minVersion = 1; }
if (maxVersion === void 0) { maxVersion = 40; }
if (mask === void 0) { mask = -1; }
if (boostEcl === void 0) { boostEcl = true; }
if (!(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION)
|| mask < -1 || mask > 7)
throw new RangeError("Invalid value");
// Find the minimal version number to use
var version;
var dataUsedBits;
for (version = minVersion;; version++) {
var dataCapacityBits_1 = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available
var usedBits = QrSegment.getTotalBits(segs, version);
if (usedBits <= dataCapacityBits_1) {
dataUsedBits = usedBits;
break; // This version number is found to be suitable
}
if (version >= maxVersion) // All versions in the range could not fit the given data
throw new RangeError("Data too long");
}
// Increase the error correction level while the data still fits in the current version number
for (var _i = 0, _a = [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]; _i < _a.length; _i++) { // From low to high
var newEcl = _a[_i];
if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8)
ecl = newEcl;
}
// Concatenate all segments to create the data bit string
var bb = [];
for (var _b = 0, segs_1 = segs; _b < segs_1.length; _b++) {
var seg = segs_1[_b];
appendBits(seg.mode.modeBits, 4, bb);
appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb);
for (var _c = 0, _d = seg.getData(); _c < _d.length; _c++) {
var b = _d[_c];
bb.push(b);
}
}
assert(bb.length == dataUsedBits);
// Add terminator and pad up to a byte if applicable
var dataCapacityBits = QrCode.getNumDataCodewords(version, ecl) * 8;
assert(bb.length <= dataCapacityBits);
appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb);
appendBits(0, (8 - bb.length % 8) % 8, bb);
assert(bb.length % 8 == 0);
// Pad with alternating bytes until data capacity is reached
for (var padByte = 0xEC; bb.length < dataCapacityBits; padByte ^= 0xEC ^ 0x11)
appendBits(padByte, 8, bb);
// Pack bits into bytes in big endian
var dataCodewords = [];
while (dataCodewords.length * 8 < bb.length)
dataCodewords.push(0);
bb.forEach(function (b, i) {
return dataCodewords[i >>> 3] |= b << (7 - (i & 7));
});
// Create the QR Code object
return new QrCode(version, ecl, dataCodewords, mask);
};
/*-- Accessor methods --*/
// Returns the color of the module (pixel) at the given coordinates, which is false
// for light or true for dark. The top left corner has the coordinates (x=0, y=0).
// If the given coordinates are out of bounds, then false (light) is returned.
QrCode.prototype.getModule = function (x, y) {
return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x];
};
/*-- Private helper methods for constructor: Drawing function modules --*/
// Reads this object's version field, and draws and marks all function modules.
QrCode.prototype.drawFunctionPatterns = function () {
// Draw horizontal and vertical timing patterns
for (var i = 0; i < this.size; i++) {
this.setFunctionModule(6, i, i % 2 == 0);
this.setFunctionModule(i, 6, i % 2 == 0);
}
// Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules)
this.drawFinderPattern(3, 3);
this.drawFinderPattern(this.size - 4, 3);
this.drawFinderPattern(3, this.size - 4);
// Draw numerous alignment patterns
var alignPatPos = this.getAlignmentPatternPositions();
var numAlign = alignPatPos.length;
for (var i = 0; i < numAlign; i++) {
for (var j = 0; j < numAlign; j++) {
// Don't draw on the three finder corners
if (!(i == 0 && j == 0 || i == 0 && j == numAlign - 1 || i == numAlign - 1 && j == 0))
this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]);
}
}
// Draw configuration data
this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor
this.drawVersion();
};
// Draws two copies of the format bits (with its own error correction code)
// based on the given mask and this object's error correction level field.
QrCode.prototype.drawFormatBits = function (mask) {
// Calculate error correction code and pack bits
var data = this.errorCorrectionLevel.formatBits << 3 | mask; // errCorrLvl is uint2, mask is uint3
var rem = data;
for (var i = 0; i < 10; i++)
rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
var bits = (data << 10 | rem) ^ 0x5412; // uint15
assert(bits >>> 15 == 0);
// Draw first copy
for (var i = 0; i <= 5; i++)
this.setFunctionModule(8, i, getBit(bits, i));
this.setFunctionModule(8, 7, getBit(bits, 6));
this.setFunctionModule(8, 8, getBit(bits, 7));
this.setFunctionModule(7, 8, getBit(bits, 8));
for (var i = 9; i < 15; i++)
this.setFunctionModule(14 - i, 8, getBit(bits, i));
// Draw second copy
for (var i = 0; i < 8; i++)
this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i));
for (var i = 8; i < 15; i++)
this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i));
this.setFunctionModule(8, this.size - 8, true); // Always dark
};
// Draws two copies of the version bits (with its own error correction code),
// based on this object's version field, iff 7 <= version <= 40.
QrCode.prototype.drawVersion = function () {
if (this.version < 7)
return;
// Calculate error correction code and pack bits
var rem = this.version; // version is uint6, in the range [7, 40]
for (var i = 0; i < 12; i++)
rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25);
var bits = this.version << 12 | rem; // uint18
assert(bits >>> 18 == 0);
// Draw two copies
for (var i = 0; i < 18; i++) {
var color = getBit(bits, i);
var a = this.size - 11 + i % 3;
var b = Math.floor(i / 3);
this.setFunctionModule(a, b, color);
this.setFunctionModule(b, a, color);
}
};
// Draws a 9*9 finder pattern including the border separator,
// with the center module at (x, y). Modules can be out of bounds.
QrCode.prototype.drawFinderPattern = function (x, y) {
for (var dy = -4; dy <= 4; dy++) {
for (var dx = -4; dx <= 4; dx++) {
var dist = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm
var xx = x + dx;
var yy = y + dy;
if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size)
this.setFunctionModule(xx, yy, dist != 2 && dist != 4);
}
}
};
// Draws a 5*5 alignment pattern, with the center module
// at (x, y). All modules must be in bounds.
QrCode.prototype.drawAlignmentPattern = function (x, y) {
for (var dy = -2; dy <= 2; dy++) {
for (var dx = -2; dx <= 2; dx++)
this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1);
}
};
// Sets the color of a module and marks it as a function module.
// Only used by the constructor. Coordinates must be in bounds.
QrCode.prototype.setFunctionModule = function (x, y, isDark) {
this.modules[y][x] = isDark;
this.isFunction[y][x] = true;
};
/*-- Private helper methods for constructor: Codewords and masking --*/
// Returns a new byte string representing the given data with the appropriate error correction
// codewords appended to it, based on this object's version and error correction level.
QrCode.prototype.addEccAndInterleave = function (data) {
var ver = this.version;
var ecl = this.errorCorrectionLevel;
if (data.length != QrCode.getNumDataCodewords(ver, ecl))
throw new RangeError("Invalid argument");
// Calculate parameter numbers
var numBlocks = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
var blockEccLen = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver];
var rawCodewords = Math.floor(QrCode.getNumRawDataModules(ver) / 8);
var numShortBlocks = numBlocks - rawCodewords % numBlocks;
var shortBlockLen = Math.floor(rawCodewords / numBlocks);
// Split data into blocks and append ECC to each block
var blocks = [];
var rsDiv = QrCode.reedSolomonComputeDivisor(blockEccLen);
for (var i = 0, k = 0; i < numBlocks; i++) {
var dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1));
k += dat.length;
var ecc = QrCode.reedSolomonComputeRemainder(dat, rsDiv);
if (i < numShortBlocks)
dat.push(0);
blocks.push(dat.concat(ecc));
}
// Interleave (not concatenate) the bytes from every block into a single sequence
var result = [];
var _loop_1 = function (i) {
blocks.forEach(function (block, j) {
// Skip the padding byte in short blocks
if (i != shortBlockLen - blockEccLen || j >= numShortBlocks)
result.push(block[i]);
});
};
for (var i = 0; i < blocks[0].length; i++) {
_loop_1(i);
}
assert(result.length == rawCodewords);
return result;
};
// Draws the given sequence of 8-bit codewords (data and error correction) onto the entire
// data area of this QR Code. Function modules need to be marked off before this is called.
QrCode.prototype.drawCodewords = function (data) {
if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8))
throw new RangeError("Invalid argument");
var i = 0; // Bit index into the data
// Do the funny zigzag scan
for (var right = this.size - 1; right >= 1; right -= 2) { // Index of right column in each column pair
if (right == 6)
right = 5;
for (var vert = 0; vert < this.size; vert++) { // Vertical counter
for (var j = 0; j < 2; j++) {
var x = right - j; // Actual x coordinate
var upward = ((right + 1) & 2) == 0;
var y = upward ? this.size - 1 - vert : vert; // Actual y coordinate
if (!this.isFunction[y][x] && i < data.length * 8) {
this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7));
i++;
}
// If this QR Code has any remainder bits (0 to 7), they were assigned as
// 0/false/light by the constructor and are left unchanged by this method
}
}
}
assert(i == data.length * 8);
};
// XORs the codeword modules in this QR Code with the given mask pattern.
// The function modules must be marked and the codeword bits must be drawn
// before masking. Due to the arithmetic of XOR, calling applyMask() with
// the same mask value a second time will undo the mask. A final well-formed
// QR Code needs exactly one (not zero, two, etc.) mask applied.
QrCode.prototype.applyMask = function (mask) {
if (mask < 0 || mask > 7)
throw new RangeError("Mask value out of range");
for (var y = 0; y < this.size; y++) {
for (var x = 0; x < this.size; x++) {
var invert = void 0;
switch (mask) {
case 0:
invert = (x + y) % 2 == 0;
break;
case 1:
invert = y % 2 == 0;
break;
case 2:
invert = x % 3 == 0;
break;
case 3:
invert = (x + y) % 3 == 0;
break;
case 4:
invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0;
break;
case 5:
invert = x * y % 2 + x * y % 3 == 0;
break;
case 6:
invert = (x * y % 2 + x * y % 3) % 2 == 0;
break;
case 7:
invert = ((x + y) % 2 + x * y % 3) % 2 == 0;
break;
default: throw new Error("Unreachable");
}
if (!this.isFunction[y][x] && invert)
this.modules[y][x] = !this.modules[y][x];
}
}
};
// Calculates and returns the penalty score based on state of this QR Code's current modules.
// This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score.
QrCode.prototype.getPenaltyScore = function () {
var result = 0;
// Adjacent modules in row having same color, and finder-like patterns
for (var y = 0; y < this.size; y++) {
var runColor = false;
var runX = 0;
var runHistory = [0, 0, 0, 0, 0, 0, 0];
for (var x = 0; x < this.size; x++) {
if (this.modules[y][x] == runColor) {
runX++;
if (runX == 5)
result += QrCode.PENALTY_N1;
else if (runX > 5)
result++;
}
else {
this.finderPenaltyAddHistory(runX, runHistory);
if (!runColor)
result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
runColor = this.modules[y][x];
runX = 1;
}
}
result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3;
}
// Adjacent modules in column having same color, and finder-like patterns
for (var x = 0; x < this.size; x++) {
var runColor = false;
var runY = 0;
var runHistory = [0, 0, 0, 0, 0, 0, 0];
for (var y = 0; y < this.size; y++) {
if (this.modules[y][x] == runColor) {
runY++;
if (runY == 5)
result += QrCode.PENALTY_N1;
else if (runY > 5)
result++;
}
else {
this.finderPenaltyAddHistory(runY, runHistory);
if (!runColor)
result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
runColor = this.modules[y][x];
runY = 1;
}
}
result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3;
}
// 2*2 blocks of modules having same color
for (var y = 0; y < this.size - 1; y++) {
for (var x = 0; x < this.size - 1; x++) {
var color = this.modules[y][x];
if (color == this.modules[y][x + 1] &&
color == this.modules[y + 1][x] &&
color == this.modules[y + 1][x + 1])
result += QrCode.PENALTY_N2;
}
}
// Balance of dark and light modules
var dark = 0;
for (var _i = 0, _a = this.modules; _i < _a.length; _i++) {
var row = _a[_i];
dark = row.reduce(function (sum, color) { return sum + (color ? 1 : 0); }, dark);
}
var total = this.size * this.size; // Note that size is odd, so dark/total != 1/2
// Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)%
var k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
assert(0 <= k && k <= 9);
result += k * QrCode.PENALTY_N4;
assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4
return result;
};
/*-- Private helper functions --*/
// Returns an ascending list of positions of alignment patterns for this version number.
// Each position is in the range [0,177), and are used on both the x and y axes.
// This could be implemented as lookup table of 40 variable-length lists of integers.
QrCode.prototype.getAlignmentPatternPositions = function () {
if (this.version == 1)
return [];
else {
var numAlign = Math.floor(this.version / 7) + 2;
var step = (this.version == 32) ? 26 :
Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;
var result = [6];
for (var pos = this.size - 7; result.length < numAlign; pos -= step)
result.splice(1, 0, pos);
return result;
}
};
// Returns the number of data bits that can be stored in a QR Code of the given version number, after
// all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
// The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table.
QrCode.getNumRawDataModules = function (ver) {
if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION)
throw new RangeError("Version number out of range");
var result = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
var numAlign = Math.floor(ver / 7) + 2;
result -= (25 * numAlign - 10) * numAlign - 55;
if (ver >= 7)
result -= 36;
}
assert(208 <= result && result <= 29648);
return result;
};
// Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
// QR Code of the given version number and error correction level, with remainder bits discarded.
// This stateless pure function could be implemented as a (40*4)-cell lookup table.
QrCode.getNumDataCodewords = function (ver, ecl) {
return Math.floor(QrCode.getNumRawDataModules(ver) / 8) -
QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] *
QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
};
// Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be
// implemented as a lookup table over all possible parameter values, instead of as an algorithm.
QrCode.reedSolomonComputeDivisor = function (degree) {
if (degree < 1 || degree > 255)
throw new RangeError("Degree out of range");
// Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
// For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93].
var result = [];
for (var i = 0; i < degree - 1; i++)
result.push(0);
result.push(1); // Start off with the monomial x^0
// Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}),
// and drop the highest monomial term which is always 1x^degree.
// Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D).
var root = 1;
for (var i = 0; i < degree; i++) {
// Multiply the current product by (x - r^i)
for (var j = 0; j < result.length; j++) {
result[j] = QrCode.reedSolomonMultiply(result[j], root);
if (j + 1 < result.length)
result[j] ^= result[j + 1];
}
root = QrCode.reedSolomonMultiply(root, 0x02);
}
return result;
};
// Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials.
QrCode.reedSolomonComputeRemainder = function (data, divisor) {
var result = divisor.map(function (_) { return 0; });
var _loop_2 = function (b) {
var factor = b ^ result.shift();
result.push(0);
divisor.forEach(function (coef, i) {
return result[i] ^= QrCode.reedSolomonMultiply(coef, factor);
});
};
for (var _i = 0, data_1 = data; _i < data_1.length; _i++) {
var b = data_1[_i];
_loop_2(b);
}
return result;
};
// Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result
// are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8.
QrCode.reedSolomonMultiply = function (x, y) {
if (x >>> 8 != 0 || y >>> 8 != 0)
throw new RangeError("Byte out of range");
// Russian peasant multiplication
var z = 0;
for (var i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11D);
z ^= ((y >>> i) & 1) * x;
}
assert(z >>> 8 == 0);
return z;
};
// Can only be called immediately after a light run is added, and
// returns either 0, 1, or 2. A helper function for getPenaltyScore().
QrCode.prototype.finderPenaltyCountPatterns = function (runHistory) {
var n = runHistory[1];
assert(n <= this.size * 3);
var core = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n;
return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0)
+ (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0);
};
// Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore().
QrCode.prototype.finderPenaltyTerminateAndCount = function (currentRunColor, currentRunLength, runHistory) {
if (currentRunColor) { // Terminate dark run
this.finderPenaltyAddHistory(currentRunLength, runHistory);
currentRunLength = 0;
}
currentRunLength += this.size; // Add light border to final run
this.finderPenaltyAddHistory(currentRunLength, runHistory);
return this.finderPenaltyCountPatterns(runHistory);
};
// Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore().
QrCode.prototype.finderPenaltyAddHistory = function (currentRunLength, runHistory) {
if (runHistory[0] == 0)
currentRunLength += this.size; // Add light border to initial run
runHistory.pop();
runHistory.unshift(currentRunLength);
};
/*-- Constants and tables --*/
// The minimum version number supported in the QR Code Model 2 standard.
QrCode.MIN_VERSION = 1;
// The maximum version number supported in the QR Code Model 2 standard.
QrCode.MAX_VERSION = 40;
// For use in getPenaltyScore(), when evaluating which mask is best.
QrCode.PENALTY_N1 = 3;
QrCode.PENALTY_N2 = 3;
QrCode.PENALTY_N3 = 40;
QrCode.PENALTY_N4 = 10;
QrCode.ECC_CODEWORDS_PER_BLOCK = [
// Version: (note that index 0 is for padding, and is set to an illegal value)
//0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
[-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
[-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
[-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
[-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // High
];
QrCode.NUM_ERROR_CORRECTION_BLOCKS = [
// Version: (note that index 0 is for padding, and is set to an illegal value)
//0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
[-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
[-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
[-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
[-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], // High
];
return QrCode;
}());
qrcodegen.QrCode = QrCode;
// Appends the given number of low-order bits of the given value
// to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len.
function appendBits(val, len, bb) {
if (len < 0 || len > 31 || val >>> len != 0)
throw new RangeError("Value out of range");
for (var i = len - 1; i >= 0; i--) // Append bit by bit
bb.push((val >>> i) & 1);
}
// Returns true iff the i'th bit of x is set to 1.
function getBit(x, i) {
return ((x >>> i) & 1) != 0;
}
// Throws an exception if the given condition is false.
function assert(cond) {
if (!cond)
throw new Error("Assertion error");
}
/*---- Data segment class ----*/
/*
* A segment of character/binary/control data in a QR Code symbol.
* Instances of this class are immutable.
* The mid-level way to create a segment is to take the payload data
* and call a static factory function such as QrSegment.makeNumeric().
* The low-level way to create a segment is to custom-make the bit buffer
* and call the QrSegment() constructor with appropriate values.
* This segment class imposes no length restrictions, but QR Codes have restrictions.
* Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.
* Any segment longer than this is meaningless for the purpose of generating QR Codes.
*/
var QrSegment = /** @class */ (function () {
/*-- Constructor (low level) and fields --*/
// Creates a new QR Code segment with the given attributes and data.
// The character count (numChars) must agree with the mode and the bit buffer length,
// but the constraint isn't checked. The given bit buffer is cloned and stored.
function QrSegment(
// The mode indicator of this segment.
mode,
// The length of this segment's unencoded data. Measured in characters for
// numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.
// Always zero or positive. Not the same as the data's bit length.
numChars,
// The data bits of this segment. Accessed through getData().
bitData) {
this.mode = mode;
this.numChars = numChars;
this.bitData = bitData;
if (numChars < 0)
throw new RangeError("Invalid argument");
this.bitData = bitData.slice(); // Make defensive copy
}
/*-- Static factory functions (mid level) --*/
// Returns a segment representing the given binary data encoded in
// byte mode. All input byte arrays are acceptable. Any text string
// can be converted to UTF-8 bytes and encoded as a byte mode segment.
QrSegment.makeBytes = function (data) {
var bb = [];
for (var _i = 0, data_2 = data; _i < data_2.length; _i++) {
var b = data_2[_i];
appendBits(b, 8, bb);
}
return new QrSegment(QrSegment.Mode.BYTE, data.length, bb);
};
// Returns a segment representing the given string of decimal digits encoded in numeric mode.
QrSegment.makeNumeric = function (digits) {
if (!QrSegment.isNumeric(digits))
throw new RangeError("String contains non-numeric characters");
var bb = [];
for (var i = 0; i < digits.length;) { // Consume up to 3 digits per iteration
var n = Math.min(digits.length - i, 3);
appendBits(parseInt(digits.substr(i, n), 10), n * 3 + 1, bb);
i += n;
}
return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb);
};
// Returns a segment representing the given text string encoded in alphanumeric mode.
// The characters allowed are: 0 to 9, A to Z (uppercase only), space,
// dollar, percent, asterisk, plus, hyphen, period, slash, colon.
QrSegment.makeAlphanumeric = function (text) {
if (!QrSegment.isAlphanumeric(text))
throw new RangeError("String contains unencodable characters in alphanumeric mode");
var bb = [];
var i;
for (i = 0; i + 2 <= text.length; i += 2) { // Process groups of 2
var temp = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45;
temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1));
appendBits(temp, 11, bb);
}
if (i < text.length) // 1 character remaining
appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb);
return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb);
};
// Returns a new mutable list of zero or more segments to represent the given Unicode text string.
// The result may use various segment modes and switch modes to optimize the length of the bit stream.
QrSegment.makeSegments = function (text) {
// Select the most efficient segment encoding automatically
if (text == "")
return [];
else if (QrSegment.isNumeric(text))
return [QrSegment.makeNumeric(text)];
else if (QrSegment.isAlphanumeric(text))
return [QrSegment.makeAlphanumeric(text)];
else
return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))];
};
// Returns a segment representing an Extended Channel Interpretation
// (ECI) designator with the given assignment value.
QrSegment.makeEci = function (assignVal) {
var bb = [];
if (assignVal < 0)
throw new RangeError("ECI assignment value out of range");
else if (assignVal < (1 << 7))
appendBits(assignVal, 8, bb);
else if (assignVal < (1 << 14)) {
appendBits(2, 2, bb);
appendBits(assignVal, 14, bb);
}
else if (assignVal < 1000000) {
appendBits(6, 3, bb);
appendBits(assignVal, 21, bb);
}
else
throw new RangeError("ECI assignment value out of range");
return new QrSegment(QrSegment.Mode.ECI, 0, bb);
};
// Tests whether the given string can be encoded as a segment in numeric mode.
// A string is encodable iff each character is in the range 0 to 9.
QrSegment.isNumeric = function (text) {
return QrSegment.NUMERIC_REGEX.test(text);
};
// Tests whether the given string can be encoded as a segment in alphanumeric mode.
// A string is encodable iff each character is in the following set: 0 to 9, A to Z
// (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.
QrSegment.isAlphanumeric = function (text) {
return QrSegment.ALPHANUMERIC_REGEX.test(text);
};
/*-- Methods --*/
// Returns a new copy of the data bits of this segment.
QrSegment.prototype.getData = function () {
return this.bitData.slice(); // Make defensive copy
};
// (Package-private) Calculates and returns the number of bits needed to encode the given segments at
// the given version. The result is infinity if a segment has too many characters to fit its length field.
QrSegment.getTotalBits = function (segs, version) {
var result = 0;
for (var _i = 0, segs_2 = segs; _i < segs_2.length; _i++) {
var seg = segs_2[_i];
var ccbits = seg.mode.numCharCountBits(version);
if (seg.numChars >= (1 << ccbits))
return Infinity; // The segment's length doesn't fit the field's bit width
result += 4 + ccbits + seg.bitData.length;
}
return result;
};
// Returns a new array of bytes representing the given string encoded in UTF-8.
QrSegment.toUtf8ByteArray = function (str) {
str = encodeURI(str);
var result = [];
for (var i = 0; i < str.length; i++) {
if (str.charAt(i) != "%")
result.push(str.charCodeAt(i));
else {
result.push(parseInt(str.substr(i + 1, 2), 16));
i += 2;
}
}
return result;
};
/*-- Constants --*/
// Describes precisely all strings that are encodable in numeric mode.
QrSegment.NUMERIC_REGEX = /^[0-9]*$/;
// Describes precisely all strings that are encodable in alphanumeric mode.
QrSegment.ALPHANUMERIC_REGEX = /^[A-Z0-9 $%*+.\/:-]*$/;
// The set of all legal characters in alphanumeric mode,
// where each character value maps to the index in the string.
QrSegment.ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
return QrSegment;
}());
qrcodegen.QrSegment = QrSegment;
})(qrcodegen || (qrcodegen = {}));
/*---- Public helper enumeration ----*/
(function (qrcodegen) {
var QrCode;
(function (QrCode) {
/*
* The error correction level in a QR Code symbol. Immutable.
*/
var Ecc = /** @class */ (function () {
/*-- Constructor and fields --*/
function Ecc(
// In the range 0 to 3 (unsigned 2-bit integer).
ordinal,
// (Package-private) In the range 0 to 3 (unsigned 2-bit integer).
formatBits) {
this.ordinal = ordinal;
this.formatBits = formatBits;
}
/*-- Constants --*/
Ecc.LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords
Ecc.MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords
Ecc.QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords
Ecc.HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords
return Ecc;
}());
QrCode.Ecc = Ecc;
})(QrCode = qrcodegen.QrCode || (qrcodegen.QrCode = {}));
})(qrcodegen || (qrcodegen = {}));
/*---- Public helper enumeration ----*/
(function (qrcodegen) {
var QrSegment;
(function (QrSegment) {
/*
* Describes how a segment's data bits are interpreted. Immutable.
*/
var Mode = /** @class */ (function () {
/*-- Constructor and fields --*/
function Mode(
// The mode indicator bits, which is a uint4 value (range 0 to 15).
modeBits,
// Number of character count bits for three different version ranges.
numBitsCharCount) {
this.modeBits = modeBits;
this.numBitsCharCount = numBitsCharCount;
}
/*-- Method --*/
// (Package-private) Returns the bit width of the character count field for a segment in
// this mode in a QR Code at the given version number. The result is in the range [0, 16].
Mode.prototype.numCharCountBits = function (ver) {
return this.numBitsCharCount[Math.floor((ver + 7) / 17)];
};
/*-- Constants --*/
Mode.NUMERIC = new Mode(0x1, [10, 12, 14]);
Mode.ALPHANUMERIC = new Mode(0x2, [9, 11, 13]);
Mode.BYTE = new Mode(0x4, [8, 16, 16]);
Mode.KANJI = new Mode(0x8, [8, 10, 12]);
Mode.ECI = new Mode(0x7, [0, 0, 0]);
return Mode;
}());
QrSegment.Mode = Mode;
})(QrSegment = qrcodegen.QrSegment || (qrcodegen.QrSegment = {}));
})(qrcodegen || (qrcodegen = {}));

View file

@ -14,6 +14,7 @@ class App.User extends App.Model
{ name: 'created_at', display: __('Created at'), tag: 'datetime', readonly: 1 },
{ name: 'updated_by_id', display: __('Updated by'), relation: 'User', readonly: 1 },
{ name: 'updated_at', display: __('Updated at'), tag: 'datetime', readonly: 1 },
{ name: 'two_factor_configured', tag: 'boolean', readonly: 1 },
]
@configure_overview = [
# 'login', 'firstname', 'lastname', 'email', 'updated_at',

View file

@ -0,0 +1,13 @@
<div>
<div class="two-factor-auth">
<p>
<%- @T('You must protect your account with two-factor authentication.') %>
</p>
<p>
<%- @T('Choose your preferred two-factor authentication method to set it up.') %>
</p>
<div class="two-factor-auth-method-buttons">
<div class="loading icon"></div>
</div>
</div>
</div>

View file

@ -0,0 +1,9 @@
<% for method in @enabledMethods: %>
<div class="two-factor-auth-method">
<button class="btn btn--secondary js-configuration-method" type="button" data-method="<%= method.identifier %>" <% if method.disabled: %>disabled<% end %>>
<%- @Icon(method.icon) %>
<%- @T(method.label) %>
</button>
<p class="help-block text-center <% if method.disabled: %>is-disabled<% end %>"><%= method.helpMessage %></p>
</div>
<% end %>

View file

@ -14,6 +14,7 @@
<div class="hero-unit">
<img class="company-logo" src="<%= @logoUrl %>" alt="<%= @C('product_name') %>">
<% if @item.showAdminPasswordLogin || user_show_password_login: %>
<form id="login">
<% if @item.errorMessage: %>
@ -26,7 +27,7 @@
<div class="formGroup-label">
<label for="username"><%- @Ti('Username / email') %></label>
</div>
<input id="username" name="username" type="text" class="form-control" value="<%= @item.username %>" autocapitalize="off" />
<input id="username" name="username" type="text" class="form-control" value="<%= @item.formPayload?.username %>" autocapitalize="off" />
</div>
<div class="form-group">

View file

@ -0,0 +1,57 @@
<div class="login fullscreen">
<div class="fullscreen-center">
<div class="fullscreen-body">
<p><%- @T('Log in to %s', @C('fqdn')) %></p>
<div class="hero-unit">
<img class="company-logo" src="<%= @logoUrl %>" alt="<%= @C('product_name') %>">
<p>
<%= @T @twoFactorMethodDetails.label %>
</p>
<form id="login">
<% if @errorMessage: %>
<div class="alert alert--danger" role="alert">
<%= @errorMessage %>
</div>
<% end %>
<input name="username" type="hidden" value="<%= @formPayload.username %>" />
<input name="password" type="hidden" value="<%= @formPayload.password %>" />
<input name="remember_me" type="hidden" value="<%= @formPayload.remember_me %>" />
<input name="two_factor_method" type="hidden" value="authenticator_app" />
<div class="form-group">
<div class="formGroup-label">
<label for="security_code"><%- @Ti('Security Code') %></label>
</div>
<input id="security_code" name="two_factor_payload" type="text" class="form-control" autocomplete="off"/>
<p class="help-text"><%= @T @twoFactorMethodDetails.helpMessage %></p>
</div>
<div class="form-controls">
<button class="btn btn--primary" type="submit"><%- @T('Sign in') %></button>
</div>
</form>
</div>
</div>
<% if @twoFactorAvailableMethods.length > 1: %>
<p>
<%- @T('Having problems?') %>
<a href="#" class="js-try-another"><%- @T('Try another method') %></a>
</p>
<% else: %>
<p>
<%- @T('Contact the administrator if you have any problems logging in.') %>
</p>
<% end %>
</div>
<div class="poweredBy">
<a href="https://zammad.org" target="_blank"><%- @Icon('logo') %></a>
<%- @T('Powered by') %>
<a href="https://zammad.org" target="_blank"><%- @Icon('logotype', 'logotype') %></a>
</div>
</div>

View file

@ -0,0 +1,26 @@
<div class="login fullscreen">
<div class="fullscreen-center">
<div class="fullscreen-body">
<p><%- @T('Log in to %s', @C('fqdn')) %></p>
<div class="hero-unit">
<img class="company-logo" src="<%= @logoUrl %>" alt="<%= @C('product_name') %>">
<% for method in @twoFactorMethods: %>
<hr>
<p class="u-clickable js-select-two-factor-method" data-method="<%= method.key %>">
<%= @T method.label %>
</p>
<% end %>
</div>
</div>
<p>
Having problems?
<a href="#" class="js-try-another"><%- @T('Try another sign-in method.') %></a>
</p>
</div>
<div class="poweredBy">
<a href="https://zammad.org" target="_blank"><%- @Icon('logo') %></a>
<%- @T('Powered by') %>
<a href="https://zammad.org" target="_blank"><%- @Icon('logotype', 'logotype') %></a>
</div>
</div>

View file

@ -1,11 +1,101 @@
<div class="page-header">
<div class="page-header-title">
<h1><%- @T('Change Your Password') %></h1>
<h1><%- @T('Password & Authentication') %></h1>
</div>
</div>
<div class="page-content">
<% if @allowsChangePassword: %>
<h2><%- @T('Change Your Password') %></h2>
<p><%- @T('Pick a name for the application, and we\'ll give you a unique token.') %></p>
<form class="settings-entry form--flexibleWidth">
<div class="password_item"></div>
<button type="submit" class="btn btn--primary"><%- @T('Submit') %></button>
</form>
</div>
<% end %>
<% if @allowsChangePassword && @allowsTwoFactor: %>
<hr>
<% end %>
<% if @allowsTwoFactor: %>
<h2><%- @T('Two-factor Authentication') %></h2>
<p><%- @T('Here you can set up and manage two-factor authentication methods for your Zammad account.') %></p>
<table class="settings-list">
<thead>
<tr>
<th><%- @T('Active') %></th>
<th><%- @T('Name') %></th>
<th><%- @T('Description') %></th>
<th><%- @T('Action') %></th>
</tr>
</thead>
<tbody>
<% for elem in @twoFactorMethods: %>
<tr data-two-factor-key="<%= elem.method %>">
<td class="settings-list-controls"><div class="settings-list-control todo <%= elem.active_icon_parent_class %>"><%- @Icon(elem.active_icon_class) %></div></td>
<td>
<div class="settings-list-wrapping-cell">
<%- @Icon(elem.details.icon, 'half-space-right') %>
<span class="half-space-right"><%= @T(elem.details.label) %></span>
<% if elem.configured and elem.default: %>
<span class="badge badge--primary"><%= @T('Default') %></span>
<% end %>
</div>
</td>
<td><%- @T(elem.details.description) %></td>
<td class="settings-list-controls">
<div class="settings-list-control dropdown dropdown--actions">
<div class="btn btn--table btn--text btn--secondary js-action" data-toggle="dropdown">
<%- @Icon('overflow-button') %>
</div>
<ul class="dropdown-menu dropdown-menu-right js-table-action-menu" role="menu">
<% if elem.configured: %>
<!--
<% if !elem.default: %>
<li role="presentation">
<a role="menuitem" tabindex="-1">
<span class="dropdown-iconSpacer">
<%- @Icon('reload') %>
</span>
<%= @T('Set as default') %>
</a>
</li>
<% end %>
<li role="presentation">
<a role="menuitem" tabindex="-1">
<span class="dropdown-iconSpacer">
<%- @Icon('pen') %>
</span>
<%= @T('Edit') %>
</a>
</li>
-->
<li role="presentation" class="danger" data-type="remove">
<a role="menuitem" tabindex="-1" href="#">
<span class="dropdown-iconSpacer">
<%- @Icon('trash') %>
</span>
<%= @T('Remove') %>
</a>
</li>
<% else: %>
<li role="presentation" class="create" data-type="setup">
<a role="menuitem" tabindex="-1" href="#">
<span class="dropdown-iconSpacer">
<%- @Icon('checkmark') %>
</span>
<%= @T('Set Up') %>
</a>
</li>
<% end %>
</ul>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>

View file

@ -1,19 +1,21 @@
<div class="page-header">
<div class="page-header-title">
<div class="zammad-switch zammad-switch--small js-setting" data-name="<%= @setting.name %>">
<input name="<%= @setting.name %>" type="checkbox" id="setting-<%= @setting.name %>" <% if @checked: %>checked<% end %>>
<label for="setting-<%= @setting.name %>"></label>
<div class="settings-entry">
<div class="page-header">
<div class="page-header-title">
<div class="zammad-switch zammad-switch--small js-setting" data-name="<%= @setting.name %>">
<input name="<%= @setting.name %>" type="checkbox" id="setting-<%= @setting.name %>" <% if @checked: %>checked<% end %>>
<label for="setting-<%= @setting.name %>"></label>
</div>
<h1><%- @T.apply(@, [@setting.title].concat(@setting.preferences.title_i18n)) %></h1>
</div>
<h1><%- @T.apply(@, [@setting.title].concat(@setting.preferences.title_i18n)) %></h1>
</div>
<div class="page-content">
<p class="help-text"><%- @T.apply(@, [@setting.description].concat(@setting.preferences.description_i18n)) %></p>
<% for localSetting in @subSetting: %>
<form class="settings-entry" data-name="<%= localSetting.name %>">
<div class="horizontal end">
<div class="form-item flex"></div>
<button type="submit" class="btn btn--primary"><%- @T('Submit') %></button>
</div>
</form>
</div>
</div>
<div class="page-content">
<p class="help-text"><%- @T.apply(@, [@setting.description].concat(@setting.preferences.description_i18n)) %></p>
<% for localSetting in @subSetting: %>
<form class="settings-entry" data-name="<%= localSetting.name %>">
<div class="horizontal end">
<div class="form-item flex"></div>
<button type="submit" class="btn btn--primary"><%- @T('Submit') %></button>
</div>
</form>
</div>

View file

@ -0,0 +1,7 @@
<div>
<div class="js-attributes"></div>
<div class="form-controls">
<button type="submit" class="btn btn--secondary btn--danger js-remove-all"><%- @T('Remove all methods') %></button>
<button type="submit" class="btn btn--danger js-remove align-right"><%- @T('Remove method') %></button>
</div>
</div>

View file

@ -0,0 +1,37 @@
<div class="main two-factor-auth flex">
<p><%= @T('To set up Authenticator App for your account, follow the steps below:') %></p>
<ol>
<li>
<p><%= @T('Unless you already have it, install one of the following authenticator apps on your mobile device:') %></p>
<ul>
<li>
<a href="https://support.google.com/accounts/answer/1066447" target="_blank"><%= @T('Google Authenticator') %></a>
</li>
<li>
<a href="https://support.authy.com/hc/en-us/articles/115001945848-Installing-Authy-apps/" target="_blank"><%= @T('Authy') %></a>
</li>
<li>
<p>
<a href="https://support.microsoft.com/en-us/account-billing/download-and-install-the-microsoft-authenticator-app-351498fc-850a-45da-b7b6-27e523b8702a" target="_blank"><%= @T('Microsoft Authenticator') %></a>
</p>
</li>
</ul>
</li>
<li>
<p><%= @T('Open your authenticator app and scan the QR code below:') %></p>
<div class="authenticator-app">
<canvas class="qr-code-canvas js-qr-code-canvas" title="<%= @T('Show authenticator app secret') %>"></canvas>
<div class="secret js-secret" title="<%= @T('Hide authenticator app secret') %>" style="display: none;" aria-label="<%= @T('Authenticator app secret') %>"><%= @config.secret %></div>
</p>
</li>
<li>
<p><%= @T('Enter the security code generated by the authenticator app:') %></p>
<p>
<div class="method-form js-payload-form"></div>
</p>
</li>
<li>
<p><%= @T('Press the button below to finish the set up.') %></p>
</li>
</ol>
</div>

View file

@ -64,6 +64,7 @@
.icon-info { width: 5px; height: 11px; }
.icon-internal-modifier { width: 9px; height: 9px; }
.icon-italic { width: 12px; height: 12px; }
.icon-key { width: 20px; height: 20px; }
.icon-knowledge-base-answer { width: 16px; height: 16px; }
.icon-knowledge-base { width: 24px; height: 24px; }
.icon-line-left-arrow { width: 34px; height: 7px; }
@ -84,6 +85,7 @@
.icon-microsoft-button { width: 29px; height: 24px; }
.icon-minus-small { width: 16px; height: 16px; }
.icon-minus { width: 20px; height: 20px; }
.icon-mobile-code { width: 15.2px; height: 20px; }
.icon-mobile-edit { width: 9px; height: 14px; }
.icon-mobile { width: 9px; height: 14px; }
.icon-mood-bad { width: 60px; height: 59px; }
@ -121,6 +123,7 @@
.icon-rss { width: 16px; height: 16px; }
.icon-saml-button { width: 29px; height: 24px; }
.icon-searchdetail { width: 18px; height: 14px; }
.icon-security-key { width: 20px; height: 20px; }
.icon-signed { width: 14px; height: 14px; }
.icon-signout { width: 15px; height: 19px; }
.icon-small-dot { width: 16px; height: 16px; }
@ -143,6 +146,7 @@
.icon-trash { width: 16px; height: 16px; }
.icon-twitter-button { width: 29px; height: 24px; }
.icon-twitter { width: 17px; height: 17px; }
.icon-two-factor { width: 17.7px; height: 20px; }
.icon-underline { width: 12px; height: 12px; }
.icon-unmute { width: 16px; height: 16px; }
.icon-unordered-list { width: 12px; height: 12px; }

View file

@ -1387,6 +1387,10 @@ pre code {
border-radius: 0;
vertical-align: baseline;
}
&.badge--primary {
background: var(--highlight);
}
}
.key-value {
@ -3302,6 +3306,11 @@ kbd {
margin-right: initial;
}
}
.help-text {
margin-top: 10px;
text-align: left;
}
}
.login .hero-unit {
@ -11219,6 +11228,12 @@ output {
.settings-list-radio-cell {
text-align: center;
}
.settings-list-wrapping-cell {
display: flex;
align-items: center;
flex-wrap: wrap;
}
}
.settings-list--roles-permissions {
@ -12381,14 +12396,18 @@ output {
position: relative;
display: block;
&:not(a) {
cursor: default !important;
}
.icon {
fill: var(--ghost-color);
fill: var(--ghost-color) !important;
vertical-align: middle;
margin: -2px 3px 0 0;
}
&.is-done .icon {
fill: var(--supergood-color);
fill: var(--supergood-color) !important;
}
}
@ -14316,3 +14335,61 @@ span.is-disabled {
margin-bottom: 10px;
}
}
.two-factor-auth {
.loading {
display: block;
margin: 25px auto;
}
&-method-buttons {
display: flex;
flex-direction: column;
width: 382px;
margin: 25px auto 0;
}
&-method {
display: flex;
flex-direction: column;
margin-bottom: 15px;
}
.is-disabled {
opacity: 0.33;
}
.method-form .form-group {
margin-bottom: 4px;
}
.authenticator-app {
--width: 306px;
--height: 306px;
position: relative;
width: var(--width);
margin: 0 auto;
.qr-code-canvas {
width: var(--width);
height: var(--height);
cursor: pointer;
}
.secret {
position: absolute;
top: 0;
left: 0;
width: var(--width);
height: var(--height);
background: rgba(0, 0, 0, 0.9);
color: var(--text-inverted);
font-family: monospace;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
}
}

View file

@ -27,6 +27,7 @@ module ApplicationController::Authenticates
if %w[test development].include?(Rails.env) && ENV['FAKE_SELENIUM_LOGIN_USER_ID'].present? && session[:user_id].blank?
session[:user_id] = ENV['FAKE_SELENIUM_LOGIN_USER_ID'].to_i
session[:user_device_updated_at] = Time.zone.now
session[:authentication_type] = 'password'
end
# logger.debug 'authentication_check'
@ -53,9 +54,15 @@ module ApplicationController::Authenticates
end
auth = Auth.new(username, password)
return authentication_check_prerequesits(auth.user, 'basic_auth') if auth.valid?
authentication_errors.push(__('Invalid BasicAuth credentials'))
begin
auth.valid!
return authentication_check_prerequesits(auth.user, 'basic_auth')
rescue Auth::Error::AuthenticationFailed
authentication_errors.push(__('Invalid BasicAuth credentials'))
rescue Auth::Error::TwoFactorRequired
authentication_errors.push(__('Two-factor authentication is not supported with HTTP BasicAuth.'))
end
end
# check http token based authentication
@ -124,7 +131,7 @@ module ApplicationController::Authenticates
def authentication_check_prerequesits(user, auth_type)
raise Exceptions::Forbidden, __('Maintenance mode enabled!') if in_maintenance_mode?(user)
raise_unified_login_error if !user.active
raise Exceptions::NotAuthorized, Auth::Error::AuthenticationFailed::MESSAGE if !user.active
current_user_set(user, auth_type)
user_device_log(user, auth_type)
@ -132,10 +139,6 @@ module ApplicationController::Authenticates
user
end
def raise_unified_login_error
raise Exceptions::NotAuthorized, __('Login failed. Have you double-checked your credentials and completed the email verification step?')
end
def authenticate_and_authorize!
authentication_check && authorize!
end

View file

@ -10,7 +10,7 @@ class SessionsController < ApplicationController
raise Exceptions::NotAuthorized, 'no valid session' if user.blank?
# return current session
render json: SessionHelper.json_hash(user).merge(config: config_frontend)
render json: SessionHelper.json_hash(user).merge(config: config_frontend, after_auth: Auth::AfterAuth.run(user, session))
rescue Exceptions::NotAuthorized => e
render json: SessionHelper.json_hash_error(e).merge(config: config_frontend)
end
@ -22,7 +22,16 @@ class SessionsController < ApplicationController
# return new session data
render status: :created,
json: SessionHelper.json_hash(user).merge(config: config_frontend)
json: SessionHelper.json_hash(user).merge(config: config_frontend, after_auth: Auth::AfterAuth.run(user, session))
rescue Auth::Error::TwoFactorRequired => e
render json: {
two_factor_required: {
default_two_factor_method: e.default_two_factor_method,
available_two_factor_methods: e.available_two_factor_methods,
}
}, status: :unprocessable_entity
rescue Auth::Error::Base => e
raise Exceptions::NotAuthorized, e.message
end
def create_sso
@ -42,7 +51,7 @@ class SessionsController < ApplicationController
session.delete(:switched_from_user_id)
authentication_check_prerequesits(user, 'SSO')
initiate_session_for(user)
initiate_session_for(user, 'sso')
redirect_to '/#'
end
@ -111,6 +120,9 @@ class SessionsController < ApplicationController
# remember last login date
authorization.user.update_last_login
# remember omnitauth login
session[:authentication_type] = 'omniauth'
# Set needed fingerprint parameter.
if request.env['omniauth.params']['fingerprint'].present?
params[:fingerprint] = request.env['omniauth.params']['fingerprint']
@ -231,20 +243,23 @@ class SessionsController < ApplicationController
private
def authenticate_with_password
auth = Auth.new(params[:username], params[:password])
raise_unified_login_error if !auth.valid?
auth = Auth.new(params[:username], params[:password],
two_factor_method: params[:two_factor_method], two_factor_payload: params[:two_factor_payload])
auth.valid!
session.delete(:switched_from_user_id)
authentication_check_prerequesits(auth.user, 'session')
end
def initiate_session_for(user)
def initiate_session_for(user, type = 'password')
request.env['rack.session.options'][:expire_after] = 1.year if params[:remember_me]
# Mark the session as "persistent". Non-persistent sessions (e.g. sessions generated by curl API call) are
# deleted periodically in SessionHelper.cleanup_expired.
session[:persistent] = true
session[:authentication_type] = type
user.activity_stream_log('session started', user.id, true)
end

View file

@ -0,0 +1,9 @@
# Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
class User::AfterAuthController < ApplicationController
prepend_before_action :authentication_check
def show
render json: Auth::AfterAuth.run(current_user, session)
end
end

View file

@ -0,0 +1,50 @@
# Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
class User::TwoFactorsController < ApplicationController
prepend_before_action :authenticate_and_authorize!
def two_factor_remove_method
params_user.two_factor_destroy_method(params[:method])
render json: {}, status: :ok
end
def two_factor_remove_all_methods
params_user.two_factor_destroy_all_methods
render json: {}, status: :ok
end
def two_factor_enabled_methods
render json: params_user.two_factor_enabled_methods, status: :ok
end
def two_factor_verify_configuration
raise Exceptions::UnprocessableEntity, __('The required parameter "method" is missing.') if !params[:method]
raise Exceptions::UnprocessableEntity, __('The required parameter "payload" is missing.') if !params[:payload]
render json: { verified: two_factor_verify_configuration? }, status: :ok
end
def two_factor_method_configuration
method_name = params[:method]
raise Exceptions::UnprocessableEntity, __('The required parameter "method" is missing.') if method_name.blank?
two_factor_method = current_user.auth_two_factor.method_object(method_name)
raise Exceptions::UnprocessableEntity, __('The two-factor authentication method is not enabled.') if !two_factor_method&.enabled? || !two_factor_method&.available?
render json: { configuration: two_factor_method.configuration_options }, status: :ok
end
private
def params_user
User.find(params[:id])
end
def two_factor_verify_configuration?
current_user.two_factor_verify_configuration?(params[:method], params[:payload], params[:configuration].permit!.to_h)
end
end

View file

@ -656,6 +656,18 @@ curl http://localhost/api/v1/users/password_change -v -u #{login}:#{password} -H
render json: { message: 'ok', user_login: current_user.login }, status: :ok
end
def password_check
raise Exceptions::UnprocessableEntity, __('The parameter "password" is missing!') if params[:password].blank?
begin
Auth.new(current_user.login, params[:password], only_verify_password: true).valid!
render json: { success: true }, status: :ok
rescue Auth::Error::AuthenticationFailed
render json: { success: false }, status: :ok
end
end
=begin
Resource:
@ -942,6 +954,12 @@ curl http://localhost/api/v1/users/avatar -v -u #{login}:#{password} -H "Content
render json: result, status: :ok
end
def two_factor_enabled_methods
user = User.find(params[:id])
render json: { methods: user.two_factor_enabled_methods }, status: :ok
end
private
def password_login?

View file

@ -44,7 +44,7 @@ const getCoreClasses: FormThemeExtension = (classes: FormThemeClasses) => {
textarea: addFloatingTextareaLabel(classes.textarea),
checkbox: extendClasses(classes.checkbox, {
outer: 'formkit-invalid:bg-red-dark formkit-errors:bg-red-dark',
wrapper: 'ltr:pl-2 rtl:pr-2 w-full justify-between',
wrapper: 'ltr:pl-2 rtl:pr-2 w-full select-none',
label: 'formkit-required:required',
input:
'h-4 w-4 border-[1.5px] border-white rounded-sm bg-transparent focus:border-blue focus:bg-blue-highlight checked:focus:color-blue checked:bg-blue checked:border-blue checked:focus:bg-blue checked:hover:bg-blue',

View file

@ -11,6 +11,7 @@ import initializeApolloClient from '#mobile/server/apollo/index.ts'
import initializeRouter from '#mobile/router/index.ts'
import { useAuthenticationStore } from '#shared/stores/authentication.ts'
import { useForceDesktop } from '#shared/composables/useForceDesktop.ts'
import { ensureAfterAuth } from './pages/login/after-auth/composable/useAfterAuthPlugins.ts'
const { forceDesktopLocalStorage } = useForceDesktop()
@ -60,4 +61,9 @@ export default async function mountApp(): Promise<void> {
}
app.mount('#app')
console.log('session', session)
if (session.afterAuth) {
await ensureAfterAuth(router, session.afterAuth)
}
}

View file

@ -0,0 +1,34 @@
// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
import { EnumAfterAuthType } from '#shared/graphql/types.ts'
import { getTestRouter } from '#tests/support/components/renderComponent.ts'
import { visitView } from '#tests/support/components/visitView.ts'
import { isNavigationFailure } from 'vue-router'
import { mockAccount } from '#tests/support/mock-account.ts'
import { mockTicketOverviews } from '#tests/support/mocks/ticket-overviews.ts'
import { ensureAfterAuth } from '../after-auth/composable/useAfterAuthPlugins.ts'
it("doesn't open the page if there is nothing to show", async () => {
mockAccount({ id: '666' })
mockTicketOverviews()
const view = await visitView('/login/after-auth')
expect(view.queryByTestId('loginAfterAuth')).not.toBeInTheDocument()
})
it('user cannot leave the after auth page', async () => {
const view = await visitView('/testing-environment')
const router = getTestRouter()
router.restoreMethods()
await ensureAfterAuth(router, {
type: EnumAfterAuthType.TwoFactorConfiguration,
})
expect(await view.findByTestId('loginAfterAuth')).toBeInTheDocument()
const error = await router.push('/login')
expect(isNavigationFailure(error)).toBe(true)
expect(view.queryByTestId('loginAfterAuth')).toBeInTheDocument()
})

View file

@ -5,6 +5,7 @@ import {
mockPublicLinksSubscription,
} from '#shared/entities/public-links/__tests__/mocks/mockPublicLinks.ts'
import { LoginDocument } from '#shared/graphql/mutations/login.api.ts'
import { EnumTwoFactorMethod } from '#shared/graphql/types.ts'
import { visitView } from '#tests/support/components/visitView.ts'
import { mockGraphQLApi } from '#tests/support/mock-graphql-api.ts'
@ -25,13 +26,14 @@ describe('testing login error handling', () => {
it('check that login request error is visible', async () => {
mockGraphQLApi(LoginDocument).willFailWithUserError({
login: {
sessionId: null,
session: null,
errors: [
{
message:
'Login failed. Have you double-checked your credentials and completed the email verification step?',
},
],
twoFactorRequired: null,
},
})
@ -50,4 +52,50 @@ describe('testing login error handling', () => {
'Login failed. Have you double-checked your credentials and completed the email verification step?',
)
})
it('check that two factor request error is visible', async () => {
mockGraphQLApi(LoginDocument).willResolve([
{
login: {
session: null,
errors: null,
twoFactorRequired: {
availableTwoFactorMethods: [EnumTwoFactorMethod.AuthenticatorApp],
defaultTwoFactorMethod: EnumTwoFactorMethod.AuthenticatorApp,
},
},
},
{
login: {
session: null,
errors: [
{
message:
'Login failed. Have you double-checked your credentials and completed the email verification step?',
},
],
twoFactorRequired: null,
},
},
])
const view = await visitView('/login')
const loginInput = view.getByPlaceholderText('Username / Email')
const passwordInput = view.getByPlaceholderText('Password')
await view.events.type(loginInput, 'admin@example.com')
await view.events.type(passwordInput, 'wrong')
await view.events.click(view.getByText('Sign in'))
await view.events.type(view.getByLabelText('Security Code'), '123456')
await view.events.click(view.getByText('Sign in'))
expect(view.getByTestId('notification')).toBeInTheDocument()
expect(view.getByTestId('notification')).toHaveTextContent(
'Login failed. Have you double-checked your credentials and completed the email verification step?',
)
})
})

View file

@ -0,0 +1,53 @@
// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
import {
mockPublicLinks,
mockPublicLinksSubscription,
} from '#shared/entities/public-links/__tests__/mocks/mockPublicLinks.ts'
import { LoginDocument } from '#shared/graphql/mutations/login.api.ts'
import { EnumTwoFactorMethod } from '#shared/graphql/types.ts'
import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockGraphQLApi } from '#tests/support/mock-graphql-api.ts'
describe('two factor login flow', () => {
beforeEach(() => {
mockPublicLinks([])
mockPublicLinksSubscription()
})
it("don't show third party on two factor page", async () => {
mockApplicationConfig({
user_show_password_login: true,
product_name: 'Zammad',
auth_facebook: true,
auth_twitter: true,
})
mockGraphQLApi(LoginDocument).willResolve({
login: {
session: null,
errors: null,
twoFactorRequired: {
availableTwoFactorMethods: [EnumTwoFactorMethod.AuthenticatorApp],
defaultTwoFactorMethod: EnumTwoFactorMethod.AuthenticatorApp,
},
},
})
const view = await visitView('/login')
expect(view.queryByTestId('loginThirdParty')).toBeInTheDocument()
const loginInput = view.getByPlaceholderText('Username / Email')
const passwordInput = view.getByPlaceholderText('Password')
await view.events.type(loginInput, 'admin@example.com')
await view.events.type(passwordInput, 'wrong')
await view.events.click(view.getByText('Sign in'))
expect(view.queryByLabelText('Security Code')).toBeInTheDocument()
expect(view.queryByTestId('loginThirdParty')).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,44 @@
// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
import { keyBy } from 'lodash-es'
import type {
EnumAfterAuthType,
SessionAfterAuth,
} from '#shared/graphql/types.ts'
import { computed, ref } from 'vue'
import type { Router } from 'vue-router'
import type { AfterAuthPlugin } from '../types.ts'
const pluginsModules = import.meta.glob<AfterAuthPlugin>('../plugins/*.ts', {
eager: true,
import: 'default',
})
const pluginsFiles = Object.values(pluginsModules)
const plugins = keyBy(pluginsFiles, 'name')
const currentPlugin = ref<EnumAfterAuthType | null>(null)
const currentPluginData = ref<Record<string, unknown> | null>(null)
export const ensureAfterAuth = async (
router: Router,
afterAuth: SessionAfterAuth,
) => {
currentPlugin.value = afterAuth.type
currentPluginData.value = afterAuth.data || null
await router.replace('/login/after-auth')
}
export const useAfterAuthPlugins = () => {
const plugin = computed(() => {
if (!currentPlugin.value) return null
return plugins[currentPlugin.value] || null
})
return {
currentPlugin: plugin,
data: currentPluginData,
plugins,
}
}

View file

@ -0,0 +1,11 @@
// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
import { EnumAfterAuthType } from '#shared/graphql/types.ts'
import TwoFactorConfiguration from '../../components/AfterAuth/TwoFactorConfiguration.vue'
import type { AfterAuthPlugin } from '../types.ts'
export default {
name: EnumAfterAuthType.TwoFactorConfiguration,
component: TwoFactorConfiguration,
title: __('Two-Factor Authentication Configuration Is Required'),
} satisfies AfterAuthPlugin

View file

@ -0,0 +1,10 @@
// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
import type { EnumAfterAuthType } from '#shared/graphql/types.ts'
import type { Component } from 'vue'
export interface AfterAuthPlugin {
name: EnumAfterAuthType
title: string
component: Component
}

View file

@ -0,0 +1,19 @@
<!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
defineProps<{
data?: unknown
}>()
defineEmits<{
(event: 'redirect', url: string): void
}>()
</script>
<template>
<div class="my-1 rounded-xl bg-red px-4 py-2 text-center text-white">
<p>{{ $t('The two-factor authentication is not configured yet.') }}</p>
<CommonLink link="/#" class="text-white underline">
{{ $t('Click here to set up a two-factor authentication method.') }}
</CommonLink>
</div>
</template>

View file

@ -0,0 +1,155 @@
<!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import Form from '#shared/components/Form/Form.vue'
import { type FormData, useForm } from '#shared/components/Form/index.ts'
import { defineFormSchema } from '#mobile/form/defineFormSchema.ts'
import { useApplicationStore } from '#shared/stores/application.ts'
import UserError from '#shared/errors/UserError.ts'
import { useNotifications } from '#shared/components/CommonNotifications/index.ts'
import { useAuthenticationStore } from '#shared/stores/authentication.ts'
import type { UserTwoFactorMethods } from '#shared/graphql/types.ts'
import { useRouter } from 'vue-router'
import type { LoginFormData } from '../types/login.ts'
import { ensureAfterAuth } from '../after-auth/composable/useAfterAuthPlugins.ts'
const emit = defineEmits<{
(e: 'error', error: UserError): void
(e: 'finish'): void
(
e: 'askTwoFactor',
twoFactor: Required<UserTwoFactorMethods>,
formData: FormData<LoginFormData>,
): void
}>()
const application = useApplicationStore()
const loginSchema = defineFormSchema([
{
isLayout: true,
component: 'FormGroup',
children: [
{
name: 'login',
type: 'text',
label: __('Username / Email'),
placeholder: __('Username / Email'),
required: true,
},
],
},
{
isLayout: true,
component: 'FormGroup',
children: [
{
name: 'password',
label: __('Password'),
placeholder: __('Password'),
type: 'password',
required: true,
},
],
},
{
isLayout: true,
element: 'div',
attrs: {
class: 'mt-2.5 flex grow items-center justify-between text-white',
},
children: [
{
type: 'checkbox',
name: 'rememberMe',
label: __('Remember me'),
wrapperClass: '!h-6',
},
// TODO support if/then in form-schema
...(application.config.user_lost_password
? [
{
isLayout: true,
component: 'CommonLink',
props: {
class: 'text-right text-white',
link: '/#password_reset',
},
children: __('Forgot password?'),
},
]
: []),
],
},
])
// TODO: workaround for disabled button state, will be changed in formkit.
const { form, isDisabled } = useForm()
const { clearAllNotifications } = useNotifications()
const authentication = useAuthenticationStore()
const router = useRouter()
const sendCredentials = (formData: FormData<LoginFormData>) => {
// Clear notifications to avoid duplicated error messages.
clearAllNotifications()
return authentication
.login(formData.login, formData.password, formData.rememberMe)
.then(({ twoFactor, afterAuth }) => {
if (afterAuth) {
return ensureAfterAuth(router, afterAuth)
}
if (!twoFactor || !twoFactor.defaultTwoFactorMethod) {
emit('finish')
} else {
emit(
'askTwoFactor',
twoFactor as Required<UserTwoFactorMethods>,
formData,
)
}
})
.catch((error: UserError) => {
if (error instanceof UserError) {
emit('error', error)
}
})
}
</script>
<template>
<Form
id="signin"
ref="form"
class="text-left"
:schema="loginSchema"
@submit="sendCredentials($event as FormData<LoginFormData>)"
>
<template #after-fields>
<div
v-if="$c.user_create_account"
class="mt-4 flex grow items-center justify-center"
>
<span class="ltr:mr-1 rtl:ml-1">{{ $t('New user?') }}</span>
<CommonLink
link="/#signup"
class="cursor-pointer select-none !text-yellow underline"
>
{{ $t('Register') }}
</CommonLink>
</div>
<FormKit
wrapper-class="mt-6 flex grow justify-center items-center"
input-class="py-2 px-4 w-full h-14 text-xl rounded-xl select-none"
variant="submit"
type="submit"
:disabled="isDisabled"
>
{{ $t('Sign in') }}
</FormKit>
</template>
</Form>
</template>

View file

@ -0,0 +1,36 @@
<!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { useApplicationStore } from '#shared/stores/application.ts'
const application = useApplicationStore()
</script>
<template>
<footer
class="mt-8 flex w-full max-w-md items-center justify-center border-t border-gray-600 py-2.5 align-middle font-medium leading-4 text-gray"
>
<CommonLink
v-if="application.hasCustomProductBranding"
link="https://zammad.org"
external
open-in-new-tab
class="ltr:mr-1 rtl:ml-1"
>
<img
:src="'/assets/images/icons/logo.svg'"
:alt="$t('Logo')"
class="h-6 w-6"
/>
</CommonLink>
<span class="ltr:mr-1 rtl:ml-1">{{ $t('Powered by') }}</span>
<CommonLink
link="https://zammad.org"
external
open-in-new-tab
class="font-semibold"
>
{{ $t('Zammad') }}
</CommonLink>
</footer>
</template>

View file

@ -0,0 +1,34 @@
<!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import CommonLogo from '#shared/components/CommonLogo/CommonLogo.vue'
defineProps<{
title: string
}>()
</script>
<template>
<div class="flex justify-center p-2">
<CommonLogo />
</div>
<h1 class="mb-6 flex justify-center p-2 text-2xl font-extrabold">
{{ title }}
</h1>
<template v-if="$c.maintenance_mode">
<div class="my-1 flex items-center rounded-xl bg-red px-4 py-2 text-white">
{{
$t(
'Zammad is currently in maintenance mode. Only administrators can log in. Please wait until the maintenance window is over.',
)
}}
</div>
</template>
<template v-if="$c.maintenance_login && $c.maintenance_login_message">
<!-- eslint-disable vue/no-v-html -->
<div
class="my-1 flex items-center rounded-xl bg-green px-4 py-2 text-white"
v-html="$c.maintenance_login_message"
></div>
</template>
</template>

View file

@ -17,7 +17,7 @@ const { fingerprint } = useFingerprint()
</script>
<template>
<section class="mb-16 mt-4 w-full max-w-md">
<section class="mb-16 mt-4 w-full max-w-md" data-test-id="loginThirdParty">
<p class="p-3 text-center">
{{
$c.user_show_password_login

View file

@ -0,0 +1,88 @@
<!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { useNotifications } from '#shared/components/CommonNotifications/composable.ts'
import Form from '#shared/components/Form/Form.vue'
import type { FormData, FormSchemaNode } from '#shared/components/Form/types.ts'
import type { TwoFactorPlugin } from '#shared/entities/two-factor/types.ts'
import UserError from '#shared/errors/UserError.ts'
import { useAuthenticationStore } from '#shared/stores/authentication.ts'
import { computed } from 'vue'
import type { LoginFormData, TwoFactorFormData } from '../types/login.ts'
const props = defineProps<{
credentials: FormData<LoginFormData>
twoFactor: TwoFactorPlugin
}>()
const emit = defineEmits<{
(e: 'finish'): void
(e: 'error', error: UserError): void
}>()
// TODO: this should be configurable by two factor plugin
const schema: FormSchemaNode[] = [
{
isLayout: true,
component: 'FormGroup',
props: {
help: computed(() => props.twoFactor.helpMessage),
},
children: [
{
type: 'text',
name: 'code',
label: __('Security Code'),
required: true,
props: {
autocomplete: 'one-time-code',
autofocus: true,
inputmode: 'numeric',
pattern: '[0-9]*',
},
},
],
},
]
const { clearAllNotifications } = useNotifications()
const authentication = useAuthenticationStore()
const confirmTwoFactor = (formData: FormData<TwoFactorFormData>) => {
// Clear notifications to avoid duplicated error messages.
clearAllNotifications()
const { login, password, rememberMe } = props.credentials
return authentication
.login(login, password, rememberMe, {
payload: formData.code,
method: props.twoFactor.name,
})
.then(() => {
emit('finish')
})
.catch((error: UserError) => {
if (error instanceof UserError) {
emit('error', error)
}
})
}
</script>
<template>
<Form
:schema="schema"
@submit="confirmTwoFactor($event as FormData<TwoFactorFormData>)"
>
<template #after-fields>
<FormKit
wrapper-class="mt-6 flex grow justify-center items-center"
input-class="py-2 px-4 w-full h-14 text-xl rounded-xl select-none"
variant="submit"
type="submit"
>
{{ $t('Sign in') }}
</FormKit>
</template>
</Form>
</template>

View file

@ -0,0 +1,28 @@
<!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import CommonSectionMenu from '#mobile/components/CommonSectionMenu/CommonSectionMenu.vue'
import CommonSectionMenuLink from '#mobile/components/CommonSectionMenu/CommonSectionMenuLink.vue'
import type { TwoFactorPlugin } from '#shared/entities/two-factor/types.ts'
import type { EnumTwoFactorMethod } from '#shared/graphql/types.ts'
defineProps<{
methods: TwoFactorPlugin[]
}>()
const emit = defineEmits<{
(e: 'select', twoFactorMethod: EnumTwoFactorMethod): void
}>()
</script>
<template>
<CommonSectionMenu>
<CommonSectionMenuLink
v-for="method of methods"
:key="method.name"
:label="method.label"
:icon="method.icon.mobile"
@click="emit('select', method.name)"
/>
</CommonSectionMenu>
</template>

View file

@ -0,0 +1,49 @@
// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
import { usePublicLinksQuery } from '#shared/entities/public-links/graphql/queries/links.api.ts'
import { PublicLinkUpdatesDocument } from '#shared/entities/public-links/graphql/subscriptions/currentLinks.api.ts'
import {
EnumPublicLinksScreen,
type PublicLinkUpdatesSubscriptionVariables,
type PublicLinkUpdatesSubscription,
type PublicLinksQuery,
} from '#shared/graphql/types.ts'
import QueryHandler from '#shared/server/apollo/handler/QueryHandler.ts'
import { computed } from 'vue'
export const usePublicLinks = () => {
const publicLinksQuery = new QueryHandler(
usePublicLinksQuery({
screen: EnumPublicLinksScreen.Login,
}),
)
publicLinksQuery.subscribeToMore<
PublicLinkUpdatesSubscriptionVariables,
PublicLinkUpdatesSubscription
>({
document: PublicLinkUpdatesDocument,
variables: {
screen: EnumPublicLinksScreen.Login,
},
updateQuery(_, { subscriptionData }) {
const publicLinks = subscriptionData.data.publicLinkUpdates?.publicLinks
// if we return empty array here, the actual query will be aborted, because we have fetchPolicy "cache-and-network"
// if we return existing value, it will throw an error, because "publicLinks" doesn't exist yet on the query result
if (!publicLinks) {
return null as unknown as PublicLinksQuery
}
return {
publicLinks,
}
},
})
const links = computed(() => {
const publicLinks = publicLinksQuery.result()
return publicLinks.value?.publicLinks || []
})
return { links }
}

View file

@ -16,6 +16,26 @@ const route: RouteRecordRaw[] = [
hasOwnLandmarks: true,
},
},
{
path: '/login/after-auth',
name: 'LoginAfterAuth',
component: () => import('./views/LoginAfterAuth.vue'),
async beforeEnter(to) {
// don't open the page if there is nothing to show
const { useAfterAuthPlugins } = await import(
'./after-auth/composable/useAfterAuthPlugins.ts'
)
const { currentPlugin } = useAfterAuthPlugins()
if (!currentPlugin.value) {
return to.redirectedFrom ? false : '/'
}
},
meta: {
requiresAuth: false,
requiredPermission: null,
hasOwnLandmarks: true,
},
},
{
path: '/logout',
name: 'Logout',

View file

@ -0,0 +1,21 @@
// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
import type { FormData } from '#shared/components/Form/types.ts'
import type { EnumTwoFactorMethod } from '#shared/graphql/types.ts'
export interface LoginFormData {
login: string
password: string
rememberMe: boolean
}
export interface TwoFactorFormData {
code: string
}
export interface LoginFlow {
state: 'credentials' | '2fa' | '2fa-select'
allowedMethods: EnumTwoFactorMethod[]
twoFactor?: EnumTwoFactorMethod
credentials?: FormData<LoginFormData>
}

View file

@ -6,35 +6,34 @@ import {
useNotifications,
NotificationTypes,
} from '#shared/components/CommonNotifications/index.ts'
import { useAuthenticationStore } from '#shared/stores/authentication.ts'
import CommonLogo from '#shared/components/CommonLogo/CommonLogo.vue'
import Form from '#shared/components/Form/Form.vue'
import { type FormData, useForm } from '#shared/components/Form/index.ts'
import UserError from '#shared/errors/UserError.ts'
import { defineFormSchema } from '#mobile/form/defineFormSchema.ts'
import { useApplicationStore } from '#shared/stores/application.ts'
import { usePublicLinksQuery } from '#shared/entities/public-links/graphql/queries/links.api.ts'
import type {
PublicLinksQuery,
PublicLinkUpdatesSubscription,
PublicLinkUpdatesSubscriptionVariables,
} from '#shared/graphql/types.ts'
import { EnumPublicLinksScreen } from '#shared/graphql/types.ts'
import { computed } from 'vue'
import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
import { PublicLinkUpdatesDocument } from '#shared/entities/public-links/graphql/subscriptions/currentLinks.api.ts'
import { computed, reactive } from 'vue'
import { useThirdPartyAuthentication } from '#shared/composables/useThirdPartyAuthentication.ts'
import type { FormData } from '#shared/components/Form/index.ts'
import { useForceDesktop } from '#shared/composables/useForceDesktop.ts'
import { useTwoFactorPlugins } from '#shared/entities/two-factor/composables/useTwoFactorPlugins.ts'
import type UserError from '#shared/errors/UserError.ts'
import type {
EnumTwoFactorMethod,
UserTwoFactorMethods,
} from '#shared/graphql/types.ts'
import LoginThirdParty from '../components/LoginThirdParty.vue'
import LoginCredentialsForm from '../components/LoginCredentialsForm.vue'
import LoginHeader from '../components/LoginHeader.vue'
import LoginTwoFactor from '../components/LoginTwoFactor.vue'
import LoginTwoFactorMethods from '../components/LoginTwoFactorMethods.vue'
import { usePublicLinks } from '../composable/usePublicLinks.ts'
import type { LoginFlow, LoginFormData } from '../types/login.ts'
import LoginFooter from '../components/LoginFooter.vue'
const route = useRoute()
const router = useRouter()
const { notify } = useNotifications()
// Output a hint when the session is no longer valid.
// This could happen because the session was deleted on the server.
if (route.query.invalidatedSession === '1') {
const { notify } = useNotifications()
notify({
message: __('The session is no longer valid. Please log in again.'),
type: NotificationTypes.Warn,
@ -43,144 +42,74 @@ if (route.query.invalidatedSession === '1') {
router.replace({ name: 'Login' })
}
const authentication = useAuthenticationStore()
const application = useApplicationStore()
const loginSchema = defineFormSchema([
{
isLayout: true,
component: 'FormGroup',
children: [
{
name: 'login',
type: 'text',
label: __('Username / Email'),
placeholder: __('Username / Email'),
required: true,
},
],
},
{
isLayout: true,
component: 'FormGroup',
children: [
{
name: 'password',
label: __('Password'),
placeholder: __('Password'),
type: 'password',
required: true,
},
],
},
{
isLayout: true,
element: 'div',
attrs: {
class: 'mt-2.5 flex grow items-center justify-between text-white',
},
children: [
{
type: 'checkbox',
name: 'rememberMe',
label: __('Remember me'),
wrapperClass: '!h-6',
},
// TODO support if/then in form-schema
...(application.config.user_lost_password
? [
{
isLayout: true,
component: 'CommonLink',
props: {
class: 'text-right text-white',
link: '/#password_reset',
},
children: __('Forgot password?'),
},
]
: []),
],
},
])
interface LoginFormData {
login: string
password: string
rememberMe: boolean
}
const publicLinksQuery = new QueryHandler(
usePublicLinksQuery({
screen: EnumPublicLinksScreen.Login,
}),
)
publicLinksQuery.subscribeToMore<
PublicLinkUpdatesSubscriptionVariables,
PublicLinkUpdatesSubscription
>({
document: PublicLinkUpdatesDocument,
variables: {
screen: EnumPublicLinksScreen.Login,
},
updateQuery(_, { subscriptionData }) {
const publicLinks = subscriptionData.data.publicLinkUpdates?.publicLinks
// if we return empty array here, the actual query will be aborted, because we have fetchPolicy "cache-and-network"
// if we return existing value, it will throw an error, because "publicLinks" doesn't exist yet on the query result
if (!publicLinks) {
return null as unknown as PublicLinksQuery
}
return {
publicLinks,
}
},
})
const links = computed(() => {
const publicLinks = publicLinksQuery.result()
return publicLinks.value?.publicLinks || []
})
// TODO: workaround for disabled button state, will be changed in formkit.
const { form, isDisabled } = useForm()
const login = (formData: FormData<LoginFormData>) => {
const { notify, clearAllNotifications } = useNotifications()
// Clear notifications to avoid duplicated error messages.
clearAllNotifications()
return authentication
.login(formData.login, formData.password, formData.rememberMe)
.then(() => {
// TODO: maybe we need some additional logic for the ThirtParty-Login situtation.
const { redirect: redirectUrl } = route.query
if (typeof redirectUrl === 'string') {
router.replace(redirectUrl)
} else {
router.replace('/')
}
})
.catch((errors: UserError) => {
if (errors instanceof UserError) {
notify({
message: errors.generalErrors[0],
type: NotificationTypes.Error,
})
}
})
}
const { links } = usePublicLinks()
const { enabledProviders, hasEnabledProviders } = useThirdPartyAuthentication()
const showPasswordLogin = computed(
() => application.config.user_show_password_login || !hasEnabledProviders,
() =>
application.config.user_show_password_login || !hasEnabledProviders.value,
)
const { forceDesktop } = useForceDesktop()
const { twoFactorPlugins, twoFactorMethods } = useTwoFactorPlugins()
const finishLogin = () => {
// TODO: maybe we need some additional logic for the ThirtParty-Login situtation.
const { redirect: redirectUrl } = route.query
if (typeof redirectUrl === 'string') {
router.replace(redirectUrl)
} else {
router.replace('/')
}
}
const loginFlow = reactive<LoginFlow>({
state: 'credentials',
allowedMethods: [],
})
const updateSecondFactor = (factor: EnumTwoFactorMethod) => {
loginFlow.twoFactor = factor
loginFlow.state = '2fa'
}
const askTwoFactor = (
twoFactor: UserTwoFactorMethods,
formData: FormData<LoginFormData>,
) => {
loginFlow.credentials = formData
loginFlow.allowedMethods = twoFactor.availableTwoFactorMethods
updateSecondFactor(twoFactor.defaultTwoFactorMethod as EnumTwoFactorMethod)
}
const twoFactorAllowedMethods = computed(() => {
return twoFactorMethods.filter((method) =>
loginFlow.allowedMethods.includes(method.name),
)
})
const twoFactorPlugin = computed(() => {
return loginFlow.twoFactor ? twoFactorPlugins[loginFlow.twoFactor] : undefined
})
const loginPageTitle = computed(() => {
const productName = String(application.config.product_name)
if (loginFlow.state === 'credentials') return productName
if (loginFlow.state === '2fa') {
return twoFactorPlugin.value?.label ?? productName
}
return __('Try another method')
})
const showError = (error: UserError) => {
notify({
message: error.generalErrors[0],
type: NotificationTypes.Error,
})
}
</script>
<template>
@ -188,66 +117,36 @@ const { forceDesktop } = useForceDesktop()
<main class="m-auto w-full max-w-md">
<div class="flex grow flex-col justify-center">
<div class="my-5 grow">
<div class="flex justify-center p-2">
<CommonLogo />
</div>
<h1 class="mb-6 flex justify-center p-2 text-2xl font-extrabold">
{{ $c.product_name }}
</h1>
<template v-if="$c.maintenance_mode">
<div
class="my-1 flex items-center rounded-xl bg-red px-4 py-2 text-white"
>
{{
$t(
'Zammad is currently in maintenance mode. Only administrators can log in. Please wait until the maintenance window is over.',
)
}}
</div>
</template>
<template v-if="$c.maintenance_login && $c.maintenance_login_message">
<!-- eslint-disable vue/no-v-html -->
<div
class="my-1 flex items-center rounded-xl bg-green px-4 py-2 text-white"
v-html="$c.maintenance_login_message"
></div>
</template>
<Form
v-if="showPasswordLogin"
id="signin"
ref="form"
class="text-left"
:schema="loginSchema"
@submit="login($event as FormData<LoginFormData>)"
>
<template #after-fields>
<div
v-if="$c.user_create_account"
class="mt-4 flex grow items-center justify-center"
>
<span class="ltr:mr-1 rtl:ml-1">{{ $t('New user?') }}</span>
<CommonLink
link="/#signup"
class="cursor-pointer select-none !text-yellow underline"
>
{{ $t('Register') }}
</CommonLink>
</div>
<FormKit
wrapper-class="mt-6 flex grow justify-center items-center"
input-class="py-2 px-4 w-full h-14 text-xl rounded-xl select-none"
variant="submit"
type="submit"
:disabled="isDisabled"
>
{{ $t('Sign in') }}
</FormKit>
</template>
</Form>
<LoginHeader :title="loginPageTitle" />
<LoginCredentialsForm
v-if="loginFlow.state === 'credentials' && showPasswordLogin"
@ask-two-factor="askTwoFactor"
@error="showError"
@finish="finishLogin"
/>
<LoginTwoFactor
v-else-if="
loginFlow.state === '2fa' &&
twoFactorPlugin &&
loginFlow.credentials
"
:credentials="loginFlow.credentials"
:two-factor="twoFactorPlugin"
@error="showError"
@finish="finishLogin"
/>
<LoginTwoFactorMethods
v-else-if="loginFlow.state === '2fa-select'"
:methods="twoFactorAllowedMethods"
@select="updateSecondFactor"
/>
</div>
</div>
</main>
<LoginThirdParty v-if="hasEnabledProviders" :providers="enabledProviders" />
<LoginThirdParty
v-if="hasEnabledProviders && loginFlow.state === 'credentials'"
:providers="enabledProviders"
/>
<section v-if="!showPasswordLogin" class="mb-6 w-full max-w-md text-center">
<p>
{{
@ -260,6 +159,25 @@ const { forceDesktop } = useForceDesktop()
{{ $t('Request the password login here.') }}
</CommonLink>
</section>
<section
v-if="loginFlow.state === '2fa' && twoFactorAllowedMethods.length > 1"
>
{{ $t('Having problems?') }}
<button
class="cursor-pointer pb-2 font-semibold leading-4 text-gray"
@click.prevent="loginFlow.state = '2fa-select'"
>
{{ $t('Try another method') }}
</button>
</section>
<div
v-if="
loginFlow.state !== 'credentials' && twoFactorAllowedMethods.length <= 1
"
class="pb-2 font-medium leading-4 text-gray"
>
{{ $t('Contact the administrator if you have any problems logging in.') }}
</div>
<CommonLink
link="/#login"
class="font-medium leading-4 text-gray"
@ -282,31 +200,6 @@ const { forceDesktop } = useForceDesktop()
</CommonLink>
</template>
</nav>
<footer
class="mt-8 flex w-full max-w-md items-center justify-center border-t border-gray-600 py-2.5 align-middle font-medium leading-4 text-gray"
>
<CommonLink
v-if="application.hasCustomProductBranding"
link="https://zammad.org"
external
open-in-new-tab
class="ltr:mr-1 rtl:ml-1"
>
<img
:src="'/assets/images/icons/logo.svg'"
:alt="$t('Logo')"
class="h-6 w-6"
/>
</CommonLink>
<span class="ltr:mr-1 rtl:ml-1">{{ $t('Powered by') }}</span>
<CommonLink
link="https://zammad.org"
external
open-in-new-tab
class="font-semibold"
>
{{ $t('Zammad') }}
</CommonLink>
</footer>
<LoginFooter />
</div>
</template>

View file

@ -0,0 +1,54 @@
<!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { ref, nextTick, watch } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import LoginHeader from '../components/LoginHeader.vue'
import LoginFooter from '../components/LoginFooter.vue'
import { useAfterAuthPlugins } from '../after-auth/composable/useAfterAuthPlugins.ts'
const { currentPlugin, data } = useAfterAuthPlugins()
const finished = ref(false)
onBeforeRouteLeave(() => {
if (!finished.value) return false
})
watch(
() => currentPlugin.value?.name,
(name) => {
if (name) {
finished.value = false
}
},
)
const router = useRouter()
// TODO 2023-05-17 Sheremet V.A. - call a query to get a possible next after auth handler
const redirect = async (route: RouteLocationRaw) => {
finished.value = true
await nextTick()
return router.replace(route)
}
</script>
<template>
<div class="flex h-full min-h-screen flex-col items-center px-6 pb-4 pt-6">
<main data-test-id="loginAfterAuth" class="m-auto w-full max-w-md">
<div class="flex grow flex-col justify-center">
<div v-if="currentPlugin" class="my-5 grow">
<LoginHeader :title="currentPlugin.title" />
<component
:is="currentPlugin.component"
:data="data"
@redirect="redirect"
/>
</div>
</div>
</main>
<LoginFooter />
</div>
</template>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M16.3,2c-3.2,0-5.7,2.6-5.7,5.7c0,0.6,0.1,1.1,0.2,1.6l-8.5,8.4C2.1,17.9,2,18.1,2,18.4v2.7C2,21.6,2.4,22,2.9,22h1.4v-1 h2.2v-2.1h2.2v-2h2.2v-2.2h2.4l1.4-1.4c0.5,0.1,1,0.2,1.6,0.2c3.2,0,5.7-2.6,5.7-5.7S19.4,2,16.3,2z M18.2,7.2 c-0.8,0-1.4-0.6-1.4-1.4s0.6-1.4,1.4-1.4s1.4,0.6,1.4,1.4C19.7,6.5,19,7.2,18.2,7.2z" />
</svg>

After

Width:  |  Height:  |  Size: 410 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19.9,10.7V7.6c0-0.6-0.4-1-1-1h-2.8v-3c0-0.9-0.7-1.6-1.6-1.6H5.7C4.8,2,4.1,2.7,4.1,3.6v16.7c0,0.9,0.7,1.6,1.6,1.6h8.7 c0.9,0,1.6-0.7,1.6-1.6v-8.7h2.8C19.5,11.7,19.9,11.2,19.9,10.7z M14.6,18.7H5.5V5.3h9.1v1.3h-4.4c-0.6,0-1,0.4-1,1v3.1 c0,0.6,0.4,1,1,1h4.4V18.7z M10.3,10.7V7.6h8.6v3.1H10.3z M15.6,9.2c0,0.5-0.4,0.9-0.9,0.9s-0.9-0.4-0.9-0.9s0.4-0.9,0.9-0.9 S15.6,8.7,15.6,9.2z M12.9,9.2c0,0.5-0.4,0.9-0.9,0.9c-0.5,0-0.9-0.4-0.9-0.9s0.4-0.9,0.9-0.9C12.4,8.3,12.9,8.7,12.9,9.2z M18.2,9.2c0,0.5-0.4,0.9-0.9,0.9c-0.5,0-0.9-0.4-0.9-0.9s0.4-0.9,0.9-0.9C17.7,8.3,18.2,8.7,18.2,9.2z" />
</svg>

After

Width:  |  Height:  |  Size: 677 B

View file

@ -66,4 +66,3 @@ const useNotifications = () => {
}
export { useNotifications }
export default useNotifications

View file

@ -6,4 +6,4 @@ export {
type NewNotification,
} from './types.ts'
export { default as useNotifications } from './composable.ts'
export { useNotifications } from './composable.ts'

View file

@ -82,7 +82,7 @@ export const useThirdPartyAuthentication = () => {
})
return {
enabledProviders: enabledProviders.value,
hasEnabledProviders: enabledProviders.value.length > 0,
enabledProviders,
hasEnabledProviders: computed(() => enabledProviders.value.length > 0),
}
}

View file

@ -0,0 +1,22 @@
// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
import { keyBy } from 'lodash-es'
import type { TwoFactorPlugin } from '../types.ts'
const pluginsModules = import.meta.glob<TwoFactorPlugin>('../plugins/*.ts', {
eager: true,
import: 'default',
})
const pluginsFiles = Object.values(pluginsModules).sort(
(p1, p2) => p1.order - p2.order,
)
const plugins = keyBy(pluginsFiles, 'name')
export const useTwoFactorPlugins = () => {
return {
twoFactorMethods: pluginsFiles,
twoFactorPlugins: plugins,
}
}

View file

@ -0,0 +1,14 @@
// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
import { EnumTwoFactorMethod } from '#shared/graphql/types.ts'
import type { TwoFactorPlugin } from '../types.ts'
export default {
name: EnumTwoFactorMethod.AuthenticatorApp,
label: __('Authenticator App'),
order: 200,
helpMessage: __('Enter the code from your two-factor authenticator app.'),
icon: {
mobile: 'mobile-authenticator-app',
},
} satisfies TwoFactorPlugin

View file

@ -0,0 +1,12 @@
// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
import type { EnumTwoFactorMethod } from '#shared/graphql/types.ts'
import type { AppSpecificRecord } from '#shared/types/app.ts'
export interface TwoFactorPlugin {
name: EnumTwoFactorMethod
label: string
order: number
icon: AppSpecificRecord<string>
helpMessage?: string
}

View file

@ -9,10 +9,20 @@ export type ReactiveFunction<TParam> = () => TParam;
export const LoginDocument = gql`
mutation login($input: LoginInput!) {
login(input: $input) {
sessionId
session {
id
afterAuth {
type
data
}
}
errors {
...errors
}
twoFactorRequired {
availableTwoFactorMethods
defaultTwoFactorMethod
}
}
}
${ErrorsFragmentDoc}`;

View file

@ -1,8 +1,18 @@
mutation login($input: LoginInput!) {
login(input: $input) {
sessionId
session {
id
afterAuth {
type
data
}
}
errors {
...errors
}
twoFactorRequired {
availableTwoFactorMethods
defaultTwoFactorMethod
}
}
}

View file

@ -0,0 +1,25 @@
import * as Types from '../types';
import gql from 'graphql-tag';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const SessionDocument = gql`
query session {
session {
id
afterAuth {
type
data
}
}
}
`;
export function useSessionQuery(options: VueApolloComposable.UseQueryOptions<Types.SessionQuery, Types.SessionQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.SessionQuery, Types.SessionQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.SessionQuery, Types.SessionQueryVariables>> = {}) {
return VueApolloComposable.useQuery<Types.SessionQuery, Types.SessionQueryVariables>(SessionDocument, {}, options);
}
export function useSessionLazyQuery(options: VueApolloComposable.UseQueryOptions<Types.SessionQuery, Types.SessionQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.SessionQuery, Types.SessionQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.SessionQuery, Types.SessionQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<Types.SessionQuery, Types.SessionQueryVariables>(SessionDocument, {}, options);
}
export type SessionQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<Types.SessionQuery, Types.SessionQueryVariables>;

View file

@ -0,0 +1,9 @@
query session {
session {
id
afterAuth {
type
data
}
}
}

View file

@ -1,19 +0,0 @@
import * as Types from '../types';
import gql from 'graphql-tag';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;
export const SessionIdDocument = gql`
query sessionId {
sessionId
}
`;
export function useSessionIdQuery(options: VueApolloComposable.UseQueryOptions<Types.SessionIdQuery, Types.SessionIdQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.SessionIdQuery, Types.SessionIdQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.SessionIdQuery, Types.SessionIdQueryVariables>> = {}) {
return VueApolloComposable.useQuery<Types.SessionIdQuery, Types.SessionIdQueryVariables>(SessionIdDocument, {}, options);
}
export function useSessionIdLazyQuery(options: VueApolloComposable.UseQueryOptions<Types.SessionIdQuery, Types.SessionIdQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.SessionIdQuery, Types.SessionIdQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.SessionIdQuery, Types.SessionIdQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<Types.SessionIdQuery, Types.SessionIdQueryVariables>(SessionIdDocument, {}, options);
}
export type SessionIdQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<Types.SessionIdQuery, Types.SessionIdQueryVariables>;

View file

@ -1,3 +0,0 @@
query sessionId {
sessionId
}

View file

@ -249,6 +249,12 @@ export type EmailAddress = {
name?: Maybe<Scalars['String']>;
};
/** Possible AfterAuth message types */
export enum EnumAfterAuthType {
/** TwoFactorConfiguration */
TwoFactorConfiguration = 'TwoFactorConfiguration'
}
/** Possible AppVersion messages */
export enum EnumAppMaintenanceType {
/** A new version of the app is available. */
@ -335,6 +341,12 @@ export enum EnumTicketStateColorCode {
Pending = 'pending'
}
/** Possible two factor authentication methods (availability depends on system configuration) */
export enum EnumTwoFactorMethod {
/** Authenticator App */
AuthenticatorApp = 'authenticator_app'
}
/** User contact option */
export enum EnumUserContact {
/** User email address */
@ -639,6 +651,10 @@ export type LoginInput = {
password: Scalars['String'];
/** Remember me - Session expire date will be set to one year */
rememberMe?: InputMaybe<Scalars['Boolean']>;
/** Two factor authentication method */
twoFactorMethod?: InputMaybe<EnumTwoFactorMethod>;
/** Two factor authentication token */
twoFactorPayload?: InputMaybe<Scalars['String']>;
};
/** Autogenerated return type of Login. */
@ -646,8 +662,10 @@ export type LoginPayload = {
__typename?: 'LoginPayload';
/** Errors encountered during execution of the mutation. */
errors?: Maybe<Array<UserError>>;
/** The current session */
sessionId?: Maybe<Scalars['String']>;
/** The current session, if the login was successful. */
session?: Maybe<Session>;
/** Two factor authentication methods available for the user about to log-in. */
twoFactorRequired?: Maybe<UserTwoFactorMethods>;
};
/** Autogenerated return type of Logout. */
@ -1341,7 +1359,7 @@ export type Queries = {
/** Generic object search */
search: Array<SearchResult>;
/** The sessionId of the currently authenticated user. */
sessionId: Scalars['String'];
session: Session;
/** Search for text modules and return them with variable interpolation */
textModuleSuggestions: Array<TextModule>;
/** Fetch a ticket by ID */
@ -1538,6 +1556,20 @@ export type Role = {
/** Objects found by search */
export type SearchResult = Organization | Ticket | User;
/** Session of the currently logged-in user */
export type Session = {
__typename?: 'Session';
afterAuth?: Maybe<SessionAfterAuth>;
id: Scalars['String'];
};
/** After-authorization information for front ends */
export type SessionAfterAuth = {
__typename?: 'SessionAfterAuth';
data?: Maybe<Scalars['JSON']>;
type: EnumAfterAuthType;
};
/** Signature */
export type Signature = {
__typename?: 'Signature';
@ -2403,6 +2435,13 @@ export type UserPermission = {
names: Array<Scalars['String']>;
};
/** Two factor authentication methods available for the user about to log-in. */
export type UserTwoFactorMethods = {
__typename?: 'UserTwoFactorMethods';
availableTwoFactorMethods: Array<EnumTwoFactorMethod>;
defaultTwoFactorMethod?: Maybe<EnumTwoFactorMethod>;
};
/** Autogenerated return type of UserUpdate. */
export type UserUpdatePayload = {
__typename?: 'UserUpdatePayload';
@ -2864,7 +2903,7 @@ export type LoginMutationVariables = Exact<{
}>;
export type LoginMutation = { __typename?: 'Mutations', login?: { __typename?: 'LoginPayload', sessionId?: string | null, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null }> | null } | null };
export type LoginMutation = { __typename?: 'Mutations', login?: { __typename?: 'LoginPayload', session?: { __typename?: 'Session', id: string, afterAuth?: { __typename?: 'SessionAfterAuth', type: EnumAfterAuthType, data?: any | null } | null } | null, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null }> | null, twoFactorRequired?: { __typename?: 'UserTwoFactorMethods', availableTwoFactorMethods: Array<EnumTwoFactorMethod>, defaultTwoFactorMethod?: EnumTwoFactorMethod | null } | null } | null };
export type LogoutMutationVariables = Exact<{ [key: string]: never; }>;
@ -2898,10 +2937,10 @@ export type LocalesQueryVariables = Exact<{
export type LocalesQuery = { __typename?: 'Queries', locales: Array<{ __typename?: 'Locale', locale: string, alias?: string | null, name: string, dir: EnumTextDirection, active: boolean }> };
export type SessionIdQueryVariables = Exact<{ [key: string]: never; }>;
export type SessionQueryVariables = Exact<{ [key: string]: never; }>;
export type SessionIdQuery = { __typename?: 'Queries', sessionId: string };
export type SessionQuery = { __typename?: 'Queries', session: { __typename?: 'Session', id: string, afterAuth?: { __typename?: 'SessionAfterAuth', type: EnumAfterAuthType, data?: any | null } | null } };
export type TicketSignatureQueryVariables = Exact<{
groupId: Scalars['ID'];

View file

@ -9,6 +9,7 @@ import { useLogoutMutation } from '#shared/graphql/mutations/logout.api.ts'
import { clearApolloClientStore } from '#shared/server/apollo/client.ts'
import useFingerprint from '#shared/composables/useFingerprint.ts'
import testFlags from '#shared/utils/testFlags.ts'
import { type EnumTwoFactorMethod } from '#shared/graphql/types.ts'
import { useSessionStore } from './session.ts'
import { useApplicationStore } from './application.ts'
import { resetAndDisposeStores } from '.'
@ -63,7 +64,11 @@ export const useAuthenticationStore = defineStore(
login: string,
password: string,
rememberMe: boolean,
): Promise<void> => {
twoFactor?: {
method: EnumTwoFactorMethod
payload: string
},
) => {
const loginMutation = new MutationHandler(
useLoginMutation({
variables: {
@ -71,6 +76,8 @@ export const useAuthenticationStore = defineStore(
login,
password,
rememberMe,
twoFactorMethod: twoFactor?.method,
twoFactorPayload: twoFactor?.payload,
},
},
context: {
@ -87,7 +94,7 @@ export const useAuthenticationStore = defineStore(
return Promise.reject(result?.login?.errors)
}
const newSessionId = result.login?.sessionId || null
const newSessionId = result.login?.session?.id || null
if (newSessionId) {
const session = useSessionStore()
@ -99,7 +106,10 @@ export const useAuthenticationStore = defineStore(
session.initialized = true
}
return Promise.resolve()
return {
twoFactor: result.login?.twoFactorRequired,
afterAuth: result.login?.session?.afterAuth,
}
}
return {

View file

@ -3,7 +3,7 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { cloneDeep } from 'lodash-es'
import { useSessionIdLazyQuery } from '#shared/graphql/queries/sessionId.api.ts'
import { useSessionLazyQuery } from '#shared/graphql/queries/session.api.ts'
import { useCurrentUserLazyQuery } from '#shared/graphql/queries/currentUser.api.ts'
import {
QueryHandler,
@ -18,23 +18,24 @@ import type {
CurrentUserQueryVariables,
CurrentUserUpdatesSubscription,
CurrentUserUpdatesSubscriptionVariables,
SessionIdQuery,
SessionIdQueryVariables,
SessionAfterAuth,
SessionQuery,
SessionQueryVariables,
} from '#shared/graphql/types.ts'
import useFingerprint from '#shared/composables/useFingerprint.ts'
import testFlags from '#shared/utils/testFlags.ts'
import log from '#shared/utils/log.ts'
import { useLocaleStore } from './locale.ts'
let sessionIdQuery: QueryHandler<SessionIdQuery, SessionIdQueryVariables>
let sessionIdQuery: QueryHandler<SessionQuery, SessionQueryVariables>
const getSessionIdQuery = () => {
const getSessionQuery = () => {
if (sessionIdQuery) return sessionIdQuery
const { fingerprint } = useFingerprint()
sessionIdQuery = new QueryHandler(
useSessionIdLazyQuery({
useSessionLazyQuery({
fetchPolicy: 'network-only',
context: {
error: {
@ -69,14 +70,16 @@ export const useSessionStore = defineStore(
'session',
() => {
const id = ref<Maybe<string>>(null)
const afterAuth = ref<Maybe<SessionAfterAuth>>(null)
const initialized = ref(false)
const checkSession = async (): Promise<string | null> => {
const sessionIdQuery = getSessionIdQuery()
const { data: result } = await sessionIdQuery.query()
const sessionQuery = getSessionQuery()
const { data: result } = await sessionQuery.query()
// Refresh the current sessionId state.
id.value = result?.sessionId || null
id.value = result?.session.id || null
afterAuth.value = result?.session.afterAuth || null
return id.value
}
@ -102,7 +105,7 @@ export const useSessionStore = defineStore(
// Check if the locale is different, then a update is needed.
const locale = useLocaleStore()
const userLocale = user.value?.preferences?.locale as string
const userLocale = user.value?.preferences?.locale as string | undefined
if (
userLocale &&
@ -160,6 +163,7 @@ export const useSessionStore = defineStore(
return {
id,
afterAuth,
initialized,
checkSession,
user,

View file

@ -64,9 +64,10 @@ vi.stubGlobal('matchMedia', (media: string) => ({
}))
vi.mock('#shared/components/CommonNotifications/composable.ts', async () => {
const { default: originalUseNotifications } = await vi.importActual<any>(
'#shared/components/CommonNotifications/composable.ts',
)
const { useNotifications: originalUseNotifications } =
await vi.importActual<any>(
'#shared/components/CommonNotifications/composable.ts',
)
let notifications: any
const useNotifications = () => {
if (notifications) return notifications

View file

@ -6,7 +6,8 @@ module Gql::Mutations
argument :input, Gql::Types::Input::LoginInputType, 'Login input fields.'
field :session_id, String, description: 'The current session'
field :session, Gql::Types::SessionType, description: 'The current session, if the login was successful.'
field :two_factor_required, Gql::Types::UserTwoFactorMethodsType, description: 'Two factor authentication methods available for the user about to log-in.'
def self.authorize(...)
true
@ -14,24 +15,42 @@ module Gql::Mutations
# reimplementation of `authenticate_with_password`
def resolve(input:)
# Register user for subsequent auth checks.
user = authenticate(**input)
return unified_login_error if !user || !context[:current_user]
{ session_id: context[:controller].session.id }
authenticate(**input)
end
private
def authenticate(login:, password:, remember_me: false)
auth = Auth.new(login, password)
if !auth.valid?
return
def authenticate(login:, password:, two_factor_method: nil, two_factor_payload: nil, remember_me: false)
auth = Auth.new(login, password, two_factor_method:, two_factor_payload:)
begin
auth.valid!
rescue Auth::Error::TwoFactorRequired => e
return {
two_factor_required: {
default_two_factor_method: e.default_two_factor_method,
available_two_factor_methods: e.available_two_factor_methods,
}
}
rescue Auth::Error::Base => e
return error_response({ message: e.message })
end
user = auth&.user
create_session(auth&.user, remember_me)
authenticate_result
end
def authenticate_result
{
session: {
id: context[:controller].session.id,
after_auth: Auth::AfterAuth.run(context.current_user, context[:controller].session)
}
}
end
def create_session(user, remember_me)
context[:controller].session.delete(:switched_from_user_id)
# authentication_check_prerequesits is private
@ -39,21 +58,18 @@ module Gql::Mutations
context[:current_user] = user
initiate_session_for(user, remember_me)
user
end
def initiate_session_for(user, remember_me)
# TODO: Check if this can be moved to a central place, because it's also the same code in the sessions controller.
context[:controller].request.env['rack.session.options'][:expire_after] = 1.year if remember_me
context[:controller].session[:persistent] = true
initiate_session_data
user.activity_stream_log('session started', user.id, true)
end
def unified_login_error
error_response({
message: __('Login failed. Have you double-checked your credentials and completed the email verification step?')
})
def initiate_session_data
context[:controller].session[:persistent] = true
context[:controller].session[:authentication_type] = 'password'
end
end
end

View file

@ -0,0 +1,18 @@
# Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
module Gql::Queries
class Session < BaseQuery
description 'The sessionId of the currently authenticated user.'
type Gql::Types::SessionType, null: false
def resolve(...)
{
id: context[:sid],
after_auth: Auth::AfterAuth.run(context.current_user, context[:controller].session)
}
end
end
end

View file

@ -1,15 +0,0 @@
# Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
module Gql::Queries
class SessionId < BaseQuery
description 'The sessionId of the currently authenticated user.'
type String, null: false
def resolve(...)
context[:sid]
end
end
end

View file

@ -0,0 +1,11 @@
# Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
module Gql::Types::Enum
class AfterAuthTypeType < BaseEnum
description 'Possible AfterAuth message types'
Auth::AfterAuth.backends.each do |klass|
value klass.type, klass.type
end
end
end

View file

@ -0,0 +1,13 @@
# Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
module Gql::Types::Enum
class TwoFactorMethodType < BaseEnum
description 'Possible two factor authentication methods (availability depends on system configuration)'
Auth::TwoFactor::Method.descendants.each do |method|
instance = method.new
value instance.method_name, instance.method_name(human: true)
end
end
end

View file

@ -7,6 +7,8 @@ module Gql::Types::Input
argument :login, String, description: 'User name'
argument :password, String, description: 'Password'
argument :two_factor_method, Gql::Types::Enum::TwoFactorMethodType, required: false, description: 'Two factor authentication method'
argument :two_factor_payload, String, required: false, description: 'Two factor authentication token'
argument :remember_me, Boolean, required: false, description: 'Remember me - Session expire date will be set to one year'
end
end

View file

@ -0,0 +1,10 @@
# Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
module Gql::Types::Session
class AfterAuthType < Gql::Types::BaseObject
description 'After-authorization information for front ends'
field :type, Gql::Types::Enum::AfterAuthTypeType, null: false
field :data, GraphQL::Types::JSON
end
end

View file

@ -0,0 +1,10 @@
# Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
module Gql::Types
class SessionType < Gql::Types::BaseObject
description 'Session of the currently logged-in user'
field :id, String, null: false
field :after_auth, Gql::Types::Session::AfterAuthType
end
end

View file

@ -0,0 +1,11 @@
# Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
module Gql::Types
class UserTwoFactorMethodsType < Gql::Types::BaseObject
description 'Two factor authentication methods available for the user about to log-in.'
field :default_two_factor_method, Gql::Types::Enum::TwoFactorMethodType
field :available_two_factor_methods, [Gql::Types::Enum::TwoFactorMethodType], null: false
end
end

View file

@ -251,6 +251,7 @@ returns
attributes.except!('password', 'token', 'tokens', 'token_ids')
end
# overwrite this method in derived classes to filter attributes, e.g. app/models/user/assets.rb
def filter_unauthorized_attributes(attributes)
attributes
end

View file

@ -12,6 +12,7 @@ class User < ApplicationModel
include HasRoles
include HasObjectManagerAttributes
include HasTaskbars
include HasTwoFactor
include User::Assets
include User::Avatar
include User::Search
@ -61,7 +62,21 @@ class User < ApplicationModel
store :preferences
association_attributes_ignored :online_notifications, :templates, :taskbars, :user_devices, :chat_sessions, :cti_caller_ids, :text_modules, :customer_tickets, :owner_tickets, :created_recent_views, :chat_agents, :data_privacy_tasks, :overviews, :mentions
association_attributes_ignored :online_notifications,
:templates,
:taskbars,
:user_devices,
:chat_sessions,
:cti_caller_ids,
:text_modules,
:customer_tickets,
:owner_tickets,
:created_recent_views,
:chat_agents,
:data_privacy_tasks,
:overviews,
:mentions,
:permissions
activity_stream_permission 'admin.user'
@ -71,8 +86,6 @@ class User < ApplicationModel
:image_source,
:preferences
association_attributes_ignored :permissions
history_attributes_ignored :password,
:last_login,
:image,

View file

@ -77,6 +77,9 @@ returns
data = organization.assets(data)
end
# two-factor
local_attributes['two_factor_configured'] = two_factor_configured?
data[ app_model ][ id ] = local_attributes
# add organization

View file

@ -0,0 +1,62 @@
# Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
# Trigger GraphQL subscriptions on user changes.
module User::HasTwoFactor
extend ActiveSupport::Concern
included do
has_many :two_factor_preferences, dependent: :destroy
end
def auth_two_factor
@auth_two_factor ||= Auth::TwoFactor.new(self)
end
def two_factor_setup_required?
auth_two_factor.user_setup_required?
end
def two_factor_configured?
auth_two_factor.user_configured?
end
def two_factor_enabled_methods
auth_two_factor
.enabled_methods
.map do |method|
{
method: method.method_name,
configured: two_factor_method_configured?(method),
default: two_factor_method_default?(method),
# configuration_possible: method.configuration_possible?, # TODO: For the e-mail/sms method (like a health check), for later.
}
end
end
def two_factor_destroy_method(method_name)
auth_two_factor
.method_object(method_name)
.destroy_user_config
end
def two_factor_destroy_all_methods
auth_two_factor.user_methods.each do |method|
auth_two_factor.method_object(method.method_name).destroy_user_config
end
end
def two_factor_verify_configuration?(method, payload, configuration)
auth_two_factor.verify_configuration?(method, payload, configuration)
end
private
def two_factor_method_configured?(method)
auth_two_factor.user_methods.include?(method)
end
def two_factor_method_default?(method)
auth_two_factor.user_methods.include?(method) && auth_two_factor.user_default_method == method
end
end

View file

@ -0,0 +1,9 @@
# Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
class User::TwoFactorPreference < ApplicationModel
include HasDefaultModelUserRelations
belongs_to :user, class_name: 'User', touch: true
store :configuration
end

View file

@ -0,0 +1,34 @@
# Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
class Controllers::User::TwoFactorsControllerPolicy < Controllers::ApplicationControllerPolicy
def two_factor_enabled_methods?
admin_access? || access?
end
def two_factor_remove_method?
admin_access? || access?
end
def two_factor_remove_all_methods?
admin_access? || access?
end
def two_factor_verify_configuration?
true
end
def two_factor_method_configuration?
true
end
private
def admin_access?
user.permissions?('admin.user')
end
def access?
record.params['id']&.to_i == user.id
end
end

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