29
0
mirror of https://github.com/joomla/joomla-cms.git synced 2024-06-25 23:02:55 +00:00

Multi-Factor Authentication (replaces Two Factor Authentication) (#37912)

* Captive TFA

Import YubiKey plugin

* Captive TFA

Prepare SQL for new plugins

* Captive TFA

Import Fixed plugin (EXAMPLE)

* Captive TFA

System plugin

* Captive TFA

Replace the two factor authentication integration in the core

* Captive TFA

Fix wrong SQL / table name

* Captive TFA

Use correct prefix in the TFA helper when getting config UI

* Captive TFA

Fix a whoopsie or four

* Captive TFA

Coffee has long stopped working

* Captive TFA

Format the Methods page

* Captive TFA

Fix wrong TFA method internal name

* Captive TFA

Make sure we get the right view in the controllers

* Captive TFA

Remove yet another integration of the legacy TFA

* Captive TFA

Automatic migration from old TFA upon first login

* Captive TFA

Frontend MVC

* Captive TFA

Frontend routing

* Captive TFA

Style the method select page

* Captive TFA

Missed a legacy integration which needs removal

* Captive TFA

Better format of the configuration UI in the profile page

* Captive TFA

Use language strings when migrating data from legacy TFA

* Captive TFA

Only show the prompt to add a TFA method if none is already added

* Captive TFA

YubiKey should allow entry batching

This means that you can authenticate with any registered
YubiKey in your user profile.

* Captive TFA

Replace Tfa::triggerEvent

* Captive TFA

Import WebAuthn plugin

* Captive TFA

Improve TFA behavior on non-HTML pages. Basically, block
them!

* Captive TFA

Replace alerts with Joomla messages

* Captive TFA

Move onUserAfterDelete code to the `joomla` user plugin

* Captive TFA

Remove the System - Two Factor Authentication plugin

Use a trait for the application and fold the rest of
the code into Joomla's core user plugin.

* Captive TFA

Remove accidental leftover references to loginguard

* Captive TFA

Import Code by Email plugin

* Captive TFA

Post-installation messages

* Captive TFA

Enable the TFA plugins on NEW installations

* Captive TFA

XML formatting

* Captive TFA

Language and grammar in comments

* Captive TFA

Rearrange XML attributes

* Captive TFA

Fix typo

* Captive TFA

Fix wrong language key name

* Captive TFA

Remove leftover legacy TFA options

* Captive TFA

Fix wrong CSS class

* Captive TFA

Merge the padding classes

* Captive TFA

This lang string should never have had a link

* Captive TFA

Hide the Key emoji from screen readers

* Captive TFA

Accessibility improvements

* Captive TFA

Accessibility improvements

* Captive TFA

Accessibility improvements

* Captive TFA

Accessibility improvements

* Captive TFA

Accessibility improvements

* Captive TFA

Accessibility improvements

* Captive TFA

Use “Two Factor Authentication” / TFA consistently

* Captive TFA

Tytytytypo

* Captive TFA

Fixed PHPCS issue unrelated to PR but reported by Drone nonetheless

* Captive TFA

Lang improvement

Co-authored-by: Brian Teeman <brian@teeman.net>

* Captive TFA

Lang improvement

Co-authored-by: Brian Teeman <brian@teeman.net>

* Captive TFA

Remove no longer valid plugin options

* Captive TFA

Typo in plugin path

* Captive TFA

Move TFA options in com_users config next to the
password options

* Captive TFA

Add Show Inline Help button to com_users' options page

* Captive TFA

Move loading static assets to the view template

See https://github.com/joomla/joomla-cms/pull/37356 for
the reasoning. This should REALLY have been documented
somewhere...

* Captive TFA

Fixed wrong plugin path

* Captive TFA

Language style guide

Co-authored-by: Brian Teeman <brian@teeman.net>

* Captive TFA

Language style guide

* SQL code style and consistency fixes

* Add "CAN FAIL" installer hint

* Change longtext to mediumtext

* Change longtext to mediumtext in update script

* No default value for method

* Use real null values for last_used

* Captive TFA

Fix JS linter errors

* Captive TFA

Fix PHPCS issues after merging @richard67 's PR

* Captive TFA

Update formatRelative to use JNEVER, simplifying the
code in the view templates.

* Captive TFA

Fix typo

* Captive TFA

Fix transcription error

* Captive TFA

Show correct TFA column in the backend Users page

* Captive TFA

Fix PHPCS errors in UsersModel unrelated to this PR

* Captive TFA

Add note about supported browsers in TOTP's link

* Captive TFA

Remove bogus ESLint notice about qrcode

* Captive TFA

Fix confusing prompt

* Captive TFA

Consistently change ->qn to ->quoteName

* Captive TFA

Strict equality check

* Captive TFA

Move setSiteTemplateStyle to the views

* Captive TFA

Rename regbackupcodes to regenerateBackupCodes

* Captive TFA

Rename dontshowthisagain to doNotShowThisAgain

* Captive TFA

Throw deprecated notices from deprecated methods

* Captive TFA

Strict comparison

* Captive TFA

Typo in comment

* Captive TFA

Rename TwoFactorAuthenticationAware to TwoFactorAuthenticationHandler

* Captive TFA

Fix comment typo

* Captive TFA

Remove variables from SQL when not necessary

* codestyle changes

* Renamed SiteTemplateAware to SiteTemplateTrait

Change made against feedback item https://github.com/joomla/joomla-cms/pull/37811#pullrequestreview-975749217 for pull request #37811 with title "Improved Two Factor Authentication".

Feedback item marked the SiteTemplateAware trait name and had the following content:

> Still ...Aware is not a good name for a trait, since it usually denotes interfaces

* Remove more instances of "2SV"

Per feedback item https://github.com/joomla/joomla-cms/pull/37811#discussion_r875012422

* s/Two Step Verification/Two Step Validation/

Per feedback item https://github.com/joomla/joomla-cms/pull/37811#discussion_r875013978

* Language style

Per feedback item https://github.com/joomla/joomla-cms/pull/37811#discussion_r875014752

* Remove unnecessary language string

* Remove redundant paragraph tags from PLG_TWOFACTORAUTH_EMAIL_XML_DESCRIPTION

Per feedback item https://github.com/joomla/joomla-cms/pull/37811#discussion_r875016433

* Remove redundant paragraph tags from PLG_TWOFACTORAUTH_EMAIL_XML_DESCRIPTION

Per feedback item https://github.com/joomla/joomla-cms/pull/37811#discussion_r875016433

The other file with the same language string I forgot to put in the previous commit.

* Remove the info tooltip in the methods list

Addresses feedback in https://github.com/joomla/joomla-cms/pull/37811#issuecomment-1128672899

* Simplify the TFA enabled / disabled message

Per feedback item https://github.com/joomla/joomla-cms/pull/37811#issuecomment-1128687773

* Fix layout of backup codes in methods list

Per feedback item https://github.com/joomla/joomla-cms/pull/37811#issuecomment-1129075315

* Fix mail message

Per https://github.com/joomla/joomla-cms/pull/37811#issuecomment-1129083232

* Confirm TFA method deletion

Per feedback item https://github.com/joomla/joomla-cms/pull/37811#issuecomment-1129077417

* Simplify code label in Email plugin

Per feedback https://github.com/joomla/joomla-cms/pull/37811#discussion_r875146855

We show short instructions above the field and the field label is simplified. Applied the same change to the Fixed plugin for consistency.

* Remove more dead code referencing the legacy TFA

* Use concrete events

This was the plan all along. Now that https://github.com/joomla/joomla-cms/pull/36578 is merged we can FINALLY do it!

* WebAuthn support for some Android devices and FIDO keys

Backported from https://github.com/joomla/joomla-cms/pull/37675

* Rename Tfa to Mfa

Ongoing process

* Move Joomla\CMS\Event\TwoFactor to Joomla\CMS\Event\MultiFactor

Ongoing process

* Two Factor Authentication => Multi-factor Authentication

Ongoing process

* `#__user_tfa` => `#__user_mfa`

Ongoing process

* twofactorauth => multifactorauth

Ongoing process

* Change the post-install message

Ongoing process

* Remove references to “second factor”

Ongoing process

* Remove the legacy TFA plugins

* I missed a few things

* I missed a few more things

* Wrong redirection from post-installation messages

Addresses https://github.com/joomla/joomla-cms/pull/37811#issuecomment-1130275542

* Fix NotifyActionLog expected event names

Addresses feedback item https://github.com/joomla/joomla-cms/pull/37811#issuecomment-1130288832

* Improve display of Last Used date

Addresses feedback item https://github.com/joomla/joomla-cms/pull/37811#issuecomment-1130290809

* MFA extension helper

moves the group to the correct alpha order in the array now that it doesnt begin with T

* Remove unused field

* Remove no longer used language strings

* Undo changes in old SQL scripts

* Improve layout and accessibility of the methods list page

Based on VoiceOver testing on macOS 12.4 and the feedback from https://github.com/joomla/joomla-cms/pull/37811#issuecomment-1130465382 and https://github.com/joomla/joomla-cms/pull/37811#issuecomment-1130480411

* Add missing options to plg_multifactorauth_email

* Sort lines alphabetically

Why not confuse the translators with out of order labels providing zero context to what they are translating? It's the One True Joomla Way...

* Add label to the One Time Emergency Password input

Per feedback item https://github.com/joomla/joomla-cms/pull/37811#issuecomment-1130488813

* Sort lines

* Fix PHPCS complaint

* Formatting of XML files

* Forgot to remove extra CSS class

* Apply suggestions from code review

Formatting, wrong copyright and version information

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Commit suggestions from code review

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Commit formatting suggestions from code review

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Commit formatting suggestions from code review

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Commit formatting suggestions from code review

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Commit formatting suggestions from code review

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Commit formatting suggestions from code review

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Commit formatting suggestions from code review

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Commit formatting suggestions from code review

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Commit formatting suggestions from code review

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Commit formatting suggestions from code review

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Update build/media_source/plg_multifactorauth_webauthn/js/webauthn.es6.js

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Fix update SQL

Feedback item https://github.com/joomla/joomla-cms/pull/37811#pullrequestreview-980749684

* Onboarding would result in a PHP exception

Feedback item https://github.com/joomla/joomla-cms/pull/37811#issuecomment-1133360971

* Make MFA plugins' publish state consistent between MySQL and PostgreSQL

Feedback item https://github.com/joomla/joomla-cms/pull/37811#pullrequestreview-980799768

* Update administrator/components/com_users/src/Controller/MethodsController.php

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Update administrator/components/com_admin/sql/updates/mysql/4.2.0-2022-05-15.sql

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Update administrator/components/com_admin/sql/updates/postgresql/4.2.0-2022-05-15.sql

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Update administrator/components/com_admin/sql/updates/mysql/4.2.0-2022-05-15.sql

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Update administrator/components/com_admin/sql/updates/postgresql/4.2.0-2022-05-15.sql

Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>

* Update administrator/components/com_admin/sql/updates/postgresql/4.2.0-2022-05-15.sql

Co-authored-by: Richard Fath <richard67@users.noreply.github.com>

* Restore obsolete language strings

Per discussion with @bembelimen

I had to rename One Time Emergency Passwords to Backup Codes so as not to make major changes to the obsolete language strings. Having them named One Time Emergency Passwords (OTEPs) was both misleading (they are not passwords, they are second factor authentication codes) and would collide with the `_OTEP_` component of language existing strings. Backup Codes is a good compromise, one that is also field tested for nearly seven years. So, there you go!

* Re-add the obsolete plugins' language files

Per discussion with @bembelimen

Yes, it's pointless, it looks wrong, it is what it is. At least I've put a header that this file needs to be removed.

* Remove no longer used twofactor field

* Rename CSS class to com-users-profile__multifactor

* Update administrator/language/en-GB/plg_multifactorauth_email.sys.ini

Co-authored-by: Brian Teeman <brian@teeman.net>

* Update administrator/language/en-GB/plg_multifactorauth_email.ini

Co-authored-by: Brian Teeman <brian@teeman.net>

* Update administrator/language/en-GB/plg_multifactorauth_email.ini

Co-authored-by: Brian Teeman <brian@teeman.net>

* Update administrator/language/en-GB/com_users.ini

Co-authored-by: Brian Teeman <brian@teeman.net>

* Update administrator/language/en-GB/com_users.ini

Co-authored-by: Brian Teeman <brian@teeman.net>

* Update administrator/language/en-GB/com_users.ini

Co-authored-by: Brian Teeman <brian@teeman.net>

* Update administrator/language/en-GB/com_users.ini

Co-authored-by: Brian Teeman <brian@teeman.net>

* Update administrator/language/en-GB/com_users.ini

Co-authored-by: Brian Teeman <brian@teeman.net>

* Accessibility improvement

* Improve language

* Change the heading level

* Fix case of extension registry file

Regression after renaming TFA to MFA

* Remove accidental double space after echo

* Remove BS3 leftovers

* Remove BS3 leftovers

* Remove BS3 leftovers

* Update administrator/components/com_users/tmpl/methods/list.php

Co-authored-by: Brian Teeman <brian@teeman.net>

* Update components/com_users/tmpl/methods/list.php

Co-authored-by: Brian Teeman <brian@teeman.net>

* PHP warnings when there are no MFA plugins enabled

* MFA onboarding was shown with no MFA plugins enabled

* Backup codes alert is narrower than page on super-wide screens

* Backup codes alert heading font size fix in backend

* Revert wording for JENFORCE_2FA_REDIRECT_MESSAGE

* Backend users without `core.manage` on com_users were blocked

They were blocked from setting up / manage their on MFA,
blocked from the onboarding page and blocked from the
captive login page.

* Onboarding in backend shouldn't have a Back button

* Improve layout of method add/edit page

* Remove unnecessary H5 tag from TOTP setup table

* Kill that bloody Back button with fire

* MFA WebAuthn: use Joomla.Text instead of Joomla.JText

* MFA WebAuthn: show meaningful error on HTTP

* MFA Email: more sensible email body

* MFA WebAuthn: must be able to edit the title

* MFA add/edit: remove placeholders, replace with help text

* Heading levels

We assume an H1 will already be output on the page. This is always true on Atum and never true on Cassiopeia — but very likely on real world sites's frontend templates. So it's a compromise which is at least better than the previous case of starting at h3 or h4.

* Editing a user would show the wrong interface

When editing a user other than ourselves we need to show the MFA editing interface for the user being edited, not the MFA editing interface for our own user.

* Refactor security checks

Now they are conforming to the original intention

* Add missing Group By to the SQL query

* Show MFA enabled when a legacy method is enabled

* Users: filter by MFA status

* Language clarification

* Move the frontend onboarding page header to the top

* User Options language clarification

* PostgreSQL installation SQL wasn't updated

* Adding periods to the end of lines of error messages you will never, ever see

* Remove a tab

* Remove another tab from a comment

* Typo removing junk

* Remove useless imports

* Busywork

* Typo in the INI file

* Align comment

* Remove redundant SQL for PostgreSQL

* Typo in labels' `for` attribute

* Move backup codes to the top of the page

* Mandatory and forbidden MFA was not taken into account

If only one group matched, due to typo.

* Show information when MFA is mandatory

* Make the buttons smaller

* The secondary button looks horrid in the frontend

* Redirect users to login page in the frontend

When they try to access a captive or methods / method page.

* MFA Email: fallback to standard mailer when the mail template isn't installed

* Delete backup codes when the last MFA method is deleted

* Use text inputs for TOTP

With the correct input box attributes

* Fix the buttons for WebAuthn

* Clarify language strings

* Use toolbar buttons in the backend

Except for screen size small and extra small. Over there we ALSO display the inline content buttons because the toolbar buttons are hidden behind an unintuitive gears icon.

JUST BECAUSE THE DEFAULT JOOMLA WAY IS TO USE A TOOLBAR IT DOES NOT MEAN THAT IT MAKES SENSE ALWAYS, EVERYWHERE. THE USER IS KING. WE SERVE THE USER, NOT OURSELVES!

* Change the icon classes

* Forgot to copy over the changes to the frontend

* Regression: configure existing authenticators

We used to set field_type to custom to make the code entry disappear. After the changes to the field type handling we need to instead set input_type to hidden.

* Backup codes should never become the default method automatically

* Improve methods list layout

Now it is more clear which methods are enabled and which are available.

* Use toolbar buttons in backend pages

Except when the screen size is extra small which is the point where the toolbar is hidden and the interface becomes unintuitive.

* Fix return URLs for backend MFA edit pages

* Edit / Delete buttons mention the auth method name in the respective button's visually hidden text

* RTL aware back buttons

* Consistent use of the term Fixed Code

* Fix typo

Co-authored-by: Brian Teeman <brian@teeman.net>
Co-authored-by: Richard Fath <richard67@users.noreply.github.com>
Co-authored-by: heelc29 <66922325+heelc29@users.noreply.github.com>
This commit is contained in:
Nicholas K. Dionysopoulos 2022-06-04 10:01:40 +03:00 committed by GitHub
parent 3dba5567a6
commit 186c21b690
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
165 changed files with 13694 additions and 2203 deletions

View File

@ -7809,6 +7809,8 @@ class JoomlaInstallerScript
'/libraries/vendor/maximebf/debugbar/build',
// From 4.1 to 4.2.0
'/libraries/vendor/nyholm/psr7/doc',
'/plugins/twofactorauth/totp',
'/plugins/twofactorauth/yubikey',
);
$status['files_checked'] = $files;

View File

@ -0,0 +1,57 @@
--
-- Create the new table for MFA
--
CREATE TABLE IF NOT EXISTS `#__user_mfa` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int unsigned NOT NULL,
`title` varchar(255) NOT NULL DEFAULT '',
`method` varchar(100) NOT NULL,
`default` tinyint NOT NULL DEFAULT 0,
`options` mediumtext NOT NULL,
`created_on` datetime NOT NULL,
`last_used` datetime,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci COMMENT='Multi-factor Authentication settings';
--
-- Remove obsolete postinstallation message
--
DELETE FROM `#__postinstall_messages` WHERE `condition_file` = 'site://plugins/twofactorauth/totp/postinstall/actions.php';
--
-- Add new MFA plugins
--
INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, `client_id`, `enabled`, `access`, `protected`, `locked`, `manifest_cache`, `params`, `custom_data`, `ordering`, `state`) VALUES
(0, 'plg_multifactorauth_totp', 'plugin', 'totp', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 1, 0),
(0, 'plg_multifactorauth_yubikey', 'plugin', 'yubikey', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 2, 0),
(0, 'plg_multifactorauth_webauthn', 'plugin', 'webauthn', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 3, 0),
(0, 'plg_multifactorauth_email', 'plugin', 'email', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 4, 0),
(0, 'plg_multifactorauth_fixed', 'plugin', 'fixed', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 5, 0);
--
-- Update MFA plugins' publish status
--
UPDATE `#__extensions` AS `a`
INNER JOIN `#__extensions` AS `b` on `a`.`element` = `b`.`element`
SET `a`.enabled = `b`.enabled
WHERE `a`.folder = 'multifactorauth'
AND `b`.folder = 'twofactorauth';
--
-- Remove legacy TFA plugins
--
DELETE FROM `#__extensions`
WHERE `type` = 'plugin' AND `folder` = 'twofactorauth' AND `element` IN ('totp', 'yubikey');
--
-- Add post-installation message
--
INSERT IGNORE INTO `#__postinstall_messages` (`extension_id`, `title_key`, `description_key`, `action_key`, `language_extension`, `language_client_id`, `type`, `action_file`, `action`, `condition_file`, `condition_method`, `version_introduced`, `enabled`)
SELECT `extension_id`, 'COM_USERS_POSTINSTALL_MULTIFACTORAUTH_TITLE', 'COM_USERS_POSTINSTALL_MULTIFACTORAUTH_BODY', 'COM_USERS_POSTINSTALL_MULTIFACTORAUTH_ACTION', 'com_users', 1, 'action', 'admin://components/com_users/postinstall/multifactorauth.php', 'com_users_postinstall_mfa_action', 'admin://components/com_users/postinstall/multifactorauth.php', 'com_users_postinstall_mfa_condition', '4.2.0', 1 FROM `#__extensions` WHERE `name` = 'files_joomla';
--
-- Create a mail template for plg_multifactorauth_email
--
INSERT IGNORE INTO `#__mail_templates` (`template_id`, `extension`, `language`, `subject`, `body`, `htmlbody`, `attachments`, `params`) VALUES
('plg_multifactorauth_email.mail', 'plg_multifactorauth_email', '', 'PLG_MULTIFACTORAUTH_EMAIL_EMAIL_SUBJECT', 'PLG_MULTIFACTORAUTH_EMAIL_EMAIL_BODY', '', '', '{"tags":["code","sitename","siteurl","username","email","fullname"]}');

View File

@ -0,0 +1,63 @@
--
-- Create the new table for MFA
--
CREATE TABLE IF NOT EXISTS "#__user_mfa" (
"id" serial NOT NULL,
"user_id" bigint NOT NULL,
"title" varchar(255) DEFAULT '' NOT NULL,
"method" varchar(100) NOT NULL,
"default" smallint DEFAULT 0 NOT NULL,
"options" text NOT NULL,
"created_on" timestamp without time zone NOT NULL,
"last_used" timestamp without time zone,
PRIMARY KEY ("id")
);
CREATE INDEX "#__user_mfa_idx_user_id" ON "#__user_mfa" ("user_id") /** CAN FAIL **/;
COMMENT ON TABLE "#__user_mfa" IS 'Multi-factor Authentication settings';
--
-- Remove obsolete postinstallation message
--
DELETE FROM "#__postinstall_messages" WHERE "condition_file" = 'site://plugins/twofactorauth/totp/postinstall/actions.php';
--
-- Add new MFA plugins
--
INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", "client_id", "enabled", "access", "protected", "locked", "manifest_cache", "params", "custom_data", "ordering", "state") VALUES
(0, 'plg_multifactorauth_totp', 'plugin', 'totp', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 1, 0),
(0, 'plg_multifactorauth_yubikey', 'plugin', 'yubikey', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 2, 0),
(0, 'plg_multifactorauth_webauthn', 'plugin', 'webauthn', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 3, 0),
(0, 'plg_multifactorauth_email', 'plugin', 'email', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 4, 0),
(0, 'plg_multifactorauth_fixed', 'plugin', 'fixed', 'multifactorauth', 0, 0, 1, 0, 1, '', '', '', 5, 0);
--
-- Update MFA plugins' publish status
--
UPDATE "#__extensions" AS "a"
SET "enabled" = "b"."enabled"
FROM "#__extensions" AS "b"
WHERE "a"."element" = "b"."element"
AND "a"."folder" = 'multifactorauth'
AND "b"."folder" = 'twofactorauth';
--
-- Remove legacy TFA plugins
--
DELETE FROM "#__extensions"
WHERE "type" = 'plugin' AND "folder" = 'twofactorauth' AND "element" IN ('totp', 'yubikey');
--
-- Add post-installation message
--
INSERT INTO "#__postinstall_messages" ("extension_id", "title_key", "description_key", "action_key", "language_extension", "language_client_id", "type", "action_file", "action", "condition_file", "condition_method", "version_introduced", "enabled")
SELECT "extension_id", 'COM_USERS_POSTINSTALL_MULTIFACTORAUTH_TITLE', 'COM_USERS_POSTINSTALL_MULTIFACTORAUTH_BODY', 'COM_USERS_POSTINSTALL_MULTIFACTORAUTH_ACTION', 'com_users', 1, 'action', 'admin://components/com_users/postinstall/multifactorauth.php', 'com_users_postinstall_mfa_action', 'admin://components/com_users/postinstall/multifactorauth.php', 'com_users_postinstall_mfa_condition', '4.2.0', 1 FROM "#__extensions" WHERE "name" = 'files_joomla'
ON CONFLICT DO NOTHING;
--
-- Create a mail template for plg_multifactorauth_email
--
INSERT INTO "#__mail_templates" ("template_id", "extension", "language", "subject", "body", "htmlbody", "attachments", "params") VALUES
('plg_multifactorauth_email.mail', 'plg_multifactorauth_email', '', 'PLG_MULTIFACTORAUTH_EMAIL_EMAIL_SUBJECT', 'PLG_MULTIFACTORAUTH_EMAIL_EMAIL_BODY', '', '', '{"tags":["code","sitename","siteurl","username","email","fullname"]}')
ON CONFLICT DO NOTHING;

View File

@ -1484,7 +1484,7 @@ ENDDATA;
*
* @since 3.10.0
*/
public function getNonCorePlugins($folderFilter = ['system','user','authentication','actionlog','twofactorauth'])
public function getNonCorePlugins($folderFilter = ['system','user','authentication','actionlog','multifactorauth'])
{
$db = $this->getDbo();
$query = $db->getQuery(true);

View File

@ -19,8 +19,6 @@ use Joomla\CMS\Router\Route;
$wa = $this->document->getWebAssetManager();
$wa->useScript('keepalive');
$twofactormethods = AuthenticationHelper::getTwoFactorMethods();
?>
<div class="alert warning">
@ -63,21 +61,6 @@ $twofactormethods = AuthenticationHelper::getTwoFactorMethods();
</div>
</div>
</div>
<?php if (count($twofactormethods) > 1) : ?>
<div class="control-group">
<div class="controls">
<div class="input-group">
<input name="secretkey" autocomplete="one-time-code" id="mod-login-secretkey" type="text" class="form-control" placeholder="<?php echo Text::_('JGLOBAL_SECRETKEY'); ?>" size="15">
<span class="input-group-text" title="<?php echo Text::_('JGLOBAL_SECRETKEY_HELP'); ?>">
<span class="icon-star" aria-hidden="true"></span>
<label for="mod-login-secretkey" class="visually-hidden">
<?php echo Text::_('JGLOBAL_SECRETKEY'); ?>
</label>
</span>
</div>
</div>
</div>
<?php endif; ?>
<div class="control-group">
<div class="controls">
<div class="btn-group">

View File

@ -15,8 +15,6 @@ use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
$twofactormethods = AuthenticationHelper::getTwoFactorMethods();
/** @var Joomla\CMS\WebAsset\WebAssetManager $wa */
$wa = $this->document->getWebAssetManager();
$wa->useScript('core')
@ -67,21 +65,6 @@ Text::script('JHIDEPASSWORD');
</div>
</div>
</div>
<?php if (count($twofactormethods) > 1) : ?>
<div class="control-group">
<div class="controls">
<div class="input-group">
<input name="secretkey" autocomplete="one-time-code" id="mod-login-secretkey" type="text" class="form-control" placeholder="<?php echo Text::_('JGLOBAL_SECRETKEY'); ?>" size="15">
<span class="input-group-text" title="<?php echo Text::_('JGLOBAL_SECRETKEY_HELP'); ?>">
<span class="icon-star" aria-hidden="true"></span>
<label for="mod-login-secretkey" class="visually-hidden">
<?php echo Text::_('JGLOBAL_SECRETKEY'); ?>
</label>
</span>
</div>
</div>
</div>
<?php endif; ?>
<div class="control-group">
<div class="controls">
<a class="btn btn-danger" href="index.php?option=com_joomlaupdate">

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<config>
<help key="Users:_Options"/>
<inlinehelp button="show"/>
<fieldset
name="user_options"
label="COM_USERS_CONFIG_USER_OPTIONS" >
@ -109,33 +110,6 @@
<option value="0">JNO</option>
<option value="1">JYES</option>
</field>
<field
name="enforce_2fa_options"
type="list"
label="COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_LABEL"
description="COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_DESC"
default="0"
filter="integer"
validate="options"
>
<option value="0">JNO</option>
<option value="1">COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_SITE</option>
<option value="2">COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_ADMIN</option>
<option value="3">COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_BOTH</option>
</field>
<field
name="enforce_2fa_usergroups"
type="usergrouplist"
label="COM_USERS_CONFIG_FIELD_ENFORCE_2FA_GROUPS_LABEL"
layout="joomla.form.field.list-fancy-select"
multiple="true"
filter="int_array"
size="10"
showon="enforce_2fa_options!:0"
/>
</fieldset>
<fieldset
@ -238,6 +212,125 @@
/>
</fieldset>
<fieldset
name="multifactorauth"
label="COM_USERS_CONFIG_MULTIFACTORAUTH_SETTINGS_LABEL"
description="COM_USERS_CONFIG_MULTIFACTORAUTH_SETTINGS_DESC"
addfieldprefix="Joomla\Component\Users\Administrator\Field"
>
<field
name="allowed_positions_frontend"
type="modulesposition"
label="COM_USERS_CONFIG_ALLOWED_POSITIONS_FRONTEND_LABEL"
description="COM_USERS_CONFIG_ALLOWED_POSITIONS_FRONTEND_DESC"
default=""
layout="joomla.form.field.list-fancy-select"
client="site"
multiple="1"
/>
<field
name="frontend_show_title"
type="radio"
label="COM_USERS_CONFIG_FRONTEND_SHOW_TITLE_LABEL"
description="COM_USERS_CONFIG_FRONTEND_SHOW_TITLE_DESC"
layout="joomla.form.field.radio.switcher"
default="1"
>
<option value="0">JNO</option>
<option value="1">JYES</option>
</field>
<field
name="allowed_positions_backend"
type="modulesposition"
label="COM_USERS_CONFIG_ALLOWED_POSITIONS_BACKEND_LABEL"
description="COM_USERS_CONFIG_ALLOWED_POSITIONS_BACKEND_DESC"
default=""
layout="joomla.form.field.list-fancy-select"
client="administrator"
multiple="1"
/>
<field
name="neverMFAUserGroups"
type="UserGroupList"
label="COM_USERS_CONFIG_NEVERMFAUSERGROUPS_LABEL"
description="COM_USERS_CONFIG_NEVERMFAUSERGROUPS_DESC"
layout="joomla.form.field.list-fancy-select"
checksuperusergroup="1"
default=""
multiple="1"
>
<option value="0">COM_USERS_CONFIG_LBL_NOGROUP</option>
</field>
<field
name="forceMFAUserGroups"
type="UserGroupList"
label="COM_USERS_CONFIG_FORCEMFAUSERGROUPS_LABEL"
description="COM_USERS_CONFIG_FORCEMFAUSERGROUPS_DESC"
layout="joomla.form.field.list-fancy-select"
checksuperusergroup="1"
default=""
multiple="1"
>
<option value="0">COM_USERS_CONFIG_LBL_NOGROUP</option>
</field>
<field
name="captive_template"
type="templatestyle"
label="COM_USERS_CONFIG_FRONTEND_CAPTIVE_TEMPLATE_LABEL"
description="COM_USERS_CONFIG_FRONTEND_CAPTIVE_TEMPLATE_DESC"
client="site"
>
<option value="">JOPTION_USE_DEFAULT</option>
</field>
<field
name="mfaonsilent"
type="radio"
label="COM_USERS_CONFIG_MFAONSILENT_LABEL"
description="COM_USERS_CONFIG_MFAONSILENT_DESC"
layout="joomla.form.field.radio.switcher"
default="0"
>
<option value="0">JNO</option>
<option value="1">JYES</option>
</field>
<field
name="silentresponses"
type="text"
label="COM_USERS_CONFIG_SILENTRESPONSES_LABEL"
description="COM_USERS_CONFIG_SILENTRESPONSES_DESC"
default="cookie, passwordless"
showon="mfaonsilent:0"
/>
<field
name="mfaredirectonlogin"
type="radio"
label="COM_USERS_CONFIG_REDIRECTONLOGIN_LABEL"
description="COM_USERS_CONFIG_REDIRECTONLOGIN_DESC"
layout="joomla.form.field.radio.switcher"
default="0"
>
<option value="0">JNO</option>
<option value="1">JYES</option>
</field>
<field
name="mfaredirecturl"
type="text"
label="COM_USERS_CONFIG_REDIRECTURL_LABEL"
description="COM_USERS_CONFIG_REDIRECTURL_DESC"
default=""
showon="redirectonlogin:1"
/>
</fieldset>
<fieldset
name="user_notes_history"
label="COM_USERS_CONFIG_FIELD_NOTES_HISTORY" >

View File

@ -17,6 +17,16 @@
>
<option value="">COM_USERS_FILTER_STATE</option>
</field>
<field
name="mfa"
type="list"
label="COM_USERS_HEADING_MFA"
onchange="this.form.submit();"
>
<option value="">COM_USERS_FILTER_MFA</option>
<option value="1">JENABLED</option>
<option value="0">JDISABLED</option>
</field>
<field
name="active"
type="useractive"

View File

@ -135,7 +135,6 @@
</fieldset>
<field name="groups" type="hidden" />
<field name="twofactor" type="hidden" />
<fields name="params">

View File

@ -0,0 +1,57 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Database\DatabaseDriver;
use Joomla\Database\ParameterType;
/**
* Post-installation message about the new Multi-factor Authentication: condition check.
*
* Returns true if neither of the two new core MFA plugins are enabled.
*
* @return boolean
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
function com_users_postinstall_mfa_condition(): bool
{
return count(PluginHelper::getPlugin('multifactorauth')) < 1;
}
/**
* Post-installation message about the new Multi-factor Authentication: action.
*
* Enables the core MFA plugins.
*
* @return void
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
function com_users_postinstall_mfa_action(): void
{
/** @var DatabaseDriver $db */
$db = Factory::getContainer()->get('DatabaseDriver');
$coreMfaPlugins = ['email', 'totp', 'webauthn', 'yubikey'];
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('multifactorauth'))
->whereIn($db->quoteName('element'), $coreMfaPlugins, ParameterType::STRING);
$db->setQuery($query);
$db->execute();
$url = 'index.php?option=com_plugins&filter[folder]=multifactorauth';
Factory::getApplication()->redirect($url);
}

View File

@ -0,0 +1,78 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\Controller;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Event\MultiFactor\Callback;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Input\Input;
use RuntimeException;
/**
* Multi-factor Authentication plugins' AJAX callback controller
*
* @since __DEPLOY_VERSION__
*/
class CallbackController extends BaseController
{
/**
* Public constructor
*
* @param array $config Plugin configuration
* @param MVCFactoryInterface|null $factory MVC Factory for the com_users component
* @param CMSApplication|null $app CMS application object
* @param Input|null $input Joomla CMS input object
*
* @since __DEPLOY_VERSION__
*/
public function __construct(array $config = [], MVCFactoryInterface $factory = null, ?CMSApplication $app = null, ?Input $input = null)
{
parent::__construct($config, $factory, $app, $input);
$this->registerDefaultTask('callback');
}
/**
* Implement a callback feature, typically used for OAuth2 authentication
*
* @param bool $cachable Can this view be cached
* @param array|bool $urlparams An array of safe url parameters and their variable types, for valid values see
* {@link JFilterInput::clean()}.
*
* @return void
* @since __DEPLOY_VERSION__
*/
public function callback($cachable = false, $urlparams = false): void
{
$app = $this->app;
// Get the Method and make sure it's non-empty
$method = $this->input->getCmd('method', '');
if (empty($method))
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
PluginHelper::importPlugin('multifactorauth');
$event = new Callback($method);
$this->app->getDispatcher()->dispatch($event->getName(), $event);
/**
* The first plugin to handle the request should either redirect or close the application. If we are still here
* no plugin handled the request successfully. Show an error.
*/
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
}

View File

@ -0,0 +1,239 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\Controller;
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Date\Date;
use Joomla\CMS\Event\GenericEvent;
use Joomla\CMS\Event\MultiFactor\NotifyActionLog;
use Joomla\CMS\Event\MultiFactor\Validate;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\Model\BackupcodesModel;
use Joomla\Component\Users\Administrator\Model\CaptiveModel;
use Joomla\Input\Input;
use ReflectionObject;
use RuntimeException;
/**
* Captive Multi-factor Authentication page controller
*
* @since __DEPLOY_VERSION__
*/
class CaptiveController extends BaseController
{
/**
* Public constructor
*
* @param array $config Plugin configuration
* @param MVCFactoryInterface|null $factory MVC Factory for the com_users component
* @param CMSApplication|null $app CMS application object
* @param Input|null $input Joomla CMS input object
*
* @since __DEPLOY_VERSION__
*/
public function __construct(array $config = [], MVCFactoryInterface $factory = null, ?CMSApplication $app = null, ?Input $input = null)
{
parent::__construct($config, $factory, $app, $input);
$this->registerTask('captive', 'display');
}
/**
* Displays the captive login page
*
* @param boolean $cachable Ignored. This page is never cached.
* @param boolean|array $urlparams Ignored. This page is never cached.
*
* @return void
* @throws Exception
* @since __DEPLOY_VERSION__
*/
public function display($cachable = false, $urlparams = false): void
{
$user = $this->app->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
// Only allow logged in Users
if ($user->guest)
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
// Get the view object
$viewLayout = $this->input->get('layout', 'default', 'string');
$view = $this->getView('Captive', 'html', '',
[
'base_path' => $this->basePath,
'layout' => $viewLayout,
]
);
$view->document = $this->app->getDocument();
// If we're already logged in go to the site's home page
if ((int) $this->app->getSession()->get('com_users.mfa_checked', 0) === 1)
{
$url = Route::_('index.php?option=com_users&task=methods.display', false);
$this->setRedirect($url);
}
// Pass the model to the view
/** @var CaptiveModel $model */
$model = $this->getModel('Captive');
$view->setModel($model, true);
/** @var BackupcodesModel $codesModel */
$codesModel = $this->getModel('Backupcodes');
$view->setModel($codesModel, false);
try
{
// Suppress all modules on the page except those explicitly allowed
$model->suppressAllModules();
}
catch (Exception $e)
{
// If we can't kill the modules we can still survive.
}
// Pass the MFA record ID to the model
$recordId = $this->input->getInt('record_id', null);
$model->setState('record_id', $recordId);
// Do not go through $this->display() because it overrides the model.
$view->display();
}
/**
* Validate the MFA code entered by the user
*
* @param bool $cachable Ignored. This page is never cached.
* @param array $urlparameters Ignored. This page is never cached.
*
* @return void
* @throws Exception
* @since __DEPLOY_VERSION__
*/
public function validate($cachable = false, $urlparameters = [])
{
// CSRF Check
$this->checkToken($this->input->getMethod());
// Get the MFA parameters from the request
$recordId = $this->input->getInt('record_id', null);
$code = $this->input->get('code', null, 'raw');
/** @var CaptiveModel $model */
$model = $this->getModel('Captive');
// Validate the MFA record
$model->setState('record_id', $recordId);
$record = $model->getRecord();
if (empty($record))
{
$event = new NotifyActionLog('onComUsersCaptiveValidateInvalidMethod');
$this->app->getDispatcher()->dispatch($event->getName(), $event);
throw new RuntimeException(Text::_('COM_USERS_MFA_INVALID_METHOD'), 500);
}
// Validate the code
$user = $this->app->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
$event = new Validate($record, $user, $code);
$results = $this->app
->getDispatcher()
->dispatch($event->getName(), $event)
->getArgument('result', []);
$isValidCode = false;
if ($record->method === 'backupcodes')
{
/** @var BackupcodesModel $codesModel */
$codesModel = $this->getModel('Backupcodes');
$results = [$codesModel->isBackupCode($code, $user)];
/**
* This is required! Do not remove!
*
* There is a store() call below. It saves the in-memory MFA record to the database. That includes the
* options key which contains the configuration of the Method. For backup codes, these are the actual codes
* you can use. When we check for a backup code validity we also "burn" it, i.e. we remove it from the
* options table and save that to the database. However, this DOES NOT update the $record here. Therefore
* the call to saveRecord() would overwrite the database contents with a record that _includes_ the backup
* code we had just burned. As a result the single use backup codes end up being multiple use.
*
* By doing a getRecord() here, right after we have "burned" any correct backup codes, we resolve this
* issue. The loaded record will reflect the database contents where the options DO NOT include the code we
* just used. Therefore the call to store() will result in the correct database state, i.e. the used backup
* code being removed.
*/
$record = $model->getRecord();
}
$isValidCode = array_reduce(
$results,
function (bool $carry, $result)
{
return $carry || boolval($result);
},
false
);
if (!$isValidCode)
{
// The code is wrong. Display an error and go back.
$captiveURL = Route::_('index.php?option=com_users&view=captive&record_id=' . $recordId, false);
$message = Text::_('COM_USERS_MFA_INVALID_CODE');
$this->setRedirect($captiveURL, $message, 'error');
$event = new NotifyActionLog('onComUsersCaptiveValidateFailed', [$record->title]);
$this->app->getDispatcher()->dispatch($event->getName(), $event);
return;
}
// Update the Last Used, UA and IP columns
$jNow = Date::getInstance();
// phpcs:ignore
$record->last_used = $jNow->toSql();
$record->store();
// Flag the user as fully logged in
$session = $this->app->getSession();
$session->set('com_users.mfa_checked', 1);
$session->set('com_users.mandatory_mfa_setup', 0);
// Get the return URL stored by the plugin in the session
$returnUrl = $session->get('com_users.return_url', '');
// If the return URL is not set or not internal to this site redirect to the site's front page
if (empty($returnUrl) || !Uri::isInternal($returnUrl))
{
$returnUrl = Uri::base();
}
$this->setRedirect($returnUrl);
$event = new NotifyActionLog('onComUsersCaptiveValidateSuccess', [$record->title]);
$this->app->getDispatcher()->dispatch($event->getName(), $event);
}
}

View File

@ -131,6 +131,13 @@ class DisplayController extends BaseController
return false;
}
elseif (in_array($view, ['captive', 'callback', 'methods', 'method']))
{
$controller = $this->factory->createController($view, 'Administrator', [], $this->app, $this->input);
$task = $this->input->get('task', '');
return $controller->execute($task);
}
return parent::display($cachable, $urlparams);
}

View File

@ -0,0 +1,514 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\Controller;
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Event\GenericEvent;
use Joomla\CMS\Event\MultiFactor\NotifyActionLog;
use Joomla\CMS\Event\MultiFactor\SaveSetup;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController as BaseControllerAlias;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\Router\Route;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
use Joomla\Component\Users\Administrator\Model\BackupcodesModel;
use Joomla\Component\Users\Administrator\Model\MethodModel;
use Joomla\Component\Users\Administrator\Table\MfaTable;
use Joomla\Input\Input;
use RuntimeException;
/**
* Multi-factor Authentication method controller
*
* @since __DEPLOY_VERSION__
*/
class MethodController extends BaseControllerAlias
{
/**
* Public constructor
*
* @param array $config Plugin configuration
* @param MVCFactoryInterface|null $factory MVC Factory for the com_users component
* @param CMSApplication|null $app CMS application object
* @param Input|null $input Joomla CMS input object
*
* @since __DEPLOY_VERSION__
*/
public function __construct(array $config = [], MVCFactoryInterface $factory = null, ?CMSApplication $app = null, ?Input $input = null)
{
// We have to tell Joomla what is the name of the view, otherwise it defaults to the name of the *component*.
$config['default_view'] = 'method';
$config['default_task'] = 'add';
parent::__construct($config, $factory, $app, $input);
}
/**
* Execute a task by triggering a Method in the derived class.
*
* @param string $task The task to perform. If no matching task is found, the '__default' task is executed, if
* defined.
*
* @return mixed The value returned by the called Method.
*
* @throws Exception
* @since __DEPLOY_VERSION__
*/
public function execute($task)
{
if (empty($task) || $task === 'display')
{
$task = 'add';
}
return parent::execute($task);
}
/**
* Add a new MFA Method
*
* @param boolean $cachable Ignored. This page is never cached.
* @param boolean|array $urlparams Ignored. This page is never cached.
*
* @return void
* @throws Exception
* @since __DEPLOY_VERSION__
*/
public function add($cachable = false, $urlparams = []): void
{
$this->assertLoggedInUser();
// Make sure I am allowed to edit the specified user
$userId = $this->input->getInt('user_id', null);
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
$this->assertCanEdit($user);
// Also make sure the Method really does exist
$method = $this->input->getCmd('method');
$this->assertMethodExists($method);
/** @var MethodModel $model */
$model = $this->getModel('Method');
$model->setState('method', $method);
// Pass the return URL to the view
$returnURL = $this->input->getBase64('returnurl');
$viewLayout = $this->input->get('layout', 'default', 'string');
$view = $this->getView('Method', 'html');
$view->setLayout($viewLayout);
$view->returnURL = $returnURL;
$view->user = $user;
$view->document = $this->app->getDocument();
$view->setModel($model, true);
$event = new NotifyActionLog('onComUsersControllerMethodBeforeAdd', [$user, $method]);
$this->app->getDispatcher()->dispatch($event->getName(), $event);
$view->display();
}
/**
* Edit an existing MFA Method
*
* @param boolean $cachable Ignored. This page is never cached.
* @param boolean|array $urlparams Ignored. This page is never cached.
*
* @return void
* @throws Exception
* @since __DEPLOY_VERSION__
*/
public function edit($cachable = false, $urlparams = []): void
{
$this->assertLoggedInUser();
// Make sure I am allowed to edit the specified user
$userId = $this->input->getInt('user_id', null);
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
$this->assertCanEdit($user);
// Also make sure the Method really does exist
$id = $this->input->getInt('id');
$record = $this->assertValidRecordId($id, $user);
if ($id <= 0)
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
/** @var MethodModel $model */
$model = $this->getModel('Method');
$model->setState('id', $id);
// Pass the return URL to the view
$returnURL = $this->input->getBase64('returnurl');
$viewLayout = $this->input->get('layout', 'default', 'string');
$view = $this->getView('Method', 'html');
$view->setLayout($viewLayout);
$view->returnURL = $returnURL;
$view->user = $user;
$view->document = $this->app->getDocument();
$view->setModel($model, true);
$event = new NotifyActionLog('onComUsersControllerMethodBeforeEdit', [$id, $user]);
$this->app->getDispatcher()->dispatch($event->getName(), $event);
$view->display();
}
/**
* Regenerate backup codes
*
* @param boolean $cachable Ignored. This page is never cached.
* @param boolean|array $urlparams Ignored. This page is never cached.
*
* @return void
* @throws Exception
* @since __DEPLOY_VERSION__
*/
public function regenerateBackupCodes($cachable = false, $urlparams = []): void
{
$this->assertLoggedInUser();
$this->checkToken($this->input->getMethod());
// Make sure I am allowed to edit the specified user
$userId = $this->input->getInt('user_id', null);
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
$this->assertCanEdit($user);
/** @var BackupcodesModel $model */
$model = $this->getModel('Backupcodes');
$model->regenerateBackupCodes($user);
$backupCodesRecord = $model->getBackupCodesRecord($user);
// Redirect
$redirectUrl = 'index.php?option=com_users&task=method.edit&user_id=' . $userId . '&id=' . $backupCodesRecord->id;
$returnURL = $this->input->getBase64('returnurl');
if (!empty($returnURL))
{
$redirectUrl .= '&returnurl=' . $returnURL;
}
$this->setRedirect(Route::_($redirectUrl, false));
$event = new NotifyActionLog('onComUsersControllerMethodAfterRegenerateBackupCodes');
$this->app->getDispatcher()->dispatch($event->getName(), $event);
}
/**
* Delete an existing MFA Method
*
* @param boolean $cachable Ignored. This page is never cached.
* @param boolean|array $urlparams Ignored. This page is never cached.
*
* @return void
* @since __DEPLOY_VERSION__
*/
public function delete($cachable = false, $urlparams = []): void
{
$this->assertLoggedInUser();
$this->checkToken($this->input->getMethod());
// Make sure I am allowed to edit the specified user
$userId = $this->input->getInt('user_id', null);
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
$this->assertCanDelete($user);
// Also make sure the Method really does exist
$id = $this->input->getInt('id');
$record = $this->assertValidRecordId($id, $user);
if ($id <= 0)
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
$type = null;
$message = null;
$event = new NotifyActionLog('onComUsersControllerMethodBeforeDelete', [$id, $user]);
$this->app->getDispatcher()->dispatch($event->getName(), $event);
try
{
$record->delete();
}
catch (Exception $e)
{
$message = $e->getMessage();
$type = 'error';
}
// Redirect
$url = Route::_('index.php?option=com_users&task=methods.display&user_id=' . $userId, false);
$returnURL = $this->input->getBase64('returnurl');
if (!empty($returnURL))
{
$url = base64_decode($returnURL);
}
$this->setRedirect($url, $message, $type);
}
/**
* Save the MFA Method
*
* @param boolean $cachable Ignored. This page is never cached.
* @param boolean|array $urlparams Ignored. This page is never cached.
*
* @return void
* @since __DEPLOY_VERSION__
*/
public function save($cachable = false, $urlparams = []): void
{
$this->assertLoggedInUser();
$this->checkToken($this->input->getMethod());
// Make sure I am allowed to edit the specified user
$userId = $this->input->getInt('user_id', null);
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
$this->assertCanEdit($user);
// Redirect
$url = Route::_('index.php?option=com_users&task=methods.display&user_id=' . $userId, false);
$returnURL = $this->input->getBase64('returnurl');
if (!empty($returnURL))
{
$url = base64_decode($returnURL);
}
// The record must either be new (ID zero) or exist
$id = $this->input->getInt('id', 0);
$record = $this->assertValidRecordId($id, $user);
// If it's a new record we need to read the Method from the request and update the (not yet created) record.
if ($record->id == 0)
{
$methodName = $this->input->getCmd('method');
$this->assertMethodExists($methodName);
$record->method = $methodName;
}
/** @var MethodModel $model */
$model = $this->getModel('Method');
// Ask the plugin to validate the input by calling onUserMultifactorSaveSetup
$result = [];
$input = $this->app->input;
$event = new NotifyActionLog('onComUsersControllerMethodBeforeSave', [$id, $user]);
$this->app->getDispatcher()->dispatch($event->getName(), $event);
try
{
$event = new SaveSetup($record, $input);
$pluginResults = $this->app
->getDispatcher()
->dispatch($event->getName(), $event)
->getArgument('result', []);
foreach ($pluginResults as $pluginResult)
{
$result = array_merge($result, $pluginResult);
}
}
catch (RuntimeException $e)
{
// Go back to the edit page
$nonSefUrl = 'index.php?option=com_users&task=method.';
if ($id)
{
$nonSefUrl .= 'edit&id=' . (int) $id;
}
else
{
$nonSefUrl .= 'add&method=' . $record->method;
}
$nonSefUrl .= '&user_id=' . $userId;
if (!empty($returnURL))
{
$nonSefUrl .= '&returnurl=' . urlencode($returnURL);
}
$url = Route::_($nonSefUrl, false);
$this->setRedirect($url, $e->getMessage(), 'error');
return;
}
// Update the record's options with the plugin response
$title = $this->input->getString('title', null);
$title = trim($title);
if (empty($title))
{
$method = $model->getMethod($record->method);
$title = $method['display'];
}
// Update the record's "default" flag
$default = $this->input->getBool('default', false);
$record->title = $title;
$record->options = $result;
$record->default = $default ? 1 : 0;
// Ask the model to save the record
$saved = $record->store();
if (!$saved)
{
// Go back to the edit page
$nonSefUrl = 'index.php?option=com_users&task=method.';
if ($id)
{
$nonSefUrl .= 'edit&id=' . (int) $id;
}
else
{
$nonSefUrl .= 'add';
}
$nonSefUrl .= '&user_id=' . $userId;
if (!empty($returnURL))
{
$nonSefUrl .= '&returnurl=' . urlencode($returnURL);
}
$url = Route::_($nonSefUrl, false);
$this->setRedirect($url, $record->getError(), 'error');
return;
}
$this->setRedirect($url);
}
/**
* Assert that the provided ID is a valid record identified for the given user
*
* @param int $id Record ID to check
* @param User|null $user User record. Null to use current user.
*
* @return MfaTable The loaded record
* @since __DEPLOY_VERSION__
*/
private function assertValidRecordId($id, ?User $user = null): MfaTable
{
if (is_null($user))
{
$user = $this->app->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
}
/** @var MethodModel $model */
$model = $this->getModel('Method');
$model->setState('id', $id);
$record = $model->getRecord($user);
// phpcs:ignore
if (is_null($record) || ($record->id != $id) || ($record->user_id != $user->id))
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
return $record;
}
/**
* Assert that the user can add / edit MFA methods.
*
* @param User|null $user User record. Null to use current user.
*
* @return void
* @throws RuntimeException|Exception
* @since __DEPLOY_VERSION__
*/
private function assertCanEdit(?User $user = null): void
{
if (!MfaHelper::canAddEditMethod($user))
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
}
/**
* Assert that the user can delete MFA records / disable MFA.
*
* @param User|null $user User record. Null to use current user.
*
* @return void
* @throws RuntimeException|Exception
* @since __DEPLOY_VERSION__
*/
private function assertCanDelete(?User $user = null): void
{
if (!MfaHelper::canDeleteMethod($user))
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
}
/**
* Assert that the specified MFA Method exists, is activated and enabled for the current user
*
* @param string|null $method The Method to check
*
* @return void
* @since __DEPLOY_VERSION__
*/
private function assertMethodExists(?string $method): void
{
/** @var MethodModel $model */
$model = $this->getModel('Method');
if (empty($method) || !$model->methodExists($method))
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
}
/**
* Assert that there is a logged in user.
*
* @return void
* @since __DEPLOY_VERSION__
*/
private function assertLoggedInUser(): void
{
$user = $this->app->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
if ($user->guest)
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
}
}

View File

@ -0,0 +1,221 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\Controller;
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Event\GenericEvent;
use Joomla\CMS\Event\MultiFactor\NotifyActionLog;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
use Joomla\Component\Users\Administrator\Model\MethodsModel;
use Joomla\Input\Input;
use ReflectionObject;
use RuntimeException;
/**
* Multi-factor Authentication methods selection and management controller
*
* @since __DEPLOY_VERSION__
*/
class MethodsController extends BaseController
{
/**
* Public constructor
*
* @param array $config Plugin configuration
* @param MVCFactoryInterface|null $factory MVC Factory for the com_users component
* @param CMSApplication|null $app CMS application object
* @param Input|null $input Joomla CMS input object
*
* @since __DEPLOY_VERSION__
*/
public function __construct($config = [], MVCFactoryInterface $factory = null, ?CMSApplication $app = null, ?Input $input = null)
{
// We have to tell Joomla what is the name of the view, otherwise it defaults to the name of the *component*.
$config['default_view'] = 'Methods';
parent::__construct($config, $factory, $app, $input);
}
/**
* Disable Multi-factor Authentication for the current user
*
* @param bool $cachable Can this view be cached
* @param array $urlparams An array of safe url parameters and their variable types, for valid values see
* {@link JFilterInput::clean()}.
*
* @return void
* @since __DEPLOY_VERSION__
*/
public function disable($cachable = false, $urlparams = []): void
{
$this->assertLoggedInUser();
$this->checkToken($this->input->getMethod());
// Make sure I am allowed to edit the specified user
$userId = $this->input->getInt('user_id', null);
$user = ($userId === null)
? $this->app->getIdentity()
: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
$user = $user ?? Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
if (!MfaHelper::canDeleteMethod($user))
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
// Delete all MFA Methods for the user
/** @var MethodsModel $model */
$model = $this->getModel('Methods');
$type = null;
$message = null;
$event = new NotifyActionLog('onComUsersControllerMethodsBeforeDisable', [$user]);
$this->app->getDispatcher()->dispatch($event->getName(), $event);
try
{
$model->deleteAll($user);
}
catch (Exception $e)
{
$message = $e->getMessage();
$type = 'error';
}
// Redirect
// phpcs:ignore
$url = Route::_('index.php?option=com_users&task=methods.display&user_id=' . $userId, false);
$returnURL = $this->input->getBase64('returnurl');
if (!empty($returnURL))
{
$url = base64_decode($returnURL);
}
$this->setRedirect($url, $message, $type);
}
/**
* List all available Multi-factor Authentication Methods available and guide the user to setting them up
*
* @param bool $cachable Can this view be cached
* @param array $urlparams An array of safe url parameters and their variable types, for valid values see
* {@link JFilterInput::clean()}.
*
* @return void
* @since __DEPLOY_VERSION__
*/
public function display($cachable = false, $urlparams = []): void
{
$this->assertLoggedInUser();
// Make sure I am allowed to edit the specified user
$userId = $this->input->getInt('user_id', null);
$user = ($userId === null)
? $this->app->getIdentity()
: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
$user = $user ?? Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
if (!MfaHelper::canShowConfigurationInterface($user))
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
$returnURL = $this->input->getBase64('returnurl');
$viewLayout = $this->input->get('layout', 'default', 'string');
$view = $this->getView('Methods', 'html');
$view->setLayout($viewLayout);
$view->returnURL = $returnURL;
$view->user = $user;
$view->document = $this->app->getDocument();
$methodsModel = $this->getModel('Methods');
$view->setModel($methodsModel, true);
$backupCodesModel = $this->getModel('Backupcodes');
$view->setModel($backupCodesModel, false);
$view->display();
}
/**
* Disable Multi-factor Authentication for the current user
*
* @param bool $cachable Can this view be cached
* @param array $urlparams An array of safe url parameters and their variable types, for valid values see
* {@link JFilterInput::clean()}.
*
* @return void
* @since __DEPLOY_VERSION__
*/
public function doNotShowThisAgain($cachable = false, $urlparams = []): void
{
$this->assertLoggedInUser();
$this->checkToken($this->input->getMethod());
// Make sure I am allowed to edit the specified user
$userId = $this->input->getInt('user_id', null);
$user = ($userId === null)
? $this->app->getIdentity()
: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
$user = $user ?? Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
if (!MfaHelper::canAddEditMethod($user))
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
$event = new NotifyActionLog('onComUsersControllerMethodsBeforeDoNotShowThisAgain', [$user]);
$this->app->getDispatcher()->dispatch($event->getName(), $event);
/** @var MethodsModel $model */
$model = $this->getModel('Methods');
$model->setFlag($user, true);
// Redirect
$url = Uri::base();
$returnURL = $this->input->getBase64('returnurl');
if (!empty($returnURL))
{
$url = base64_decode($returnURL);
}
$this->setRedirect($url);
}
/**
* Assert that there is a user currently logged in
*
* @return void
* @since __DEPLOY_VERSION__
*/
private function assertLoggedInUser(): void
{
$user = $this->app->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
if ($user->guest)
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
}
}

View File

@ -0,0 +1,210 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\DataShape;
use InvalidArgumentException;
/**
* @property string $pre_message Custom HTML to display above the MFA form
* @property string $field_type How to render the MFA code field. "input" or "custom".
* @property string $input_type The type attribute for the HTML input box. Typically "text" or "password".
* @property string $placeholder Placeholder text for the HTML input box. Leave empty if you don't need it.
* @property string $label Label to show above the HTML input box. Leave empty if you don't need it.
* @property string $html Custom HTML. Only used when field_type = custom.
* @property string $post_message Custom HTML to display below the MFA form
* @property bool $hide_submit Should I hide the default Submit button?
* @property bool $allowEntryBatching Is this method validating against all configured authenticators of this type?
* @property string $help_url URL for help content
*
* @since __DEPLOY_VERSION__
*/
class CaptiveRenderOptions extends DataShapeObject
{
/**
* Display a standard HTML5 input field. Use the input_type, placeholder and label properties to set it up.
*
* @since __DEPLOY_VERSION__
*/
public const FIELD_INPUT = 'input';
/**
* Display a custom HTML document. Use the html property to set it up.
*
* @since __DEPLOY_VERSION__
*/
public const FIELD_CUSTOM = 'custom';
/**
* Custom HTML to display above the MFA form
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $pre_message = '';
/**
* How to render the MFA code field. "input" (HTML input element) or "custom" (custom HTML)
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $field_type = 'input';
/**
* The type attribute for the HTML input box. Typically "text" or "password". Use any HTML5 input type.
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $input_type = '';
/**
* Attributes other than type and id which will be added to the HTML input box.
*
* @var array
* @@since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $input_attributes = [];
/**
* Placeholder text for the HTML input box. Leave empty if you don't need it.
*
* @var string
* @since __DEPLOY_VERSION__
*/
protected $placeholder = '';
/**
* Label to show above the HTML input box. Leave empty if you don't need it.
*
* @var string
* @since __DEPLOY_VERSION__
*/
protected $label = '';
/**
* Custom HTML. Only used when field_type = custom.
*
* @var string
* @since __DEPLOY_VERSION__
*/
protected $html = '';
/**
* Custom HTML to display below the MFA form
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $post_message = '';
/**
* Should I hide the default Submit button?
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $hide_submit = false;
/**
* Additional CSS classes for the submit button (apply the MFA setup)
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $submit_class = '';
/**
* Icon class to use for the submit button
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $submit_icon = 'icon icon-rightarrow icon-arrow-right';
/**
* Language key to use for the text on the submit button
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $submit_text = 'COM_USERS_MFA_VALIDATE';
/**
* Is this MFA method validating against all configured authenticators of the same type?
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
protected $allowEntryBatching = true;
/**
* URL for help content
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $help_url = '';
/**
* Setter for the field_type property
*
* @param string $value One of self::FIELD_INPUT, self::FIELD_CUSTOM
*
* @since __DEPLOY_VERSION__
* @throws InvalidArgumentException
*/
// phpcs:ignore
protected function setField_type(string $value)
{
if (!in_array($value, [self::FIELD_INPUT, self::FIELD_CUSTOM]))
{
throw new InvalidArgumentException('Invalid value for property field_type.');
}
// phpcs:ignore
$this->field_type = $value;
}
/**
* Setter for the input_attributes property.
*
* @param array $value The value to set
*
* @return void
* @@since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected function setInput_attributes(array $value)
{
$forbiddenAttributes = ['id', 'type', 'name', 'value'];
foreach ($forbiddenAttributes as $key)
{
if (isset($value[$key]))
{
unset($value[$key]);
}
}
// phpcs:ignore
$this->input_attributes = $value;
}
}

View File

@ -0,0 +1,209 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\DataShape;
use InvalidArgumentException;
// This line is required because of the PHP 8 attributes which are necessary to prevent PHP notices
//phpcs:ignoreFile
/**
* Generic helper for handling data shapes in com_users
*
* @since __DEPLOY_VERSION__
*/
abstract class DataShapeObject implements \ArrayAccess
{
/**
* Public constructor
*
* @param array $array The data to initialise this object with
*
* @since __DEPLOY_VERSION__
*/
public function __construct(array $array = [])
{
if (!is_array($array) && !($array instanceof self))
{
throw new InvalidArgumentException(sprintf('%s needs an array or a %s object', __METHOD__, __CLASS__));
}
foreach (($array instanceof self) ? $array->asArray() : $array as $k => $v)
{
$this[$k] = $v;
}
}
/**
* Get the data shape as a key-value array
*
* @return array
*
* @since __DEPLOY_VERSION__
*/
public function asArray(): array
{
return get_object_vars($this);
}
/**
* Merge another data shape object or key-value array into this object.
*
* @param array|self $newValues The object or array to merge into self.
*
* @return $this
*
* @since __DEPLOY_VERSION__
*/
public function merge($newValues): self
{
if (!is_array($newValues) && !($newValues instanceof self))
{
throw new InvalidArgumentException(sprintf('%s needs an array or a %s object', __METHOD__, __CLASS__));
}
foreach (($newValues instanceof self) ? $newValues->asArray() : $newValues as $k => $v)
{
if (!isset($this->{$k}))
{
continue;
}
$this[$k] = $v;
}
return $this;
}
/**
* Magic getter
*
* @param string $name The name of the property to retrieve
*
* @return mixed
*
* @since __DEPLOY_VERSION__
*/
public function __get($name)
{
$methodName = 'get' . ucfirst($name);
if (method_exists($this, $methodName))
{
return $this->{$methodName};
}
if (property_exists($this, $name))
{
return $this->{$name};
}
throw new InvalidArgumentException(sprintf('Property %s not found in %s', $name, __CLASS__));
}
/**
* Magic Setter
*
* @param string $name The property to set the value for
* @param mixed $value The property value to set it to
*
* @return mixed
* @since __DEPLOY_VERSION__
*/
public function __set($name, $value)
{
$methodName = 'set' . ucfirst($name);
if (method_exists($this, $methodName))
{
return $this->{$methodName}($value);
}
if (property_exists($this, $name))
{
$this->{$name} = $value;
}
throw new InvalidArgumentException(sprintf('Property %s not found in %s', $name, __CLASS__));
}
/**
* Is a property set?
*
* @param string $name Property name
*
* @return boolean Does it exist in the object?
* @since __DEPLOY_VERSION__
*/
#[\ReturnTypeWillChange]
public function __isset($name)
{
$methodName = 'get' . ucfirst($name);
return method_exists($this, $methodName) || property_exists($this, $name);
}
/**
* Does the property exist (array access)?
*
* @param string $offset Property name
*
* @return boolean
* @since __DEPLOY_VERSION__
*/
#[\ReturnTypeWillChange]
public function offsetExists($offset)
{
return isset($this->{$offset});
}
/**
* Get the value of a property (array access).
*
* @param string $offset Property name
*
* @return mixed
* @since __DEPLOY_VERSION__
*/
#[\ReturnTypeWillChange]
public function offsetGet($offset)
{
return $this->{$offset};
}
/**
* Set the value of a property (array access).
*
* @param string $offset Property name
* @param mixed $value Property value
*
* @return void
* @since __DEPLOY_VERSION__
*/
#[\ReturnTypeWillChange]
public function offsetSet($offset, $value)
{
$this->{$offset} = $value;
}
/**
* Unset a property (array access).
*
* @param string $offset Property name
*
* @return mixed
* @since __DEPLOY_VERSION__
*/
#[\ReturnTypeWillChange]
public function offsetUnset($offset)
{
throw new \LogicException(sprintf('You cannot unset members of %s', __CLASS__));
}
}

View File

@ -0,0 +1,116 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\DataShape;
use Joomla\Component\Users\Administrator\Table\MfaTable;
/**
* @property string $name Internal code of this MFA Method
* @property string $display User-facing name for this MFA Method
* @property string $shortinfo Short description of this MFA Method displayed to the user
* @property string $image URL to the logo image for this Method
* @property bool $canDisable Are we allowed to disable it?
* @property bool $allowMultiple Are we allowed to have multiple instances of it per user?
* @property string $help_url URL for help content
* @property bool $allowEntryBatching Allow authentication against all entries of this MFA Method.
*
* @since __DEPLOY_VERSION__
*/
class MethodDescriptor extends DataShapeObject
{
/**
* Internal code of this MFA Method
*
* @var string
* @since __DEPLOY_VERSION__
*/
protected $name = '';
/**
* User-facing name for this MFA Method
*
* @var string
* @since __DEPLOY_VERSION__
*/
protected $display = '';
/**
* Short description of this MFA Method displayed to the user
*
* @var string
* @since __DEPLOY_VERSION__
*/
protected $shortinfo = '';
/**
* URL to the logo image for this Method
*
* @var string
* @since __DEPLOY_VERSION__
*/
protected $image = '';
/**
* Are we allowed to disable it?
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
protected $canDisable = true;
/**
* Are we allowed to have multiple instances of it per user?
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
protected $allowMultiple = false;
/**
* URL for help content
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $help_url = '';
/**
* Allow authentication against all entries of this MFA Method.
*
* Otherwise authentication takes place against a SPECIFIC entry at a time.
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
protected $allowEntryBatching = false;
/**
* Active authentication methods, used internally only
*
* @var MfaTable[]
* @since __DEPLOY_VERSION__
* @internal
*/
protected $active = [];
/**
* Adds an active MFA method
*
* @param MfaTable $record The MFA method record to add
*
* @return void
* @since __DEPLOY_VERSION__
*/
public function addActiveMethod(MfaTable $record)
{
$this->active[$record->id] = $record;
}
}

View File

@ -0,0 +1,259 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\DataShape;
use InvalidArgumentException;
use Joomla\Database\ParameterType;
/**
* Data shape for Method Setup Render Options
*
* @property string $default_title Default title if you are setting up this MFA Method for the first time
* @property string $pre_message Custom HTML to display above the MFA setup form
* @property string $table_heading Heading for displayed tabular data. Typically used to display a list of fixed MFA
* codes, TOTP setup parameters etc
* @property array $tabular_data Any tabular data to display (label => custom HTML). See above
* @property array $hidden_data Hidden fields to include in the form (name => value)
* @property string $field_type How to render the MFA setup code field. "input" (HTML input element) or "custom"
* (custom HTML)
* @property string $input_type The type attribute for the HTML input box. Typically "text" or "password". Use any
* HTML5 input type.
* @property string $input_value Pre-filled value for the HTML input box. Typically used for fixed codes, the fixed
* YubiKey ID etc.
* @property string $placeholder Placeholder text for the HTML input box. Leave empty if you don't need it.
* @property string $label Label to show above the HTML input box. Leave empty if you don't need it.
* @property string $html Custom HTML. Only used when field_type = custom.
* @property bool $show_submit Should I show the submit button (apply the MFA setup)?
* @property string $submit_class Additional CSS classes for the submit button (apply the MFA setup)
* @property string $post_message Custom HTML to display below the MFA setup form
* @property string $help_url A URL with help content for this Method to display to the user
*
* @since __DEPLOY_VERSION__
*/
class SetupRenderOptions extends DataShapeObject
{
/**
* Display a standard HTML5 input field. Use the input_type, placeholder and label properties to set it up.
*
* @since __DEPLOY_VERSION__
*/
public const FIELD_INPUT = 'input';
/**
* Display a custom HTML document. Use the html property to set it up.
*
* @since __DEPLOY_VERSION__
*/
public const FIELD_CUSTOM = 'custom';
/**
* Default title if you are setting up this MFA Method for the first time
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $default_title = '';
/**
* Custom HTML to display above the MFA setup form parameters etc
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $pre_message = '';
/**
* Heading for displayed tabular data. Typically used to display a list of fixed MFA codes, TOTP setup
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $table_heading = '';
/**
* Any tabular data to display (label => custom HTML). See above
*
* @var array
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $tabular_data = [];
/**
* Hidden fields to include in the form (name => value)
*
* @var array
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $hidden_data = [];
/**
* How to render the MFA setup code field. "input" (HTML input element) or "custom" (custom HTML)
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $field_type = 'input';
/**
* The type attribute for the HTML input box. Typically "text" or "password". Use any HTML5 input type.
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $input_type = 'text';
/**
* Attributes other than type and id which will be added to the HTML input box.
*
* @var array
* @@since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $input_attributes = [];
/**
* Pre-filled value for the HTML input box. Typically used for fixed codes, the fixed YubiKey ID etc.
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $input_value = '';
/**
* Placeholder text for the HTML input box. Leave empty if you don't need it.
*
* @var string
* @since __DEPLOY_VERSION__
*/
protected $placeholder = '';
/**
* Label to show above the HTML input box. Leave empty if you don't need it.
*
* @var string
* @since __DEPLOY_VERSION__
*/
protected $label = '';
/**
* Custom HTML. Only used when field_type = custom.
*
* @var string
* @since __DEPLOY_VERSION__
*/
protected $html = '';
/**
* Should I show the submit button (apply the MFA setup)?
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $show_submit = true;
/**
* Additional CSS classes for the submit button (apply the MFA setup)
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $submit_class = '';
/**
* Icon class to use for the submit button
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $submit_icon = 'icon icon-ok';
/**
* Language key to use for the text on the submit button
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $submit_text = 'JSAVE';
/**
* Custom HTML to display below the MFA setup form
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $post_message = '';
/**
* A URL with help content for this Method to display to the user
*
* @var string
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $help_url = '';
/**
* Setter for the field_type property
*
* @param string $value One of self::FIELD_INPUT, self::FIELD_CUSTOM
*
* @since __DEPLOY_VERSION__
* @throws InvalidArgumentException
*/
// phpcs:ignore
protected function setField_type($value)
{
if (!in_array($value, [self::FIELD_INPUT, self::FIELD_CUSTOM]))
{
throw new InvalidArgumentException('Invalid value for property field_type.');
}
// phpcs:ignore
$this->field_type = $value;
}
/**
* Setter for the input_attributes property.
*
* @param array $value The value to set
*
* @return void
* @@since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected function setInput_attributes(array $value)
{
$forbiddenAttributes = ['id', 'type', 'name', 'value'];
foreach ($forbiddenAttributes as $key)
{
if (isset($value[$key]))
{
unset($value[$key]);
}
}
// phpcs:ignore
$this->input_attributes = $value;
}
}

View File

@ -46,6 +46,29 @@ class Dispatcher extends ComponentDispatcher
}
}
/**
* Special case: Multi-factor Authentication
*
* We allow access to all MFA views and tasks. Access control for MFA tasks is performed in
* the Controllers since what is allowed depends on who is logged in and whose account you
* are trying to modify. Implementing these checks in the Dispatcher would violate the
* separation of concerns.
*/
$allowedViews = ['callback', 'captive', 'method', 'methods'];
$isAllowedTask = array_reduce(
$allowedViews,
function ($carry, $taskPrefix) use ($task)
{
return $carry || strpos($task, $taskPrefix . '.') === 0;
},
false
);
if (in_array(strtolower($view), $allowedViews) || $isAllowedTask)
{
return;
}
parent::checkAccess();
}
}

View File

@ -0,0 +1,23 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\Field;
defined('_JEXEC') || die();
/**
* Select modules positions.
*
* Reuses the same field from com_modules. Don't lose it; reuse it!
*
* @since __DEPLOY_VERSION__
*/
class ModulesPositionField extends \Joomla\Component\Modules\Administrator\Field\ModulesPositionField
{
}

View File

@ -0,0 +1,379 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\Helper;
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Document\HtmlDocument;
use Joomla\CMS\Event\MultiFactor\GetMethod;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Uri\Uri;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor;
use Joomla\Component\Users\Administrator\Model\BackupcodesModel;
use Joomla\Component\Users\Administrator\Model\MethodsModel;
use Joomla\Component\Users\Administrator\Table\MfaTable;
use Joomla\Component\Users\Administrator\View\Methods\HtmlView;
use Joomla\Database\DatabaseDriver;
use Joomla\Database\ParameterType;
/**
* Helper functions for captive MFA handling
*
* @since __DEPLOY_VERSION__
*/
abstract class Mfa
{
/**
* Cache of all currently active MFAs
*
* @var array|null
* @since __DEPLOY_VERSION__
*/
protected static $allMFAs = null;
/**
* Are we inside the administrator application
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
protected static $isAdmin = null;
/**
* Get the HTML for the Multi-factor Authentication configuration interface for a user.
*
* This helper method uses a sort of primitive HMVC to display the com_users' Methods page which
* renders the MFA configuration interface.
*
* @param User $user The user we are going to show the configuration UI for.
*
* @return string|null The HTML of the UI; null if we cannot / must not show it.
* @throws Exception
* @since __DEPLOY_VERSION__
*/
public static function getConfigurationInterface(User $user): ?string
{
// Check the conditions
if (!self::canShowConfigurationInterface($user))
{
return null;
}
/** @var CMSApplication $app */
$app = Factory::getApplication();
if (!$app->input->getCmd('option', '') === 'com_users')
{
$app->getLanguage()->load('com_users');
$app->getDocument()
->getWebAssetManager()
->getRegistry()
->addExtensionRegistryFile('com_users');
}
// Get a model
/** @var MVCFactoryInterface $factory */
$factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory();
/** @var MethodsModel $methodsModel */
$methodsModel = $factory->createModel('Methods', 'Administrator');
/** @var BackupcodesModel $methodsModel */
$backupCodesModel = $factory->createModel('Backupcodes', 'Administrator');
// Get a view object
$appRoot = $app->isClient('site') ? \JPATH_SITE : \JPATH_ADMINISTRATOR;
$prefix = $app->isClient('site') ? 'Site' : 'Administrator';
/** @var HtmlView $view */
$view = $factory->createView('Methods', $prefix, 'Html',
[
'base_path' => $appRoot . '/components/com_users',
]
);
$view->setModel($methodsModel, true);
/** @noinspection PhpParamsInspection */
$view->setModel($backupCodesModel);
$view->document = $app->getDocument();
$view->returnURL = base64_encode(Uri::getInstance()->toString());
$view->user = $user;
$view->set('forHMVC', true);
@ob_start();
try
{
$view->display();
}
catch (\Throwable $e)
{
@ob_end_clean();
/**
* This is intentional! When you are developing a Multi-factor Authentication plugin you
* will inevitably mess something up and end up with an error. This would cause the
* entire MFA configuration page to dissappear. No problem! Set Debug System to Yes in
* Global Configuration and you can see the error exception which will help you solve
* your problem.
*/
if (defined('JDEBUG') && JDEBUG)
{
throw $e;
}
return null;
}
return @ob_get_clean();
}
/**
* Get a list of all of the MFA Methods
*
* @return MethodDescriptor[]
* @since __DEPLOY_VERSION__
*/
public static function getMfaMethods(): array
{
PluginHelper::importPlugin('multifactorauth');
if (is_null(self::$allMFAs))
{
// Get all the plugin results
$event = new GetMethod;
$temp = Factory::getApplication()
->getDispatcher()
->dispatch($event->getName(), $event)
->getArgument('result', []);
// Normalize the results
self::$allMFAs = [];
foreach ($temp as $method)
{
if (!is_array($method) && !($method instanceof MethodDescriptor))
{
continue;
}
$method = $method instanceof MethodDescriptor
? $method : new MethodDescriptor($method);
if (empty($method['name']))
{
continue;
}
self::$allMFAs[$method['name']] = $method;
}
}
return self::$allMFAs;
}
/**
* Is the current user allowed to add/edit MFA methods for $user?
*
* This is only allowed if I am adding / editing methods for myself.
*
* If the target user is a member of any group disallowed to use MFA this will return false.
*
* @param User|null $user The user you want to know if we're allowed to edit
*
* @return boolean
* @throws Exception
* @since __DEPLOY_VERSION__
*/
public static function canAddEditMethod(?User $user = null): bool
{
// Cannot do MFA operations on no user or a guest user.
if (is_null($user) || $user->guest)
{
return false;
}
// If the user is in a user group which disallows MFA we cannot allow adding / editing methods.
$neverMFAGroups = ComponentHelper::getParams('com_users')->get('neverMFAUserGroups', []);
$neverMFAGroups = is_array($neverMFAGroups) ? $neverMFAGroups : [];
if (count(array_intersect($user->getAuthorisedGroups(), $neverMFAGroups)))
{
return false;
}
// Check if this is the same as the logged-in user.
$myUser = Factory::getApplication()->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
return $myUser->id === $user->id;
}
/**
* Is the current user allowed to delete MFA methods / disable MFA for $user?
*
* This is allowed if:
* - The user being queried is the same as the logged-in user
* - The logged-in user is a Super User AND the queried user is NOT a Super User.
*
* Note that Super Users can be edited by their own user only for security reasons. If a Super
* User gets locked out they must use the Backup Codes to regain access. If that's not possible,
* they will need to delete their records from the `#__user_mfa` table.
*
* @param User|null $user The user being queried.
*
* @return boolean
* @throws Exception
* @since __DEPLOY_VERSION__
*/
public static function canDeleteMethod(?User $user = null): bool
{
// Cannot do MFA operations on no user or a guest user.
if (is_null($user) || $user->guest)
{
return false;
}
$myUser = Factory::getApplication()->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
return $myUser->id === $user->id
|| ($myUser->authorise('core.admin') && !$user->authorise('core.admin'));
}
/**
* Return all MFA records for a specific user
*
* @param int|null $userId User ID. NULL for currently logged in user.
*
* @return MfaTable[]
* @throws Exception
*
* @since __DEPLOY_VERSION__
*/
public static function getUserMfaRecords(?int $userId): array
{
if (empty($userId))
{
$user = Factory::getApplication()->getIdentity() ?: Factory::getUser();
$userId = $user->id ?: 0;
}
/** @var DatabaseDriver $db */
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__user_mfa'))
->where($db->quoteName('user_id') . ' = :user_id')
->bind(':user_id', $userId, ParameterType::INTEGER);
try
{
$ids = $db->setQuery($query)->loadColumn() ?: [];
}
catch (Exception $e)
{
$ids = [];
}
if (empty($ids))
{
return [];
}
/** @var MVCFactoryInterface $factory */
$factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory();
// Map all results to MFA table objects
$records = array_map(
function ($id) use ($factory)
{
/** @var MfaTable $record */
$record = $factory->createTable('Mfa', 'Administrator');
$loaded = $record->load($id);
return $loaded ? $record : null;
},
$ids
);
// Let's remove Methods we couldn't decrypt when reading from the database.
$hasBackupCodes = false;
$records = array_filter(
$records,
function ($record) use (&$hasBackupCodes)
{
$isValid = !is_null($record) && (!empty($record->options));
if ($isValid && ($record->method === 'backupcodes'))
{
$hasBackupCodes = true;
}
return $isValid;
}
);
// If the only Method is backup codes it's as good as having no records
if ((count($records) === 1) && $hasBackupCodes)
{
return [];
}
return $records;
}
/**
* Are the conditions for showing the MFA configuration interface met?
*
* @param User|null $user The user to be configured
*
* @return boolean
* @throws Exception
* @since __DEPLOY_VERSION__
*/
public static function canShowConfigurationInterface(?User $user = null): bool
{
// If I have no user to check against that's all the checking I can do.
if (empty($user))
{
return false;
}
// I need at least one MFA method plugin for the setup interface to make any sense.
$plugins = PluginHelper::getPlugin('multifactorauth');
if (count($plugins) < 1)
{
return false;
}
/** @var CMSApplication $app */
$app = Factory::getApplication();
// We can only show a configuration page in the front- or backend application.
if (!$app->isClient('site') && !$app->isClient('administrator'))
{
return false;
}
// Only show the configuration page if we have an HTML document
if (!($app->getDocument() instanceof HtmlDocument))
{
return false;
}
// I must be able to add, edit or delete the user's MFA settings
return self::canAddEditMethod($user) || self::canDeleteMethod($user);
}
}

View File

@ -110,37 +110,18 @@ class UsersHelper extends ContentHelper
}
/**
* Creates a list of two factor authentication methods used in com_users
* on user view
* No longer used.
*
* @return array
*
* @since 3.2.0
* @throws \Exception
*
* @deprecated __DEPLOY_VERSION__ Will be removed in 5.0
*/
public static function getTwoFactorMethods()
{
PluginHelper::importPlugin('twofactorauth');
$identities = Factory::getApplication()->triggerEvent('onUserTwofactorIdentify', array());
$options = array(
HTMLHelper::_('select.option', 'none', Text::_('JGLOBAL_OTPMETHOD_NONE'), 'value', 'text'),
);
if (!empty($identities))
{
foreach ($identities as $identity)
{
if (!is_object($identity))
{
continue;
}
$options[] = HTMLHelper::_('select.option', $identity->method, $identity->title, 'value', 'text');
}
}
return $options;
return [];
}
/**

View File

@ -0,0 +1,304 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\Model;
use Joomla\CMS\Crypt\Crypt;
use Joomla\CMS\Date\Date;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\Table\MfaTable;
/**
* Model for managing backup codes
*
* @since __DEPLOY_VERSION__
*/
class BackupcodesModel extends BaseDatabaseModel
{
/**
* Caches the backup codes per user ID
*
* @var array
* @since __DEPLOY_VERSION__
*/
protected $cache = [];
/**
* Get the backup codes record for the specified user
*
* @param User|null $user The user in question. Use null for the currently logged in user.
*
* @return MfaTable|null Record object or null if none is found
* @throws \Exception
* @since __DEPLOY_VERSION__
*/
public function getBackupCodesRecord(User $user = null): ?MfaTable
{
// Make sure I have a user
if (empty($user))
{
$user = Factory::getApplication()->getIdentity() ?:
Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
}
/** @var MfaTable $record */
$record = $this->getTable('Mfa', 'Administrator');
$loaded = $record->load(
[
'user_id' => $user->id,
'method' => 'backupcodes',
]
);
if (!$loaded)
{
$record = null;
}
return $record;
}
/**
* Generate a new set of backup codes for the specified user. The generated codes are immediately saved to the
* database and the internal cache is updated.
*
* @param User|null $user Which user to generate codes for?
*
* @return void
* @throws \Exception
* @since __DEPLOY_VERSION__
*/
public function regenerateBackupCodes(User $user = null): void
{
// Make sure I have a user
if (empty($user))
{
$user = Factory::getApplication()->getIdentity() ?:
Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
}
// Generate backup codes
$backupCodes = [];
for ($i = 0; $i < 10; $i++)
{
// Each backup code is 2 groups of 4 digits
$backupCodes[$i] = sprintf('%04u%04u', random_int(0, 9999), random_int(0, 9999));
}
// Save the backup codes to the database and update the cache
$this->saveBackupCodes($backupCodes, $user);
}
/**
* Saves the backup codes to the database
*
* @param array $codes An array of exactly 10 elements
* @param User|null $user The user for which to save the backup codes
*
* @return boolean
* @throws \Exception
* @since __DEPLOY_VERSION__
*/
public function saveBackupCodes(array $codes, ?User $user = null): bool
{
// Make sure I have a user
if (empty($user))
{
$user = Factory::getApplication()->getIdentity() ?:
Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
}
// Try to load existing backup codes
$existingCodes = $this->getBackupCodes($user);
$jNow = Date::getInstance();
/** @var MfaTable $record */
$record = $this->getTable('Mfa', 'Administrator');
if (is_null($existingCodes))
{
$record->reset();
$newData = [
'user_id' => $user->id,
'title' => Text::_('COM_USERS_PROFILE_OTEPS'),
'method' => 'backupcodes',
'default' => 0,
'created_on' => $jNow->toSql(),
'options' => $codes,
];
}
else
{
$record->load(
[
'user_id' => $user->id,
'method' => 'backupcodes',
]
);
$newData = [
'options' => $codes,
];
}
$saved = $record->save($newData);
if (!$saved)
{
return false;
}
// Finally, update the cache
$this->cache[$user->id] = $codes;
return true;
}
/**
* Returns the backup codes for the specified user. Cached values will be preferentially returned, therefore you
* MUST go through this model's Methods ONLY when dealing with backup codes.
*
* @param User|null $user The user for which you want the backup codes
*
* @return array|null The backup codes, or null if they do not exist
* @throws \Exception
* @since __DEPLOY_VERSION__
*/
public function getBackupCodes(User $user = null): ?array
{
// Make sure I have a user
if (empty($user))
{
$user = Factory::getApplication()->getIdentity() ?:
Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
}
if (isset($this->cache[$user->id]))
{
return $this->cache[$user->id];
}
// If there is no cached record try to load it from the database
$this->cache[$user->id] = null;
// Try to load the record
/** @var MfaTable $record */
$record = $this->getTable('Mfa', 'Administrator');
$loaded = $record->load(
[
'user_id' => $user->id,
'method' => 'backupcodes',
]
);
if ($loaded)
{
$this->cache[$user->id] = $record->options;
}
return $this->cache[$user->id];
}
/**
* Check if the provided string is a backup code. If it is, it will be removed from the list (replaced with an empty
* string) and the codes will be saved to the database. All comparisons are performed in a timing safe manner.
*
* @param string $code The code to check
* @param User|null $user The user to check against
*
* @return boolean
* @throws \Exception
* @since __DEPLOY_VERSION__
*/
public function isBackupCode($code, ?User $user = null): bool
{
// Load the backup codes
$codes = $this->getBackupCodes($user) ?: array_fill(0, 10, '');
// Keep only the numbers in the provided $code
$code = filter_var($code, FILTER_SANITIZE_NUMBER_INT);
$code = trim($code);
// Check if the code is in the array. We always check against ten codes to prevent timing attacks which
// determine the amount of codes.
$result = false;
// The two arrays let us always add an element to an array, therefore having PHP expend the same amount of time
// for the correct code, the incorrect codes and the fake codes.
$newArray = [];
$dummyArray = [];
$realLength = count($codes);
$restLength = 10 - $realLength;
for ($i = 0; $i < $realLength; $i++)
{
if (hash_equals($codes[$i], $code))
{
// This may seem redundant but makes sure both branches of the if-block are isochronous
$result = $result || true;
$newArray[] = '';
$dummyArray[] = $codes[$i];
}
else
{
// This may seem redundant but makes sure both branches of the if-block are isochronous
$result = $result || false;
$dummyArray[] = '';
$newArray[] = $codes[$i];
}
}
/**
* This is an intentional waste of time, symmetrical to the code above, making sure
* evaluating each of the total of ten elements takes the same time. This code should never
* run UNLESS someone messed up with our backup codes array and it no longer contains 10
* elements.
*/
$otherResult = false;
$temp1 = '';
for ($i = 0; $i < 10; $i++)
{
$temp1[$i] = random_int(0, 99999999);
}
for ($i = 0; $i < $restLength; $i++)
{
if (Crypt::timingSafeCompare($temp1[$i], $code))
{
$otherResult = $otherResult || true;
$newArray[] = '';
$dummyArray[] = $temp1[$i];
}
else
{
$otherResult = $otherResult || false;
$newArray[] = '';
$dummyArray[] = $temp1[$i];
}
}
// This last check makes sure than an empty code does not validate
$result = $result && !hash_equals('', $code);
// Save the backup codes
$this->saveBackupCodes($newArray, $user);
// Finally return the result
return $result;
}
}

View File

@ -0,0 +1,443 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\Model;
use Exception;
use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Event\MultiFactor\Captive;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\DataShape\CaptiveRenderOptions;
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
use Joomla\Component\Users\Administrator\Table\MfaTable;
use Joomla\Event\Event;
/**
* Captive Multi-factor Authentication page's model
*
* @since __DEPLOY_VERSION__
*/
class CaptiveModel extends BaseDatabaseModel
{
/**
* Cache of the names of the currently active MFA Methods
*
* @var array|null
* @since __DEPLOY_VERSION__
*/
protected $activeMFAMethodNames = null;
/**
* Prevents Joomla from displaying any modules.
*
* This is implemented with a trick. If you use jdoc tags to load modules the JDocumentRendererHtmlModules
* uses JModuleHelper::getModules() to load the list of modules to render. This goes through JModuleHelper::load()
* which triggers the onAfterModuleList event after cleaning up the module list from duplicates. By resetting
* the list to an empty array we force Joomla to not display any modules.
*
* Similar code paths are followed by any canonical code which tries to load modules. So even if your template does
* not use jdoc tags this code will still work as expected.
*
* @param CMSApplication|null $app The CMS application to manipulate
*
* @return void
* @throws Exception
*
* @since __DEPLOY_VERSION__
*/
public function suppressAllModules(CMSApplication $app = null): void
{
if (is_null($app))
{
$app = Factory::getApplication();
}
$app->registerEvent('onAfterModuleList', [$this, 'onAfterModuleList']);
}
/**
* Get the MFA records for the user which correspond to active plugins
*
* @param User|null $user The user for which to fetch records. Skip to use the current user.
* @param bool $includeBackupCodes Should I include the backup codes record?
*
* @return array
* @throws Exception
*
* @since __DEPLOY_VERSION__
*/
public function getRecords(User $user = null, bool $includeBackupCodes = false): array
{
if (is_null($user))
{
$user = Factory::getApplication()->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
}
// Get the user's MFA records
$records = MfaHelper::getUserMfaRecords($user->id);
// No MFA Methods? Then we obviously don't need to display a Captive login page.
if (empty($records))
{
return [];
}
// Get the enabled MFA Methods' names
$methodNames = $this->getActiveMethodNames();
// Filter the records based on currently active MFA Methods
$ret = [];
$methodNames[] = 'backupcodes';
$methodNames = array_unique($methodNames);
if (!$includeBackupCodes)
{
$methodNames = array_filter(
$methodNames,
function ($method)
{
return $method != 'backupcodes';
}
);
}
foreach ($records as $record)
{
// Backup codes must not be included in the list. We add them in the View, at the end of the list.
if (in_array($record->method, $methodNames))
{
$ret[$record->id] = $record;
}
}
return $ret;
}
/**
* Return all the active MFA Methods' names
*
* @return array
* @since __DEPLOY_VERSION__
*/
private function getActiveMethodNames(): ?array
{
if (!is_null($this->activeMFAMethodNames))
{
return $this->activeMFAMethodNames;
}
// Let's get a list of all currently active MFA Methods
$mfaMethods = MfaHelper::getMfaMethods();
// If no MFA Method is active we can't really display a Captive login page.
if (empty($mfaMethods))
{
$this->activeMFAMethodNames = [];
return $this->activeMFAMethodNames;
}
// Get a list of just the Method names
$this->activeMFAMethodNames = [];
foreach ($mfaMethods as $mfaMethod)
{
$this->activeMFAMethodNames[] = $mfaMethod['name'];
}
return $this->activeMFAMethodNames;
}
/**
* Get the currently selected MFA record for the current user. If the record ID is empty, it does not correspond to
* the currently logged in user or does not correspond to an active plugin null is returned instead.
*
* @param User|null $user The user for which to fetch records. Skip to use the current user.
*
* @return MfaTable|null
* @throws Exception
*
* @since __DEPLOY_VERSION__
*/
public function getRecord(?User $user = null): ?MfaTable
{
$id = (int) $this->getState('record_id', null);
if ($id <= 0)
{
return null;
}
if (is_null($user))
{
$user = Factory::getApplication()->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
}
/** @var MfaTable $record */
$record = $this->getTable('Mfa', 'Administrator');
$loaded = $record->load(
[
'user_id' => $user->id,
'id' => $id,
]
);
if (!$loaded)
{
return null;
}
$methodNames = $this->getActiveMethodNames();
if (!in_array($record->method, $methodNames) && ($record->method != 'backupcodes'))
{
return null;
}
return $record;
}
/**
* Load the Captive login page render options for a specific MFA record
*
* @param MfaTable $record The MFA record to process
*
* @return CaptiveRenderOptions The rendering options
* @since __DEPLOY_VERSION__
*/
public function loadCaptiveRenderOptions(?MfaTable $record): CaptiveRenderOptions
{
$renderOptions = new CaptiveRenderOptions;
if (empty($record))
{
return $renderOptions;
}
$event = new Captive($record);
$results = Factory::getApplication()
->getDispatcher()
->dispatch($event->getName(), $event)
->getArgument('result', []);
if (empty($results))
{
if ($record->method === 'backupcodes')
{
return $renderOptions->merge(
[
'input_type' => 'number',
'label' => Text::_('COM_USERS_USER_BACKUPCODE'),
]
);
}
return $renderOptions;
}
foreach ($results as $result)
{
if (empty($result))
{
continue;
}
return $renderOptions->merge($result);
}
return $renderOptions;
}
/**
* Returns the title to display in the Captive login page, or an empty string if no title is to be displayed.
*
* @return string
* @since __DEPLOY_VERSION__
*/
public function getPageTitle(): string
{
// In the frontend we can choose if we will display a title
$showTitle = (bool) ComponentHelper::getParams('com_users')
->get('frontend_show_title', 1);
if (!$showTitle)
{
return '';
}
return Text::_('COM_USERS_USER_MULTIFACTOR_AUTH');
}
/**
* Translate a MFA Method's name into its human-readable, display name
*
* @param string $name The internal MFA Method name
*
* @return string
* @since __DEPLOY_VERSION__
*/
public function translateMethodName(string $name): string
{
static $map = null;
if (!is_array($map))
{
$map = [];
$mfaMethods = MfaHelper::getMfaMethods();
if (!empty($mfaMethods))
{
foreach ($mfaMethods as $mfaMethod)
{
$map[$mfaMethod['name']] = $mfaMethod['display'];
}
}
}
if ($name == 'backupcodes')
{
return Text::_('COM_USERS_USER_BACKUPCODES');
}
return $map[$name] ?? $name;
}
/**
* Translate a MFA Method's name into the relative URL if its logo image
*
* @param string $name The internal MFA Method name
*
* @return string
* @since __DEPLOY_VERSION__
*/
public function getMethodImage(string $name): string
{
static $map = null;
if (!is_array($map))
{
$map = [];
$mfaMethods = MfaHelper::getMfaMethods();
if (!empty($mfaMethods))
{
foreach ($mfaMethods as $mfaMethod)
{
$map[$mfaMethod['name']] = $mfaMethod['image'];
}
}
}
if ($name == 'backupcodes')
{
return 'media/com_users/images/emergency.svg';
}
return $map[$name] ?? $name;
}
/**
* Process the modules list on Joomla! 4.
*
* Joomla! 4.x is passing an Event object. The first argument of the event object is the array of modules. After
* filtering it we have to overwrite the event argument (NOT just return the new list of modules). If a future
* version of Joomla! uses immutable events we'll have to use Reflection to do that or Joomla! would have to fix
* the way this event is handled, taking its return into account. For now, we just abuse the mutable event
* properties - a feature of the event objects we discussed in the Joomla! 4 Working Group back in August 2015.
*
* @param Event $event The Joomla! event object
*
* @return void
* @throws Exception
*
* @since __DEPLOY_VERSION__
*/
public function onAfterModuleList(Event $event): void
{
$modules = $event->getArgument(0);
if (empty($modules))
{
return;
}
$this->filterModules($modules);
$event->setArgument(0, $modules);
}
/**
* This is the Method which actually filters the sites modules based on the allowed module positions specified by
* the user.
*
* @param array $modules The list of the site's modules. Passed by reference.
*
* @return void The by-reference value is modified instead.
* @since __DEPLOY_VERSION__
* @throws Exception
*/
private function filterModules(array &$modules): void
{
$allowedPositions = $this->getAllowedModulePositions();
if (empty($allowedPositions))
{
$modules = [];
return;
}
$filtered = [];
foreach ($modules as $module)
{
if (in_array($module->position, $allowedPositions))
{
$filtered[] = $module;
}
}
$modules = $filtered;
}
/**
* Get a list of module positions we are allowed to display
*
* @return array
* @throws Exception
*
* @since __DEPLOY_VERSION__
*/
private function getAllowedModulePositions(): array
{
$isAdmin = Factory::getApplication()->isClient('administrator');
// Load the list of allowed module positions from the component's settings. May be different for front- and back-end
$configKey = 'allowed_positions_' . ($isAdmin ? 'backend' : 'frontend');
$res = ComponentHelper::getParams('com_users')->get($configKey, []);
// In the backend we must always add the 'title' module position
if ($isAdmin)
{
$res[] = 'title';
$res[] = 'toolbar';
}
return $res;
}
}

View File

@ -0,0 +1,273 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\Model;
use Exception;
use Joomla\CMS\Event\GenericEvent;
use Joomla\CMS\Event\MultiFactor\GetSetup;
use Joomla\CMS\Language\Text;
use Joomla\Component\Users\Administrator\DataShape\SetupRenderOptions;
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
use Joomla\Component\Users\Administrator\Table\MfaTable;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
/**
* Multi-factor Authentication management model
*
* @since __DEPLOY_VERSION__
*/
class MethodModel extends BaseDatabaseModel
{
/**
* List of MFA Methods
*
* @var array
* @since __DEPLOY_VERSION__
*/
protected $mfaMethods = null;
/**
* Get the specified MFA Method's record
*
* @param string $method The Method to retrieve.
*
* @return array
* @since __DEPLOY_VERSION__
*/
public function getMethod(string $method): array
{
if (!$this->methodExists($method))
{
return [
'name' => $method,
'display' => '',
'shortinfo' => '',
'image' => '',
'canDisable' => true,
'allowMultiple' => true,
];
}
return $this->mfaMethods[$method];
}
/**
* Is the specified MFA Method available?
*
* @param string $method The Method to check.
*
* @return boolean
* @since __DEPLOY_VERSION__
*/
public function methodExists(string $method): bool
{
if (!is_array($this->mfaMethods))
{
$this->populateMfaMethods();
}
return isset($this->mfaMethods[$method]);
}
/**
* @param User|null $user The user record. Null to use the currently logged in user.
*
* @return array
* @throws Exception
*
* @since __DEPLOY_VERSION__
*/
public function getRenderOptions(?User $user = null): SetupRenderOptions
{
if (is_null($user))
{
$user = Factory::getApplication()->getIdentity() ?: Factory::getUser();
}
$renderOptions = new SetupRenderOptions;
$event = new GetSetup($this->getRecord($user));
$results = Factory::getApplication()
->getDispatcher()
->dispatch($event->getName(), $event)
->getArgument('result', []);
if (empty($results))
{
return $renderOptions;
}
foreach ($results as $result)
{
if (empty($result))
{
continue;
}
return $renderOptions->merge($result);
}
return $renderOptions;
}
/**
* Get the specified MFA record. It will return a fake default record when no record ID is specified.
*
* @param User|null $user The user record. Null to use the currently logged in user.
*
* @return MfaTable
* @throws Exception
*
* @since __DEPLOY_VERSION__
*/
public function getRecord(User $user = null): MfaTable
{
if (is_null($user))
{
$user = Factory::getApplication()->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
}
$defaultRecord = $this->getDefaultRecord($user);
$id = (int) $this->getState('id', 0);
if ($id <= 0)
{
return $defaultRecord;
}
/** @var MfaTable $record */
$record = $this->getTable('Mfa', 'Administrator');
$loaded = $record->load(
[
'user_id' => $user->id,
'id' => $id,
]
);
if (!$loaded)
{
return $defaultRecord;
}
if (!$this->methodExists($record->method))
{
return $defaultRecord;
}
return $record;
}
/**
* Return the title to use for the page
*
* @return string
*
* @since __DEPLOY_VERSION__
*/
public function getPageTitle(): string
{
$task = $this->getState('task', 'edit');
switch ($task)
{
case 'mfa':
$key = 'COM_USERS_USER_MULTIFACTOR_AUTH';
break;
default:
$key = sprintf('COM_USERS_MFA_%s_PAGE_HEAD', $task);
break;
}
return Text::_($key);
}
/**
* @param User|null $user The user record. Null to use the current user.
*
* @return MfaTable
* @throws Exception
*
* @since __DEPLOY_VERSION__
*/
protected function getDefaultRecord(?User $user = null): MfaTable
{
if (is_null($user))
{
$user = Factory::getApplication()->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
}
$method = $this->getState('method');
$title = '';
if (is_null($this->mfaMethods))
{
$this->populateMfaMethods();
}
if ($method && isset($this->mfaMethods[$method]))
{
$title = $this->mfaMethods[$method]['display'];
}
/** @var MfaTable $record */
$record = $this->getTable('Mfa', 'Administrator');
$record->bind(
[
'id' => null,
'user_id' => $user->id,
'title' => $title,
'method' => $method,
'default' => 0,
'options' => [],
]
);
return $record;
}
/**
* Populate the list of MFA Methods
*
* @return void
* @since __DEPLOY_VERSION__
*/
private function populateMfaMethods(): void
{
$this->mfaMethods = [];
$mfaMethods = MfaHelper::getMfaMethods();
if (empty($mfaMethods))
{
return;
}
foreach ($mfaMethods as $method)
{
$this->mfaMethods[$method['name']] = $method;
}
// We also need to add the backup codes Method
$this->mfaMethods['backupcodes'] = [
'name' => 'backupcodes',
'display' => Text::_('COM_USERS_USER_BACKUPCODES'),
'shortinfo' => Text::_('COM_USERS_USER_BACKUPCODES_DESC'),
'image' => 'media/com_users/images/emergency.svg',
'canDisable' => false,
'allowMultiple' => false,
];
}
}

View File

@ -0,0 +1,241 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\Model;
use DateInterval;
use DateTimeZone;
use Exception;
use Joomla\CMS\Date\Date;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
use Joomla\Database\ParameterType;
use RuntimeException;
/**
* Multi-factor Authentication Methods list page's model
*
* @since __DEPLOY_VERSION__
*/
class MethodsModel extends BaseDatabaseModel
{
/**
* Returns a list of all available MFA methods and their currently active records for a given user.
*
* @param User|null $user The user object. Skip to use the current user.
*
* @return array
* @throws Exception
*
* @since __DEPLOY_VERSION__
*/
public function getMethods(?User $user = null): array
{
if (is_null($user))
{
$user = Factory::getApplication()->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
}
if ($user->guest)
{
return [];
}
// Get an associative array of MFA Methods
$rawMethods = MfaHelper::getMfaMethods();
$methods = [];
foreach ($rawMethods as $method)
{
$method['active'] = [];
$methods[$method['name']] = $method;
}
// Put the user MFA records into the Methods array
$userMfaRecords = MfaHelper::getUserMfaRecords($user->id);
if (!empty($userMfaRecords))
{
foreach ($userMfaRecords as $record)
{
if (!isset($methods[$record->method]))
{
continue;
}
$methods[$record->method]->addActiveMethod($record);
}
}
return $methods;
}
/**
* Delete all Multi-factor Authentication Methods for the given user.
*
* @param User|null $user The user object to reset MFA for. Null to use the current user.
*
* @return void
* @throws Exception
*
* @since __DEPLOY_VERSION__
*/
public function deleteAll(?User $user = null): void
{
// Make sure we have a user object
if (is_null($user))
{
$user = Factory::getApplication()->getIdentity() ?: Factory::getUser();
}
// If the user object is a guest (who can't have MFA) we abort with an error
if ($user->guest)
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
$db = $this->getDbo();
$query = $db->getQuery(true)
->delete($db->quoteName('#__user_mfa'))
->where($db->quoteName('user_id') . ' = :user_id')
->bind(':user_id', $user->id, ParameterType::INTEGER);
$db->setQuery($query)->execute();
}
/**
* Format a relative timestamp. It deals with timestamps today and yesterday in a special manner. Example returns:
* Yesterday, 13:12
* Today, 08:33
* January 1, 2015
*
* @param string $dateTimeText The database time string to use, e.g. "2017-01-13 13:25:36"
*
* @return string The formatted, human-readable date
* @throws Exception
*
* @since __DEPLOY_VERSION__
*/
public function formatRelative(?string $dateTimeText): string
{
if (empty($dateTimeText))
{
return Text::_('JNEVER');
}
// The timestamp is given in UTC. Make sure Joomla! parses it as such.
$utcTimeZone = new DateTimeZone('UTC');
$jDate = new Date($dateTimeText, $utcTimeZone);
$unixStamp = $jDate->toUnix();
// I'm pretty sure we didn't have MFA in Joomla back in 1970 ;)
if ($unixStamp < 0)
{
return Text::_('JNEVER');
}
// I need to display the date in the user's local timezone. That's how you do it.
$user = Factory::getApplication()->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
$userTZ = $user->getParam('timezone', 'UTC');
$tz = new DateTimeZone($userTZ);
$jDate->setTimezone($tz);
// Default format string: way in the past, the time of the day is not important
$formatString = Text::_('COM_USERS_MFA_LBL_DATE_FORMAT_PAST');
$containerString = Text::_('COM_USERS_MFA_LBL_PAST');
// If the timestamp is within the last 72 hours we may need a special format
if ($unixStamp > (time() - (72 * 3600)))
{
// Is this timestamp today?
$jNow = new Date;
$jNow->setTimezone($tz);
$checkNow = $jNow->format('Ymd', true);
$checkDate = $jDate->format('Ymd', true);
if ($checkDate == $checkNow)
{
$formatString = Text::_('COM_USERS_MFA_LBL_DATE_FORMAT_TODAY');
$containerString = Text::_('COM_USERS_MFA_LBL_TODAY');
}
else
{
// Is this timestamp yesterday?
$jYesterday = clone $jNow;
$jYesterday->setTime(0, 0, 0);
$oneSecond = new DateInterval('PT1S');
$jYesterday->sub($oneSecond);
$checkYesterday = $jYesterday->format('Ymd', true);
if ($checkDate == $checkYesterday)
{
$formatString = Text::_('COM_USERS_MFA_LBL_DATE_FORMAT_YESTERDAY');
$containerString = Text::_('COM_USERS_MFA_LBL_YESTERDAY');
}
}
}
return sprintf($containerString, $jDate->format($formatString, true));
}
/**
* Set the user's "don't show this again" flag.
*
* @param User $user The user to check
* @param bool $flag True to set the flag, false to unset it (it will be set to 0, actually)
*
* @return void
*
* @since __DEPLOY_VERSION__
*/
public function setFlag(User $user, bool $flag = true): void
{
$db = $this->getDbo();
$profileKey = 'mfa.dontshow';
$query = $db->getQuery(true)
->select($db->quoteName('profile_value'))
->from($db->quoteName('#__user_profiles'))
->where($db->quoteName('user_id') . ' = :user_id')
->where($db->quoteName('profile_key') . ' = :profileKey')
->bind(':user_id', $user->id, ParameterType::INTEGER)
->bind(':profileKey', $profileKey, ParameterType::STRING);
try
{
$result = $db->setQuery($query)->loadResult();
}
catch (Exception $e)
{
return;
}
$exists = !is_null($result);
$object = (object) [
'user_id' => $user->id,
'profile_key' => 'mfa.dontshow',
'profile_value' => ($flag ? 1 : 0),
'ordering' => 1,
];
if (!$exists)
{
$db->insertObject('#__user_profiles', $object);
}
else
{
$db->updateObject('#__user_profiles', $object, ['user_id', 'profile_key']);
}
}
}

View File

@ -278,57 +278,6 @@ class UserModel extends AdminModel
}
}
// Handle the two factor authentication setup
if (isset($data['twofactor']['method']))
{
$twoFactorMethod = $data['twofactor']['method'];
// Get the current One Time Password (two factor auth) configuration
$otpConfig = $this->getOtpConfig($pk);
if ($twoFactorMethod != 'none')
{
// Run the plugins
PluginHelper::importPlugin('twofactorauth');
$otpConfigReplies = Factory::getApplication()->triggerEvent('onUserTwofactorApplyConfiguration', array($twoFactorMethod));
// Look for a valid reply
foreach ($otpConfigReplies as $reply)
{
if (!is_object($reply) || empty($reply->method) || ($reply->method != $twoFactorMethod))
{
continue;
}
$otpConfig->method = $reply->method;
$otpConfig->config = $reply->config;
break;
}
// Save OTP configuration.
$this->setOtpConfig($pk, $otpConfig);
// Generate one time emergency passwords if required (depleted or not set)
if (empty($otpConfig->otep))
{
$oteps = $this->generateOteps($pk);
}
}
else
{
$otpConfig->method = 'none';
$otpConfig->config = array();
$this->setOtpConfig($pk, $otpConfig);
}
// Unset the raw data
unset($data['twofactor']);
// Reload the user record with the updated OTP configuration
$user->load($pk);
}
// Bind the data.
if (!$user->bind($data))
{
@ -997,463 +946,176 @@ class UserModel extends AdminModel
}
/**
* Returns the one time password (OTP) a.k.a. two factor authentication
* configuration for a particular user.
* No longer used
*
* @param integer $userId The numeric ID of the user
* @param integer $userId Ignored
*
* @return \stdClass An object holding the OTP configuration for this user
* @return \stdClass
*
* @since 3.2
* @deprecated __DEPLOY_VERSION__ Will be removed in 5.0
*/
public function getOtpConfig($userId = null)
{
$userId = (!empty($userId)) ? $userId : (int) $this->getState('user.id');
@trigger_error(
sprintf(
'%s() is deprecated. Use \Joomla\Component\Users\Administrator\Helper\Mfa::getUserMfaRecords() instead.',
__METHOD__
),
E_USER_DEPRECATED
);
// Initialise
$otpConfig = (object) array(
// Return the configuration object
return (object) array(
'method' => 'none',
'config' => array(),
'otep' => array()
);
/**
* Get the raw data, without going through User (required in order to
* be able to modify the user record before logging in the user).
*/
$db = $this->getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__users'))
->where($db->quoteName('id') . ' = :id')
->bind(':id', $userId, ParameterType::INTEGER);
$db->setQuery($query);
$item = $db->loadObject();
// Make sure this user does have OTP enabled
if (empty($item->otpKey))
{
return $otpConfig;
}
// Get the encrypted data
list($method, $config) = explode(':', $item->otpKey, 2);
$encryptedOtep = $item->otep;
// Get the secret key, yes the thing that is saved in the configuration file
$key = $this->getOtpConfigEncryptionKey();
// Cleanup old encryption methods, and convert to using openssl as the adapter to use.
if (strpos($config, '{') === false)
{
/**
* This part of the if statement block of code has been reviewed just before 4.0.0 release and determined that it is wrong,
* and has never worked.
*
* The aim is/was to migrate away from mcrypt encrypted data by decrypting the data and then re-encrypting
* it with the openssl adapter, but there has been a bug for a long time in the constructing of the
* mcrypt Aes class, where the number of parameters passed were wrong, meaning it was actually returning
* an openssl adapter not an mcrypt one.
*
* Rather than fix this just before 4.0.0 release, we will deprecate this block and remove it in 5.0.0
*
* @deprecated 4.0.0 Will be removed in 5.0.0 - always use the openssl (default) adapter with the Aes class from now on.
*/
// We use the openssl adapter by default now.
$openssl = new Aes($key, 256);
/**
* Deal with legacy mcrypt encrypted data
* NOTE THIS NEXT LINE IS WRONG and contains wrong number of params, thus returns the openssl adapter and not the mcrypt adapter.
*/
$mcrypt = new Aes($key, 256, 'cbc', null, 'mcrypt');
// Attempt to decrypt using the mcrypt adapter, under normal circumstances this should fail (We no longer use mcrypt adapter to encrypt).
$decryptedConfig = $mcrypt->decryptString($config);
// If we were able to decrypt using the mcrypt adapter, { will be in the config (JSON String), so lets update to openssl adapter use.
if (strpos($decryptedConfig, '{') !== false)
{
// Data encrypted with mcrypt, decrypt it, and then convert to openssl.
$decryptedOtep = $mcrypt->decryptString($encryptedOtep);
$encryptedOtep = $openssl->encryptString($decryptedOtep);
}
else
{
// Config data seems to be save encrypted, this can happen with 3.6.3 and openssl, lets get the data.
$decryptedConfig = $openssl->decryptString($config);
}
$otpKey = $method . ':' . $decryptedConfig;
$query = $db->getQuery(true)
->update($db->quoteName('#__users'))
->set($db->quoteName('otep') . ' = :otep')
->set($db->quoteName('otpKey') . ' = :otpKey')
->where($db->quoteName('id') . ' = :id')
->bind(':otep', $encryptedOtep)
->bind(':otpKey', $otpKey)
->bind(':id', $userId, ParameterType::INTEGER);
$db->setQuery($query);
$db->execute();
}
else
{
$decryptedConfig = $config;
}
// Create an encryptor class
$aes = new Aes($key, 256);
// Decrypt the data
$decryptedOtep = $aes->decryptString($encryptedOtep);
// Remove the null padding added during encryption
$decryptedConfig = rtrim($decryptedConfig, "\0");
$decryptedOtep = rtrim($decryptedOtep, "\0");
// Update the configuration object
$otpConfig->method = $method;
$otpConfig->config = @json_decode($decryptedConfig);
$otpConfig->otep = @json_decode($decryptedOtep);
/*
* If the decryption failed for any reason we essentially disable the
* two-factor authentication. This prevents impossible to log in sites
* if the site admin changes the site secret for any reason.
*/
if (is_null($otpConfig->config))
{
$otpConfig->config = array();
}
if (is_object($otpConfig->config))
{
$otpConfig->config = (array) $otpConfig->config;
}
if (is_null($otpConfig->otep))
{
$otpConfig->otep = array();
}
if (is_object($otpConfig->otep))
{
$otpConfig->otep = (array) $otpConfig->otep;
}
// Return the configuration object
return $otpConfig;
}
/**
* Sets the one time password (OTP) a.k.a. two factor authentication
* configuration for a particular user. The $otpConfig object is the same as
* the one returned by the getOtpConfig method.
* No longer used
*
* @param integer $userId The numeric ID of the user
* @param \stdClass $otpConfig The OTP configuration object
* @param integer $userId Ignored
* @param \stdClass $otpConfig Ignored
*
* @return boolean True on success
*
* @since 3.2
* @deprecated __DEPLOY_VERSION__ Will be removed in 5.0
*/
public function setOtpConfig($userId, $otpConfig)
{
$userId = (!empty($userId)) ? $userId : (int) $this->getState('user.id');
$updates = (object) array(
'id' => $userId,
'otpKey' => '',
'otep' => ''
@trigger_error(
sprintf(
'%s() is deprecated. Multi-factor Authentication actions are handled by plugins in the multifactorauth folder.',
__METHOD__
),
E_USER_DEPRECATED
);
// Create an encryptor class
$key = $this->getOtpConfigEncryptionKey();
$aes = new Aes($key, 256);
// Create the encrypted option strings
if (!empty($otpConfig->method) && ($otpConfig->method != 'none'))
{
$decryptedConfig = json_encode($otpConfig->config);
$decryptedOtep = json_encode($otpConfig->otep);
$updates->otpKey = $otpConfig->method . ':' . $decryptedConfig;
$updates->otep = $aes->encryptString($decryptedOtep);
}
$db = $this->getDbo();
$result = $db->updateObject('#__users', $updates, 'id');
return $result;
return true;
}
/**
* Gets the symmetric encryption key for the OTP configuration data. It
* currently returns the site's secret.
* No longer used
*
* @return string The encryption key
* @return string
*
* @since 3.2
* @deprecated __DEPLOY_VERSION__ Will be removed in 5.0
*/
public function getOtpConfigEncryptionKey()
{
@trigger_error(
sprintf(
'%s() is deprecated. Use \Joomla\CMS\Factory::getApplication()->get(\'secret\') instead',
__METHOD__
),
E_USER_DEPRECATED
);
return Factory::getApplication()->get('secret');
}
/**
* Gets the configuration forms for all two-factor authentication methods
* in an array.
* No longer used
*
* @param integer $userId The user ID to load the forms for (optional)
* @param integer $userId Ignored
*
* @return array
* @return array Empty array
*
* @since 3.2
* @throws \Exception
*
* @deprecated __DEPLOY_VERSION__ Will be removed in 5.0.
*/
public function getTwofactorform($userId = null)
{
$userId = (!empty($userId)) ? $userId : (int) $this->getState('user.id');
@trigger_error(
sprintf(
'%s() is deprecated. Use \Joomla\Component\Users\Administrator\Helper\Mfa::getConfigurationInterface()',
__METHOD__
),
E_USER_DEPRECATED
);
$otpConfig = $this->getOtpConfig($userId);
PluginHelper::importPlugin('twofactorauth');
return Factory::getApplication()->triggerEvent('onUserTwofactorShowConfiguration', array($otpConfig, $userId));
return [];
}
/**
* Generates a new set of One Time Emergency Passwords (OTEPs) for a given user.
* No longer used
*
* @param integer $userId The user ID
* @param integer $count How many OTEPs to generate? Default: 10
* @param integer $userId Ignored
* @param integer $count Ignored
*
* @return array The generated OTEPs
* @return array Empty array
*
* @since 3.2
* @deprecated __DEPLOY_VERSION__ Wil be removed in 5.0.
*/
public function generateOteps($userId, $count = 10)
{
$userId = (!empty($userId)) ? $userId : (int) $this->getState('user.id');
@trigger_error(
sprintf(
'%s() is deprecated. See \Joomla\Component\Users\Administrator\Model\BackupcodesModel::saveBackupCodes()',
__METHOD__
),
E_USER_DEPRECATED
);
// Initialise
$oteps = array();
// Get the OTP configuration for the user
$otpConfig = $this->getOtpConfig($userId);
// If two factor authentication is not enabled, abort
if (empty($otpConfig->method) || ($otpConfig->method == 'none'))
{
return $oteps;
}
$salt = '0123456789';
$base = strlen($salt);
$length = 16;
for ($i = 0; $i < $count; $i++)
{
$makepass = '';
$random = Crypt::genRandomBytes($length + 1);
$shift = ord($random[0]);
for ($j = 1; $j <= $length; ++$j)
{
$makepass .= $salt[($shift + ord($random[$j])) % $base];
$shift += ord($random[$j]);
}
$oteps[] = $makepass;
}
$otpConfig->otep = $oteps;
// Save the now modified OTP configuration
$this->setOtpConfig($userId, $otpConfig);
return $oteps;
return [];
}
/**
* Checks if the provided secret key is a valid two factor authentication
* secret key. If not, it will check it against the list of one time
* emergency passwords (OTEPs). If it's a valid OTEP it will also remove it
* from the user's list of OTEPs.
* No longer used. Always returns true.
*
* This method will return true in the following conditions:
* - The two factor authentication is not enabled
* - You have provided a valid secret key for
* - You have provided a valid OTEP
* @param integer $userId Ignored
* @param string $secretKey Ignored
* @param array $options Ignored
*
* You can define the following options in the $options array:
* otp_config The OTP (one time password, a.k.a. two factor auth)
* configuration object. If not set we'll load it automatically.
* warn_if_not_req Issue a warning if you are checking a secret key against
* a user account which doesn't have any two factor
* authentication method enabled.
* warn_irq_msg The string to use for the warn_if_not_req warning
*
* @param integer $userId The user's numeric ID
* @param string $secretKey The secret key you want to check
* @param array $options Options; see above
*
* @return boolean True if it's a valid secret key for this user.
* @return boolean Always true
*
* @since 3.2
* @throws \Exception
*
* @deprecated __DEPLOY_VERSION__ Will be removed in 5.0. MFA validation is done in the captive login.
*/
public function isValidSecretKey($userId, $secretKey, $options = array())
{
// Load the user's OTP (one time password, a.k.a. two factor auth) configuration
if (!array_key_exists('otp_config', $options))
{
$otpConfig = $this->getOtpConfig($userId);
$options['otp_config'] = $otpConfig;
}
else
{
$otpConfig = $options['otp_config'];
}
// Check if the user has enabled two factor authentication
if (empty($otpConfig->method) || ($otpConfig->method == 'none'))
{
// Load language
$lang = Factory::getLanguage();
$extension = 'com_users';
$source = JPATH_ADMINISTRATOR . '/components/' . $extension;
$lang->load($extension, JPATH_ADMINISTRATOR)
|| $lang->load($extension, $source);
$warn = true;
$warnMessage = Text::_('COM_USERS_ERROR_SECRET_CODE_WITHOUT_TFA');
if (array_key_exists('warn_if_not_req', $options))
{
$warn = $options['warn_if_not_req'];
}
if (array_key_exists('warn_irq_msg', $options))
{
$warnMessage = $options['warn_irq_msg'];
}
// Warn the user if they are using a secret code but they have not
// enabled two factor auth in their account.
if (!empty($secretKey) && $warn)
{
try
{
$app = Factory::getApplication();
$app->enqueueMessage($warnMessage, 'warning');
}
catch (\Exception $exc)
{
// This happens when we are in CLI mode. In this case
// no warning is issued
return true;
}
}
return true;
}
$credentials = array(
'secretkey' => $secretKey,
@trigger_error(
sprintf(
'%s() is deprecated. Multi-factor Authentication actions are handled by plugins in the multifactorauth folder.',
__METHOD__
),
E_USER_DEPRECATED
);
// Try to validate the OTP
PluginHelper::importPlugin('twofactorauth');
$otpAuthReplies = Factory::getApplication()->triggerEvent('onUserTwofactorAuthenticate', array($credentials, $options));
$check = false;
/*
* This looks like noob code but DO NOT TOUCH IT and do not convert
* to in_array(). During testing in_array() inexplicably returned
* null when the OTEP begins with a zero! o_O
*/
if (!empty($otpAuthReplies))
{
foreach ($otpAuthReplies as $authReply)
{
$check = $check || $authReply;
}
}
// Fall back to one time emergency passwords
if (!$check)
{
$check = $this->isValidOtep($userId, $secretKey, $otpConfig);
}
return $check;
return true;
}
/**
* Checks if the supplied string is a valid one time emergency password
* (OTEP) for this user. If it is it will be automatically removed from the
* user's list of OTEPs.
* No longer used
*
* @param integer $userId The user ID against which you are checking
* @param string $otep The string you want to test for validity
* @param object $otpConfig Optional; the two factor authentication configuration (automatically fetched if not set)
* @param integer $userId Ignored
* @param string $otep Ignored
* @param object $otpConfig Ignored
*
* @return boolean True if it's a valid OTEP or if two factor auth is not
* enabled in this user's account.
* @return boolean Always true
*
* @since 3.2
* @deprecated __DEPLOY_VERSION__ Will be removed in 5.0
*/
public function isValidOtep($userId, $otep, $otpConfig = null)
{
if (is_null($otpConfig))
{
$otpConfig = $this->getOtpConfig($userId);
}
@trigger_error(
sprintf(
'%s() is deprecated. Multi-factor Authentication actions are handled by plugins in the multifactorauth folder.',
__METHOD__
),
E_USER_DEPRECATED
);
// Did the user use an OTEP instead?
if (empty($otpConfig->otep))
{
if (empty($otpConfig->method) || ($otpConfig->method == 'none'))
{
// Two factor authentication is not enabled on this account.
// Any string is assumed to be a valid OTEP.
return true;
}
else
{
/**
* Two factor authentication enabled and no OTEPs defined. The
* user has used them all up. Therefore anything they enter is
* an invalid OTEP.
*/
return false;
}
}
// Clean up the OTEP (remove dashes, spaces and other funny stuff
// our beloved users may have unwittingly stuffed in it)
$otep = filter_var($otep, FILTER_SANITIZE_NUMBER_INT);
$otep = str_replace('-', '', $otep);
$check = false;
// Did we find a valid OTEP?
if (in_array($otep, $otpConfig->otep))
{
// Remove the OTEP from the array
$otpConfig->otep = array_diff($otpConfig->otep, array($otep));
$this->setOtpConfig($userId, $otpConfig);
// Return true; the OTEP was a valid one
$check = true;
}
return $check;
return true;
}
}

View File

@ -16,6 +16,9 @@ use Joomla\CMS\Date\Date;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor;
use Joomla\Component\Users\Administrator\Helper\Mfa;
use Joomla\Database\DatabaseQuery;
use Joomla\Database\ParameterType;
use Joomla\Utilities\ArrayHelper;
@ -63,6 +66,7 @@ class UsersModel extends ListModel
'range',
'lastvisitrange',
'state',
'mfa'
);
}
@ -140,6 +144,11 @@ class UsersModel extends ListModel
$id .= ':' . $this->getState('filter.group_id');
$id .= ':' . $this->getState('filter.range');
if (PluginHelper::isEnabled('multifactorauth'))
{
$id .= ':' . $this->getState('filter.mfa');
}
return parent::getStoreId($id);
}
@ -187,8 +196,11 @@ class UsersModel extends ListModel
foreach ($items as $item)
{
$userIds[] = (int) $item->id;
// phpcs:ignore
$item->group_count = 0;
// phpcs:ignore
$item->group_names = '';
// phpcs:ignore
$item->note_count = 0;
}
@ -244,14 +256,17 @@ class UsersModel extends ListModel
{
if (isset($userGroups[$item->id]))
{
// phpcs:ignore
$item->group_count = $userGroups[$item->id]->group_count;
// Group_concat in other databases is not supported
$item->group_names = $this->_getUserDisplayedGroups($item->id);
// phpcs:ignore
$item->group_names = $this->getUserDisplayedGroups($item->id);
}
if (isset($userNotes[$item->id]))
{
// phpcs:ignore
$item->note_count = $userNotes[$item->id]->note_count;
}
}
@ -263,6 +278,29 @@ class UsersModel extends ListModel
return $this->cache[$store];
}
/**
* Get the filter form
*
* @param array $data data
* @param boolean $loadData load current data
*
* @return Form|null The \JForm object or null if the form can't be found
*
* @since __DEPLOY_VERSION__
*/
public function getFilterForm($data = [], $loadData = true)
{
$form = parent::getFilterForm($data, $loadData);
if (empty($form) || PluginHelper::isEnabled('multifactorauth'))
{
return $form;
}
$form->removeField('mfa', 'filter');
}
/**
* Build an SQL query to load the list data.
*
@ -286,6 +324,51 @@ class UsersModel extends ListModel
$query->from($db->quoteName('#__users') . ' AS a');
// Include MFA information
if (PluginHelper::isEnabled('multifactorauth'))
{
$subQuery = $db->getQuery(true)
->select(
[
'MIN(' . $db->quoteName('user_id') . ') AS ' . $db->quoteName('uid'),
'COUNT(*) AS ' . $db->quoteName('mfaRecords')
]
)
->from($db->quoteName('#__user_mfa'))
->group($db->quoteName('user_id'));
$query->select($db->quoteName('mfa.mfaRecords'))
->join(
'left',
'(' . $subQuery . ') AS ' . $db->quoteName('mfa'),
$db->quoteName('mfa.uid') . ' = ' . $db->quoteName('a.id')
);
$mfaState = $this->getState('filter.mfa');
if (is_numeric($mfaState))
{
$mfaState = (int) $mfaState;
if ($mfaState === 1)
{
$query->where(
'((' . $db->quoteName('mfa.mfaRecords') . ' > 0) OR (' .
$db->quoteName('a.otpKey') . ' IS NOT NULL AND ' .
$db->quoteName('a.otpKey') . ' != ' . $db->quote('') . '))'
);
}
else
{
$query->where(
'((' . $db->quoteName('mfa.mfaRecords') . ' = 0 OR ' .
$db->quoteName('mfa.mfaRecords') . ' IS NULL) AND (' .
$db->quoteName('a.otpKey') . ' IS NULL OR ' .
$db->quoteName('a.otpKey') . ' = ' . $db->quote('') . '))'
);
}
}
}
// If the model is set to check item state, add to the query.
$state = $this->getState('filter.state');
@ -560,7 +643,7 @@ class UsersModel extends ListModel
*
* @return string Groups titles imploded :$
*/
protected function _getUserDisplayedGroups($userId)
protected function getUserDisplayedGroups($userId)
{
$db = $this->getDbo();
$query = $db->getQuery(true)

View File

@ -0,0 +1,135 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\Service;
use Joomla\CMS\Encrypt\Aes;
use Joomla\CMS\Factory;
/**
* Data encryption service.
*
* @since __DEPLOY_VERSION__
*/
class Encrypt
{
/**
* The encryption engine used by this service
*
* @var Aes
* @since __DEPLOY_VERSION__
*/
private $aes;
/**
* EncryptService constructor.
*
* @since __DEPLOY_VERSION__
*/
public function __construct()
{
$this->initialize();
}
/**
* Encrypt the plaintext $data and return the ciphertext prefixed by ###AES128###
*
* @param string $data The plaintext data
*
* @return string The ciphertext, prefixed by ###AES128###
*
* @since __DEPLOY_VERSION__
*/
public function encrypt(string $data): string
{
if (!is_object($this->aes))
{
return $data;
}
$this->aes->setPassword($this->getPassword(), false);
$encrypted = $this->aes->encryptString($data, true);
return '###AES128###' . $encrypted;
}
/**
* Decrypt the ciphertext, prefixed by ###AES128###, and return the plaintext.
*
* @param string $data The ciphertext, prefixed by ###AES128###
* @param bool $legacy Use legacy key expansion? Use it to decrypt data encrypted with FOF 3.
*
* @return string The plaintext data
*
* @since __DEPLOY_VERSION__
*/
public function decrypt(string $data, bool $legacy = false): string
{
if (substr($data, 0, 12) != '###AES128###')
{
return $data;
}
$data = substr($data, 12);
if (!is_object($this->aes))
{
return $data;
}
$this->aes->setPassword($this->getPassword(), $legacy);
$decrypted = $this->aes->decryptString($data, true, $legacy);
// Decrypted data is null byte padded. We have to remove the padding before proceeding.
return rtrim($decrypted, "\0");
}
/**
* Initialize the AES cryptography object
*
* @return void
* @since __DEPLOY_VERSION__
*/
private function initialize(): void
{
if (is_object($this->aes))
{
return;
}
$password = $this->getPassword();
if (empty($password))
{
return;
}
$this->aes = new Aes('cbc');
$this->aes->setPassword($password);
}
/**
* Returns the password used to encrypt information in the component
*
* @return string
*
* @since __DEPLOY_VERSION__
*/
private function getPassword(): string
{
try
{
return Factory::getApplication()->get('secret', '');
}
catch (\Exception $e)
{
return '';
}
}
}

View File

@ -0,0 +1,460 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\Table;
use Exception;
use Joomla\CMS\Date\Date;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\Table\Table;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
use Joomla\Component\Users\Administrator\Model\BackupcodesModel;
use Joomla\Component\Users\Administrator\Service\Encrypt;
use Joomla\Database\DatabaseDriver;
use Joomla\Database\ParameterType;
use Joomla\Event\DispatcherInterface;
use RuntimeException;
use Throwable;
/**
* Table for the Multi-Factor Authentication records
*
* @property int $id Record ID.
* @property int $user_id User ID
* @property string $title Record title.
* @property string $method MFA Method (corresponds to one of the plugins).
* @property int $default Is this the default Method?
* @property array $options Configuration options for the MFA Method.
* @property string $created_on Date and time the record was created.
* @property string $last_used Date and time the record was last used successfully.
*
* @since __DEPLOY_VERSION__
*/
class MfaTable extends Table
{
/**
* Delete flags per ID, set up onBeforeDelete and used onAfterDelete
*
* @var array
* @since __DEPLOY_VERSION__
*/
private $deleteFlags = [];
/**
* Encryption service
*
* @var Encrypt
* @since __DEPLOY_VERSION__
*/
private $encryptService;
/**
* Indicates that columns fully support the NULL value in the database
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
// phpcs:ignore
protected $_supportNullValue = true;
/**
* Table constructor
*
* @param DatabaseDriver $db Database driver object
* @param DispatcherInterface|null $dispatcher Events dispatcher object
*
* @since __DEPLOY_VERSION__
*/
public function __construct(DatabaseDriver $db, DispatcherInterface $dispatcher = null)
{
parent::__construct('#__user_mfa', 'id', $db, $dispatcher);
$this->encryptService = new Encrypt;
}
/**
* Method to store a row in the database from the Table instance properties.
*
* If a primary key value is set the row with that primary key value will be updated with the instance property values.
* If no primary key value is set a new row will be inserted into the database with the properties from the Table instance.
*
* @param boolean $updateNulls True to update fields even if they are null.
*
* @return boolean True on success.
*
* @since __DEPLOY_VERSION__
*/
public function store($updateNulls = true)
{
// Encrypt the options before saving them
$this->options = $this->encryptService->encrypt(json_encode($this->options ?: []));
// Set last_used date to null if empty or zero date
// phpcs:ignore
if (!((int) $this->last_used))
{
// phpcs:ignore
$this->last_used = null;
}
// phpcs:ignore
$records = MfaHelper::getUserMfaRecords($this->user_id);
if ($this->id)
{
// Existing record. Remove it from the list of records.
$records = array_filter(
$records,
function ($rec) {
return $rec->id != $this->id;
}
);
}
// Update the dates on a new record
if (empty($this->id))
{
// phpcs:ignore
$this->created_on = Date::getInstance()->toSql();
// phpcs:ignore
$this->last_used = null;
}
// Do I need to mark this record as the default?
if ($this->default == 0)
{
$hasDefaultRecord = array_reduce(
$records,
function ($carry, $record)
{
return $carry || ($record->default == 1);
},
false
);
$this->default = $hasDefaultRecord ? 0 : 1;
}
// Let's find out if we are saving a new MFA method record without having backup codes yet.
$mustCreateBackupCodes = false;
if (empty($this->id) && $this->method !== 'backupcodes')
{
// Do I have any backup records?
$hasBackupCodes = array_reduce(
$records,
function (bool $carry, $record)
{
return $carry || $record->method === 'backupcodes';
},
false
);
$mustCreateBackupCodes = !$hasBackupCodes;
// If the only other entry is the backup records one I need to make this the default method
if ($hasBackupCodes && count($records) === 1)
{
$this->default = 1;
}
}
// Store the record
try
{
$result = parent::store($updateNulls);
}
catch (Throwable $e)
{
$this->setError($e->getMessage());
$result = false;
}
// Decrypt the options (they must be decrypted in memory)
$this->decryptOptions();
if ($result)
{
// If this record is the default unset the default flag from all other records
$this->switchDefaultRecord();
// Do I need to generate backup codes?
if ($mustCreateBackupCodes)
{
$this->generateBackupCodes();
}
}
return $result;
}
/**
* Method to load a row from the database by primary key and bind the fields to the Table instance properties.
*
* @param mixed $keys An optional primary key value to load the row by, or an array of fields to match.
* If not set the instance property value is used.
* @param boolean $reset True to reset the default values before loading the new row.
*
* @return boolean True if successful. False if row not found.
*
* @since __DEPLOY_VERSION__
* @throws \InvalidArgumentException
* @throws RuntimeException
* @throws \UnexpectedValueException
*/
public function load($keys = null, $reset = true)
{
$result = parent::load($keys, $reset);
if ($result)
{
$this->decryptOptions();
}
return $result;
}
/**
* Method to delete a row from the database table by primary key value.
*
* @param mixed $pk An optional primary key value to delete. If not set the instance property value is used.
*
* @return boolean True on success.
*
* @since __DEPLOY_VERSION__
* @throws \UnexpectedValueException
*/
public function delete($pk = null)
{
$record = $this;
if ($pk != $this->id)
{
$record = clone $this;
$record->reset();
$result = $record->load($pk);
if (!$result)
{
// If the record does not exist I will stomp my feet and deny your request
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
}
$user = Factory::getApplication()->getIdentity()
?? Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
// The user must be a registered user, not a guest
if ($user->guest)
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
// Save flags used onAfterDelete
$this->deleteFlags[$record->id] = [
'default' => $record->default,
// phpcs:ignore
'numRecords' => $this->getNumRecords($record->user_id),
// phpcs:ignore
'user_id' => $record->user_id,
'method' => $record->method,
];
if (\is_null($pk))
{
// phpcs:ignore
$pk = [$this->_tbl_key => $this->id];
}
elseif (!\is_array($pk))
{
// phpcs:ignore
$pk = [$this->_tbl_key => $pk];
}
$isDeleted = parent::delete($pk);
if ($isDeleted)
{
$this->afterDelete($pk);
}
return $isDeleted;
}
/**
* Decrypt the possibly encrypted options
*
* @return void
* @since __DEPLOY_VERSION__
*/
private function decryptOptions(): void
{
// Try with modern decryption
$decrypted = @json_decode($this->encryptService->decrypt($this->options ?? ''), true);
if (is_string($decrypted))
{
$decrypted = @json_decode($decrypted, true);
}
// Fall back to legacy decryption
if (!is_array($decrypted))
{
$decrypted = @json_decode($this->encryptService->decrypt($this->options ?? '', true), true);
if (is_string($decrypted))
{
$decrypted = @json_decode($decrypted, true);
}
}
$this->options = $decrypted ?: [];
}
/**
* If this record is set to be the default, unset the default flag from the other records for the same user.
*
* @return void
* @since __DEPLOY_VERSION__
*/
private function switchDefaultRecord(): void
{
if (!$this->default)
{
return;
}
/**
* This record is marked as default, therefore we need to unset the default flag from all other records for this
* user.
*/
$db = $this->getDbo();
$query = $db->getQuery(true)
->update($db->quoteName('#__user_mfa'))
->set($db->quoteName('default') . ' = 0')
->where($db->quoteName('user_id') . ' = :user_id')
->where($db->quoteName('id') . ' != :id')
// phpcs:ignore
->bind(':user_id', $this->user_id, ParameterType::INTEGER)
->bind(':id', $this->id, ParameterType::INTEGER);
$db->setQuery($query)->execute();
}
/**
* Regenerate backup code is the flag is set.
*
* @return void
* @throws Exception
* @since __DEPLOY_VERSION__
*/
private function generateBackupCodes(): void
{
/** @var MVCFactoryInterface $factory */
$factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory();
/** @var BackupcodesModel $backupCodes */
$backupCodes = $factory->createModel('Backupcodes', 'Administrator');
// phpcs:ignore
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($this->user_id);
$backupCodes->regenerateBackupCodes($user);
}
/**
* Runs after successfully deleting a record
*
* @param int|array $pk The promary key of the deleted record
*
* @return void
* @since __DEPLOY_VERSION__
*/
private function afterDelete($pk): void
{
if (is_array($pk))
{
// phpcs:ignore
$pk = $pk[$this->_tbl_key] ?? array_shift($pk);
}
if (!isset($this->deleteFlags[$pk]))
{
return;
}
if (($this->deleteFlags[$pk]['numRecords'] <= 2) && ($this->deleteFlags[$pk]['method'] != 'backupcodes'))
{
/**
* This was the second to last MFA record in the database (the last one is the `backupcodes`). Therefore, we
* need to delete the remaining entry and go away. We don't trigger this if the Method we are deleting was
* the `backupcodes` because we might just be regenerating the backup codes.
*/
$db = $this->getDbo();
$query = $db->getQuery(true)
->delete($db->quoteName('#__user_mfa'))
->where($db->quoteName('user_id') . ' = :user_id')
->bind(':user_id', $this->deleteFlags[$pk]['user_id'], ParameterType::INTEGER);
$db->setQuery($query)->execute();
unset($this->deleteFlags[$pk]);
return;
}
// This was the default record. Promote the next available record to default.
if ($this->deleteFlags[$pk]['default'])
{
$db = $this->getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__user_mfa'))
->where($db->quoteName('user_id') . ' = :user_id')
->where($db->quoteName('method') . ' != ' . $db->quote('backupcodes'))
->bind(':user_id', $this->deleteFlags[$pk]['user_id'], ParameterType::INTEGER);
$ids = $db->setQuery($query)->loadColumn();
if (empty($ids))
{
return;
}
$id = array_shift($ids);
$query = $db->getQuery(true)
->update($db->quoteName('#__user_mfa'))
->set($db->quoteName('default') . ' = 1')
->where($db->quoteName('id') . ' = :id')
->bind(':id', $id, ParameterType::INTEGER);
$db->setQuery($query)->execute();
}
}
/**
* Get the number of MFA records for a give user ID
*
* @param int $userId The user ID to check
*
* @return integer
*
* @since __DEPLOY_VERSION__
*/
private function getNumRecords(int $userId): int
{
$db = $this->getDbo();
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__user_mfa'))
->where($db->quoteName('user_id') . ' = :user_id')
->bind(':user_id', $userId, ParameterType::INTEGER);
$numOldRecords = $db->setQuery($query)->loadResult();
return (int) $numOldRecords;
}
}

View File

@ -0,0 +1,228 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\View\Captive;
use Exception;
use Joomla\CMS\Event\MultiFactor\BeforeDisplayMethods;
use Joomla\CMS\Event\MultiFactor\NotifyActionLog;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\Button\BasicButton;
use Joomla\CMS\Toolbar\Toolbar;
use Joomla\CMS\Toolbar\ToolbarFactoryInterface;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
use Joomla\Component\Users\Administrator\Model\BackupcodesModel;
use Joomla\Component\Users\Administrator\Model\CaptiveModel;
use Joomla\Component\Users\Administrator\View\SiteTemplateTrait;
use stdClass;
/**
* View for Multi-factor Authentication captive page
*
* @since __DEPLOY_VERSION__
*/
class HtmlView extends BaseHtmlView
{
use SiteTemplateTrait;
/**
* The MFA Method records for the current user which correspond to enabled plugins
*
* @var array
* @since __DEPLOY_VERSION__
*/
public $records = [];
/**
* The currently selected MFA Method record against which we'll be authenticating
*
* @var null|stdClass
* @since __DEPLOY_VERSION__
*/
public $record = null;
/**
* The Captive MFA page's rendering options
*
* @var array|null
* @since __DEPLOY_VERSION__
*/
public $renderOptions = null;
/**
* The title to display at the top of the page
*
* @var string
* @since __DEPLOY_VERSION__
*/
public $title = '';
/**
* Is this an administrator page?
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
public $isAdmin = false;
/**
* Does the currently selected Method allow authenticating against all of its records?
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
public $allowEntryBatching = false;
/**
* All enabled MFA Methods (plugins)
*
* @var array
* @since __DEPLOY_VERSION__
*/
public $mfaMethods;
/**
* Execute and display a template script.
*
* @param string $tpl The name of the template file to parse; automatically searches through the template paths.
*
* @return void A string if successful, otherwise an Error object.
*
* @throws Exception
* @since __DEPLOY_VERSION__
*/
public function display($tpl = null)
{
$this->setSiteTemplateStyle();
$app = Factory::getApplication();
$user = Factory::getApplication()->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
$event = new BeforeDisplayMethods($user);
$app->getDispatcher()->dispatch($event->getName(), $event);
/** @var CaptiveModel $model */
$model = $this->getModel();
// Load data from the model
$this->isAdmin = $app->isClient('administrator');
$this->records = $this->get('records');
$this->record = $this->get('record');
$this->mfaMethods = MfaHelper::getMfaMethods();
if (!empty($this->records))
{
/** @var BackupcodesModel $codesModel */
$codesModel = $this->getModel('Backupcodes');
$backupCodesRecord = $codesModel->getBackupCodesRecord();
if (!is_null($backupCodesRecord))
{
$backupCodesRecord->title = Text::_('COM_USERS_USER_BACKUPCODES');
$this->records[] = $backupCodesRecord;
}
}
// If we only have one record there's no point asking the user to select a MFA Method
if (empty($this->record) && !empty($this->records))
{
// Default to the first record
$this->record = reset($this->records);
// If we have multiple records try to make this record the default
if (count($this->records) > 1)
{
foreach ($this->records as $record)
{
if ($record->default)
{
$this->record = $record;
break;
}
}
}
}
// Set the correct layout based on the availability of a MFA record
$this->setLayout('default');
// If we have no record selected or explicitly asked to run the 'select' task use the correct layout
if (is_null($this->record) || ($model->getState('task') == 'select'))
{
$this->setLayout('select');
}
switch ($this->getLayout())
{
case 'select':
$this->allowEntryBatching = 1;
$event = new NotifyActionLog('onComUsersCaptiveShowSelect', []);
Factory::getApplication()->getDispatcher()->dispatch($event->getName(), $event);
break;
case 'default':
default:
$this->renderOptions = $model->loadCaptiveRenderOptions($this->record);
$this->allowEntryBatching = $this->renderOptions['allowEntryBatching'] ?? 0;
$event = new NotifyActionLog(
'onComUsersCaptiveShowCaptive',
[
$this->escape($this->record->title),
]
);
Factory::getApplication()->getDispatcher()->dispatch($event->getName(), $event);
break;
}
// Which title should I use for the page?
$this->title = $this->get('PageTitle');
// Back-end: always show a title in the 'title' module position, not in the page body
if ($this->isAdmin)
{
ToolbarHelper::title(Text::_('COM_USERS_HEADING_MFA'), 'users user-lock');
$this->title = '';
}
if ($this->isAdmin && $this->getLayout() === 'default')
{
$bar = Toolbar::getInstance();
$button = (new BasicButton('user-mfa-submit'))
->text($this->renderOptions['submit_text'])
->icon($this->renderOptions['submit_icon']);
$bar->appendButton($button);
$button = (new BasicButton('user-mfa-logout'))
->text('COM_USERS_MFA_LOGOUT')
->buttonClass('btn btn-danger')
->icon('icon icon-lock');
$bar->appendButton($button);
if (count($this->records) > 1)
{
$arrow = Factory::getApplication()->getLanguage()->isRtl() ? 'arrow-right' : 'arrow-left';
$button = (new BasicButton('user-mfa-choose-another'))
->text('COM_USERS_MFA_USE_DIFFERENT_METHOD')
->icon('icon-' . $arrow);
$bar->appendButton($button);
}
}
// Display the view
parent::display($tpl);
}
}

View File

@ -0,0 +1,226 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\View\Method;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Toolbar\Button\BasicButton;
use Joomla\CMS\Toolbar\Button\LinkButton;
use Joomla\CMS\Toolbar\Toolbar;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\Model\MethodModel;
/**
* View for Multi-factor Authentication method add/edit page
*
* @since __DEPLOY_VERSION__
*/
class HtmlView extends BaseHtmlView
{
/**
* Is this an administrator page?
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
public $isAdmin = false;
/**
* The editor page render options
*
* @var array
* @since __DEPLOY_VERSION__
*/
public $renderOptions = [];
/**
* The MFA Method record being edited
*
* @var object
* @since __DEPLOY_VERSION__
*/
public $record = null;
/**
* The title text for this page
*
* @var string
* @since __DEPLOY_VERSION__
*/
public $title = '';
/**
* The return URL to use for all links and forms
*
* @var string
* @since __DEPLOY_VERSION__
*/
public $returnURL = null;
/**
* The user object used to display this page
*
* @var User
* @since __DEPLOY_VERSION__
*/
public $user = null;
/**
* The backup codes for the current user. Only applies when the backup codes record is being "edited"
*
* @var array
* @since __DEPLOY_VERSION__
*/
public $backupCodes = [];
/**
* Am I editing an existing Method? If it's false then I'm adding a new Method.
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
public $isEditExisting = false;
/**
* Execute and display a template script.
*
* @param string $tpl The name of the template file to parse; automatically searches through the template paths.
*
* @return void
*
* @throws \Exception
* @see \JViewLegacy::loadTemplate()
* @since __DEPLOY_VERSION__
*/
public function display($tpl = null): void
{
$app = Factory::getApplication();
if (empty($this->user))
{
$this->user = Factory::getApplication()->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
}
/** @var MethodModel $model */
$model = $this->getModel();
$this->setLayout('edit');
$this->renderOptions = $model->getRenderOptions($this->user);
$this->record = $model->getRecord($this->user);
$this->title = $model->getPageTitle();
$this->isAdmin = $app->isClient('administrator');
// Backup codes are a special case, rendered with a special layout
if ($this->record->method == 'backupcodes')
{
$this->setLayout('backupcodes');
$backupCodes = $this->record->options;
if (!is_array($backupCodes))
{
$backupCodes = [];
}
$backupCodes = array_filter(
$backupCodes,
function ($x) {
return !empty($x);
}
);
if (count($backupCodes) % 2 != 0)
{
$backupCodes[] = '';
}
/**
* The call to array_merge resets the array indices. This is necessary since array_filter kept the indices,
* meaning our elements are completely out of order.
*/
$this->backupCodes = array_merge($backupCodes);
}
// Set up the isEditExisting property.
$this->isEditExisting = !empty($this->record->id);
// Back-end: always show a title in the 'title' module position, not in the page body
if ($this->isAdmin)
{
ToolbarHelper::title($this->title, 'users user-lock');
$helpUrl = $this->renderOptions['help_url'];
if (!empty($helpUrl))
{
ToolbarHelper::help('', false, $helpUrl);
}
$this->title = '';
}
$returnUrl = empty($this->returnURL) ? '' : base64_decode($this->returnURL);
$returnUrl = $returnUrl ?: Route::_('index.php?option=com_users&task=methods.display&user_id=' . $this->user->id);
if ($this->isAdmin && $this->getLayout() === 'edit')
{
$bar = Toolbar::getInstance();
$button = (new BasicButton('user-mfa-edit-save'))
->text($this->renderOptions['submit_text'])
->icon($this->renderOptions['submit_icon'])
->onclick('document.getElementById(\'user-mfa-edit-save\').click()');
if ($this->renderOptions['show_submit'] || $this->isEditExisting)
{
$bar->appendButton($button);
}
$button = (new LinkButton('user-mfa-edit-cancel'))
->text('JCANCEL')
->buttonClass('btn btn-danger')
->icon('icon-cancel-2')
->url($returnUrl);
$bar->appendButton($button);
}
elseif ($this->isAdmin && $this->getLayout() === 'backupcodes')
{
$bar = Toolbar::getInstance();
$arrow = Factory::getApplication()->getLanguage()->isRtl() ? 'arrow-right' : 'arrow-left';
$button = (new LinkButton('user-mfa-edit-cancel'))
->text('JTOOLBAR_BACK')
->icon('icon-' . $arrow)
->url($returnUrl);
$bar->appendButton($button);
$button = (new LinkButton('user-mfa-edit-cancel'))
->text('COM_USERS_MFA_BACKUPCODES_RESET')
->buttonClass('btn btn-danger')
->icon('icon-refresh')
->url(
Route::_(
sprintf(
"index.php?option=com_users&task=method.regenerateBackupCodes&user_id=%s&%s=1&returnurl=%s",
$this->user->id,
Factory::getApplication()->getFormToken(),
base64_encode($returnUrl)
)
)
);
$bar->appendButton($button);
}
// Display the view
parent::display($tpl);
}
}

View File

@ -0,0 +1,200 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\View\Methods;
use Joomla\CMS\Event\MultiFactor\NotifyActionLog;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\DataShape\MethodDescriptor;
use Joomla\Component\Users\Administrator\Model\BackupcodesModel;
use Joomla\Component\Users\Administrator\Model\MethodsModel;
use Joomla\Component\Users\Administrator\View\SiteTemplateTrait;
/**
* View for Multi-factor Authentication methods list page
*
* @since __DEPLOY_VERSION__
*/
class HtmlView extends BaseHtmlView
{
use SiteTemplateTrait;
/**
* Is this an administrator page?
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
public $isAdmin = false;
/**
* The MFA Methods available for this user
*
* @var array
* @since __DEPLOY_VERSION__
*/
public $methods = [];
/**
* The return URL to use for all links and forms
*
* @var string
* @since __DEPLOY_VERSION__
*/
public $returnURL = null;
/**
* Are there any active MFA Methods at all?
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
public $mfaActive = false;
/**
* Which Method has the default record?
*
* @var string
* @since __DEPLOY_VERSION__
*/
public $defaultMethod = '';
/**
* The user object used to display this page
*
* @var User
* @since __DEPLOY_VERSION__
*/
public $user = null;
/**
* Is this page part of the mandatory Multi-factor Authentication setup?
*
* @var boolean
* @since __DEPLOY_VERSION__
*/
public $isMandatoryMFASetup = false;
/**
* Execute and display a template script.
*
* @param string $tpl The name of the template file to parse; automatically searches through the template paths.
*
* @return void
*
* @throws \Exception
* @see \JViewLegacy::loadTemplate()
* @since __DEPLOY_VERSION__
*/
public function display($tpl = null): void
{
$this->setSiteTemplateStyle();
$app = Factory::getApplication();
if (empty($this->user))
{
$this->user = Factory::getApplication()->getIdentity()
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
}
/** @var MethodsModel $model */
$model = $this->getModel();
if ($this->getLayout() !== 'firsttime')
{
$this->setLayout('default');
}
$this->methods = $model->getMethods($this->user);
$this->isAdmin = $app->isClient('administrator');
$activeRecords = 0;
foreach ($this->methods as $methodName => $method)
{
$methodActiveRecords = count($method['active']);
if (!$methodActiveRecords)
{
continue;
}
$activeRecords += $methodActiveRecords;
$this->mfaActive = true;
foreach ($method['active'] as $record)
{
if ($record->default)
{
$this->defaultMethod = $methodName;
break;
}
}
}
// If there are no backup codes yet we should create new ones
/** @var BackupcodesModel $model */
$model = $this->getModel('backupcodes');
$backupCodes = $model->getBackupCodes($this->user);
if ($activeRecords && empty($backupCodes))
{
$model->regenerateBackupCodes($this->user);
}
$backupCodesRecord = $model->getBackupCodesRecord($this->user);
if (!is_null($backupCodesRecord))
{
$this->methods = array_merge(
[
'backupcodes' => new MethodDescriptor(
[
'name' => 'backupcodes',
'display' => Text::_('COM_USERS_USER_BACKUPCODES'),
'shortinfo' => Text::_('COM_USERS_USER_BACKUPCODES_DESC'),
'image' => 'media/com_users/images/emergency.svg',
'canDisable' => false,
'active' => [$backupCodesRecord],
]
)
],
$this->methods
);
}
$this->isMandatoryMFASetup = $activeRecords === 0 && $app->getSession()->get('com_users.mandatory_mfa_setup', 0) === 1;
// Back-end: always show a title in the 'title' module position, not in the page body
if ($this->isAdmin)
{
ToolbarHelper::title(Text::_('COM_USERS_MFA_LIST_PAGE_HEAD'), 'users user-lock');
if (Factory::getApplication()->getIdentity()->authorise('core.manage', 'com_users'))
{
ToolbarHelper::back('JTOOLBAR_BACK', Route::_('index.php?option=com_users'));
}
}
// Display the view
parent::display($tpl);
$event = new NotifyActionLog('onComUsersViewMethodsAfterDisplay', [$this]);
Factory::getApplication()->getDispatcher()->dispatch($event->getName(), $event);
Text::script('JGLOBAL_CONFIRM_DELETE');
}
}

View File

@ -0,0 +1,69 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Administrator\View;
use Exception;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Factory;
use ReflectionException;
use ReflectionObject;
/**
* Dynamically modify the frontend template when showing a MFA captive page.
*
* @since __DEPLOY_VERSION__
*/
trait SiteTemplateTrait
{
/**
* Set a specific site template style in the frontend application
*
* @return void
* @throws Exception
* @since __DEPLOY_VERSION__
*/
private function setSiteTemplateStyle(): void
{
$app = Factory::getApplication();
$templateStyle = (int) ComponentHelper::getParams('com_users')->get('captive_template', '');
if (empty($templateStyle) || !$app->isClient('site'))
{
return;
}
$itemId = $app->input->get('Itemid');
if (!empty($itemId))
{
return;
}
$app->input->set('templateStyle', $templateStyle);
try
{
$refApp = new ReflectionObject($app);
$refTemplate = $refApp->getProperty('template');
$refTemplate->setAccessible(true);
$refTemplate->setValue($app, null);
}
catch (ReflectionException $e)
{
return;
}
$template = $app->getTemplate(true);
$app->set('theme', $template->template);
$app->set('themeParams', $template->params);
}
}

View File

@ -18,6 +18,9 @@ use Joomla\CMS\MVC\View\GenericDataException;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Object\CMSObject;
use Joomla\CMS\Toolbar\ToolbarHelper;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserFactoryInterface;
use Joomla\Component\Users\Administrator\Helper\Mfa;
/**
* User view class.
@ -63,21 +66,12 @@ class HtmlView extends BaseHtmlView
protected $state;
/**
* Configuration forms for all two-factor authentication methods
* The Multi-factor Authentication configuration interface for the user.
*
* @var array
* @since 3.2
* @var string|null
* @since __DEPLOY_VERSION__
*/
protected $tfaform;
/**
* Returns the one time password (OTP) a.k.a. two factor authentication
* configuration for the user.
*
* @var \stdClass
* @since 3.2
*/
protected $otpConfig;
protected $mfaConfigurationUI;
/**
* Display the view
@ -98,10 +92,8 @@ class HtmlView extends BaseHtmlView
$app->redirect('index.php?option=com_users&view=users');
}
$this->form = $this->get('Form');
$this->state = $this->get('State');
$this->tfaform = $this->get('Twofactorform');
$this->otpConfig = $this->get('otpConfig');
$this->form = $this->get('Form');
$this->state = $this->get('State');
// Check for errors.
if (count($errors = $this->get('Errors')))
@ -121,7 +113,28 @@ class HtmlView extends BaseHtmlView
$this->form->setValue('password', null);
$this->form->setValue('password2', null);
/** @var User $userBeingEdited */
$userBeingEdited = Factory::getContainer()
->get(UserFactoryInterface::class)
->loadUserById($this->item->id);
if ($this->item->id > 0 && (int) $userBeingEdited->id == (int) $this->item->id)
{
try
{
$this->mfaConfigurationUI = Mfa::canShowConfigurationInterface($userBeingEdited)
? Mfa::getConfigurationInterface($userBeingEdited)
: '';
}
catch (\Exception $e)
{
// In case something goes really wrong with the plugins; prevents hard breaks.
$this->mfaConfigurationUI = null;
}
}
parent::display($tpl);
$this->addToolbar();
}

View File

@ -0,0 +1,135 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\Component\Users\Administrator\Model\CaptiveModel;
use Joomla\Component\Users\Administrator\View\Captive\HtmlView;
use Joomla\Utilities\ArrayHelper;
// phpcs:ignoreFile
/**
* @var HtmlView $this View object
* @var CaptiveModel $model The model
*/
$model = $this->getModel();
$this->document->getWebAssetManager()
->useScript('com_users.two-factor-focus');
?>
<div class="users-mfa-captive card card-body">
<h2 id="users-mfa-title">
<?php if (!empty($this->title)): ?>
<?php echo $this->title ?> <small> &ndash;
<?php endif; ?>
<?php if (!$this->allowEntryBatching): ?>
<?php echo $this->escape($this->record->title) ?>
<?php else: ?>
<?php echo $this->escape($this->getModel()->translateMethodName($this->record->method)) ?>
<?php endif; ?>
<?php if (!empty($this->title)): ?>
</small>
<?php endif; ?>
<?php if (!empty($this->renderOptions['help_url'])): ?>
<span class="float-end">
<a href="<?php echo $this->renderOptions['help_url'] ?>"
class="btn btn-sm btn-secondary"
target="_blank"
>
<span class="icon icon-question-sign" aria-hidden="true"></span>
<span class="visually-hidden"><?php echo Text::_('JHELP') ?></span>
</a>
</span>
<?php endif;?>
</h2>
<?php if ($this->renderOptions['pre_message']): ?>
<div class="users-mfa-captive-pre-message text-muted">
<?php echo $this->renderOptions['pre_message'] ?>
</div>
<?php endif; ?>
<form action="<?php echo Route::_('index.php?option=com_users&task=captive.validate&record_id=' . ((int) $this->record->id)) ?>"
id="users-mfa-captive-form"
method="post"
class="form-horizontal"
>
<?php echo HTMLHelper::_('form.token') ?>
<div id="users-mfa-captive-form-method-fields" class="container">
<?php if ($this->renderOptions['field_type'] == 'custom'): ?>
<?php echo $this->renderOptions['html']; ?>
<?php endif; ?>
<div class="row mb-3 <?php echo $this->renderOptions['input_type'] === 'hidden' ? 'd-none' : '' ?>">
<?php if ($this->renderOptions['label']): ?>
<label for="users-mfa-code" class="col-sm-3 col-form-label">
<?php echo $this->renderOptions['label'] ?>
</label>
<?php endif; ?>
<?php
$attributes = array_merge(
[
'type' => $this->renderOptions['input_type'],
'name' => 'code',
'value' => '',
'placeholder' => $this->renderOptions['placeholder'] ?? null,
'id' => 'users-mfa-code',
'class' => 'form-control'
],
$this->renderOptions['input_attributes']
);
if (strpos($attributes['class'], 'form-control') === false)
{
$attributes['class'] .= ' form-control';
}
?>
<input <?php echo ArrayHelper::toString($attributes) ?>>
</div>
</div>
<div id="users-mfa-captive-form-standard-buttons" class="row my-3 d-sm-none">
<div class="col-sm-9 offset-sm-3">
<button class="btn btn-primary me-3 <?php echo $this->renderOptions['submit_class'] ?>"
id="users-mfa-captive-button-submit"
style="<?php echo $this->renderOptions['hide_submit'] ? 'display: none' : '' ?>"
type="submit">
<span class="<?php echo $this->renderOptions['submit_icon'] ?>" aria-hidden="true"></span>
<?php echo Text::_($this->renderOptions['submit_text']); ?>
</button>
<a href="<?php echo Route::_('index.php?option=com_login&task=logout&' . Factory::getApplication()->getFormToken() . '=1') ?>"
class="btn btn-danger btn-sm"
id="users-mfa-captive-button-logout">
<span class="icon icon-lock" aria-hidden="true"></span>
<?php echo Text::_('COM_USERS_MFA_LOGOUT'); ?>
</a>
<?php if (count($this->records) > 1): ?>
<a id="users-mfa-captive-form-choose-another"
class="btn btn-link"
href="<?php echo Route::_('index.php?option=com_users&view=captive&task=select') ?>">
<?php echo Text::_('COM_USERS_MFA_USE_DIFFERENT_METHOD'); ?>
</a>
<?php endif; ?>
</div>
</div>
</form>
<?php if ($this->renderOptions['post_message']): ?>
<div class="users-mfa-captive-post-message">
<?php echo $this->renderOptions['post_message'] ?>
</div>
<?php endif; ?>
</div>

View File

@ -0,0 +1,75 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
// Prevent direct access
defined('_JEXEC') or die;
use Joomla\Component\Users\Administrator\View\Captive\HtmlView;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
// phpcs:ignoreFile
/** @var HtmlView $this */
$shownMethods = [];
?>
<div id="com-users-select">
<h2 id="com-users-select-heading">
<?php echo Text::_('COM_USERS_MFA_SELECT_PAGE_HEAD'); ?>
</h2>
<div id="com-users-select-information">
<p>
<?php echo Text::_('COM_USERS_LBL_SELECT_INSTRUCTIONS'); ?>
</p>
</div>
<div class="com-users-select-methods p-2">
<?php foreach ($this->records as $record):
if (!array_key_exists($record->method, $this->mfaMethods) && ($record->method != 'backupcodes')) continue;
$allowEntryBatching = isset($this->mfaMethods[$record->method]) ? $this->mfaMethods[$record->method]['allowEntryBatching'] : false;
if ($this->allowEntryBatching)
{
if ($allowEntryBatching && in_array($record->method, $shownMethods)) continue;
$shownMethods[] = $record->method;
}
$methodName = $this->getModel()->translateMethodName($record->method);
?>
<a class="com-users-method p-2 border-top border-dark bg-light d-flex flex-row flex-wrap justify-content-start align-items-center text-decoration-none gap-2 text-body"
href="<?php echo Route::_('index.php?option=com_users&view=captive&record_id=' . $record->id)?>">
<img src="<?php echo Uri::root() . $this->getModel()->getMethodImage($record->method) ?>"
alt="<?php echo $this->escape(strip_tags($record->title)) ?>"
class="com-users-method-image img-fluid" />
<?php if (!$this->allowEntryBatching || !$allowEntryBatching): ?>
<span class="com-users-method-title flex-grow-1 fs-5 fw-bold">
<?php if ($record->method === 'backupcodes'): ?>
<?php echo $record->title ?>
<?php else: ?>
<?php echo $this->escape($record->title) ?>
<?php endif; ?>
</span>
<small class="com-users-method-name text-muted">
<?php echo $methodName ?>
</small>
<?php else: ?>
<span class="com-users-method-title flex-grow-1 fs-5 fw-bold">
<?php echo $methodName ?>
</span>
<small class="com-users-method-name text-muted">
<?php echo $methodName ?>
</small>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
</div>

View File

@ -0,0 +1,82 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
// Prevent direct access
defined('_JEXEC') or die;
use Joomla\Component\Users\Administrator\View\Method\HtmlView;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
// phpcs:ignoreFile
/** @var HtmlView $this */
HTMLHelper::_('bootstrap.tooltip', '.hasTooltip');
$cancelURL = Route::_('index.php?option=com_users&task=methods.display&user_id=' . $this->user->id);
if (!empty($this->returnURL))
{
$cancelURL = $this->escape(base64_decode($this->returnURL));
}
if ($this->record->method != 'backupcodes')
{
throw new RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'), 403);
}
?>
<h2>
<?php echo Text::_('COM_USERS_USER_BACKUPCODES') ?>
</h2>
<p class="text-muted">
<?php echo Text::_('COM_USERS_USER_BACKUPCODES_DESC') ?>
</p>
<table class="table table-striped">
<?php for ($i = 0; $i < (count($this->backupCodes) / 2); $i++): ?>
<tr>
<td>
<?php if (!empty($this->backupCodes[2 * $i])): ?>
<?php // This is a Key emoji; we can hide it from screen readers ?>
<span aria-hidden="true">&#128273;</span>
<?php echo $this->backupCodes[2 * $i] ?>
<?php endif; ?>
</td>
<td>
<?php if (!empty($this->backupCodes[1 + 2 * $i])): ?>
<?php // This is a Key emoji; we can hide it from screen readers ?>
<span aria-hidden="true">&#128273;</span>
<?php echo $this->backupCodes[1 + 2 * $i] ?>
<?php endif ;?>
</td>
</tr>
<?php endfor; ?>
</table>
<div class="alert alert-info">
<span class="icon-info-circle" aria-hidden="true"></span>
<?php echo Text::_('COM_USERS_MFA_BACKUPCODES_RESET_INFO'); ?>
</div>
<div class="d-sm-none">
<a class="btn btn-danger" href="<?php echo Route::_(sprintf("index.php?option=com_users&task=method.regenerateBackupCodes&user_id=%s&%s=1%s", $this->user->id, Factory::getApplication()->getFormToken(), empty($this->returnURL) ? '' : '&returnurl=' . $this->returnURL)) ?>">
<span class="icon icon-refresh" aria-hidden="true"></span>
<?php echo Text::_('COM_USERS_MFA_BACKUPCODES_RESET'); ?>
</a>
<a href="<?php echo $cancelURL ?>"
class="btn btn-secondary">
<span class="icon icon-cancel-2 icon-ban-circle"></span>
<?php echo Text::_('JCANCEL'); ?>
</a>
</div>

View File

@ -0,0 +1,187 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
// Prevent direct access
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\Component\Users\Administrator\View\Method\HtmlView;
use Joomla\Utilities\ArrayHelper;
// phpcs:ignoreFile
/** @var HtmlView $this */
$cancelURL = Route::_('index.php?option=com_users&task=methods.display&user_id=' . $this->user->id);
if (!empty($this->returnURL))
{
$cancelURL = $this->escape(base64_decode($this->returnURL));
}
$recordId = (int)$this->record->id ?? 0;
$method = $this->record->method ?? $this->getModel()->getState('method');
$userId = (int)$this->user->id ?? 0;
$headingLevel = 2;
$hideSubmit = !$this->renderOptions['show_submit'] && !$this->isEditExisting
?>
<div class="card card-body">
<form action="<?php echo Route::_(sprintf("index.php?option=com_users&task=method.save&id=%d&method=%s&user_id=%d", $recordId, $method, $userId)) ?>"
class="form form-horizontal" id="com-users-method-edit" method="post">
<?php echo HTMLHelper::_('form.token') ?>
<?php if (!empty($this->returnURL)): ?>
<input type="hidden" name="returnurl" value="<?php echo $this->escape($this->returnURL) ?>">
<?php endif; ?>
<?php if (!empty($this->renderOptions['hidden_data'])): ?>
<?php foreach ($this->renderOptions['hidden_data'] as $key => $value): ?>
<input type="hidden" name="<?php echo $this->escape($key) ?>" value="<?php echo $this->escape($value) ?>">
<?php endforeach; ?>
<?php endif; ?>
<?php if (!empty($this->title)): ?>
<?php if (!empty($this->renderOptions['help_url'])): ?>
<span class="float-end">
<a href="<?php echo $this->renderOptions['help_url'] ?>"
class="btn btn-sm btn-default btn-inverse btn-dark"
target="_blank"
>
<span class="icon icon-question-sign" aria-hidden="true"></span>
<span class="visually-hidden"><?php echo Text::_('JHELP') ?></span>
</a>
</span>
<?php endif;?>
<h<?php echo $headingLevel ?> id="com-users-method-edit-head">
<?php echo Text::_($this->title) ?>
</h<?php echo $headingLevel ?>>
<?php $headingLevel++ ?>
<?php endif; ?>
<div class="row">
<label class="col-sm-3 col-form-label"
for="com-users-method-edit-title">
<?php echo Text::_('COM_USERS_MFA_EDIT_FIELD_TITLE'); ?>
</label>
<div class="col-sm-9">
<input type="text"
class="form-control"
id="com-users-method-edit-title"
name="title"
value="<?php echo $this->escape($this->record->title) ?>"
aria-describedby="com-users-method-edit-help">
<p class="form-text" id="com-users-method-edit-help">
<?php echo $this->escape(Text::_('COM_USERS_MFA_EDIT_FIELD_TITLE_DESC')) ?>
</p>
</div>
</div>
<div class="row">
<div class="col-sm-9 offset-sm-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="com-users-is-default-method" <?php echo $this->record->default ? 'checked="checked"' : ''; ?> name="default">
<label class="form-check-label" for="com-users-is-default-method">
<?php echo Text::_('COM_USERS_MFA_EDIT_FIELD_DEFAULT'); ?>
</label>
</div>
</div>
</div>
<?php if (!empty($this->renderOptions['pre_message'])): ?>
<div class="com-users-method-edit-pre-message text-muted mt-4 mb-3">
<?php echo $this->renderOptions['pre_message'] ?>
</div>
<?php endif; ?>
<?php if (!empty($this->renderOptions['tabular_data'])): ?>
<div class="com-users-method-edit-tabular-container">
<?php if (!empty($this->renderOptions['table_heading'])): ?>
<h<?php echo $headingLevel ?> class="h3 border-bottom mb-3">
<?php echo $this->renderOptions['table_heading'] ?>
</h<?php echo $headingLevel ?>>
<?php endif; ?>
<table class="table table-striped">
<tbody>
<?php foreach ($this->renderOptions['tabular_data'] as $cell1 => $cell2): ?>
<tr>
<td>
<?php echo $cell1 ?>
</td>
<td>
<?php echo $cell2 ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if ($this->renderOptions['field_type'] == 'custom'): ?>
<?php echo $this->renderOptions['html']; ?>
<?php endif; ?>
<div class="row mb-3 <?php echo $this->renderOptions['input_type'] === 'hidden' ? 'd-none' : '' ?>">
<?php if ($this->renderOptions['label']): ?>
<label class="col-sm-3 col-form-label" for="com-users-method-code">
<?php echo $this->renderOptions['label']; ?>
</label>
<?php endif; ?>
<div class="col-sm-9" <?php echo $this->renderOptions['label'] ? '' : 'offset-sm-3' ?>>
<?php
$attributes = array_merge(
[
'type' => $this->renderOptions['input_type'],
'name' => 'code',
'value' => $this->escape($this->renderOptions['input_value']),
'id' => 'com-users-method-code',
'class' => 'form-control',
'aria-describedby' => 'com-users-method-code-help',
],
$this->renderOptions['input_attributes']
);
if (strpos($attributes['class'], 'form-control') === false)
{
$attributes['class'] .= ' form-control';
}
?>
<input <?php echo ArrayHelper::toString($attributes) ?>>
<p class="form-text" id="com-users-method-code-help">
<?php echo $this->escape($this->renderOptions['placeholder']) ?>
</p>
</div>
</div>
<div class="container d-sm-none">
<div class="row mb-3">
<div class="col-sm-9 offset-sm-3">
<button type="submit"
id="user-mfa-edit-save"
class="btn btn-primary me-3 <?php echo $hideSubmit ? 'd-none' : '' ?> <?php echo $this->renderOptions['submit_class'] ?>">
<span class="<?php echo $this->renderOptions['submit_icon'] ?>" aria-hidden="true"></span>
<?php echo Text::_($this->renderOptions['submit_text']); ?>
</button>
<a href="<?php echo $cancelURL ?>"
id="user-mfa-edit-cancel"
class="btn btn-sm btn-danger">
<span class="icon icon-cancel-2" aria-hidden="true"></span>
<?php echo Text::_('JCANCEL'); ?>
</a>
</div>
</div>
</div>
<?php if (!empty($this->renderOptions['post_message'])): ?>
<div class="com-users-method-edit-post-message text-muted">
<?php echo $this->renderOptions['post_message'] ?>
</div>
<?php endif; ?>
</form>
</div>

View File

@ -0,0 +1,53 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
// Prevent direct access
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\Component\Users\Administrator\View\Methods\HtmlView;
// phpcs:ignoreFile
/** @var HtmlView $this */
?>
<div id="com-users-methods-list">
<div id="com-users-methods-reset-container" class="d-flex align-items-center border border-1 rounded-3 p-2 bg-light">
<div id="com-users-methods-reset-message" class="flex-grow-1">
<?php echo Text::_('COM_USERS_MFA_LIST_STATUS_' . ($this->mfaActive ? 'ON' : 'OFF')) ?>
</div>
<?php if ($this->mfaActive): ?>
<div>
<a href="<?php echo Route::_('index.php?option=com_users&task=methods.disable&' . Factory::getApplication()->getFormToken() . '=1' . ($this->returnURL ? '&returnurl=' . $this->escape(urlencode($this->returnURL)) : '') . '&user_id=' . $this->user->id) ?>"
class="btn btn-danger btn-sm">
<?php echo Text::_('COM_USERS_MFA_LIST_REMOVEALL'); ?>
</a>
</div>
<?php endif; ?>
</div>
<?php if (!count($this->methods)): ?>
<div id="com-users-methods-list-instructions" class="alert alert-info mt-2">
<span class="icon icon-info-circle" aria-hidden="true"></span>
<?php echo Text::_('COM_USERS_MFA_LIST_INSTRUCTIONS'); ?>
</div>
<?php elseif ($this->isMandatoryMFASetup): ?>
<div class="alert alert-info my-3">
<h3 class="alert-heading">
<?php echo Text::_('COM_USERS_MFA_MANDATORY_NOTICE_HEAD') ?>
</h3>
<p>
<?php echo Text::_('COM_USERS_MFA_MANDATORY_NOTICE_BODY') ?>
</p>
</div>
<?php endif ?>
<?php $this->setLayout('list'); echo $this->loadTemplate(); ?>
</div>

View File

@ -0,0 +1,49 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
// Prevent direct access
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\Component\Users\Administrator\View\Methods\HtmlView;
// phpcs:ignoreFile
/** @var HtmlView $this */
$headingLevel = 2;
?>
<div id="com-users-methods-list">
<?php if (!$this->isAdmin): ?>
<h<?php echo $headingLevel ?> id="com-users-methods-list-head">
<?php echo Text::_('COM_USERS_MFA_FIRSTTIME_PAGE_HEAD'); ?>
</h<?php echo $headingLevel++ ?>>
<?php endif; ?>
<div id="com-users-methods-list-instructions" class="alert alert-info">
<h<?php echo $headingLevel ?> class="alert-heading">
<span class="fa fa-shield-alt" aria-hidden="true"></span>
<?php echo Text::_('COM_USERS_MFA_FIRSTTIME_INSTRUCTIONS_HEAD'); ?>
</h<?php echo $headingLevel ?>>
<p>
<?php echo Text::_('COM_USERS_MFA_FIRSTTIME_INSTRUCTIONS_WHATITDOES'); ?>
</p>
<a href="<?php echo Route::_(
'index.php?option=com_users&task=methods.doNotShowThisAgain' .
($this->returnURL ? '&returnurl=' . $this->escape(urlencode($this->returnURL)) : '') .
'&user_id=' . $this->user->id .
'&' . Factory::getApplication()->getFormToken() . '=1'
)?>"
class="btn btn-danger w-100">
<?php echo Text::_('COM_USERS_MFA_FIRSTTIME_NOTINTERESTED'); ?>
</a>
</div>
<?php $this->setLayout('list'); echo $this->loadTemplate(); ?>
</div>

View File

@ -0,0 +1,144 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
// Prevent direct access
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Uri\Uri;
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
use Joomla\Component\Users\Administrator\Model\MethodsModel;
use Joomla\Component\Users\Administrator\View\Methods\HtmlView;
// phpcs:ignoreFile
/** @var HtmlView $this */
HTMLHelper::_('bootstrap.tooltip', '.hasTooltip');
/** @var MethodsModel $model */
$model = $this->getModel();
$this->document->getWebAssetManager()->useScript('com_users.two-factor-list');
$canAddEdit = MfaHelper::canAddEditMethod($this->user);
$canDelete = MfaHelper::canDeleteMethod($this->user);
?>
<div id="com-users-methods-list-container">
<?php foreach($this->methods as $methodName => $method):
$methodClass = 'com-users-methods-list-method-name-' . htmlentities($method['name'])
. ($this->defaultMethod == $methodName ? ' com-users-methods-list-method-default' : '');
?>
<div class="com-users-methods-list-method <?php echo $methodClass?> mx-1 mt-3 mb-4 card <?php echo count($method['active']) ? 'border-secondary' : '' ?>">
<div class="com-users-methods-list-method-header card-header <?php echo count($method['active']) ? 'border-secondary bg-secondary text-white' : 'bg-light' ?> d-flex flex-wrap align-items-center gap-2">
<div class="com-users-methods-list-method-image pt-1 px-3 pb-2 bg-light rounded-2">
<img src="<?php echo Uri::root() . $method['image'] ?>"
alt="<?php echo $this->escape($method['display']) ?>"
class="img-fluid"
>
</div>
<div class="com-users-methods-list-method-title flex-grow-1 d-flex flex-column">
<h3 class="<?php echo count($method['active']) ? 'text-white' : '' ?> fs-2 p-0 m-0 d-flex gap-3 align-items-center">
<span class="me-1 flex-grow-1">
<?php echo $method['display'] ?>
</span>
<?php if ($this->defaultMethod == $methodName): ?>
<span id="com-users-methods-list-method-default-tag" class="badge bg-info p-2 fs-4 me-1">
<?php echo Text::_('COM_USERS_MFA_LIST_DEFAULTTAG') ?>
</span>
<?php endif; ?>
</h3>
</div>
</div>
<div class="com-users-methods-list-method-records-container card-body">
<div class="com-users-methods-list-method-info my-1 pb-1 text-muted">
<?php echo $method['shortinfo'] ?>
</div>
<?php if (count($method['active'])): ?>
<div class="com-users-methods-list-method-records pt-2 my-2">
<?php foreach($method['active'] as $record): ?>
<div class="com-users-methods-list-method-record d-flex flex-row flex-wrap justify-content-start border-top py-2">
<div class="com-users-methods-list-method-record-info flex-grow-1 d-flex flex-column align-items-start gap-1">
<?php if ($methodName === 'backupcodes'): ?>
<div class="alert alert-info mt-1 w-100">
<?php if ($canAddEdit): ?>
<?php echo Text::sprintf('COM_USERS_MFA_BACKUPCODES_PRINT_PROMPT_HEAD', Route::_('index.php?option=com_users&task=method.edit&id=' . (int) $record->id . ($this->returnURL ? '&returnurl=' . $this->escape(urlencode($this->returnURL)) : '') . '&user_id=' . $this->user->id)) ?>
<?php endif ?>
</div>
<?php else: ?>
<h4 class="com-users-methods-list-method-record-title-container mb-1 fs-3">
<?php if ($record->default): ?>
<span id="com-users-methods-list-method-default-badge-small"
class="text-warning me-1 hasTooltip"
title="<?php echo $this->escape(Text::_('COM_USERS_MFA_LIST_DEFAULTTAG')) ?>">
<span class="icon icon-star" aria-hidden="true"></span>
<span class="visually-hidden"><?php echo $this->escape(Text::_('COM_USERS_MFA_LIST_DEFAULTTAG')) ?></span>
</span>
<?php endif; ?>
<span class="com-users-methods-list-method-record-title fw-bold">
<?php echo $this->escape($record->title); ?>
</span>
</h4>
<?php endif; ?>
<div class="com-users-methods-list-method-record-lastused my-1 d-flex flex-row flex-wrap justify-content-start gap-5 text-muted w-100">
<span class="com-users-methods-list-method-record-createdon">
<?php echo Text::sprintf('COM_USERS_MFA_LBL_CREATEDON', $model->formatRelative($record->created_on)) ?>
</span>
<span class="com-users-methods-list-method-record-lastused-date">
<?php echo Text::sprintf('COM_USERS_MFA_LBL_LASTUSED', $model->formatRelative($record->last_used)) ?>
</span>
</div>
</div>
<?php if ($methodName !== 'backupcodes' && ($canAddEdit || $canDelete)): ?>
<div class="com-users-methods-list-method-record-actions my-2 d-flex flex-row flex-wrap justify-content-center align-content-center align-items-start">
<?php if ($canAddEdit): ?>
<a class="com-users-methods-list-method-record-edit btn btn-secondary btn-sm mx-1 hasTooltip"
href="<?php echo Route::_('index.php?option=com_users&task=method.edit&id=' . (int) $record->id . ($this->returnURL ? '&returnurl=' . $this->escape(urlencode($this->returnURL)) : '') . '&user_id=' . $this->user->id)?>"
title="<?php echo Text::_('JACTION_EDIT') ?> <?php echo $this->escape($record->title); ?>">
<span class="icon icon-pencil" aria-hidden="true"></span>
<span class="visually-hidden"><?php echo Text::_('JACTION_EDIT') ?> <?php echo $this->escape($record->title); ?></span>
</a>
<?php endif ?>
<?php if ($method['canDisable'] && $canDelete): ?>
<a class="com-users-methods-list-method-record-delete btn btn-danger btn-sm mx-1 hasTooltip"
href="<?php echo Route::_('index.php?option=com_users&task=method.delete&id=' . (int) $record->id . ($this->returnURL ? '&returnurl=' . $this->escape(urlencode($this->returnURL)) : '') . '&user_id=' . $this->user->id . '&' . Factory::getApplication()->getFormToken() . '=1')?>"
title="<?php echo Text::_('JACTION_DELETE') ?> <?php echo $this->escape($record->title); ?>">
<span class="icon icon-trash" aria-hidden="true"></span>
<span class="visually-hidden"><?php echo Text::_('JACTION_DELETE') ?> <?php echo $this->escape($record->title); ?></span>
</a>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($canAddEdit && (empty($method['active']) || $method['allowMultiple'])): ?>
<div class="com-users-methods-list-method-addnew-container border-top pt-2">
<a href="<?php echo Route::_('index.php?option=com_users&task=method.add&method=' . $this->escape(urlencode($method['name'])) . ($this->returnURL ? '&returnurl=' . $this->escape(urlencode($this->returnURL)) : '') . '&user_id=' . $this->user->id)?>"
class="com-users-methods-list-method-addnew btn btn-outline-primary btn-sm"
>
<span class="icon-plus-2" aria-hidden="true"></span>
<?php echo Text::sprintf('COM_USERS_MFA_ADD_AUTHENTICATOR_OF_TYPE', $method['display']) ?>
</a>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>

View File

@ -16,11 +16,12 @@ use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
use Joomla\Component\Users\Administrator\Helper\UsersHelper;
/** @var Joomla\Component\Users\Administrator\View\User\HtmlView $this */
/** @var Joomla\CMS\WebAsset\WebAssetManager $wa */
$wa = $this->document->getWebAssetManager();
$wa->useScript('keepalive')
->useScript('form.validate')
->useScript('com_users.two-factor-switcher');
->useScript('form.validate');
$input = Factory::getApplication()->input;
@ -63,50 +64,12 @@ $this->useCoreUI = true;
echo LayoutHelper::render('joomla.edit.params', $this);
?>
<?php if (!empty($this->tfaform) && $this->item->id) : ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'twofactorauth', Text::_('COM_USERS_USER_TWO_FACTOR_AUTH')); ?>
<?php if (!empty($this->mfaConfigurationUI)) : ?>
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'multifactorauth', Text::_('COM_USERS_USER_MULTIFACTOR_AUTH')); ?>
<fieldset class="options-form">
<legend><?php echo Text::_('COM_USERS_USER_TWO_FACTOR_AUTH'); ?></legend>
<div class="control-group">
<div class="control-label">
<label id="jform_twofactor_method-lbl" for="jform_twofactor_method">
<?php echo Text::_('COM_USERS_USER_FIELD_TWOFACTOR_LABEL'); ?>
</label>
</div>
<div class="controls">
<?php echo HTMLHelper::_('select.genericlist', UsersHelper::getTwoFactorMethods(), 'jform[twofactor][method]', array('onchange' => 'Joomla.twoFactorMethodChange();', 'class' => 'form-select'), 'value', 'text', $this->otpConfig->method, 'jform_twofactor_method', false); ?>
</div>
</div>
<div id="com_users_twofactor_forms_container">
<?php foreach ($this->tfaform as $form) : ?>
<?php $class = $form['method'] == $this->otpConfig->method ? '' : ' class="hidden"'; ?>
<div id="com_users_twofactor_<?php echo $form['method'] ?>"<?php echo $class; ?>>
<?php echo $form['form'] ?>
</div>
<?php endforeach; ?>
</div>
<legend><?php echo Text::_('COM_USERS_USER_MULTIFACTOR_AUTH'); ?></legend>
<?php echo $this->mfaConfigurationUI ?>
</fieldset>
<hr>
<h3>
<?php echo Text::_('COM_USERS_USER_OTEPS'); ?>
</h3>
<div class="alert alert-info">
<span class="icon-info-circle" aria-hidden="true"></span><span class="visually-hidden"><?php echo Text::_('INFO'); ?></span>
<?php echo Text::_('COM_USERS_USER_OTEPS_DESC'); ?>
</div>
<?php if (empty($this->otpConfig->otep)) : ?>
<div class="alert alert-warning">
<span class="icon-exclamation-circle" aria-hidden="true"></span><span class="visually-hidden"><?php echo Text::_('WARNING'); ?></span>
<?php echo Text::_('COM_USERS_USER_OTEPS_WAIT_DESC'); ?>
</div>
<?php else : ?>
<?php foreach ($this->otpConfig->otep as $otep) : ?>
<?php echo wordwrap($otep, 4, '-', true); ?><br>
<?php endforeach; ?>
<?php endif; ?>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
<?php endif; ?>

View File

@ -18,6 +18,10 @@ use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\String\PunycodeHelper;
/** @var \Joomla\Component\Users\Administrator\View\Users\HtmlView $this */
// phpcs:ignoreFile
/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */
$wa = $this->document->getWebAssetManager();
$wa->useScript('table.columns')
@ -26,7 +30,7 @@ $wa->useScript('table.columns')
$listOrder = $this->escape($this->state->get('list.ordering'));
$listDirn = $this->escape($this->state->get('list.direction'));
$loggeduser = Factory::getUser();
$tfa = PluginHelper::isEnabled('twofactorauth');
$mfa = PluginHelper::isEnabled('multifactorauth');
?>
<form action="<?php echo Route::_('index.php?option=com_users&view=users'); ?>" method="post" name="adminForm" id="adminForm">
@ -66,9 +70,9 @@ $tfa = PluginHelper::isEnabled('twofactorauth');
<th scope="col" class="w-5 text-center d-md-table-cell">
<?php echo HTMLHelper::_('searchtools.sort', 'COM_USERS_HEADING_ACTIVATED', 'a.activation', $listDirn, $listOrder); ?>
</th>
<?php if ($tfa) : ?>
<?php if ($mfa) : ?>
<th scope="col" class="w-5 text-center d-none d-md-table-cell">
<?php echo Text::_('COM_USERS_HEADING_TFA'); ?>
<?php echo Text::_('COM_USERS_HEADING_MFA'); ?>
</th>
<?php endif; ?>
<th scope="col" class="w-12 d-none d-md-table-cell">
@ -149,15 +153,19 @@ $tfa = PluginHelper::isEnabled('twofactorauth');
echo HTMLHelper::_('jgrid.state', HTMLHelper::_('users.activateStates'), $activated, $i, 'users.', (boolean) $activated);
?>
</td>
<?php if ($tfa) : ?>
<?php if ($mfa) : ?>
<td class="text-center d-none d-md-table-cell">
<span class="tbody-icon">
<?php if (!empty($item->otpKey)) : ?>
<span class="icon-check" aria-hidden="true"></span>
<span class="visually-hidden"><?php echo Text::_('COM_USERS_TFA_ACTIVE'); ?></span>
<?php if ($item->mfaRecords > 0 || !empty($item->otpKey)) : ?>
<span class="icon-check" aria-hidden="true" aria-describedby="tip-mfa<?php echo $i; ?>"></span>
<div role="tooltip" id="tip-mfa<?php echo $i; ?>">
<?php echo Text::_('COM_USERS_MFA_ACTIVE'); ?>
</div>
<?php else : ?>
<span class="icon-times" aria-hidden="true"></span>
<span class="visually-hidden"><?php echo Text::_('COM_USERS_TFA_NOTACTIVE'); ?></span>
<span class="icon-times" aria-hidden="true" aria-describedby="tip-mfa<?php echo $i; ?>"></span>
<div role="tooltip" id="tip-mfa<?php echo $i; ?>">
<?php echo Text::_('COM_USERS_MFA_NOTACTIVE'); ?>
</div>
<?php endif; ?>
</span>
</td>

View File

@ -29,6 +29,7 @@
<filename>users.xml</filename>
<folder>forms</folder>
<folder>helpers</folder>
<folder>postinstall</folder>
<folder>services</folder>
<folder>src</folder>
<folder>tmpl</folder>

View File

@ -6,6 +6,5 @@
COM_LOGIN="Login"
COM_LOGIN_JOOMLA_ADMINISTRATION_LOGIN="Joomla! Administration Login"
COM_LOGIN_RETURN_TO_SITE_HOME_PAGE="Go to site home page"
COM_LOGIN_TWOFACTOR="For Two-Factor Authentication"
COM_LOGIN_VALID="Use a valid username and password to gain access to the Administrator Backend."
COM_LOGIN_XML_DESCRIPTION="This component lets users login to the site."

View File

@ -22,6 +22,10 @@ COM_USERS_BATCH_SET="Move To Group"
COM_USERS_CATEGORIES_TITLE="User Notes: Categories"
COM_USERS_CATEGORY_HEADING="Category"
COM_USERS_CONFIGURATION="Users: Options"
COM_USERS_CONFIG_ALLOWED_POSITIONS_BACKEND_DESC="When displaying the backend Multi-factor Authentication page all modules will be hidden except those in the positions selected here. Please note that modules in the <code>title</code> position are always shown: this is required to show the backend page icon and title."
COM_USERS_CONFIG_ALLOWED_POSITIONS_BACKEND_LABEL="Allowed backend module positions"
COM_USERS_CONFIG_ALLOWED_POSITIONS_FRONTEND_DESC="When displaying the frontend Multi-factor Authentication page all modules will be hidden except those in the positions selected here."
COM_USERS_CONFIG_ALLOWED_POSITIONS_FRONTEND_LABEL="Allowed frontend module positions"
COM_USERS_CONFIG_DOMAIN_OPTIONS="Email Domain Options"
COM_USERS_CONFIG_FIELD_ALLOWREGISTRATION_LABEL="Allow User Registration"
COM_USERS_CONFIG_FIELD_CAPTCHA_LABEL="Captcha"
@ -34,12 +38,6 @@ COM_USERS_CONFIG_FIELD_DOMAIN_RULE_DESC="Select whether to allow or disallow the
COM_USERS_CONFIG_FIELD_DOMAIN_RULE_LABEL="Rule"
COM_USERS_CONFIG_FIELD_DOMAIN_RULE_OPTION_ALLOW="Allow"
COM_USERS_CONFIG_FIELD_DOMAIN_RULE_OPTION_DISALLOW="Disallow"
COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_ADMIN="Admin (Backend)"
COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_BOTH="Both"
COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_DESC="You must enable at least one Two Factor Authentication plugin."
COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_LABEL="Enforce Two Factor Authentication"
COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_SITE="Site (Frontend)"
COM_USERS_CONFIG_FIELD_ENFORCE_2FA_GROUPS_LABEL="Enforce Two Factor Authentication for Usergroups"
COM_USERS_CONFIG_FIELD_FRONTEND_LANG_LABEL="Frontend Language"
COM_USERS_CONFIG_FIELD_FRONTEND_RESET_COUNT_LABEL="Maximum Reset Count"
COM_USERS_CONFIG_FIELD_FRONTEND_RESET_TIME_LABEL="Reset Time (hours)"
@ -59,10 +57,29 @@ COM_USERS_CONFIG_FIELD_SUBJECT_PREFIX_LABEL="Subject Prefix"
COM_USERS_CONFIG_FIELD_USERACTIVATION_LABEL="New User Account Activation"
COM_USERS_CONFIG_FIELD_USERACTIVATION_OPTION_ADMINACTIVATION="Administrator"
COM_USERS_CONFIG_FIELD_USERACTIVATION_OPTION_SELFACTIVATION="Self"
COM_USERS_CONFIG_FORCEMFAUSERGROUPS_DESC="Any user who belongs in <em>any</em> of the following user groups will be required to enable Multi-factor Authentication before being able to use the site."
COM_USERS_CONFIG_FORCEMFAUSERGROUPS_LABEL="Enforce Multi-factor Authentication"
COM_USERS_CONFIG_FRONTEND_CAPTIVE_TEMPLATE_DESC="Choose the frontend template style to use in the Multi-factor Authentication page. Select “- Use Default -” to use the default site template style."
COM_USERS_CONFIG_FRONTEND_CAPTIVE_TEMPLATE_LABEL="Frontend template style"
COM_USERS_CONFIG_FRONTEND_SHOW_TITLE_DESC="Should I display a title in the frontend Multi-factor Authentication verification page? Please note that the title is always displayed in the backend. If you need to change the title please override the language key <code>COM_USERS_HEADING_MFA</code> using the System, Manage, Language Overrides page of the site's backend."
COM_USERS_CONFIG_FRONTEND_SHOW_TITLE_LABEL="Show title in frontend"
COM_USERS_CONFIG_IMPORT_FAILED="An error was encountered while importing the configuration: %s."
COM_USERS_CONFIG_INTEGRATION_SETTINGS_DESC="These settings determine how the Users Component will integrate with other extensions."
COM_USERS_CONFIG_LBL_NOGROUP="( no group )"
COM_USERS_CONFIG_MFAONSILENT_DESC="Should the user have to go through Multi-factor Authentication after a silent user login? Silent logins are those which do not require a username and password e.g. the Remember Me feature, WebAuthn etc."
COM_USERS_CONFIG_MFAONSILENT_LABEL="Multi-factor Authentication after silent login"
COM_USERS_CONFIG_MULTIFACTORAUTH_SETTINGS_DESC="Configure how Multi-factor Authentication works in Joomla."
COM_USERS_CONFIG_MULTIFACTORAUTH_SETTINGS_LABEL="Multi-factor Authentication"
COM_USERS_CONFIG_NEVERMFAUSERGROUPS_DESC="Any user who belongs in <em>any</em> of the following user groups will be exempt from Multi-factor Authentication. Even if they have set up Multi-factor Authentication methods they will not be asked to use them when they are logging in, nor will they be able to view them, remove them, or change their configuration."
COM_USERS_CONFIG_NEVERMFAUSERGROUPS_LABEL="Disable Multi-factor Authentication"
COM_USERS_CONFIG_PASSWORD_OPTIONS="Password Options"
COM_USERS_CONFIG_REDIRECTONLOGIN_DESC="If the user has not yet set up Multi-factor Authentication and this option is enabled they will be redirected to the Multi-factor Authentication setup page or the custom URL you set up below. This is meant to be a simple way to to let your users know that Multi-factor Authentication is an option on your site."
COM_USERS_CONFIG_REDIRECTONLOGIN_LABEL="Onboard new users"
COM_USERS_CONFIG_REDIRECTURL_DESC="If it's not empty redirects to this URL instead of the Multi-factor Authentication setup page when the option above is enabled. WARNING: This must be a URL inside your site. You cannot log in to an external link or to a different subdomain."
COM_USERS_CONFIG_REDIRECTURL_LABEL="Custom redirection URL"
COM_USERS_CONFIG_SAVE_FAILED="An error was encountered while saving the configuration: %s."
COM_USERS_CONFIG_SILENTRESPONSES_DESC="For experts. A commaseparated list of Joomla authentication response types which are considered silent logins. The default is <code>cookie</code> (the Remember Me feature) and <code>passwordless</code> (WebAuthn)."
COM_USERS_CONFIG_SILENTRESPONSES_LABEL="Silent login authentication response types (for experts)"
COM_USERS_CONFIG_USER_OPTIONS="User Options"
COM_USERS_COUNT_DISABLED_USERS="Blocked Users"
COM_USERS_COUNT_ENABLED_USERS="Enabled Users"
@ -83,7 +100,6 @@ COM_USERS_ERROR_CANNOT_BATCH_SUPERUSER="A non-Super User can't perform batch ope
COM_USERS_ERROR_INVALID_GROUP="Invalid Group"
COM_USERS_ERROR_LEVELS_NOLEVELS_SELECTED="No View Permission Level(s) selected."
COM_USERS_ERROR_NO_ADDITIONS="The selected user(s) are already assigned to the selected group."
COM_USERS_ERROR_SECRET_CODE_WITHOUT_TFA="You have entered a Secret Code but two factor authentication is not enabled in your user account. If you want to use a secret code to secure your login please edit your user profile and enable two factor authentication."
COM_USERS_ERROR_VIEW_LEVEL_IN_USE="You can't delete the view access level '%d:%s' because it is being used by content."
COM_USERS_FIELDS_USER_FIELDS_TITLE="Users: Fields"
COM_USERS_FIELDS_USER_FIELD_ADD_TITLE="Users: New Field"
@ -116,6 +132,7 @@ COM_USERS_FILTER_ACTIVE="- Select Active State -"
COM_USERS_FILTER_NOTES="Show notes list"
COM_USERS_FILTER_STATE="- Select State -"
COM_USERS_FILTER_USERGROUP="- Select Group -"
COM_USERS_FILTER_MFA="- Multi-factor Authentication -"
COM_USERS_GROUPS_CONFIRM_DELETE="Are you sure you wish to delete groups that have users?"
COM_USERS_GROUPS_NO_ITEM_SELECTED="No User Groups selected."
COM_USERS_GROUPS_N_ITEMS_DELETED="%d User Groups deleted."
@ -156,6 +173,7 @@ COM_USERS_HEADING_LEVEL_NAME_DESC="Level Name descending"
COM_USERS_HEADING_LFT="LFT"
COM_USERS_HEADING_LFT_ASC="LFT ascending"
COM_USERS_HEADING_LFT_DESC="LFT descending"
COM_USERS_HEADING_MFA="Multi-factor Authentication"
COM_USERS_HEADING_NAME="Name"
COM_USERS_HEADING_REGISTRATION_DATE="Registered"
COM_USERS_HEADING_REGISTRATION_DATE_ASC="Registration date ascending"
@ -166,13 +184,13 @@ COM_USERS_HEADING_REVIEW_DESC="Review Date descending"
COM_USERS_HEADING_SUBJECT="Subject"
COM_USERS_HEADING_SUBJECT_ASC="Subject ascending"
COM_USERS_HEADING_SUBJECT_DESC="Subject descending"
COM_USERS_HEADING_TFA="Two Factor"
COM_USERS_HEADING_USER="User"
COM_USERS_HEADING_USERNAME_ASC="Username ascending"
COM_USERS_HEADING_USERNAME_DESC="Username descending"
COM_USERS_HEADING_USERS_IN_GROUP="Users in group"
COM_USERS_HEADING_USER_ASC="User ascending"
COM_USERS_HEADING_USER_DESC="User descending"
COM_USERS_LBL_SELECT_INSTRUCTIONS="Please select how you would like to verify your login to this site."
COM_USERS_LEVELS_N_ITEMS_DELETED="%d View Permission Levels deleted."
COM_USERS_LEVELS_N_ITEMS_DELETED_1="View Permission Level deleted."
COM_USERS_LEVELS_TABLE_CAPTION="Table of Viewing Access Levels"
@ -229,9 +247,45 @@ COM_USERS_MASSMAIL_MAIL_BODY="{BODY} {BODYSUFFIX}"
COM_USERS_MASSMAIL_MAIL_SUBJECT="{SUBJECTPREFIX} {SUBJECT}"
COM_USERS_MASS_MAIL="Mass Mail Users"
COM_USERS_MASS_MAIL_DESC="Mass Mail options."
COM_USERS_MFA_ACTIVE="Uses Multi-factor Authentication"
COM_USERS_MFA_ADD_AUTHENTICATOR_OF_TYPE="Add a new %s"
COM_USERS_MFA_ADD_PAGE_HEAD="Add a Multi-factor Authentication Method"
COM_USERS_MFA_BACKUPCODES_PRINT_PROMPT="Backup Codes let you log into the site if your regular Multi-factor Authentication method does not work or you no longer have access to it. Each code can be used <em>only once</em>."
COM_USERS_MFA_BACKUPCODES_PRINT_PROMPT_HEAD="<a href=\"%s\">Print these codes</a> and keep them in your wallet."
COM_USERS_MFA_BACKUPCODES_RESET="Regenerate Backup Codes"
COM_USERS_MFA_BACKUPCODES_RESET_INFO="Use the “Regenerate Backup Codes” button on the toolbar to generate a new set of Backup Codes. We recommend that you do this if you think your Backup Codes are compromised, e.g. someone got hold of a printout with them, or if you are running low on available Backup Codes."
COM_USERS_MFA_EDIT_FIELD_DEFAULT="Make this the default Multi-factor Authentication method"
COM_USERS_MFA_EDIT_FIELD_TITLE="Title"
COM_USERS_MFA_EDIT_FIELD_TITLE_DESC="You and the site administrators will see this name in the list of available Multi-factor Authentication methods for your user account. Please do not include any sensitive or personally identifiable information."
COM_USERS_MFA_EDIT_PAGE_HEAD="Modify a Multi-factor Authentication method"
COM_USERS_MFA_FIRSTTIME_INSTRUCTIONS_HEAD="Use Multi-factor Authentication for added security"
COM_USERS_MFA_FIRSTTIME_INSTRUCTIONS_WHATITDOES="Here's how it works. Add a Multi-factor Authentication method below. From now on, every time you log into the site you will be asked to use this method to complete the login. Even if someone steals your username and password they won't have access to your account on this site."
COM_USERS_MFA_FIRSTTIME_NOTINTERESTED="Don't show this again"
COM_USERS_MFA_FIRSTTIME_PAGE_HEAD="Set up your Multi-factor Authentication"
COM_USERS_MFA_INVALID_CODE = "Multi-factor Authentication failed. Please try again."
COM_USERS_MFA_INVALID_METHOD="Invalid Multi-factor Authentication method."
COM_USERS_MFA_LBL_CREATEDON="Added: %s"
COM_USERS_MFA_LBL_DATE_FORMAT_PAST="F d, Y"
COM_USERS_MFA_LBL_DATE_FORMAT_TODAY="H:i"
COM_USERS_MFA_LBL_DATE_FORMAT_YESTERDAY="H:i"
COM_USERS_MFA_LBL_LASTUSED="Last used: %s"
COM_USERS_MFA_LBL_PAST="%s"
COM_USERS_MFA_LBL_TODAY="Today, %s"
COM_USERS_MFA_LBL_YESTERDAY="Yesterday, %s"
COM_USERS_MFA_LIST_DEFAULTTAG="Default"
COM_USERS_MFA_LIST_INSTRUCTIONS="Add at least one Multi-factor Authentication method. Every time you log into the site you will be asked to provide it."
COM_USERS_MFA_LIST_PAGE_HEAD="Your Multi-factor Authentication options"
COM_USERS_MFA_LIST_REMOVEALL="Turn Off"
COM_USERS_MFA_LIST_STATUS_OFF="Multi-factor Authentication is <strong>not enabled</strong>."
COM_USERS_MFA_LIST_STATUS_ON="Multi-factor Authentication is enabled."
COM_USERS_MFA_LOGOUT="Log Out"
COM_USERS_MFA_MANDATORY_NOTICE_BODY="Please enable a Multi-factor Authentication method for your user account. You will not be able to proceed using the site until you do so."
COM_USERS_MFA_MANDATORY_NOTICE_HEAD="Multi-factor Authentication is mandatory for your user account"
COM_USERS_MFA_NOTACTIVE="Does not use Multi-factor Authentication"
COM_USERS_MFA_SELECT_PAGE_HEAD="Select a Multi-factor Authentication method"
COM_USERS_MFA_USE_DIFFERENT_METHOD="Select a different method"
COM_USERS_MFA_VALIDATE="Validate"
COM_USERS_NEW_NOTE="New Note"
COM_USERS_NOTE_FORM_EDIT="Edit Note"
COM_USERS_NOTE_FORM_NEW="New Note"
COM_USERS_NOTES="User Notes: New/Edit"
COM_USERS_NOTES_EMPTYSTATE_BUTTON_ADD="Add your first note"
COM_USERS_NOTES_EMPTYSTATE_CONTENT="User Notes can be used to store a range of information about each user on your site."
@ -250,6 +304,8 @@ COM_USERS_NOTES_N_ITEMS_TRASHED_1="User Note trashed."
COM_USERS_NOTES_N_ITEMS_UNPUBLISHED="%d User Notes unpublished."
COM_USERS_NOTES_N_ITEMS_UNPUBLISHED_1="User Note unpublished."
COM_USERS_NOTES_TABLE_CAPTION="Table of User Notes"
COM_USERS_NOTE_FORM_EDIT="Edit Note"
COM_USERS_NOTE_FORM_NEW="New Note"
COM_USERS_NOTE_N_SUBJECT="#%d %s"
COM_USERS_NO_ACTION="No Action"
COM_USERS_NO_LEVELS_SELECTED="No Viewing Access Levels selected."
@ -293,6 +349,9 @@ COM_USERS_OPTION_SELECT_COMPONENT="- Select Component -"
COM_USERS_OPTION_SELECT_LEVEL_END="- Select End Level -"
COM_USERS_OPTION_SELECT_LEVEL_START="- Select Start Level -"
COM_USERS_PASSWORD_RESET_REQUIRED="Password Reset Required"
COM_USERS_POSTINSTALL_MULTIFACTORAUTH_ACTION="Enable the new Multi-factor Authentication plugins"
COM_USERS_POSTINSTALL_MULTIFACTORAUTH_BODY="<p>Joomla! comes with a drastically improved <a href="https://en.wikipedia.org/wiki/Multi-factor_authentication" target="_blank" rel="noreferrer">Multi-factor Authentication</a> experience to help you secure the logins of your users.</p><p>Unlike the Two Factor Authentication feature in previous versions of Joomla, users <em>no longer have to enter a Security Code with their username and password</em>. The Multi-factor Authentication happens in a separate step after logging into the site. Until they complete their Multi-factor Authentication validation users cannot navigate to other pages or use the site. This makes Multi-factor Authentication <a href="https://en.wikipedia.org/wiki/Phishing" target="_blank" rel="noreferrer">phishing</a>&ndash;resistant. It also allows for interactive validation methods like WebAuthn (including integration with Windows Hello, Apple TouchID / FaceID and Android Biometric Screen Lock), or sending 6-digit authentication codes by email. Both of these interactive, convenient methods are now available as plugins shipped with Joomla! itself.</p>"
COM_USERS_POSTINSTALL_MULTIFACTORAUTH_TITLE="Improved Multi-factor Authentication"
COM_USERS_REQUIRE_PASSWORD_RESET="Require Password Reset"
COM_USERS_REVIEW_HEADING="Review Date"
COM_USERS_SEARCH_ACCESS_LEVELS="Search Viewing Access Levels"
@ -313,8 +372,6 @@ COM_USERS_SUBMENU_LEVELS="Viewing Access Levels"
COM_USERS_SUBMENU_NOTES="User Notes"
COM_USERS_SUBMENU_NOTE_CATEGORIES="User Note Categories"
COM_USERS_SUBMENU_USERS="Users"
COM_USERS_TFA_ACTIVE="Uses Two Factor Authentication"
COM_USERS_TFA_NOTACTIVE="Does not use Two Factor Authentication"
COM_USERS_TOOLBAR_ACTIVATE="Activate"
COM_USERS_TOOLBAR_BLOCK="Block"
COM_USERS_TOOLBAR_MAIL_SEND_MAIL="Send Email"
@ -333,6 +390,9 @@ COM_USERS_USERS_N_ITEMS_DELETED="%d users deleted."
COM_USERS_USERS_N_ITEMS_DELETED_1="User deleted."
COM_USERS_USERS_TABLE_CAPTION="Table of Users"
COM_USERS_USER_ACCOUNT_DETAILS="Account Details"
COM_USERS_USER_BACKUPCODE="Backup Code"
COM_USERS_USER_BACKUPCODES="Backup Codes"
COM_USERS_USER_BACKUPCODES_DESC="Lets you access the site if all other Multi-factor Authentication methods you have set up fail."
COM_USERS_USER_BATCH_FAILED="An error was encountered while performing the batch operation: %s."
COM_USERS_USER_BATCH_SUCCESS="Batch operation completed."
COM_USERS_USER_FIELD_BACKEND_LANGUAGE_LABEL="Backend Language"
@ -353,19 +413,15 @@ COM_USERS_USER_FIELD_REQUIRERESET_LABEL="Require Password Reset"
COM_USERS_USER_FIELD_RESETCOUNT_LABEL="Password Reset Count"
COM_USERS_USER_FIELD_SENDEMAIL_LABEL="Receive System Emails"
COM_USERS_USER_FIELD_TIMEZONE_LABEL="Time Zone"
COM_USERS_USER_FIELD_TWOFACTOR_LABEL="Authentication Method"
COM_USERS_USER_FIELD_USERNAME_LABEL="Login Name (Username)"
COM_USERS_USER_FORM_EDIT="Edit User"
COM_USERS_USER_FORM_NEW="New User"
COM_USERS_USER_GROUPS_HAVING_ACCESS="User Groups With Viewing Access"
COM_USERS_USER_HEADING="User"
COM_USERS_USER_MULTIFACTOR_AUTH="Multi-factor Authentication"
COM_USERS_USER_NEW_USER_TITLE="New User Details"
COM_USERS_USER_OTEPS="One time emergency passwords"
COM_USERS_USER_OTEPS_DESC="If you do not have access to your two factor authentication device you can use any of the following passwords instead of a regular security code. Each one of these emergency passwords is immediately destroyed upon use. We recommend printing these passwords out and keeping the printout in a safe and accessible location, eg your wallet or a safety deposit box."
COM_USERS_USER_OTEPS_WAIT_DESC="There are no emergency one time passwords generated in your account. The passwords will be generated automatically and displayed here as soon as you activate two factor authentication."
COM_USERS_USER_SAVE_FAILED="An error was encountered while saving the member: %s."
COM_USERS_USER_SAVE_SUCCESS="User saved."
COM_USERS_USER_TWO_FACTOR_AUTH="Two Factor Authentication"
COM_USERS_VIEW_DEBUG_GROUP_TITLE="Permissions for Group #%d, %s"
COM_USERS_VIEW_DEBUG_USER_TITLE="Permissions for User #%d, %s"
COM_USERS_VIEW_EDIT_GROUP_TITLE="Users: Edit Group"
@ -385,3 +441,20 @@ JLIB_RULES_SETTING_NOTES_COM_USERS="Changes apply to this component only.<br><em
COM_CATEGORIES_CATEGORY_ADD_TITLE="User Notes: New Category" ; Categories overrides
COM_CATEGORIES_CATEGORY_EDIT_TITLE="User Notes: Edit Category" ; Categories overrides
; Obsolete language strings since __DEPLOY_VERSION__ -- Remove them in Joomla 5.0
COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_ADMIN="Admin (Backend)"
COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_BOTH="Both"
COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_DESC="You must enable at least one Two Factor Authentication plugin."
COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_LABEL="Enforce Two Factor Authentication"
COM_USERS_CONFIG_FIELD_ENFORCE_2FA_FIELD_SITE="Site (Frontend)"
COM_USERS_CONFIG_FIELD_ENFORCE_2FA_GROUPS_LABEL="Enforce Two Factor Authentication for Usergroups"
COM_USERS_ERROR_SECRET_CODE_WITHOUT_TFA="You have entered a Secret Code but two factor authentication is not enabled in your user account. If you want to use a secret code to secure your login please edit your user profile and enable two factor authentication."
COM_USERS_HEADING_TFA="Two Factor"
COM_USERS_TFA_ACTIVE="Uses Two Factor Authentication"
COM_USERS_TFA_NOTACTIVE="Does not use Two Factor Authentication"
COM_USERS_USER_FIELD_TWOFACTOR_LABEL="Authentication Method"
COM_USERS_USER_OTEPS="One time emergency passwords"
COM_USERS_USER_OTEPS_DESC="If you do not have access to your two factor authentication device you can use any of the following passwords instead of a regular security code. Each one of these emergency passwords is immediately destroyed upon use. We recommend printing these passwords out and keeping the printout in a safe and accessible location, eg your wallet or a safety deposit box."
COM_USERS_USER_OTEPS_WAIT_DESC="There are no emergency one time passwords generated in your account. The passwords will be generated automatically and displayed here as soon as you activate two factor authentication."
COM_USERS_USER_TWO_FACTOR_AUTH="Two Factor Authentication"

View File

@ -349,7 +349,7 @@ JGLOBAL_AUTH_FAIL="Authentication failed"
JGLOBAL_AUTH_FAILED="Failed to authenticate: %s"
JGLOBAL_AUTH_INCORRECT="Incorrect username/password"
JGLOBAL_AUTH_INVALID_PASS="Username and password do not match or you do not have an account yet."
JGLOBAL_AUTH_INVALID_SECRETKEY="The two factor authentication Secret Key is invalid."
JGLOBAL_AUTH_INVALID_SECRETKEY="The Multi-factor Authentication Secret Key is invalid."
JGLOBAL_AUTH_NO_REDIRECT="Could not redirect to server: %s"
JGLOBAL_AUTH_NO_USER="Username and password do not match or you do not have an account yet."
JGLOBAL_AUTH_NOT_CONNECT="Unable to connect to authentication service."
@ -559,7 +559,7 @@ JGLOBAL_ORDER_DIRECTION_LABEL="Direction"
JGLOBAL_ORDERING="Article Order"
JGLOBAL_ORDERING_DATE_DESC="If articles are ordered by date, which date to use."
JGLOBAL_ORDERING_DATE_LABEL="Date for Ordering"
JGLOBAL_OTPMETHOD_NONE="Disable Two Factor Authentication"
JGLOBAL_OTPMETHOD_NONE="Disable Multi-factor Authentication"
JGLOBAL_PAGINATION_DESC="Show or hide Pagination support. Pagination provides page links at the bottom of the page that allow the User to navigate to additional pages. These are needed if the Information will not fit on one page."
JGLOBAL_PAGINATION_LABEL="Pagination"
JGLOBAL_PAGINATION_RESULTS_DESC="Show or hide pagination summary, for example, &quot;Page 1 of 4&quot;."
@ -587,7 +587,7 @@ JGLOBAL_ROOT_PARENT="- No parent -"
JGLOBAL_SAVE_HISTORY_OPTIONS_DESC="Automatically save old versions of an item. If set to Yes, old versions of items are saved automatically. When editing, you may restore from a previous version of the item."
JGLOBAL_SAVE_HISTORY_OPTIONS_LABEL="Enable Versions"
JGLOBAL_SECRETKEY="Secret Key"
JGLOBAL_SECRETKEY_HELP="If you have enabled two factor authentication in your user account please enter your secret key. If you do not know what this means, you can leave this field blank."
JGLOBAL_SECRETKEY_HELP="If you have enabled Multi-factor Authentication in your user account please enter your secret key. If you do not know what this means, you can leave this field blank."
JGLOBAL_SEF_NOIDS_DESC="Remove the IDs from the URLs of this component."
JGLOBAL_SEF_NOIDS_LABEL="Remove IDs from URLs"
JGLOBAL_SEF_TITLE="Routing"

View File

@ -4,5 +4,4 @@
; Note : All ini files need to be saved as UTF-8
PLG_AUTHENTICATION_JOOMLA="Authentication - Joomla"
PLG_AUTHENTICATION_JOOMLA_ERR_SECRET_CODE_WITHOUT_TFA="You need to enable two factor authentication in your user profile to use the secret code field."
PLG_AUTHENTICATION_JOOMLA_XML_DESCRIPTION="<p>Handles Joomla's default User authentication.</p><p><strong>Warning! You must have at least one authentication plugin enabled or you will lose all access to your site.</strong></p>"

View File

@ -0,0 +1,27 @@
; Joomla! Project
; (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
PLG_MULTIFACTORAUTH_EMAIL="Multi-factor Authentication - Authentication Code by Email"
PLG_MULTIFACTORAUTH_EMAIL_CONFIG_FORCE_ENABLE_DESC="Should I automatically add the Authentication Code by Email as an option for <em>all</em> users? Useful to provide a fallback to users who have lost access to their main authenticator and haven't kept a copy of the backup codes at the expense of some degree of control and security."
PLG_MULTIFACTORAUTH_EMAIL_CONFIG_FORCE_ENABLE_LABEL="Force Enable"
PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_120="Two minutes (recommended)"
PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_180="Three minutes"
PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_300="Five minutes"
PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_30="Half a minute"
PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_60="One minute"
PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_DESC="A new code is generated every this many minutes. Do note that a generated code is valid for at least this much time and at most twice as much time. The higher this period is the more likely it is for the code to be brute forced, therefore the least secure your site is. A period of 2 minutes is a good trade-off between usability and security."
PLG_MULTIFACTORAUTH_EMAIL_CONFIG_TIMESTEP_LABEL="Code Generation Period"
PLG_MULTIFACTORAUTH_EMAIL_EMAIL_BODY="Multi-factor Authentication on {SITENAME}. Your authentication code is {CODE}."
PLG_MULTIFACTORAUTH_EMAIL_EMAIL_SUBJECT="Your {SITENAME} authentication code is -{CODE}-"
PLG_MULTIFACTORAUTH_EMAIL_ERR_INVALID_CODE="Invalid or expired code. Please reload the page to send yourself a new code. Make sure to enter the code within two minutes since you requested the code."
PLG_MULTIFACTORAUTH_EMAIL_LBL_DISPLAYEDAS="Code by Email"
PLG_MULTIFACTORAUTH_EMAIL_LBL_LABEL="Authentication Code"
PLG_MULTIFACTORAUTH_EMAIL_LBL_PRE_MESSAGE="You have received a six digit Multi-factor Authentication code in your email. Please enter it below."
PLG_MULTIFACTORAUTH_EMAIL_LBL_SETUP_PLACEHOLDER="Six Digit Authentication Code"
PLG_MULTIFACTORAUTH_EMAIL_LBL_SHORTINFO="Receive six digit codes by email."
PLG_MULTIFACTORAUTH_EMAIL_MAIL_LBL="Joomla: Authentication Code by Email"
PLG_MULTIFACTORAUTH_EMAIL_MAIL_MAIL_DESC="Sent to users from the Multi-factor Authentication page when using the “Authentication Code by Email” option."
PLG_MULTIFACTORAUTH_EMAIL_MAIL_MAIL_TITLE="Code sent by email"
PLG_MULTIFACTORAUTH_EMAIL_XML_DESCRIPTION="Use time limited, six digit security codes sent to you by email."

View File

@ -0,0 +1,7 @@
; Joomla! Project
; (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
PLG_MULTIFACTORAUTH_EMAIL="Multi-factor Authentication - Authentication Code by Email"
PLG_MULTIFACTORAUTH_EMAIL_XML_DESCRIPTION="Use time limited, six digit security codes sent to you by email."

View File

@ -0,0 +1,17 @@
; Joomla! Project
; (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
PLG_MULTIFACTORAUTH_FIXED="Multi-factor Authentication - Fixed Code"
PLG_MULTIFACTORAUTH_FIXED_ERR_EMPTYCODE="Your fixed code cannot be empty."
PLG_MULTIFACTORAUTH_FIXED_LBL_DEFAULTTITLE="Fixed Code"
PLG_MULTIFACTORAUTH_FIXED_LBL_DISPLAYEDAS="Fixed Code"
PLG_MULTIFACTORAUTH_FIXED_LBL_LABEL="Fixed Code"
PLG_MULTIFACTORAUTH_FIXED_LBL_PLACEHOLDER="Enter your Fixed Code"
PLG_MULTIFACTORAUTH_FIXED_LBL_POSTMESSAGE="<p class=\"alert alert-info\">The messages appearing above and below the code area can be customized by overriding the language strings <code>PLG_MULTIFACTORAUTH_FIXED_LBL_PREMESSAGE</code> and <code>PLG_MULTIFACTORAUTH_FIXED_LBL_POSTMESSAGE</code>.</p>"
PLG_MULTIFACTORAUTH_FIXED_LBL_PREMESSAGE="<p>This is a demonstration Multi-factor Authentication plugin for Joomla. You need to enter the fixed code you configured when enabling the Multi-factor Authentication for this user. It effectively works as a second password.</p>"
PLG_MULTIFACTORAUTH_FIXED_LBL_SETUP_POSTMESSAGE="<p class=\"alert alert-info\">The messages appearing above and below the setup area can be customized by overriding the language strings <code>PLG_MULTIFACTORAUTH_FIXED_LBL_SETUP_PREMESSAGE</code> and <code>PLG_MULTIFACTORAUTH_FIXED_LBL_SETUP_POSTMESSAGE</code></p>"
PLG_MULTIFACTORAUTH_FIXED_LBL_SETUP_PREMESSAGE="<p>Enter a Fixed Code below. This Fixed Code will be required to be entered after logging in before you're able to use the site.</p>"
PLG_MULTIFACTORAUTH_FIXED_LBL_SHORTINFO="Choose your own preset code. <strong>For demonstration purposes only</strong>."
PLG_MULTIFACTORAUTH_FIXED_XML_DESCRIPTION="A demonstration Multi-factor Authentication plugin using a fixed code (a &ldquo;second password&rdquo;). <strong>Do not use on live sites, it is not secure.</strong> This plugin is only meant to be used as an example for developers interested in creating their own plugins."

View File

@ -0,0 +1,7 @@
; Joomla! Project
; (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
PLG_MULTIFACTORAUTH_FIXED="Multi-factor Authentication - Fixed Code"
PLG_MULTIFACTORAUTH_FIXED_XML_DESCRIPTION="A demonstration Multi-factor Authentication plugin using a fixed code (a &ldquo;second password&rdquo;). <strong>Do not use on live sites, it is not secure.</strong> This plugin is only meant to be used as an example for developers interested in creating their own plugins."

View File

@ -0,0 +1,20 @@
; Joomla! Project
; (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
PLG_MULTIFACTORAUTH_TOTP="Multi-factor Authentication - Verification Code"
PLG_MULTIFACTORAUTH_TOTP_ERR_VALIDATIONFAILED="You did not enter a valid verification code. Please check your authenticator app setup, and make sure that the time and time zone on your device is set correctly."
PLG_MULTIFACTORAUTH_TOTP_LBL_LABEL="Enter the six digit verification code"
PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_INSTRUCTIONS="Set up your verification code (also known as an “authenticator code”) using the information below. You can use an authenticator app (such Google Authenticator, Authy, LastPass Authenticator, etc), your favorite password manager (1Password, BitWarden, Keeper, KeePassXC, Strongbox, etc) or, in some cases, your browser."
PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_PLACEHOLDER="Six Digit Code"
PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_HEADING="Authenticator app setup"
PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_KEY="Enter this key"
PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_LINK="Click this link"
PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_LINK_NOTE="<small>Only works on supported browsers, e.g. Safari.</small>"
PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_LINK_TEXT="<a href=\"%s\">Set up your verification code</a>"
PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_QR="Scan or right click / long tap this QR code"
PLG_MULTIFACTORAUTH_TOTP_LBL_SETUP_TABLE_SUBHEAD="Use <em>one</em> of the following alternative methods to set up the verification code in your authenticator application, password manager or browser."
PLG_MULTIFACTORAUTH_TOTP_METHOD_TITLE="Verification code"
PLG_MULTIFACTORAUTH_TOTP_SHORTINFO="Use 6-digit codes generated by an app every 30 seconds."
PLG_MULTIFACTORAUTH_TOTP_XML_DESCRIPTION="Multi-factor Authentication for your site's users using six digit verification codes generated by an authenticator app (Google Authenticator, Authy, LastPass Authenticator, etc), a password manager (1Password, BitWarden, Keeper, KeePassXC, Strongbox, etc) or, in some cases, their browser."

View File

@ -0,0 +1,7 @@
; Joomla! Project
; (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
PLG_MULTIFACTORAUTH_TOTP="Multi-factor Authentication - Verification Code"
PLG_MULTIFACTORAUTH_TOTP_XML_DESCRIPTION="Multi-factor Authentication for your site's users using six digit verification codes generated by an authenticator app (Google Authenticator, Authy, LastPass Authenticator, etc), a password manager (1Password, BitWarden, Keeper, KeePassXC, Strongbox, etc) or, in some cases, their browser."

View File

@ -0,0 +1,23 @@
; Joomla! Project
; (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
PLG_MULTIFACTORAUTH_WEBAUTHN="Multi-factor Authentication - Web Authentication"
PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST="Invalid authentication request."
PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_PK="The authenticator registration has failed. The authenticator response received from the browser does not match the Public Key issued by the server. This means that someone tried to hack you or something is broken."
PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_INVALID_USER="For security reasons you are not allowed to register authenticators on behalf of another user."
PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_NO_ATTESTED_DATA="Something went wrong but no further information about the error is available at this time. Please retry registering your authenticator."
PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_CREATE_NO_PK="The server has not issued a Public Key for authenticator registration but somehow received an authenticator registration request from the browser. This means that someone tried to hack you or something is broken."
PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTAVAILABLE_BODY="Your browser doesn't support the WebAuthn standard. Not all browsers are compatible with WebAuthn on all devices just yet."
PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTAVAILABLE_HEAD="Your browser lacks support for WebAuthn"
PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTHTTPS_BODY="Please access the site over HTTPS to enable Multi-factor Authentication with WebAuthn."
PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTHTTPS_HEAD="WebAuthn is only available on HTTPS"
PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NO_STORED_CREDENTIAL="You have not configured an Authenticator yet or the Authenticator you are trying to use is ineligible."
PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_CONFIGURED="You have already configured your Authenticator. Please note that you can only modify its title from this page."
PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_DISPLAYEDAS="Web Authentication"
PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_INSTRUCTIONS="Use the &ldquo;%s&rdquo; button on this page to start the Web Authentication process. Then please follow the instructions given to you by your browser to complete Web Authentication with your preferred Authenticator."
PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_REGISTERKEY="Register your Authenticator"
PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_SHORTINFO="Use WebAuthn with any hardware or software security key."
PLG_MULTIFACTORAUTH_WEBAUTHN_LBL_VALIDATEKEY="Validate with your Authenticator"
PLG_MULTIFACTORAUTH_WEBAUTHN_XML_DESCRIPTION="Use W3C Web Authentication (Webauthn) as a Multi-factor Authentication method. All modern browsers support it. Most browsers offer device-specific authentication protected by a password and/or biometrics (fingerprint sensor, face scan, ...)."

View File

@ -0,0 +1,7 @@
; Joomla! Project
; (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
PLG_MULTIFACTORAUTH_WEBAUTHN="Multi-factor Authentication - Web Authentication"
PLG_MULTIFACTORAUTH_WEBAUTHN_XML_DESCRIPTION="Use W3C Web Authentication (Webauthn) as a Multi-factor Authentication method. All modern browsers support it. Most browsers offer device-specific authentication protected by a password and/or biometrics (fingerprint sensor, face scan, ...)."

View File

@ -0,0 +1,15 @@
; Joomla! Project
; (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
PLG_MULTIFACTORAUTH_YUBIKEY="Multi-factor Authentication - YubiKey"
PLG_MULTIFACTORAUTH_YUBIKEY_CODE_LABEL="YubiKey code"
PLG_MULTIFACTORAUTH_YUBIKEY_ERR_VALIDATIONFAILED="You did not enter a valid YubiKey secret code or the YubiCloud servers are unreachable at this time."
PLG_MULTIFACTORAUTH_YUBIKEY_LBL_AFTERSETUP_INSTRUCTIONS="You have already set up your Yubikey (the one generating codes starting with <code>%s</code>). You can only change its title from this page."
PLG_MULTIFACTORAUTH_YUBIKEY_LBL_SETUP_INSTRUCTIONS="Please provide a code generated by your <a href='https://www.yubico.com/'>YubiKey</a> below and then click or touch the Confirm button. The first twelve characters, which are the unique identification code for your YubiKey, will be saved."
PLG_MULTIFACTORAUTH_YUBIKEY_LBL_SETUP_LABEL="Yubikey Identification"
PLG_MULTIFACTORAUTH_YUBIKEY_LBL_SETUP_PLACEHOLDER="Enter a Yubikey code"
PLG_MULTIFACTORAUTH_YUBIKEY_METHOD_TITLE="YubiKey"
PLG_MULTIFACTORAUTH_YUBIKEY_SHORTINFO="Use YubiKey secure hardware tokens."
PLG_MULTIFACTORAUTH_YUBIKEY_XML_DESCRIPTION="Allows users on your site to use Multi-factor Authentication using a YubiKey secure hardware token. Users need their own Yubikey available from https://www.yubico.com/. To use Multi-factor Authentication users have to edit their user profile and enable Multi-factor Authentication."

View File

@ -0,0 +1,7 @@
; Joomla! Project
; (C) 2013 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
PLG_MULTIFACTORAUTH_YUBIKEY="Multi-factor Authentication - YubiKey"
PLG_MULTIFACTORAUTH_YUBIKEY_XML_DESCRIPTION="Allows users on your site to use Multi-factor Authentication using a YubiKey secure hardware token. Users need their own Yubikey available from https://www.yubico.com/. To use Multi-factor Authentication users have to edit their user profile and enable Multi-factor Authentication."

View File

@ -2,6 +2,7 @@
; (C) 2013 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
; Obsolete since __DEPLOY_VERSION__ -- The entire file must be removed in Joomla 5.0
PLG_TWOFACTORAUTH_TOTP="Two Factor Authentication - Google Authenticator"
PLG_TWOFACTORAUTH_TOTP_ERR_VALIDATIONFAILED="You did not enter a valid security code. Please check your Google Authenticator setup and make sure that the time on your device matches the time on the site."

View File

@ -2,6 +2,7 @@
; (C) 2013 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
; Obsolete since __DEPLOY_VERSION__ -- The entire file must be removed in Joomla 5.0
PLG_TWOFACTORAUTH_TOTP="Two Factor Authentication - Google Authenticator"
PLG_TWOFACTORAUTH_TOTP_XML_DESCRIPTION="Allows users on your site to use two factor authentication using <a href=\"https://en.wikipedia.org/wiki/Google_Authenticator\" target=\"_blank\" rel=\"noopener noreferrer\">Google Authenticator</a> or other compatible time-based One Time Password generators such as <a href=\"https://freeotp.github.io/\" target=\"_blank\">FreeOTP</a>. To use two factor authentication please edit the user profile and enable two factor authentication."

View File

@ -2,6 +2,7 @@
; (C) 2013 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
; Obsolete since __DEPLOY_VERSION__ -- The entire file must be removed in Joomla 5.0
PLG_TWOFACTORAUTH_TOTP_RESET_HEAD="Your YubiKey is already linked to your user account."
PLG_TWOFACTORAUTH_TOTP_RESET_TEXT="If you want to unlink your YubiKey from your user account or use another YubiKey, please first disable two factor authentication and save your user profile. Then come back to this user profile page and re-activate two factor authentication with the YubiKey method."

View File

@ -2,6 +2,7 @@
; (C) 2013 Open Source Matters, Inc. <https://www.joomla.org>
; License GNU General Public License version 2 or later; see LICENSE.txt
; Note : All ini files need to be saved as UTF-8
; Obsolete since __DEPLOY_VERSION__ -- The entire file must be removed in Joomla 5.0
PLG_TWOFACTORAUTH_YUBIKEY="Two Factor Authentication - YubiKey"
PLG_TWOFACTORAUTH_YUBIKEY_XML_DESCRIPTION="Allows users on your site to use two factor authentication using a YubiKey secure hardware token. Users need their own Yubikey available from https://www.yubico.com/. To use two factor authentication users have to edit their user profile and enable two factor authentication."

View File

@ -14,7 +14,6 @@ use Joomla\CMS\Helper\ModuleHelper;
use Joomla\Module\Login\Administrator\Helper\LoginHelper;
$langs = LoginHelper::getLanguageList();
$twofactormethods = AuthenticationHelper::getTwoFactorMethods();
$extraButtons = AuthenticationHelper::getLoginButtons('form-login');
$return = LoginHelper::getReturnUri();

View File

@ -65,26 +65,6 @@ Text::script('JHIDEPASSWORD');
</div>
<div class="mt-4">
<?php if (count($twofactormethods) > 1): ?>
<div class="form-group">
<label for="mod-login-secretkey">
<span class="label"><?php echo Text::_('JGLOBAL_SECRETKEY'); ?></span>
<span class="form-control-hint">
<?php echo Text::_('COM_LOGIN_TWOFACTOR'); ?>
</span>
</label>
<div class="input-group">
<input
name="secretkey"
autocomplete="one-time-code"
id="mod-login-secretkey"
type="text"
class="form-control"
>
</div>
</div>
<?php endif; ?>
<?php if (!empty($langs)) : ?>
<div class="form-group">
<label for="lang">

View File

@ -348,7 +348,7 @@ JGLOBAL_AUTH_FAIL="Authentication failed"
JGLOBAL_AUTH_FAILED="Failed to authenticate: %s"
JGLOBAL_AUTH_INCORRECT="Incorrect username/password"
JGLOBAL_AUTH_INVALID_PASS="Username and password do not match or you do not have an account yet."
JGLOBAL_AUTH_INVALID_SECRETKEY="The two factor authentication Secret Key is invalid."
JGLOBAL_AUTH_INVALID_SECRETKEY="The Multi-factor Authentication Secret Key is invalid."
JGLOBAL_AUTH_NO_REDIRECT="Could not redirect to server: %s"
JGLOBAL_AUTH_NO_USER="Username and password do not match or you do not have an account yet."
JGLOBAL_AUTH_NOT_CONNECT="Unable to connect to authentication service."
@ -553,7 +553,7 @@ JGLOBAL_ORDER_DIRECTION_LABEL="Direction"
JGLOBAL_ORDERING="Article Order"
JGLOBAL_ORDERING_DATE_DESC="If articles are ordered by date, which date to use."
JGLOBAL_ORDERING_DATE_LABEL="Date for Ordering"
JGLOBAL_OTPMETHOD_NONE="Disable Two Factor Authentication"
JGLOBAL_OTPMETHOD_NONE="Disable Multi-factor Authentication"
JGLOBAL_PAGINATION_DESC="Show or hide Pagination support. Pagination provides page links at the bottom of the page that allow the User to navigate to additional pages. These are needed if the Information will not fit on one page."
JGLOBAL_PAGINATION_LABEL="Pagination"
JGLOBAL_PAGINATION_RESULTS_DESC="Show or hide pagination summary, for example, &quot;Page 1 of 4&quot;."
@ -581,7 +581,7 @@ JGLOBAL_ROOT_PARENT="- No parent -"
JGLOBAL_SAVE_HISTORY_OPTIONS_DESC="Automatically save old versions of an item. If set to Yes, old versions of items are saved automatically. When editing, you may restore from a previous version of the item."
JGLOBAL_SAVE_HISTORY_OPTIONS_LABEL="Enable Versions"
JGLOBAL_SECRETKEY="Secret Key"
JGLOBAL_SECRETKEY_HELP="If you have enabled two factor authentication in your user account please enter your secret key. If you do not know what this means, you can leave this field blank."
JGLOBAL_SECRETKEY_HELP="If you have enabled Multi-factor Authentication in your user account please enter your secret key. If you do not know what this means, you can leave this field blank."
JGLOBAL_SEF_NOIDS_DESC="Remove the IDs from the URLs of this component."
JGLOBAL_SEF_NOIDS_LABEL="Remove IDs from URLs"
JGLOBAL_SEF_TITLE="Routing"

View File

@ -0,0 +1 @@
<svg height="45" viewBox="0 0 113 45.000001" width="113" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.26346604 0 0 .25599983 -138.31967 -172.80458)"><circle cx="587.5" cy="764.86218" fill="#fc0" r="62.5"/><path d="m676.999999 702.36215h25v25h-25z"/><path d="m726.999999 702.36212h224.99998v24.999987h-224.99998z"/><path d="m676.999999 752.36215h25v25h-25z"/><path d="m726.999999 752.36212h224.99998v24.999987h-224.99998z"/><path d="m676.999999 802.36215h25v25h-25z"/><path d="m726.999999 802.36212h224.99998v24.999987h-224.99998z"/><text font-family="sans-serif" font-size="64.253807" letter-spacing="0" word-spacing="0" x="569.52271" y="809.21387"><tspan font-family="Arial" font-size="128.507614" x="569.52271" y="809.21387">!</tspan></text></g></svg>

After

Width:  |  Height:  |  Size: 765 B

View File

@ -52,9 +52,9 @@
}
},
{
"name": "com_users.two-factor-switcher.es5",
"name": "com_users.two-factor-focus.es5",
"type": "script",
"uri": "com_users/two-factor-switcher-es5.min.js",
"uri": "com_users/two-factor-focus-es5.min.js",
"dependencies": [
"core"
],
@ -64,11 +64,34 @@
}
},
{
"name": "com_users.two-factor-switcher",
"name": "com_users.two-factor-focus",
"type": "script",
"uri": "com_users/two-factor-switcher.min.js",
"uri": "com_users/two-factor-focus.min.js",
"dependencies": [
"com_users.two-factor-switcher.es5"
"com_users.two-factor-focus.es5"
],
"attributes": {
"type": "module"
}
},
{
"name": "com_users.two-factor-list.es5",
"type": "script",
"uri": "com_users/two-factor-list-es5.min.js",
"dependencies": [
"core"
],
"attributes": {
"nomodule": true,
"defer": true
}
},
{
"name": "com_users.two-factor-list",
"type": "script",
"uri": "com_users/two-factor-list.min.js",
"dependencies": [
"com_users.two-factor-list.es5"
],
"attributes": {
"type": "module"

View File

@ -0,0 +1,62 @@
/**
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
(() => {
'use strict';
document.addEventListener('DOMContentLoaded', () => {
const elCodeField = document.getElementById('users-mfa-code');
const elValidateButton = document.getElementById('users-mfa-captive-button-submit');
const elToolbarButton = document.getElementById('toolbar-user-mfa-submit').querySelector('button');
// Focus the code field. If the code field is hidden, focus the submit button (useful e.g. for WebAuthn)
if (
elCodeField && elCodeField.style.display !== 'none'
&& !elCodeField.classList.contains('visually-hidden') && elCodeField.type !== 'hidden'
) {
elCodeField.focus();
} else {
if (elValidateButton) {
elValidateButton.focus();
}
if (elToolbarButton) {
elToolbarButton.focus();
}
}
// Capture the admin toolbar buttons, make them click the inline buttons
document.querySelectorAll('.button-user-mfa-submit').forEach((elButton) => {
elButton.addEventListener('click', (e) => {
e.preventDefault();
elValidateButton.click();
});
});
document.querySelectorAll('.button-user-mfa-logout').forEach((elButton) => {
elButton.addEventListener('click', (e) => {
e.preventDefault();
const elLogout = document.getElementById('users-mfa-captive-button-logout');
if (elLogout) {
elLogout.click();
}
});
});
document.querySelectorAll('.button-user-mfa-choose-another').forEach((elButton) => {
elButton.addEventListener('click', (e) => {
e.preventDefault();
const elChooseAnother = document.getElementById('users-mfa-captive-form-choose-another');
if (elChooseAnother) {
elChooseAnother.click();
}
});
});
});
})();

View File

@ -0,0 +1,20 @@
/**
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
((Joomla, window) => {
'use strict';
document.addEventListener('DOMContentLoaded', () => {
[].slice.call(
document.querySelectorAll('.com-users-methods-list-method-record-delete'),
).forEach((el) => {
el.addEventListener('click', (event) => {
if (!window.confirm(Joomla.Text._('JGLOBAL_CONFIRM_DELETE'))) {
event.preventDefault();
}
});
});
});
})(Joomla, window);

View File

@ -1,28 +0,0 @@
/**
* @copyright (C) 2018 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
Joomla = window.Joomla || {};
((Joomla) => {
'use strict';
document.addEventListener('DOMContentLoaded', () => {
Joomla.twoFactorMethodChange = () => {
const method = document.getElementById('jform_twofactor_method');
if (method) {
const selectedPane = `com_users_twofactor_${method.value}`;
const twoFactorForms = [].slice.call(document.querySelectorAll('#com_users_twofactor_forms_container > div'));
twoFactorForms.forEach((value) => {
const { id } = value;
if (id !== selectedPane) {
document.getElementById(id).classList.add('hidden');
} else {
document.getElementById(id).classList.remove('hidden');
}
});
}
};
});
})(Joomla);

View File

@ -0,0 +1 @@
<svg height="45" viewBox="0 0 113 45.000001" width="113" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 -1007.3621)"><g stroke="#000" transform="matrix(.44200448 0 0 .32705441 24.178584 700.49417)"><rect fill="#fff" height="88.730034" rx="4.283042" ry="5.00832" stroke-width="1.852602" width="106.23293" x="-53.223305" y="963.70575"/><path d="m-53.149606 968.0786 52.64452973 35.569 54.04316127-35.8604" fill="none" stroke-width="2"/></g><text font-family="sans-serif" font-size="22.963503" letter-spacing="0" word-spacing="0" x="49.70187" y="1038.443"><tspan x="49.70187" y="1038.443">Email</tspan></text></g></svg>

After

Width:  |  Height:  |  Size: 630 B

View File

@ -0,0 +1 @@
<svg height="45" viewBox="0 0 113 45" width="113" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.24829121 0 0 .23800411 -5.214116 -112.50479)"><path d="m24.028307 505.39047h443.94339v123.94337h-443.94339z" fill="none" stroke="#000" stroke-width="6.056613"/><circle cx="71.154327" cy="567.36218" r="29.999994"/><circle cx="147.82098" cy="567.36218" r="29.999994"/><circle cx="224.48766" cy="567.36218" r="29.999994"/><circle cx="301.15433" cy="567.36218" r="29.999994"/><path d="m350 516.36212h9.999984v99.999985h-9.999984z"/></g></svg>

After

Width:  |  Height:  |  Size: 545 B

View File

@ -0,0 +1 @@
<svg height="45" viewBox="0 0 113 45.000001" width="113" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.24829121 0 0 .23800411 -129.11143 -113.50479)"><path d="m523.02832 505.39047h443.94339v123.94337h-443.94339z" fill="none" stroke="#000" stroke-width="6.056613"/><text font-family="sans-serif" font-size="93.592659" letter-spacing="0" word-spacing="0" x="540.51196" y="600.6712"><tspan x="540.51196" y="600.6712">123 456</tspan></text></g></svg>

After

Width:  |  Height:  |  Size: 457 B

View File

@ -0,0 +1,35 @@
{
"$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
"name": "plg_multifactorauth_totp",
"version": "4.0.0",
"description": "Time-Based One Time Password",
"license": "GPL-2.0-or-later",
"assets": [
{
"name": "plg_multifactorauth_totp.setup",
"type": "script",
"uri": "plg_multifactorauth_totp/setup.min.js",
"dependencies": [
"plg_multifactorauth_totp.setup.es5",
"qrcode",
"core"
],
"attributes": {
"type": "module"
}
},
{
"name": "plg_multifactorauth_totp.setup.es5",
"type": "script",
"uri": "plg_multifactorauth_totp/setup-es5.min.js",
"dependencies": [
"qrcode",
"core"
],
"attributes": {
"nomodule": true,
"defer": true
}
}
]
}

View File

@ -0,0 +1,26 @@
/**
* @package Joomla.Plugin
* @subpackage Multifactorauth.webauthn
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
((Joomla, document, qrcode) => {
'use strict';
document.addEventListener('DOMContentLoaded', () => {
const elTarget = document.getElementById('users-mfa-totp-qrcode');
const qrData = Joomla.getOptions('plg_multifactorauth_totp.totp.qr');
if (!elTarget || !qrData) {
return;
}
const qr = qrcode(0, 'H');
qr.addData(qrData);
qr.make();
elTarget.innerHTML = qr.createImgTag(4);
});
// eslint-disable-next-line no-undef
})(Joomla, document, qrcode);

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -0,0 +1,33 @@
{
"$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
"name": "plg_multifactorauth_webauthn",
"version": "4.0.0",
"description": "WebAuthn integration for Joomla Multi-factor Authentication",
"license": "GPL-2.0-or-later",
"assets": [
{
"name": "plg_multifactorauth_webauthn.webauthn",
"type": "script",
"uri": "plg_multifactorauth_webauthn/webauthn.min.js",
"dependencies": [
"plg_multifactorauth_webauthn.webauthn.es5",
"core"
],
"attributes": {
"defer": true
}
},
{
"name": "plg_multifactorauth_webauthn.webauthn.es5",
"type": "script",
"uri": "plg_multifactorauth_webauthn/webauthn-es5.min.js",
"dependencies": [
"core"
],
"attributes": {
"nomodule": true,
"defer": true
}
}
]
}

View File

@ -0,0 +1,196 @@
/**
* @package Joomla.Plugin
* @subpackage Multifactorauth.webauthn
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
((Joomla, document) => {
'use strict';
let authData = null;
const arrayToBase64String = (a) => btoa(String.fromCharCode(...a));
const base64url2base64 = (input) => {
let output = input
.replace(/-/g, '+')
.replace(/_/g, '/');
const pad = output.length % 4;
if (pad) {
if (pad === 1) {
throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding');
}
output += new Array(5 - pad).join('=');
}
return output;
};
const displayError = (message) => {
try {
Joomla.renderMessages({
error: message,
});
} catch (e) {
alert(message);
}
};
const handleError = (message) => {
try {
document.getElementById('plg_multifactorauth_webauthn_validate_button').style.disabled = 'null';
} catch (e) {
// Do nothing
}
displayError(message);
};
const setUp = (e) => {
e.preventDefault();
// Make sure the browser supports Webauthn
if (!('credentials' in navigator)) {
displayError(Joomla.Text._('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTAVAILABLE_HEAD'));
return false;
}
const rawPKData = document.forms['com-users-method-edit']
.querySelectorAll('input[name="pkRequest"]')[0].value;
const publicKey = JSON.parse(atob(rawPKData));
// Convert the public key information to a format usable by the browser's credentials manager
publicKey.challenge = Uint8Array.from(window.atob(base64url2base64(publicKey.challenge)), (c) => c.charCodeAt(0));
publicKey.user.id = Uint8Array.from(window.atob(publicKey.user.id), (c) => c.charCodeAt(0));
if (publicKey.excludeCredentials) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map((data) => {
data.id = Uint8Array.from(
window.atob(base64url2base64(data.id)),
(c) => c.charCodeAt(0),
);
return data;
});
}
// Ask the browser to prompt the user for their authenticator
navigator.credentials.create({ publicKey })
.then((data) => {
const publicKeyCredential = {
id: data.id,
type: data.type,
rawId: arrayToBase64String(new Uint8Array(data.rawId)),
response: {
clientDataJSON: arrayToBase64String(
new Uint8Array(data.response.clientDataJSON),
),
attestationObject: arrayToBase64String(
new Uint8Array(data.response.attestationObject),
),
},
};
// Store the WebAuthn reply
document.getElementById('com-users-method-code').value = btoa(JSON.stringify(publicKeyCredential));
// Submit the form
document.forms['com-users-method-edit'].submit();
}, (error) => {
// An error occurred: timeout, request to provide the authenticator refused, hardware / software
// error...
handleError(error);
});
return false;
};
const validate = () => {
// Make sure the browser supports Webauthn
if (!('credentials' in navigator)) {
displayError(Joomla.Text._('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NOTAVAILABLE_HEAD'));
return;
}
const publicKey = authData;
if (!publicKey.challenge) {
handleError(Joomla.Text._('PLG_MULTIFACTORAUTH_WEBAUTHN_ERR_NO_STORED_CREDENTIAL'));
return;
}
publicKey.challenge = Uint8Array.from(window.atob(base64url2base64(publicKey.challenge)), (c) => c.charCodeAt(0));
if (publicKey.allowCredentials) {
publicKey.allowCredentials = publicKey.allowCredentials.map((data) => {
data.id = Uint8Array.from(
window.atob(base64url2base64(data.id)),
(c) => c.charCodeAt(0),
);
return data;
});
}
navigator.credentials.get({ publicKey })
.then((data) => {
const publicKeyCredential = {
id: data.id,
type: data.type,
rawId: arrayToBase64String(new Uint8Array(data.rawId)),
response: {
authenticatorData: arrayToBase64String(
new Uint8Array(data.response.authenticatorData),
),
clientDataJSON: arrayToBase64String(
new Uint8Array(data.response.clientDataJSON),
),
signature: arrayToBase64String(
new Uint8Array(data.response.signature),
),
userHandle: data.response.userHandle ? arrayToBase64String(
new Uint8Array(data.response.userHandle),
) : null,
},
};
document.getElementById('users-mfa-code').value = btoa(JSON.stringify(publicKeyCredential));
document.getElementById('users-mfa-captive-form').submit();
}, (error) => {
// Example: timeout, interaction refused...
handleError(error);
});
};
const onValidateClick = (event) => {
event.preventDefault();
authData = JSON.parse(window.atob(Joomla.getOptions('com_users.authData')));
document.getElementById('users-mfa-captive-button-submit').style.disabled = 'disabled';
validate();
return false;
};
document.getElementById('multifactorauth-webauthn-missing').style.display = 'none';
if (typeof (navigator.credentials) === 'undefined') {
document.getElementById('multifactorauth-webauthn-missing').style.display = 'block';
document.getElementById('multifactorauth-webauthn-controls').style.display = 'none';
}
window.addEventListener('DOMContentLoaded', () => {
if (Joomla.getOptions('com_users.pagetype') === 'validate') {
document.getElementById('users-mfa-captive-button-submit')
.addEventListener('click', onValidateClick);
} else {
document.querySelectorAll('.multifactorauth_webauthn_setup').forEach((btn) => {
btn.addEventListener('click', setUp);
});
}
});
})(Joomla, document);

View File

@ -0,0 +1 @@
<svg height="45" viewBox="0 0 113 44.999998" width="113" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-189.28571 -431.64791)"><path d="m191.78571 431.64791c-1.385 0-2.5 1.115-2.5 2.5v40c0 1.385 1.115 2.5 2.5 2.5h80c1.385 0 2.5-1.115 2.5-2.5v-5h25c1.385 0 2.5-1.115 2.5-2.5v-25c0-1.385-1.115-2.5-2.5-2.5h-25v-5c0-1.385-1.115-2.5-2.5-2.5zm11.25 16.25a6.25 6.2500003 0 0 1 6.25 6.25 6.25 6.2500003 0 0 1 -6.25 6.25 6.25 6.2500003 0 0 1 -6.25-6.25 6.25 6.2500003 0 0 1 6.25-6.25z"/><g fill="#ffd42a"><ellipse cx="240.53571" cy="454.14792" rx="11.25" ry="11.250001"/><path d="m274.28571 443.89792h25v4.750001h-25z"/><path d="m274.28571 449.21661h22.5v4.750001h-22.5z"/><path d="m274.28571 454.39792h22.5v4.750001h-22.5z"/><path d="m274.28571 459.64792h25v4.750001h-25z"/></g><ellipse cx="240.78571" cy="454.14792" fill="#0f0" rx="2.274937" ry="2.274937"/></g></svg>

After

Width:  |  Height:  |  Size: 874 B

View File

@ -25,16 +25,6 @@
/>
</fieldset>
<field
name="secretkey"
type="text"
label="JGLOBAL_SECRETKEY"
autocomplete="one-time-code"
class=""
filter="int"
size="25"
/>
<fieldset>
<field
name="return"

View File

@ -67,9 +67,4 @@
</fieldset>
<!-- Used to get the two factor authentication configuration -->
<field
name="twofactor"
type="hidden"
/>
</form>

View File

@ -0,0 +1,22 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Site\Controller;
use Joomla\Component\Users\Administrator\Controller\CallbackController as AdminCallbackController;
/**
* Multi-factor Authentication plugins' AJAX callback controller
*
* @since __DEPLOY_VERSION__
*/
class CallbackController extends AdminCallbackController
{
}

View File

@ -0,0 +1,55 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Site\Controller;
use Joomla\CMS\Router\Route;
use Joomla\Component\Users\Administrator\Controller\CaptiveController as AdminCaptiveController;
/**
* Captive Multi-factor Authentication page controller
*
* @since __DEPLOY_VERSION__
*/
class CaptiveController extends AdminCaptiveController
{
/**
* Execute a task by triggering a Method in the derived class.
*
* @param string $task The task to perform.
*
* @return mixed The value returned by the called Method.
*
* @throws \Exception
* @since __DEPLOY_VERSION__
*/
public function execute($task)
{
try
{
return parent::execute($task);
}
catch (\Exception $e)
{
if ($e->getCode() !== 403)
{
throw $e;
}
if ($this->app->getIdentity()->guest)
{
$this->setRedirect(Route::_('index.php?option=com_users&view=login', false));
return null;
}
}
return null;
}
}

View File

@ -112,6 +112,16 @@ class DisplayController extends BaseController
$model = $this->getModel($vName);
break;
case 'captive':
case 'methods':
case 'method':
$controller = $this->factory->createController($vName, 'Site', [], $this->app, $this->input);
$task = $this->input->get('task', '');
return $controller->execute($task);
break;
default:
$model = $this->getModel('Login');
break;

View File

@ -0,0 +1,55 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Site\Controller;
use Joomla\CMS\Router\Route;
use Joomla\Component\Users\Administrator\Controller\MethodController as AdminMethodController;
/**
* Multi-factor Authentication method controller
*
* @since __DEPLOY_VERSION__
*/
class MethodController extends AdminMethodController
{
/**
* Execute a task by triggering a Method in the derived class.
*
* @param string $task The task to perform.
*
* @return mixed The value returned by the called Method.
*
* @throws \Exception
* @since __DEPLOY_VERSION__
*/
public function execute($task)
{
try
{
return parent::execute($task);
}
catch (\Exception $e)
{
if ($e->getCode() !== 403)
{
throw $e;
}
if ($this->app->getIdentity()->guest)
{
$this->setRedirect(Route::_('index.php?option=com_users&view=login', false));
return null;
}
}
return null;
}
}

View File

@ -0,0 +1,55 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Site\Controller;
use Joomla\CMS\Router\Route;
use Joomla\Component\Users\Administrator\Controller\MethodsController as AdminMethodsController;
/**
* Multi-factor Authentication methods selection and management controller
*
* @since __DEPLOY_VERSION__
*/
class MethodsController extends AdminMethodsController
{
/**
* Execute a task by triggering a Method in the derived class.
*
* @param string $task The task to perform.
*
* @return mixed The value returned by the called Method.
*
* @throws \Exception
* @since __DEPLOY_VERSION__
*/
public function execute($task)
{
try
{
return parent::execute($task);
}
catch (\Exception $e)
{
if ($e->getCode() !== 403)
{
throw $e;
}
if ($this->app->getIdentity()->guest)
{
$this->setRedirect(Route::_('index.php?option=com_users&view=login', false));
return null;
}
}
return null;
}
}

View File

@ -0,0 +1,20 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Site\Model;
/**
* Model for managing backup codes
*
* @since __DEPLOY_VERSION__
*/
class BackupcodesModel extends \Joomla\Component\Users\Administrator\Model\BackupcodesModel
{
}

View File

@ -0,0 +1,20 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Site\Model;
/**
* Captive Multi-factor Authentication page's model
*
* @since __DEPLOY_VERSION__
*/
class CaptiveModel extends \Joomla\Component\Users\Administrator\Model\CaptiveModel
{
}

View File

@ -0,0 +1,20 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Site\Model;
/**
* Multi-factor Authentication Method management model
*
* @since __DEPLOY_VERSION__
*/
class MethodModel extends \Joomla\Component\Users\Administrator\Model\MethodModel
{
}

View File

@ -0,0 +1,20 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Site\Model;
/**
* Multi-factor Authentication Methods list page's model
*
* @since __DEPLOY_VERSION__
*/
class MethodsModel extends \Joomla\Component\Users\Administrator\Model\MethodsModel
{
}

View File

@ -23,6 +23,7 @@ use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\String\PunycodeHelper;
use Joomla\CMS\User\User;
use Joomla\CMS\User\UserHelper;
use Joomla\Component\Users\Administrator\Model\UserModel;
use Joomla\Registry\Registry;
/**
@ -263,60 +264,6 @@ class ProfileModel extends FormModel
// Unset block and sendEmail so they do not get overwritten
unset($data['block'], $data['sendEmail']);
// Handle the two factor authentication setup
if (array_key_exists('twofactor', $data))
{
$model = $this->bootComponent('com_users')->getMVCFactory()
->createModel('User', 'Administrator');
$twoFactorMethod = $data['twofactor']['method'];
// Get the current One Time Password (two factor auth) configuration
$otpConfig = $model->getOtpConfig($userId);
if ($twoFactorMethod !== 'none')
{
// Run the plugins
PluginHelper::importPlugin('twofactorauth');
$otpConfigReplies = Factory::getApplication()->triggerEvent('onUserTwofactorApplyConfiguration', array($twoFactorMethod));
// Look for a valid reply
foreach ($otpConfigReplies as $reply)
{
if (!is_object($reply) || empty($reply->method) || ($reply->method != $twoFactorMethod))
{
continue;
}
$otpConfig->method = $reply->method;
$otpConfig->config = $reply->config;
break;
}
// Save OTP configuration.
$model->setOtpConfig($userId, $otpConfig);
// Generate one time emergency passwords if required (depleted or not set)
if (empty($otpConfig->otep))
{
$model->generateOteps($userId);
}
}
else
{
$otpConfig->method = 'none';
$otpConfig->config = array();
$model->setOtpConfig($userId, $otpConfig);
}
// Unset the raw data
unset($data['twofactor']);
// Reload the user record with the updated OTP configuration
$user->load($userId);
}
// Bind the data.
if (!$user->bind($data))
{
@ -358,38 +305,37 @@ class ProfileModel extends FormModel
* @return array
*
* @since 3.2
* @deprecated __DEPLOY_VERSION__ Will be removed in 5.0.
*/
public function getTwofactorform($userId = null)
{
$userId = (!empty($userId)) ? $userId : (int) $this->getState('user.id');
$model = $this->bootComponent('com_users')->getMVCFactory()
->createModel('User', 'Administrator');
$otpConfig = $model->getOtpConfig($userId);
PluginHelper::importPlugin('twofactorauth');
return Factory::getApplication()->triggerEvent('onUserTwofactorShowConfiguration', array($otpConfig, $userId));
return [];
}
/**
* Returns the one time password (OTP) a.k.a. two factor authentication
* configuration for a particular user.
* No longer used
*
* @param integer $userId The numeric ID of the user
* @param integer $userId Ignored
*
* @return \stdClass An object holding the OTP configuration for this user
* @return \stdClass
*
* @since 3.2
* @deprecated __DEPLOY_VERSION__ Will be removed in 5.0
*/
public function getOtpConfig($userId = null)
{
$userId = (!empty($userId)) ? $userId : (int) $this->getState('user.id');
@trigger_error(
sprintf(
'%s() is deprecated. Use \Joomla\Component\Users\Administrator\Helper\Mfa::getUserMfaRecords() instead.',
__METHOD__
),
E_USER_DEPRECATED
);
/** @var UserModel $model */
$model = $this->bootComponent('com_users')
->getMVCFactory()->createModel('User', 'Administrator');
return $model->getOtpConfig($userId);
return $model->getOtpConfig();
}
}

View File

@ -41,6 +41,13 @@ class Router extends RouterView
$this->registerView(new RouterViewConfiguration('registration'));
$this->registerView(new RouterViewConfiguration('remind'));
$this->registerView(new RouterViewConfiguration('reset'));
$this->registerView(new RouterViewConfiguration('callback'));
$this->registerView(new RouterViewConfiguration('captive'));
$this->registerView(new RouterViewConfiguration('methods'));
$method = new RouterViewConfiguration('method');
$method->setKey('id');
$this->registerView($method);
parent::__construct($app, $menu);
@ -48,4 +55,32 @@ class Router extends RouterView
$this->attachRule(new StandardRules($this));
$this->attachRule(new NomenuRules($this));
}
/**
* Get the method ID from a URL segment
*
* @param string $segment The URL segment
* @param array $query The URL query parameters
*
* @return integer
* @since __DEPLOY_VERSION__
*/
public function getMethodId($segment, $query)
{
return (int) $segment;
}
/**
* Get a segment from a method ID
*
* @param integer $id The method ID
* @param array $query The URL query parameters
*
* @return int[]
* @since __DEPLOY_VERSION__
*/
public function getMethodSegment($id, $query)
{
return [$id => (int) $id];
}
}

View File

@ -0,0 +1,20 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Site\View\Captive;
/**
* View for Multi-factor Authentication captive page
*
* @since __DEPLOY_VERSION__
*/
class HtmlView extends \Joomla\Component\Users\Administrator\View\Captive\HtmlView
{
}

View File

@ -63,12 +63,13 @@ class HtmlView extends BaseHtmlView
protected $pageclass_sfx = '';
/**
* Array containing the available two factor authentication methods
* No longer used
*
* @var string
* @var boolean
* @since 4.0.0
* @deprecated __DEPLOY_VERSION__ Will be removed in 5.0.
*/
protected $tfa = '';
protected $tfa = false;
/**
* Additional buttons to show on the login page
@ -110,9 +111,6 @@ class HtmlView extends BaseHtmlView
$this->setLayout($active->query['layout']);
}
$tfa = AuthenticationHelper::getTwoFactorMethods();
$this->tfa = is_array($tfa) && count($tfa) > 1;
$this->extraButtons = AuthenticationHelper::getLoginButtons('com-users-login__form');
// Escape strings for HTML output

View File

@ -0,0 +1,20 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Site\View\Method;
/**
* View for Multi-factor Authentication method add/edit page
*
* @since __DEPLOY_VERSION__
*/
class HtmlView extends \Joomla\Component\Users\Administrator\View\Method\HtmlView
{
}

View File

@ -0,0 +1,20 @@
<?php
/**
* @package Joomla.Administrator
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Users\Site\View\Methods;
/**
* View for Multi-factor Authentication methods list page
*
* @since __DEPLOY_VERSION__
*/
class HtmlView extends \Joomla\Component\Users\Administrator\View\Methods\HtmlView
{
}

View File

@ -19,7 +19,7 @@ use Joomla\CMS\Object\CMSObject;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\User\User;
use Joomla\Component\Users\Administrator\Helper\UsersHelper;
use Joomla\Component\Users\Administrator\Helper\Mfa;
use Joomla\Database\DatabaseDriver;
/**
@ -67,30 +67,6 @@ class HtmlView extends BaseHtmlView
*/
protected $db;
/**
* Configuration forms for all two-factor authentication methods.
*
* @var array
* @since 4.0.0
*/
protected $twofactorform;
/**
* List of two factor authentication methods available.
*
* @var array
* @since 4.0.0
*/
protected $twofactormethods;
/**
* One time password (OTP) a.k.a. two factor authentication configuration for the user.
*
* @var \stdClass
* @since 4.0.0
*/
protected $otpConfig;
/**
* The page class suffix
*
@ -99,6 +75,14 @@ class HtmlView extends BaseHtmlView
*/
protected $pageclass_sfx = '';
/**
* The Multi-factor Authentication configuration interface for the user.
*
* @var string|null
* @since __DEPLOY_VERSION__
*/
protected $mfaConfigurationUI;
/**
* Execute and display a template script.
*
@ -114,14 +98,12 @@ class HtmlView extends BaseHtmlView
$user = Factory::getUser();
// Get the view data.
$this->data = $this->get('Data');
$this->form = $this->getModel()->getForm(new CMSObject(array('id' => $user->id)));
$this->state = $this->get('State');
$this->params = $this->state->get('params');
$this->twofactorform = $this->get('Twofactorform');
$this->twofactormethods = UsersHelper::getTwoFactorMethods();
$this->otpConfig = $this->get('OtpConfig');
$this->db = Factory::getDbo();
$this->data = $this->get('Data');
$this->form = $this->getModel()->getForm(new CMSObject(['id' => $user->id]));
$this->state = $this->get('State');
$this->params = $this->state->get('params');
$this->mfaConfigurationUI = Mfa::getConfigurationInterface($user);
$this->db = Factory::getDbo();
// Check for errors.
if (count($errors = $this->get('Errors')))

View File

@ -0,0 +1,139 @@
<?php
/**
* @package Joomla.Site
* @subpackage com_users
*
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\Component\Users\Site\Model\CaptiveModel;
use Joomla\Component\Users\Site\View\Captive\HtmlView;
use Joomla\Utilities\ArrayHelper;
// phpcs:ignoreFile
/**
* @var HtmlView $this View object
* @var CaptiveModel $model The model
*/
$model = $this->getModel();
if ($this->renderOptions['field_type'] !== 'custom')
{
$this->document->getWebAssetManager()
->useScript('com_users.two-factor-focus');
}
?>
<div class="users-mfa-captive card card-body">
<h2 id="users-mfa-title">
<?php if (!empty($this->renderOptions['help_url'])): ?>
<span class="float-end">
<a href="<?php echo $this->renderOptions['help_url'] ?>"
class="btn btn-sm btn-secondary"
target="_blank"
>
<span class="icon icon-question-sign" aria-hidden="true"></span>
<span class="visually-hidden"><?php echo Text::_('JHELP') ?></span>
</a>
</span>
<?php endif;?>
<?php if (!empty($this->title)): ?>
<?php echo $this->title ?> <small> &ndash;
<?php endif; ?>
<?php if (!$this->allowEntryBatching): ?>
<?php echo $this->escape($this->record->title) ?>
<?php else: ?>
<?php echo $this->escape($this->getModel()->translateMethodName($this->record->method)) ?>
<?php endif; ?>
<?php if (!empty($this->title)): ?>
</small>
<?php endif; ?>
</h2>
<?php if ($this->renderOptions['pre_message']): ?>
<div class="users-mfa-captive-pre-message text-muted">
<?php echo $this->renderOptions['pre_message'] ?>
</div>
<?php endif; ?>
<form action="<?php echo Route::_('index.php?option=com_users&task=captive.validate&record_id=' . ((int) $this->record->id)) ?>"
id="users-mfa-captive-form"
method="post"
class="form-horizontal"
>
<?php echo HTMLHelper::_('form.token') ?>
<div id="users-mfa-captive-form-method-fields">
<?php if ($this->renderOptions['field_type'] == 'custom'): ?>
<?php echo $this->renderOptions['html']; ?>
<?php endif; ?>
<div class="row mb-3">
<?php if ($this->renderOptions['label']): ?>
<label for="users-mfa-code" class="col-sm-3 col-form-label">
<?php echo $this->renderOptions['label'] ?>
</label>
<?php endif; ?>
<div class="col-sm-9 <?php echo $this->renderOptions['label'] ? '' : 'offset-sm-3' ?>">
<?php
$attributes = array_merge(
[
'type' => $this->renderOptions['input_type'],
'name' => 'code',
'value' => '',
'placeholder' => $this->renderOptions['placeholder'] ?? null,
'id' => 'users-mfa-code',
'class' => 'form-control'
],
$this->renderOptions['input_attributes']
);
if (strpos($attributes['class'], 'form-control') === false)
{
$attributes['class'] .= ' form-control';
}
?>
<input <?php echo ArrayHelper::toString($attributes) ?>>
</div>
</div>
</div>
<div id="users-mfa-captive-form-standard-buttons" class="row my-3">
<div class="col-sm-9 offset-sm-3">
<button class="btn btn-primary me-3 <?php echo $this->renderOptions['submit_class'] ?>"
id="users-mfa-captive-button-submit"
style="<?php echo $this->renderOptions['hide_submit'] ? 'display: none' : '' ?>"
type="submit">
<span class="<?php echo $this->renderOptions['submit_icon'] ?>" aria-hidden="true"></span>
<?php echo Text::_($this->renderOptions['submit_text']); ?>
</button>
<a href="<?php echo Route::_('index.php?option=com_users&task=user.logout&' . Factory::getApplication()->getFormToken() . '=1') ?>"
class="btn btn-danger btn-sm" id="users-mfa-captive-button-logout">
<span class="icon icon-lock" aria-hidden="true"></span>
<?php echo Text::_('COM_USERS_MFA_LOGOUT'); ?>
</a>
<?php if (count($this->records) > 1): ?>
<div id="users-mfa-captive-form-choose-another" class="my-3">
<a href="<?php echo Route::_('index.php?option=com_users&view=captive&task=select') ?>">
<?php echo Text::_('COM_USERS_MFA_USE_DIFFERENT_METHOD'); ?>
</a>
</div>
<?php endif; ?>
</div>
</div>
</form>
<?php if ($this->renderOptions['post_message']): ?>
<div class="users-mfa-captive-post-message">
<?php echo $this->renderOptions['post_message'] ?>
</div>
<?php endif; ?>
</div>

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