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>
|
|
@ -15,4 +15,4 @@ paths = [
|
|||
'''^tmp/''',
|
||||
]
|
||||
regexTarget = "line"
|
||||
regexes = []
|
||||
regexes = []
|
||||
|
|
|
|||
3
Gemfile
|
|
@ -93,6 +93,9 @@ end
|
|||
gem 'doorkeeper'
|
||||
gem 'oauth2'
|
||||
|
||||
# authentication - two factor
|
||||
gem 'rotp', require: false
|
||||
|
||||
# authentication - third party
|
||||
gem 'omniauth-rails_csrf_protection'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
|
|
@ -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'
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
class App.TwoFactorConfigurationMethodAuthenticatorApp extends App.TwoFactorConfigurationMethod
|
||||
methodModalClass: ->
|
||||
App.TwoFactorConfigurationModalAuthenticatorApp
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
||||
|
|
@ -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')
|
||||
865
app/assets/javascripts/app/lib/base/qrcodegen.js
Normal 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 = {}));
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 %>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
9
app/controllers/user/after_auth_controller.rb
Normal 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
|
||||
50
app/controllers/user/two_factors_controller.rb
Normal 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
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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?',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
10
app/frontend/apps/mobile/pages/login/after-auth/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
21
app/frontend/apps/mobile/pages/login/types/login.ts
Normal 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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -66,4 +66,3 @@ const useNotifications = () => {
|
|||
}
|
||||
|
||||
export { useNotifications }
|
||||
export default useNotifications
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ export {
|
|||
type NewNotification,
|
||||
} from './types.ts'
|
||||
|
||||
export { default as useNotifications } from './composable.ts'
|
||||
export { useNotifications } from './composable.ts'
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export const useThirdPartyAuthentication = () => {
|
|||
})
|
||||
|
||||
return {
|
||||
enabledProviders: enabledProviders.value,
|
||||
hasEnabledProviders: enabledProviders.value.length > 0,
|
||||
enabledProviders,
|
||||
hasEnabledProviders: computed(() => enabledProviders.value.length > 0),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
12
app/frontend/shared/entities/two-factor/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
mutation login($input: LoginInput!) {
|
||||
login(input: $input) {
|
||||
sessionId
|
||||
session {
|
||||
id
|
||||
afterAuth {
|
||||
type
|
||||
data
|
||||
}
|
||||
}
|
||||
errors {
|
||||
...errors
|
||||
}
|
||||
twoFactorRequired {
|
||||
availableTwoFactorMethods
|
||||
defaultTwoFactorMethod
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
app/frontend/shared/graphql/queries/session.api.ts
Normal 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>;
|
||||
9
app/frontend/shared/graphql/queries/session.graphql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
query session {
|
||||
session {
|
||||
id
|
||||
afterAuth {
|
||||
type
|
||||
data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
query sessionId {
|
||||
sessionId
|
||||
}
|
||||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
app/graphql/gql/queries/session.rb
Normal 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
|
||||
|
|
@ -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
|
||||
11
app/graphql/gql/types/enum/after_auth_type_type.rb
Normal 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
|
||||
13
app/graphql/gql/types/enum/two_factor_method_type.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
10
app/graphql/gql/types/session/after_auth_type.rb
Normal 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
|
||||
10
app/graphql/gql/types/session_type.rb
Normal 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
|
||||
11
app/graphql/gql/types/user_two_factor_methods_type.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
62
app/models/user/has_two_factor.rb
Normal 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
|
||||
9
app/models/user/two_factor_preference.rb
Normal 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
|
||||
|
|
@ -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
|
||||