diff --git a/.env b/.env index 9e2cfacc..c75a6113 100644 --- a/.env +++ b/.env @@ -15,8 +15,10 @@ APP_NAME="Userli" APP_URL="https://users.example.org" +APP_DOMAIN="users.example.org" PROJECT_NAME="example.org" PROJECT_URL="https://www.example.org" +PROJECT_LOGO_URL="https://www.example.org/logo.png" SENDER_ADDRESS="admin@example.org" NOTIFICATION_ADDRESS="monitoring@example.org" SEND_MAIL=1 diff --git a/UPGRADE.md b/UPGRADE.md index bab8b320..7ec66904 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,15 @@ # Upgrade documentation +## Upgrade from 3.0.0 or lower + +The new twofactor authentication (2FA) feature requires the database schema to +be updated: + + ALTER TABLE virtual_users + ADD totp_confirmed TINYINT(1) DEFAULT 0 NOT NULL, + ADD totp_secret VARCHAR(255) DEFAULT NULL; + ADD totp_backup_codes LONGTEXT NOT NULL; + ## Upgrade from 2.6.1 or lower The new OpenPGP WKD feature requires GnuPG (>=2.1.14) to be installed. diff --git a/assets/css/app.css b/assets/css/app.css index c0ecc7de..5dc98615 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -84,6 +84,10 @@ body { margin-bottom: 10px; } +.alert-header { + text-align: center; +} + .flash-notification { position: absolute; right: 10px; @@ -96,3 +100,8 @@ body { .ascii { font-family: Menlo, Monaco, Consolas, "Courier New", monospace; } + +.twofactor-backup-codes { + white-space: pre-line; + padding-left: 15px; +} diff --git a/composer.json b/composer.json index cc881b70..b59a43eb 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,10 @@ "nelmio/security-bundle": "^2.5", "pear/crypt_gpg": "^1.6", "ramsey/uuid": "^4.1", + "scheb/2fa-backup-code": "^5.13", + "scheb/2fa-bundle": "^5.13", + "scheb/2fa-qr-code": "^5.13", + "scheb/2fa-totp": "^5.13", "sensio/framework-extra-bundle": "^5.0.0", "sonata-project/admin-bundle": "3.75.0", "sonata-project/doctrine-orm-admin-bundle": "^3.24", diff --git a/composer.lock b/composer.lock index 80fa5ec8..b6912ca4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,129 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e3b8286b96a428bf53060a7569ba379f", + "content-hash": "cf6ece46cbcabba74de66e66ee6722d4", "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "2.0.7", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "d70c840f68657ce49094b8d91f9ee0cc07fbf66c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/d70c840f68657ce49094b8d91f9ee0cc07fbf66c", + "reference": "d70c840f68657ce49094b8d91f9ee0cc07fbf66c", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.1", + "phpunit/phpunit": "^7 | ^8 | ^9", + "spatie/phpunit-snapshot-assertions": "^4.2.9", + "squizlabs/php_codesniffer": "^3.4" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.7" + }, + "time": "2022-03-14T02:02:36+00:00" + }, + { + "name": "beberlei/assert", + "version": "v3.3.2", + "source": { + "type": "git", + "url": "https://github.com/beberlei/assert.git", + "reference": "cb70015c04be1baee6f5f5c953703347c0ac1655" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/beberlei/assert/zipball/cb70015c04be1baee6f5f5c953703347c0ac1655", + "reference": "cb70015c04be1baee6f5f5c953703347c0ac1655", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "php": "^7.0 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "*", + "phpstan/phpstan": "*", + "phpunit/phpunit": ">=6.0.0", + "yoast/phpunit-polyfills": "^0.1.0" + }, + "suggest": { + "ext-intl": "Needed to allow Assertion::count(), Assertion::isCountable(), Assertion::minCount(), and Assertion::maxCount() to operate on ResourceBundles" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Assert/functions.php" + ], + "psr-4": { + "Assert\\": "lib/Assert" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de", + "role": "Lead Developer" + }, + { + "name": "Richard Quadling", + "email": "rquadling@gmail.com", + "role": "Collaborator" + } + ], + "description": "Thin assertion library for input validation in business models.", + "keywords": [ + "assert", + "assertion", + "validation" + ], + "support": { + "issues": "https://github.com/beberlei/assert/issues", + "source": "https://github.com/beberlei/assert/tree/v3.3.2" + }, + "time": "2021-12-16T21:41:27+00:00" + }, { "name": "brick/math", "version": "0.9.3", @@ -142,6 +263,53 @@ ], "time": "2022-05-24T11:56:16+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "5abf82f213618696dda8e3bf6f64dd042d8542b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/5abf82f213618696dda8e3bf6f64dd042d8542b2", + "reference": "5abf82f213618696dda8e3bf6f64dd042d8542b2", + "shasum": "" + }, + "require-dev": { + "phpunit/phpunit": "^7 | ^8 | ^9", + "squizlabs/php_codesniffer": "^3.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.3" + }, + "time": "2020-10-02T16:03:48+00:00" + }, { "name": "doctrine/annotations", "version": "1.13.2", @@ -1658,6 +1826,81 @@ ], "time": "2022-06-18T20:57:19+00:00" }, + { + "name": "endroid/qr-code", + "version": "3.9.7", + "source": { + "type": "git", + "url": "https://github.com/endroid/qr-code.git", + "reference": "94563d7b3105288e6ac53a67ae720e3669fac1f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/94563d7b3105288e6ac53a67ae720e3669fac1f6", + "reference": "94563d7b3105288e6ac53a67ae720e3669fac1f6", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^2.0", + "khanamiryan/qrcode-detector-decoder": "^1.0.5", + "myclabs/php-enum": "^1.5", + "php": "^7.3||^8.0", + "symfony/options-resolver": "^3.4||^4.4||^5.0", + "symfony/property-access": "^3.4||^4.4||^5.0" + }, + "require-dev": { + "endroid/quality": "^1.5.2", + "setasign/fpdf": "^1.8" + }, + "suggest": { + "ext-gd": "Required for generating PNG images", + "roave/security-advisories": "Avoids installation of package versions with vulnerabilities", + "setasign/fpdf": "Required to use the FPDF writer.", + "symfony/security-checker": "Checks your composer.lock for vulnerabilities" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Endroid\\QrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeroen van den Enden", + "email": "info@endroid.nl" + } + ], + "description": "Endroid QR Code", + "homepage": "https://github.com/endroid/qr-code", + "keywords": [ + "bundle", + "code", + "endroid", + "php", + "qr", + "qrcode" + ], + "support": { + "issues": "https://github.com/endroid/qr-code/issues", + "source": "https://github.com/endroid/qr-code/tree/3.9.7" + }, + "funding": [ + { + "url": "https://github.com/endroid", + "type": "github" + } + ], + "time": "2021-04-20T19:10:54+00:00" + }, { "name": "ircmaxell/password-compat", "version": "v1.0.4", @@ -1862,6 +2105,61 @@ ], "time": "2021-12-28T20:59:55+00:00" }, + { + "name": "khanamiryan/qrcode-detector-decoder", + "version": "1.0.5.2", + "source": { + "type": "git", + "url": "https://github.com/khanamiryan/php-qrcode-detector-decoder.git", + "reference": "04fdd58d86a387065f707dc6d3cc304c719910c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/khanamiryan/php-qrcode-detector-decoder/zipball/04fdd58d86a387065f707dc6d3cc304c719910c1", + "reference": "04fdd58d86a387065f707dc6d3cc304c719910c1", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "phpunit/phpunit": "^5.7 | ^7.5 | ^8.0 | ^9.0" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Common/customFunctions.php" + ], + "psr-4": { + "Zxing\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT", + "Apache-2.0" + ], + "authors": [ + { + "name": "Ashot Khanamiryan", + "email": "a.khanamiryan@gmail.com", + "homepage": "https://github.com/khanamiryan", + "role": "Developer" + } + ], + "description": "QR code decoder / reader", + "homepage": "https://github.com/khanamiryan/php-qrcode-detector-decoder/", + "keywords": [ + "barcode", + "qr", + "zxing" + ], + "support": { + "issues": "https://github.com/khanamiryan/php-qrcode-detector-decoder/issues", + "source": "https://github.com/khanamiryan/php-qrcode-detector-decoder/tree/1.0.5.2" + }, + "time": "2021-07-13T18:46:38+00:00" + }, { "name": "knplabs/knp-menu", "version": "v3.3.0", @@ -2217,6 +2515,66 @@ }, "time": "2015-10-01T19:20:19+00:00" }, + { + "name": "myclabs/php-enum", + "version": "1.8.3", + "source": { + "type": "git", + "url": "https://github.com/myclabs/php-enum.git", + "reference": "b942d263c641ddb5190929ff840c68f78713e937" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/php-enum/zipball/b942d263c641ddb5190929ff840c68f78713e937", + "reference": "b942d263c641ddb5190929ff840c68f78713e937", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "1.*", + "vimeo/psalm": "^4.6.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "MyCLabs\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP Enum contributors", + "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" + } + ], + "description": "PHP Enum implementation", + "homepage": "http://github.com/myclabs/php-enum", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/myclabs/php-enum/issues", + "source": "https://github.com/myclabs/php-enum/tree/1.8.3" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum", + "type": "tidelift" + } + ], + "time": "2021-07-05T08:18:36+00:00" + }, { "name": "nelmio/security-bundle", "version": "v2.12.0", @@ -2285,6 +2643,73 @@ }, "time": "2022-02-23T06:10:58+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v2.6.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "58c3f47f650c94ec05a151692652a868995d2938" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/58c3f47f650c94ec05a151692652a868995d2938", + "reference": "58c3f47f650c94ec05a151692652a868995d2938", + "shasum": "" + }, + "require": { + "php": "^7|^8" + }, + "require-dev": { + "phpunit/phpunit": "^6|^7|^8|^9", + "vimeo/psalm": "^1|^2|^3|^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2022-06-14T06:56:20+00:00" + }, { "name": "pear/console_commandline", "version": "v1.2.4", @@ -2843,6 +3268,221 @@ ], "time": "2021-09-25T23:10:38+00:00" }, + { + "name": "scheb/2fa-backup-code", + "version": "v5.13.2", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-backup-code.git", + "reference": "5584eb7a2c3deb80635c7173ad77858e51129c35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-backup-code/zipball/5584eb7a2c3deb80635c7173ad77858e51129c35", + "reference": "5584eb7a2c3deb80635c7173ad77858e51129c35", + "shasum": "" + }, + "require": { + "scheb/2fa-bundle": "self.version" + }, + "type": "library", + "autoload": { + "psr-4": { + "Scheb\\TwoFactorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Scheb", + "email": "me@christianscheb.de" + } + ], + "description": "Extends scheb/2fa-bundle with backup codes support", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "backup-codes", + "symfony", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-backup-code/tree/v5.13.2" + }, + "time": "2022-01-03T10:21:24+00:00" + }, + { + "name": "scheb/2fa-bundle", + "version": "v5.13.2", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-bundle.git", + "reference": "dc575cc7bc94fa3a52b547698086f2ef015d2e81" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-bundle/zipball/dc575cc7bc94fa3a52b547698086f2ef015d2e81", + "reference": "dc575cc7bc94fa3a52b547698086f2ef015d2e81", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.2.5", + "symfony/config": "^4.4|^5.0", + "symfony/dependency-injection": "^4.4|^5.0", + "symfony/event-dispatcher": "^4.4|^5.0", + "symfony/framework-bundle": "^4.4|^5.0", + "symfony/http-foundation": "^4.4|^5.0", + "symfony/http-kernel": "^4.4|^5.0", + "symfony/property-access": "^4.4|^5.0", + "symfony/security-bundle": "^4.4.1|^5.0", + "symfony/twig-bundle": "^4.4|^5.0" + }, + "conflict": { + "scheb/two-factor-bundle": "*" + }, + "suggest": { + "scheb/2fa-backup-code": "Emergency codes when you have no access to other methods", + "scheb/2fa-email": "Send codes by email", + "scheb/2fa-google-authenticator": "Google Authenticator support", + "scheb/2fa-qr-code": "Generate QR codes for Google Authenticator / TOTP", + "scheb/2fa-totp": "Temporary one-time password (TOTP) support (Google Authenticator compatible)", + "scheb/2fa-trusted-device": "Trusted devices support" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Scheb\\TwoFactorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Scheb", + "email": "me@christianscheb.de" + } + ], + "description": "A generic interface to implement two-factor authentication in Symfony applications", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "symfony", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-bundle/tree/v5.13.2" + }, + "time": "2022-04-16T10:18:34+00:00" + }, + { + "name": "scheb/2fa-qr-code", + "version": "v5.13.2", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-qr-code.git", + "reference": "1b30d3f32c443c20ddbd4830c7b41dfd17e4dcaf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-qr-code/zipball/1b30d3f32c443c20ddbd4830c7b41dfd17e4dcaf", + "reference": "1b30d3f32c443c20ddbd4830c7b41dfd17e4dcaf", + "shasum": "" + }, + "require": { + "endroid/qr-code": "^3.0", + "scheb/2fa-bundle": "self.version" + }, + "type": "library", + "autoload": { + "psr-4": { + "Scheb\\TwoFactorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Scheb", + "email": "me@christianscheb.de" + } + ], + "description": "Extends scheb/2fa-bundle with the ability to render QR-codes for Google Authenticator or TOTP", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "qr-code", + "symfony", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-qr-code/tree/v5.13.2" + }, + "time": "2022-01-03T10:21:24+00:00" + }, + { + "name": "scheb/2fa-totp", + "version": "v5.13.2", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-totp.git", + "reference": "6b03afbfeedd3e6fab491690a9702410e2770244" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-totp/zipball/6b03afbfeedd3e6fab491690a9702410e2770244", + "reference": "6b03afbfeedd3e6fab491690a9702410e2770244", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^2.2", + "scheb/2fa-bundle": "self.version", + "spomky-labs/otphp": "^9.1|^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Scheb\\TwoFactorBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Scheb", + "email": "me@christianscheb.de" + } + ], + "description": "Extends scheb/2fa-bundle with two-factor authentication using TOTP", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "symfony", + "totp", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-totp/tree/v5.13.2" + }, + "time": "2022-01-03T10:21:24+00:00" + }, { "name": "sensio/framework-extra-bundle", "version": "v5.6.1", @@ -3823,6 +4463,81 @@ ], "time": "2022-05-21T21:48:39+00:00" }, + { + "name": "spomky-labs/otphp", + "version": "v10.0.3", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/otphp.git", + "reference": "9784d9f7c790eed26e102d6c78f12c754036c366" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/9784d9f7c790eed26e102d6c78f12c754036c366", + "reference": "9784d9f7c790eed26e102d6c78f12c754036c366", + "shasum": "" + }, + "require": { + "beberlei/assert": "^3.0", + "ext-mbstring": "*", + "paragonie/constant_time_encoding": "^2.0", + "php": "^7.2|^8.0", + "thecodingmachine/safe": "^0.1.14|^1.0|^2.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.0", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-beberlei-assert": "^0.12", + "phpstan/phpstan-deprecation-rules": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpstan/phpstan-strict-rules": "^0.12", + "phpunit/phpunit": "^8.0", + "thecodingmachine/phpstan-safe-rule": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "v10.0": "10.0.x-dev", + "v9.0": "9.0.x-dev", + "v8.3": "8.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "OTPHP\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/otphp/contributors" + } + ], + "description": "A PHP library for generating one time passwords according to RFC 4226 (HOTP Algorithm) and the RFC 6238 (TOTP Algorithm) and compatible with Google Authenticator", + "homepage": "https://github.com/Spomky-Labs/otphp", + "keywords": [ + "FreeOTP", + "RFC 4226", + "RFC 6238", + "google authenticator", + "hotp", + "otp", + "totp" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/otphp/issues", + "source": "https://github.com/Spomky-Labs/otphp/tree/v10.0.3" + }, + "time": "2022-03-17T08:00:35+00:00" + }, { "name": "swiftmailer/swiftmailer", "version": "v6.3.0", @@ -8818,6 +9533,145 @@ ], "time": "2022-06-20T08:31:17+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v1.3.3", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/a8ab0876305a4cdaef31b2350fcb9811b5608dbc", + "reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "phpstan/phpstan": "^0.12", + "squizlabs/php_codesniffer": "^3.2", + "thecodingmachine/phpstan-strict-rules": "^0.12" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.1-dev" + } + }, + "autoload": { + "files": [ + "deprecated/apc.php", + "deprecated/libevent.php", + "deprecated/mssql.php", + "deprecated/stats.php", + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/ingres-ii.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/msql.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/mysqlndMs.php", + "generated/mysqlndQc.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/password.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pdf.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/simplexml.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "psr-4": { + "Safe\\": [ + "lib/", + "deprecated/", + "generated/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v1.3.3" + }, + "time": "2020-10-28T17:51:34+00:00" + }, { "name": "tuupola/base32", "version": "1.0.0", diff --git a/config/bundles.php b/config/bundles.php index 7990d619..4e72d160 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -7,6 +7,7 @@ Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true], Mopa\Bundle\BootstrapBundle\MopaBootstrapBundle::class => ['all' => true], Nelmio\SecurityBundle\NelmioSecurityBundle::class => ['all' => true], + Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true], Sonata\BlockBundle\SonataBlockBundle::class => ['all' => true], Sonata\AdminBundle\SonataAdminBundle::class => ['all' => true], diff --git a/config/doctrine/User.orm.yml b/config/doctrine/User.orm.yml index 2cff851a..774ef902 100644 --- a/config/doctrine/User.orm.yml +++ b/config/doctrine/User.orm.yml @@ -51,6 +51,15 @@ App\Entity\User: mailCryptPublicKey: type: text nullable: true + totpSecret: + type: string + nullable: true + totpConfirmed: + type: boolean + options: + default: 0 + totpBackupCodes: + type: array manyToOne: domain: targetEntity: Domain diff --git a/config/packages/scheb_2fa.yaml b/config/packages/scheb_2fa.yaml new file mode 100644 index 00000000..801d46f8 --- /dev/null +++ b/config/packages/scheb_2fa.yaml @@ -0,0 +1,17 @@ +# See the configuration reference at https://symfony.com/bundles/SchebTwoFactorBundle/5.x/configuration.html +scheb_two_factor: + # Google Authenticator config + totp: + enabled: true + server_name: "%env(APP_DOMAIN)%" + issuer: "%env(PROJECT_NAME)%" + template: Security/2fa_form.html.twig + parameters: + image: "%env(PROJECT_LOGO_URL)%" + # Backup codes config + backup_codes: + enabled: true + + # The security token classes, which trigger two-factor authentication. + security_tokens: + - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 26f677d6..ec1e6792 100755 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -109,6 +109,10 @@ security: logout: success_handler: App\Handler\LogoutSuccessHandler invalidate_session: false + two_factor: + auth_form_path: 2fa_login + check_path: 2fa_login_check + enable_csrf: true # activate different ways to authenticate # https://symfony.com/doc/current/security.html#firewalls-authentication @@ -120,12 +124,16 @@ security: # Note: Only the *first* access control that matches will be used access_control: - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/logout, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "^/[a-z]{2}/init", roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "^/[a-z]{2}/login", roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: "^/[a-z]{2}/logout", roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "^/[a-z]{2}/recovery", roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "^/[a-z]{2}/register", roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "^/[a-z]{2}/$", roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/2fa, roles: IS_AUTHENTICATED_2FA_IN_PROGRESS } + - { path: "^/[a-z]{2}/2fa", roles: IS_AUTHENTICATED_2FA_IN_PROGRESS } - { path: ^/admin, roles: ROLE_DOMAIN_ADMIN } - { path: "^/[a-z]{2}/voucher", roles: ROLE_USER, allow_if: "!is_granted('ROLE_SUSPICIOUS')"} - { path: "^/[a-z]{2}/alias", roles: ROLE_USER, allow_if: "!is_granted('ROLE_SPAM')"} diff --git a/config/routes.yaml b/config/routes.yaml index c5f772d7..a2261937 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -108,6 +108,38 @@ user_recovery_token_ack: requirements: _locale: '%locales%' +# TwofactorController + +user_twofactor: + path: /{_locale}/user/twofactor + controller: App\Controller\TwofactorController::twofactorAction + requirements: + _locale: '%locales%' + +user_twofactor_confirm: + path: /{_locale}/user/twofactor_confirm + controller: App\Controller\TwofactorController::twofactorConfirmAction + requirements: + _locale: '%locales%' + +user_twofactor_backup_ack: + path: /{_locale}/user/twofactor_backup_codes + controller: App\Controller\TwofactorController::twofactorBackupAckAction + requirements: + _locale: '%locales%' + +user_twofactor_disable: + path: /{_locale}/user/twofactor_disable + controller: App\Controller\TwofactorController::twofactorDisableAction + requirements: + _locale: '%locales%' + +user_twofactor_qrcode: + path: /{_locale}/user/twofactor/qrcode + controller: App\Controller\TwofactorController::displayTotpQrCode + requirements: + _locale: '%locales%' + ## InitController init: path: /{_locale}/init diff --git a/config/routes/scheb_2fa.yaml b/config/routes/scheb_2fa.yaml new file mode 100644 index 00000000..53acd388 --- /dev/null +++ b/config/routes/scheb_2fa.yaml @@ -0,0 +1,19 @@ +2fa_login: + path: /{_locale}/2fa + defaults: + _controller: "scheb_two_factor.form_controller::form" + requirements: + _locale: '%locales%' + +2fa_login_check: + path: /{_locale}/2fa_check + requirements: + _locale: '%locales%' + +2fa_login_fallback: + path: /2fa + defaults: + _controller: "scheb_two_factor.form_controller::form" + +2fa_login_check_fallback: + path: /2fa_check diff --git a/config/validator/validation.yaml b/config/validator/validation.yaml index f4e6d328..63e66089 100644 --- a/config/validator/validation.yaml +++ b/config/validator/validation.yaml @@ -94,3 +94,21 @@ App\Form\Model\RecoveryResetPassword: - App\Validator\Constraints\PasswordPolicy: ~ - NotCompromisedPassword: skipOnError: true + +App\Form\Model\Twofactor: + properties: + password: + - Symfony\Component\Security\Core\Validator\Constraints\UserPassword: + message: form.wrong-password + +App\Form\Model\TwofactorConfirm: + properties: + totpSecret: + - NotNull: ~ + - App\Validator\Constraints\TotpSecret: ~ + +App\Form\Model\TwofactorBackupAck: + properties: + ack: + - IsTrue: + message: form.twofactor-backup-ack-missing diff --git a/default_translations/de/messages.de.yml b/default_translations/de/messages.de.yml index 2b27a20b..1a045c61 100644 --- a/default_translations/de/messages.de.yml +++ b/default_translations/de/messages.de.yml @@ -3,8 +3,9 @@ start: intro: Hier kannst du dein E-Mail-Konto erstellen und verwalten. registration-header: Registrierung registration-text: > - Du hast noch kein E-Mail-Konto bei %domain%? Kein Problem! Lass dir von einer - Freund*in einen Einladungscode schicken. Damit kannst du sofort ein Konto erstellen. + Du hast noch kein E-Mail-Konto bei %domain%? Kein Problem! + Lass dir von einer Freund:in einen Einladungscode schicken. Damit kannst + du sofort ein Konto erstellen. registration-button: Konto anlegen account-settings: Konto verwalten account-settings-desc: Passwörter und mehr @@ -13,7 +14,7 @@ start: aliases-delete: Alias-Adresse löschen aliases-desc: Verschleiere deine Identität vouchers: Einladungscodes - vouchers-desc: Lade Freund*innen ein + vouchers-desc: Lade Freund:innen ein webmail: Webmail webmail-desc: Lies und schreib E-Mails openpgp-delete: OpenPGP-Schlüssel löschen @@ -25,13 +26,14 @@ index: title: Verwalte dein E-Mail-Konto voucher-headline: Deine Einladungscodes voucher-limit: Du hast keine Einladungscodes mehr verfügbar. - voucher-disable: "Du bekommst am %date% Uhr drei Einladungscodes gutgeschrieben.\ - \ Dies dient der Vermeidung von Massenregistrierungen und Spam.\n" + voucher-disable: > + Du bekommst am %date% Uhr drei Einladungscodes gutgeschrieben. + Dies dient der Vermeidung von Massenregistrierungen und Spam. voucher-explanation: > - Nutze deine Einladungscodes um Freund*innen hierher einzuladen. Standardmäßig - hast du drei Einladungen zur Verfügung. Wenn du mehr brauchst, wende dich an die - Admins. Wir speichern Einladungsdaten für einen begrenzten Zeitraum um Missbrauch - zu verhindern. + Nutze deine Einladungscodes um Freund:innen hierher einzuladen. + Standardmäßig hast du drei Einladungen zur Verfügung. Wenn du mehr + brauchst, wende dich an die Admins. Wir speichern Einladungsdaten für + einen begrenzten Zeitraum um Missbrauch zu verhindern. alias-headline: Deine %alias_type% Alias-Adressen alias-limit: Du hast das Limit von %alias_limit% erlaubten %alias_type% Alias-Adressen erreicht. @@ -42,7 +44,7 @@ index: delete-description: > Du kannst dein Konto bei %project_name% löschen. Dadurch werden deine E-Mails unwiderruflich gelöscht. Eine spätere Wiederherstellung ist nicht möglich. -

Um dein Konto zu löschen, wirst du aufgefordert dein Passwort einzugeben. +

Um dein Konto zu löschen, wirst du aufgefordert dein Passwort einzugeben. delete-button: Konto löschen recovery-token-button: Wiederherstellungscode verwalten logged_in_as: Du bist angemeldet als %user%. @@ -73,6 +75,8 @@ form: oclock-by: Uhr von actual-password: Aktuelles Passwort new-custom-alias: Neue Alias-Adresse + plain-password: Neues Passwort + plain-password_confirmation: Neues Passwort bestätigen change-password: Passwort ändern delete-account: Konto löschen delete-password: Passwort @@ -85,10 +89,8 @@ form: recovery-start: Wiederherstellen registration-recovery-token-ack: Ich habe den Code an einem sicheren Ort abgespeichert registration-recovery-token-next-button: Weiter + domain: Domain add: Hinzufügen - domain: Domäne - plain-password_confirmation: Neues Passwort bestätigen - plain-password: Neues Passwort openpgp-key-file: "Deinen Schlüssel als Datei hochladen:" openpgp-key-file-mimetype: Die hochgeladende Datei enthält keine OpenPGP-Schlüssel. openpgp-key-upload-or: oder @@ -98,6 +100,39 @@ form: Format hoch. openpgp-key-submit: OpenPGP-Schlüssel veröffentlichen openpgp-delete: OpenPGP-Schlüssel löschen + verify: Bestätigen + twofactor: Zwei-Faktor-Authentifizierung + twofactor-short: 2FA + twofactor-login: Zwei-Faktor-Authentifizierung + twofactor-login-placeholder: 6-stelliger Code + twofactor-login-auth-code: Authentifizierungs-Code + twofactor-backup-code-ack: Ich habe die Backup-Codes an einem sicheren Ort abgelegt. + twofactor-login-desc: > + Öffne deine Zwei-Faktor (TOTP) App für den Code. + twofactor-login-cancel: Login abbrechen + twofactor-enable: Zwei-Faktor-Authentifizierung aktivieren + twofactor-disable: Zwei-Faktor-Authentifizierung deaktivieren + +account: + twofactor: + headline: Zwei-Faktor-Authentifizierung + unset: Zwei-Faktor-Authentifizierung ist noch nicht aktiv. + unset-extra: Du brauchst eine TOTP App auf deinem Smartphone um Zwei-Faktor-Authentifizierung zu aktivieren. + set: Zwei-Faktor-Authentifizierung ist aktiv. + button: Zwei-Faktor-Authentifizierung konfigurieren + lead: Zwei-Faktor-Authentifizierung bringt zusätzliche Sicherheit für deinen Account. + desc-login: > + Mit Zwei-Faktor-Authentifizierung musst du beim Login ein zweites Passwort eingeben, + welches du von deiner Zwei-Faktor-App bekommst. Dieses Extra-Passwort ist nur zum + Login unter %app_url% nötig, nicht für andere Dienste wie etwa deine Mails. + desc-lost: > + Falls du Zugang zu deiner Zwei-Faktor-App verlierst, musst du dich mit Hilfe eines der + Zwei-Faktor Backup Codes anmelden. + enable-lead: > + Scanne das Bild mit deiner Zwei-Faktor-App. + backup-ack-lead: > + Backup-Codes für die Zwei-Faktor-Authentifizierung. Kopiere und speichere sie an einem sicheren Ort. + recovery: header: Konto wiederherstellen lead: Passwort zurücksetzen mit Hilfe des Wiederherstellungscodes @@ -123,7 +158,7 @@ registration: information: Hinweis information-intro: > Du möchtest ein E-Mail-Konto bei %project_name% registrieren. - Eine Registrierung ist nur möglich, wenn dich eine Freund*in zu unserem Dienst + Eine Registrierung ist nur möglich, wenn dich eine Freund:in zu unserem Dienst einlädt, dir also einen Einladungscode übergibt. information-password-policy: > Wichtig: Du benötigst ein Passwort, das aus mindestens 12 Zeichen @@ -194,10 +229,9 @@ flashes: alias-deletion-successful: Deine Alias-Adresse wurde gelöscht. recovery-token-invalid: E-Mail-Adresse oder Wiederherstellungscode sind falsch! recovery-token-ack: Du hast einen Wiederherstellungscode erzeugt. - recovery-reauthenticate: Etwas ist schiefgelaufen. Du musst dich erneut authentifizieren! + revocery-reauthenticate: Etwas ist schief gelaufen. Bitte erneut authentifizieren! recovery-password-changed: Dein Passwort wurde geändert. recovery-next-login: Du kannst dich nun einloggen. - revocery-reauthenticate: Etwas ist schief gelaufen. Bitte erneut authentifizieren! openpgp-deletion-successful: Dein OpenPGP-Schlüssel wurde gelöscht. openpgp-key-upload-successful: Dein OpenPGP-Schlüssel wurde hochgeladen. openpgp-key-upload-error-no-openpgp: Die hochgadene Datei enthält keine OpenPGP-Schlüssel. @@ -222,7 +256,7 @@ random: zufälligen mail: welcome-subject: Willkommen bei %domain% welcome-body: | - Liebe Nutzer*in, + Liebe Nutzer:in, willkommen an Bord. Im Web findest du Anleitungen zur Einrichtung deines E-Mail-Kontos mit verschiedenen Client-Programmen. @@ -232,7 +266,7 @@ mail: E-Mail-Kontos kommen. In einer Woche erhälst du drei Einladungscodes, die du unter - %app_url%/voucher abrufen kannst. Diese kannst du an Freund*innen + %app_url%/voucher abrufen kannst. Diese kannst du an Freund:innen weiterleiten und sie so zu %project_name% einladen. Viel Spaß mit deinem E-Mail-Konto, wünscht dir @@ -281,23 +315,23 @@ Alias: Alias-Adresse Voucher: Einladungscode Reserved Name: Reservierter Name copy-to-clipboard: In Zwischenablage kopieren +init.title: Erstellen der Erstkonfiguration +init_domain: + lead: Vielen Dank fürs installieren von Userli. Derzeit sind keine Domänen oder + Konten vorhanden. + text: Bitte wähle Deine primäre Domäne aus, die Userli behandeln soll. init_user: + lead: Primäre Domäne wurde erstellt. text: E-Mail-Standards erfordern, dass das Konto postmaster@%domain% vorhanden ist. Wähle ein Kennwort für dieses erste Administratorkonto aus und melde dich anschließend an. - lead: Primäre Domäne wurde erstellt. -init_domain: - text: Bitte wähle Deine primäre Domäne aus, die Userli behandeln soll. - lead: Vielen Dank fürs installieren von Userli. Derzeit sind keine Domänen oder - Konten vorhanden. -init.title: Erstellen der Erstkonfiguration openpgp: key-info: Veröffentlichter OpenPGP-Schlüssel delete-button: OpenPGP-Schlüssel löschen information: Information information-details: Hier kannst du deinen OpenPGP-Schlüssel hochladen, damit er in unserem Web Key - Directory veröffentlicht wird. Damit hilfst du Freund*innen, deinen Schlüssel + Directory veröffentlicht wird. Damit hilfst du Freund:innen, deinen Schlüssel automatisch und sicher zu finden. keyid-label: "Schlüssel-ID:" fingerprint-label: "Fingerprint:" diff --git a/default_translations/en/messages.en.yml b/default_translations/en/messages.en.yml index 82ea0a8a..262ee38d 100644 --- a/default_translations/en/messages.en.yml +++ b/default_translations/en/messages.en.yml @@ -45,8 +45,7 @@ index: You can delete your %project_name% e-mail account. This will delete all your e-mails. A recovery at a later date is not possible. -

- To delete your account you need to submit your actual password. +

To delete your account you need to submit your actual password. delete-button: Delete account recovery-token-button: Manage recovery token logged_in_as: You are logged in as %user%. @@ -100,6 +99,40 @@ form: openpgp-key-select-one: Please upload key either as file or as ASCII text. openpgp-key-submit: Publish OpenPGP key openpgp-delete: Delete OpenPGP key + verify: Verify + twofactor: Two-factor authentication + twofactor-short: 2FA + twofactor-login: Two-factor authentication + twofactor-login-placeholder: 6-digit code + twofactor-login-auth-code: Authentication code + twofactor-backup-code-ack: I stored the backup codes at a secure place + twofactor-login-desc: > + Open your two-factor authenticator (TOTP) app to view your authentication code. + twofactor-login-cancel: Cancel login + twofactor-enable: Enable two-factor authentication + twofactor-disable: Disable two-factor authentication + +account: + twofactor: + headline: Two-factor authentication + unset: You don't have two-factor authentication enabled yet. + unset-extra: You need a TOTP app on your mobile device to enable two-factor authentication. + set: Two-factor authentication is enabled. + button: Configure two-factor authentication + lead: Two-factor authentication adds an additional layer of security to your account. + desc-login: > + With two-factor authentication, you have to provide a second password at login, that + you get from your two-factor authenticator app. This extra password is only required + when logging into %app_url%. It's not required for accessing other services like + your mails. + desc-lost: > + If you ever loose access to your two-factor authenticator app, you will have to authenticate + using one of the two-factor backup codes. + enable-lead: > + Scan the image below with your two-factor app. + backup-ack-lead: > + Backup codes for two-factor authentication. Copy and store them at a secure place. + recovery: header: Recover your account lead: Reset the password using your recovery token @@ -128,8 +161,7 @@ registration: We only allow registrations if a friend invites you with an invite code to our services. information-password-policy: > - Important: - You need a password with at least 12 characters. + Important: You need a password with at least 12 characters. Passwords will be tested against a database of known bad passwords. label-email: Your preferred e-mail address @@ -139,9 +171,8 @@ welcome: lead: You have set up an account with us successfully. What are the next steps? next-button: To the overview text: > -

Ready to go

-

In the web you can find guides for settting up your e-mail account with several - client programs.

+

Ready to go

In the web you can find guides for settting up your e-mail + account with several client programs.

In one week you will receive three invite codes, which you can collect at %app_url%. You can pass these on to friends and invite them to register with %project_name%.

diff --git a/default_translations/en/validators.en.yml b/default_translations/en/validators.en.yml index 3e76f71d..5f87d8fc 100644 --- a/default_translations/en/validators.en.yml +++ b/default_translations/en/validators.en.yml @@ -9,6 +9,8 @@ form: missing-domain: "The domain is not under management in this system." registration-recovery-token-noack: "This field needs to be checked." invalid-token: "This token has an invalid format." + twofactor-secret-invalid: "The verification code is not valid." + twofactor-backup-ack-missing: "This needs to be checked." registration: voucher-invalid: "The invite code is invalid." diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index b74985c5..71d79611 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -10,6 +10,7 @@ use Behat\MinkExtension\Context\MinkContext; use Behat\Symfony2Extension\Context\KernelDictionary; use Doctrine\ORM\Tools\SchemaTool; +use OTPHP\TOTP; use Symfony\Component\BrowserKit\Cookie; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; @@ -130,6 +131,16 @@ public function theFollowingUserExists(TableNode $table) case 'mailCryptPublicKey': $user->setMailCryptPublicKey($value); break; + case 'totpConfirmed': + $user->setTotpConfirmed($value); + break; + case 'totpSecret': + $user->setTotpSecret($value); + break; + case 'totp_backup_codes': + $user->generateBackupCodes(); + $this->setPlaceholder('totp_backup_codes', $user->getBackupCodes()); + break; } } @@ -416,6 +427,18 @@ public function iShouldSeeEmptyConsoleOutput() } } + /** + * @Then I enter TOTP backup code + */ + public function iEnterTotpBackupCode() + { + $totpBackupCodes = $this->getPlaceholder('totp_backup_codes'); + if (!$totpBackupCodes) { + throw new \Exception('No TOTP backup codes cached'); + } + $this->fillField('_auth_code', $totpBackupCodes[0]); + } + /** * @Then /^File "([^"]*)" should exist$/ */ diff --git a/features/login.feature b/features/login.feature index 2c059203..9eed9b72 100644 --- a/features/login.feature +++ b/features/login.feature @@ -119,3 +119,47 @@ Feature: Login Then I should be on "/en/" And the response status code should be 200 And I should see text matching "E-mail access has been turned off" + + @login-2fa + Scenario: Login fails with invalid TOTP code if two-factor auth is enabled + When the following User exists: + | email | password | roles | totpConfirmed | totpSecret | + | twofactor@example.org | asdasd | ROLE_USER | 1 | secret | + And I am on "/login" + And I fill in the following: + | username | twofactor@example.org | + | password | asdasd | + And I press "Sign in" + + Then I should be on "/en/2fa" + And I should see text matching "Authentication code" + + And I fill in "_auth_code" with "invalid-token" + And I press "Verify" + + Then I should be on "/en/2fa" + And I should see text matching "The verification code is not valid." + + And I follow "Cancel login" + Then I should be on "/en/" + And the response status code should be 200 + + @login-2fa + Scenario: Login works with two-factor backup code if two-factor auth is enabled + When the following User exists: + | email | password | roles | totpConfirmed | totpSecret | totp_backup_codes | + | twofactor@example.org | asdasd | ROLE_USER | 1 | secret | true | + And I am on "/login" + And I fill in the following: + | username | twofactor@example.org | + | password | asdasd | + And I press "Sign in" + + Then I should be on "/en/2fa" + And I should see text matching "Authentication code" + + And I enter TOTP backup code + And I press "Verify" + + Then I should be on "/en/" + And the response status code should be 200 diff --git a/features/user.feature b/features/user.feature index 4ef13ac5..932b8c2e 100644 --- a/features/user.feature +++ b/features/user.feature @@ -155,3 +155,32 @@ Feature: User And I press "Create new recovery token" Then I should see text matching "The following recovery token got created for you" + + @twofactor-auth + Scenario: Enable two-factor authentication #1 and enter wrong password + When I am authenticated as "user@example.org" + And I am on "/user/twofactor" + And I fill in the following: + | twofactor_password | wrong-password | + And I press "Enable two-factor authentication" + + Then I should be on "/en/user/twofactor" + And I should see text matching "Wrong password" + + @twofactor-auth + Scenario: Enable two-factor authentication + When I am authenticated as "user@example.org" + And I am on "/user/twofactor" + And I fill in the following: + | twofactor_password | asdasd | + And I press "Enable two-factor authentication" + + Then I should be on "/en/user/twofactor" + And I should see text matching "Scan the image below with your two-factor app." + + And I fill in the following: + | twofactor_confirm_totpSecret | invalid-secret | + And I press "Verify" + + Then I should be on "/en/user/twofactor_confirm" + And I should see text matching "The verification code is not valid." diff --git a/src/Admin/UserAdmin.php b/src/Admin/UserAdmin.php index 3299d2be..da0e9fcd 100644 --- a/src/Admin/UserAdmin.php +++ b/src/Admin/UserAdmin.php @@ -85,10 +85,23 @@ protected function configureFormFields(FormMapper $form): void 'help' => (null !== $userId && $user->hasMailCryptSecretBox()) ? 'Disabled because user has a MailCrypt key pair defined' : null, ]) + ->add('totp_confirmed', CheckboxType::class, [ + 'label' => 'form.twofactor', + 'required' => false, + 'data' => (null !== $userId) ? $user->isTotpAuthenticationEnabled() : false, + 'disabled' => null === $userId || !$user->isTotpAuthenticationEnabled(), + 'help' => 'Can only be enabled by user', + ]) + ->add('recovery_secret_box', CheckboxType::class, [ + 'label' => 'Recovery Token', + 'data' => (null !== $userId) ? $user->hasRecoverySecretBox() : false, + 'disabled' => true, + 'help' => 'Can only be configured by user', + ]) ->add('roles', ChoiceType::class, [ 'choices' => [Roles::getAll()], 'multiple' => true, - 'expanded' => true, + 'expanded' => false, 'label' => 'form.roles', ]) ->add('quota', null, [ @@ -182,6 +195,9 @@ protected function configureListFields(ListMapper $list): void ->addIdentifier('email') ->add('creationTime') ->add('updatedTime') + ->add('isTotpAuthenticationEnabled', 'boolean', [ + 'label' => 'form.twofactor-short', + ]) ->add('recoverySecretBox', 'boolean', [ 'label' => 'Recovery Token', ]) @@ -236,6 +252,12 @@ public function preUpdate($object): void } else { $object->updateUpdatedTime(); } + + if (false === $object->getTotpConfirmed()) { + $object->setTotpSecret(null); + $object->setTotpConfirmed(false); + $object->clearBackupCodes(); + } } /** diff --git a/src/Command/MuninAccountCommand.php b/src/Command/MuninAccountCommand.php index 595df697..7f00da05 100644 --- a/src/Command/MuninAccountCommand.php +++ b/src/Command/MuninAccountCommand.php @@ -71,6 +71,9 @@ protected function execute(InputInterface $input, OutputInterface $output): void $output->writeln('mail_crypt_keys.label Active accounts with mailbox encryption'); $output->writeln('mail_crypt_keys.type GAUGE'); $output->writeln('mail_crypt_keys.min 0'); + $output->writeln('twofactor.label Active accounts with two-factor authentication'); + $output->writeln('twofactor.type GAUGE'); + $output->writeln('twofactor.min 0'); $output->writeln('openpgp_keys.label OpenPGP keys'); $output->writeln('openpgp_keys.type GAUGE'); $output->writeln('openpgp_keys.min 0'); @@ -82,6 +85,7 @@ protected function execute(InputInterface $input, OutputInterface $output): void $output->writeln(sprintf('deleted.value %d', $this->userRepository->countDeletedUsers())); $output->writeln(sprintf('recovery_tokens.value %d', $this->userRepository->countUsersWithRecoveryToken())); $output->writeln(sprintf('mail_crypt_keys.value %d', $this->userRepository->countUsersWithMailCrypt())); + $output->writeln(sprintf('twofactor.value %d', $this->userRepository->countUsersWithTwofactor())); $output->writeln(sprintf('openpgp_keys.value %d', $this->openPgpKeyRepository->countKeys())); } } diff --git a/src/Command/UsersResetCommand.php b/src/Command/UsersResetCommand.php index c909af79..d3a459ae 100644 --- a/src/Command/UsersResetCommand.php +++ b/src/Command/UsersResetCommand.php @@ -129,10 +129,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->write(sprintf("New recovery token (please hand over to user): %s\n\n", $user->getPlainRecoveryToken())); } + // Reset twofactor settings + $user->setTotpConfirmed(false); + $user->setTotpSecret(null); + $user->clearBackupCodes(); + // Clear plain password and flush changes to database $user->eraseCredentials(); $user->erasePlainMailCryptPrivateKey(); $user->erasePlainRecoveryToken(); + $this->manager->flush(); // Clear users mailbox diff --git a/src/Controller/StartController.php b/src/Controller/StartController.php index 921dae05..a37250e6 100644 --- a/src/Controller/StartController.php +++ b/src/Controller/StartController.php @@ -236,6 +236,7 @@ public function accountAction(Request $request): Response 'user_domain' => $user->getDomain(), 'password_form' => $passwordChangeForm->createView(), 'recovery_secret_set' => $user->hasRecoverySecretBox(), + 'twofactor_enabled' => $user->isTotpAuthenticationEnabled(), ] ); } diff --git a/src/Controller/TwofactorController.php b/src/Controller/TwofactorController.php new file mode 100644 index 00000000..98e9f418 --- /dev/null +++ b/src/Controller/TwofactorController.php @@ -0,0 +1,304 @@ +getUser()) { + throw new \Exception('User should not be null'); + } + + $twofactorModel = new Twofactor(); + $form = $this->createForm(TwofactorType::class, $twofactorModel); + + $twofactorConfirmModel = new TwofactorConfirm(); + $confirmForm = $this->createForm( + TwofactorConfirmType::class, + $twofactorConfirmModel, + [ + 'action' => $this->generateUrl('user_twofactor_confirm'), + 'method' => 'post', + ] + ); + + $twofactorDisableModel = new Twofactor(); + $disableForm = $this->createForm( + TwofactorType::class, + $twofactorDisableModel, + [ + 'action' => $this->generateUrl('user_twofactor_disable'), + 'method' => 'post', + ] + ); + + if ('POST' === $request->getMethod()) { + $form->handleRequest($request); + $disableForm->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $user->setTotpSecret($totpAuthenticator->generateSecret()); + $user->generateBackupCodes(); + $this->getDoctrine()->getManager()->flush(); + + return $this->render('User/twofactor.html.twig', + [ + 'form' => $form->createView(), + 'confirmForm' => $confirmForm->createView(), + 'disableForm' => $disableForm->createView(), + 'twofactor_enable' => true, + 'twofactor_enabled' => $user->isTotpAuthenticationEnabled(), + ] + ); + } + } + + return $this->render('User/twofactor.html.twig', + [ + 'form' => $form->createView(), + 'confirmForm' => $confirmForm->createView(), + 'disableForm' => $disableForm->createView(), + 'twofactor_enable' => false, + 'twofactor_enabled' => $user->isTotpAuthenticationEnabled(), + ] + ); + } + + /** + * @throws \Exception + */ + public function twofactorConfirmAction(Request $request): Response + { + if (null === $user = $this->getUser()) { + throw new \Exception('User should not be null'); + } + + $twofactorConfirmModel = new TwofactorConfirm(); + $confirmForm = $this->createForm(TwofactorConfirmType::class, $twofactorConfirmModel); + + if ('POST' === $request->getMethod()) { + $confirmForm->handleRequest($request); + + $twofactorModel = new Twofactor(); + $form = $this->createForm( + TwofactorType::class, + $twofactorModel, + [ + 'action' => $this->generateUrl('user_twofactor'), + 'method' => 'post', + ] + ); + + $twofactorDisableModel = new Twofactor(); + $disableForm = $this->createForm( + TwofactorType::class, + $twofactorDisableModel, + [ + 'action' => $this->generateUrl('user_twofactor_disable'), + 'method' => 'post', + ] + ); + + $twofactorBackupAckModel = new TwofactorBackupAck(); + $backupAckForm = $this->createForm( + TwofactorBackupAckType::class, + $twofactorBackupAckModel, + [ + 'action' => $this->generateUrl('user_twofactor_backup_ack'), + 'method' => 'post', + ] + ); + + if ($confirmForm->isSubmitted()) { + if ($confirmForm->isValid()) { + return $this->render('User/twofactor.html.twig', + [ + 'form' => $form->createView(), + 'confirmForm' => $confirmForm->createView(), + 'backupAckForm' => $backupAckForm->createView(), + 'disableForm' => $disableForm->createView(), + 'twofactor_enable' => true, + 'twofactor_enabled' => $user->isTotpAuthenticationEnabled(), + 'twofactor_backup_codes' => $user->getBackupCodes(), + ] + ); + } + + // Again render form to display form errors + return $this->render('User/twofactor.html.twig', + [ + 'form' => $form->createView(), + 'confirmForm' => $confirmForm->createView(), + 'backupAckForm' => $backupAckForm->createView(), + 'disableForm' => $disableForm->createView(), + 'twofactor_enable' => true, + 'twofactor_enabled' => $user->isTotpAuthenticationEnabled(), + ] + ); + } + } + + return $this->redirectToRoute('user_twofactor'); + } + + /** + * @throws \Exception + */ + public function twofactorBackupAckAction(Request $request): Response + { + if (null === $user = $this->getUser()) { + throw new \Exception('User should not be null'); + } + + $twofactorBackupAckModel = new TwofactorBackupAck(); + $backupAckForm = $this->createForm( + TwofactorBackupAckType::class, + $twofactorBackupAckModel, + [ + 'action' => $this->generateUrl('user_twofactor_backup_ack'), + 'method' => 'post', + ] + ); + + if ('POST' === $request->getMethod()) { + $backupAckForm->handleRequest($request); + + if ($backupAckForm->isSubmitted()) { + if ($backupAckForm->isValid()) { + $user->setTotpConfirmed(true); + $this->getDoctrine()->getManager()->flush(); + + return $this->redirectToRoute('user_twofactor'); + } else { + $twofactorModel = new Twofactor(); + $form = $this->createForm( + TwofactorType::class, + $twofactorModel, + [ + 'action' => $this->generateUrl('user_twofactor'), + 'method' => 'post', + ] + ); + + $twofactorConfirmModel = new TwofactorConfirm(); + $confirmForm = $this->createForm( + TwofactorConfirmType::class, + $twofactorConfirmModel, + [ + 'action' => $this->generateUrl('user_twofactor_confirm'), + 'method' => 'post', + ] + ); + + $twofactorDisableModel = new Twofactor(); + $disableForm = $this->createForm( + TwofactorType::class, + $twofactorDisableModel, + [ + 'action' => $this->generateUrl('user_twofactor_disable'), + 'method' => 'post', + ] + ); + + return $this->render('User/twofactor.html.twig', + [ + 'form' => $form->createView(), + 'confirmForm' => $confirmForm->createView(), + 'backupAckForm' => $backupAckForm->createView(), + 'disableForm' => $disableForm->createView(), + 'twofactor_enable' => true, + 'twofactor_enabled' => $user->isTotpAuthenticationEnabled(), + 'twofactor_backup_codes' => $user->getBackupCodes(), + ] + ); + } + } + } + + return $this->redirectToRoute('user_twofactor'); + } + + /** + * @throws \Exception + */ + public function twofactorDisableAction(Request $request): Response + { + if (null === $user = $this->getUser()) { + throw new \Exception('User should not be null'); + } + + $twofactorDisableModel = new Twofactor(); + $disableForm = $this->createForm(TwofactorType::class, $twofactorDisableModel); + + if ('POST' === $request->getMethod()) { + $disableForm->handleRequest($request); + + if ($disableForm->isSubmitted()) { + if ($disableForm->isValid()) { + $user->setTotpConfirmed(false); + $user->setTotpSecret(null); + $this->getDoctrine()->getManager()->flush(); + + return $this->redirectToRoute('user_twofactor'); + } + + $twofactorModel = new Twofactor(); + $form = $this->createForm( + TwofactorType::class, + $twofactorModel, + [ + 'action' => $this->generateUrl('user_twofactor'), + 'method' => 'post', + ] + ); + + // Again render form to display form errors + return $this->render('User/twofactor.html.twig', + [ + 'form' => $form->createView(), + 'disableForm' => $disableForm->createView(), + 'twofactor_enable' => false, + 'twofactor_enabled' => $user->isTotpAuthenticationEnabled(), + ] + ); + } + } + + return $this->redirectToRoute('user_twofactor'); + } + + /** + * @return Response + * + * @throws \Exception + */ + public function displayTotpQrCode(QrCodeGenerator $qrCodeGenerator) + { + /** @var $user TwoFactorInterface */ + if (null === $user = $this->getUser()) { + throw new \Exception('User should not be null'); + } + + $qrCode = $qrCodeGenerator->getTotpQrCode($user); + + return new Response($qrCode->writeString(), 200, ['Content-Type' => 'image/png']); + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 050de921..6ed0635e 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -23,11 +23,15 @@ use App\Traits\RecoverySecretBoxTrait; use App\Traits\RecoveryStartTimeTrait; use App\Traits\SaltTrait; +use App\Traits\TwofactorBackupCodeTrait; +use App\Traits\TwofactorTrait; use App\Traits\UpdatedTimeTrait; +use Scheb\TwoFactorBundle\Model\BackupCodeInterface; +use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface; use Symfony\Component\Security\Core\Encoder\EncoderAwareInterface; use Symfony\Component\Security\Core\User\UserInterface; -class User implements UserInterface, EncoderAwareInterface +class User implements UserInterface, EncoderAwareInterface, TwoFactorInterface, BackupCodeInterface { use IdTrait; use CreationTimeTrait; @@ -50,6 +54,8 @@ class User implements UserInterface, EncoderAwareInterface use PlainMailCryptPrivateKeyTrait; use MailCryptPublicKeyTrait; use OpenPgpKeyTrait; + use TwofactorTrait; + use TwofactorBackupCodeTrait; public const CURRENT_PASSWORD_VERSION = 2; diff --git a/src/Form/Model/Twofactor.php b/src/Form/Model/Twofactor.php new file mode 100644 index 00000000..b11fb845 --- /dev/null +++ b/src/Form/Model/Twofactor.php @@ -0,0 +1,9 @@ +add('ack', CheckboxType::class, [ + 'required' => true, + 'label' => 'form.twofactor-backup-code-ack', + ]) + ->add('submit', SubmitType::class, ['label' => 'form.verify']); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => TwofactorBackupAck::class]); + } + + public function getBlockPrefix(): string + { + return self::NAME; + } +} diff --git a/src/Form/TwofactorConfirmType.php b/src/Form/TwofactorConfirmType.php new file mode 100644 index 00000000..6355fb7c --- /dev/null +++ b/src/Form/TwofactorConfirmType.php @@ -0,0 +1,38 @@ +add('totpSecret', TextType::class, [ + 'required' => true, + 'label' => 'form.twofactor-login-auth-code', + ]) + ->add('submit', SubmitType::class, ['label' => 'form.verify']); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => TwofactorConfirm::class]); + } + + public function getBlockPrefix(): string + { + return self::NAME; + } +} diff --git a/src/Form/TwofactorType.php b/src/Form/TwofactorType.php new file mode 100644 index 00000000..eeada88f --- /dev/null +++ b/src/Form/TwofactorType.php @@ -0,0 +1,35 @@ +add('password', PasswordType::class, ['label' => 'form.password']) + ->add('submit', SubmitType::class, ['label' => 'form.twofactor-enable']); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => Twofactor::class]); + } + + public function getBlockPrefix(): string + { + return self::NAME; + } +} diff --git a/src/Handler/UserAuthenticationHandler.php b/src/Handler/UserAuthenticationHandler.php index ab078860..73ed8a73 100644 --- a/src/Handler/UserAuthenticationHandler.php +++ b/src/Handler/UserAuthenticationHandler.php @@ -4,8 +4,8 @@ use App\Entity\User; use App\Event\LoginEvent; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Security\Core\Encoder\EncoderFactory; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** * Class UserAuthenticationHandler. diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 772d5e47..e531ee9a 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -90,4 +90,13 @@ public function countUsersWithMailCrypt(): int ->andWhere(Criteria::expr()->eq('mailCrypt', true)) )->count(); } + + public function countUsersWithTwofactor(): int + { + return $this->matching(Criteria::create() + ->where(Criteria::expr()->eq('deleted', false)) + ->andWhere(Criteria::expr()->eq('totpConfirmed', 1)) + ->andWhere(Criteria::expr()->neq('totpSecret', null)) + )->count(); + } } diff --git a/src/Traits/TwofactorBackupCodeTrait.php b/src/Traits/TwofactorBackupCodeTrait.php new file mode 100644 index 00000000..27b9efd3 --- /dev/null +++ b/src/Traits/TwofactorBackupCodeTrait.php @@ -0,0 +1,59 @@ +totpBackupCodes; + } + + /** + * {@inheritdoc} + */ + public function isBackupCode(string $code): bool + { + return in_array($code, $this->totpBackupCodes, true); + } + + /** + * {@inheritdoc} + */ + public function invalidateBackupCode(string $code): void + { + $key = array_search($code, $this->totpBackupCodes, true); + if (false !== $key) { + unset($this->totpBackupCodes[$key]); + } + } + + public function clearBackupCodes(): void + { + $this->totpBackupCodes = []; + } + + /** + * {@inheritdoc} + */ + public function addBackupCode(string $backupCode): void + { + if (!in_array($backupCode, $this->totpBackupCodes)) { + $this->totpBackupCodes[] = $backupCode; + } + } + + public function generateBackupCodes(): array + { + $codes = []; + for ($i = 0; $i < 6; ++$i) { + $codes[] = (string) random_int(100000, 999999); + } + $this->totpBackupCodes = $codes; + + return $this->totpBackupCodes; + } +} diff --git a/src/Traits/TwofactorTrait.php b/src/Traits/TwofactorTrait.php new file mode 100644 index 00000000..0fa370ef --- /dev/null +++ b/src/Traits/TwofactorTrait.php @@ -0,0 +1,55 @@ +totpConfirmed; + } + + /** + * {@inheritdoc} + */ + public function getTotpAuthenticationUsername(): string + { + return $this->getUsername(); + } + + /** + * {@inheritdoc} + */ + public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface + { + // Settings that are compatible with Google Authenticator specification + return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6); + } + + public function setTotpSecret(?string $totpSecret): void + { + $this->totpSecret = $totpSecret; + } + + public function getTotpConfirmed(): bool + { + return (bool) $this->totpConfirmed; + } + + public function setTotpConfirmed(bool $totpConfirmed): void + { + $this->totpConfirmed = $totpConfirmed; + } +} diff --git a/src/Validator/Constraints/TotpSecret.php b/src/Validator/Constraints/TotpSecret.php new file mode 100644 index 00000000..9cb08773 --- /dev/null +++ b/src/Validator/Constraints/TotpSecret.php @@ -0,0 +1,14 @@ +tokenStorage = $tokenStorage; + $this->totpAuthenticator = $totpAuthenticator; + } + + /** + * @param $value + */ + public function validate($value, Constraint $constraint): bool + { + if (!$constraint instanceof TotpSecret) { + throw new UnexpectedTypeException($constraint, TotpSecret::class); + } + + if (null === $value || '' === $value) { + return true; + } + + if (!is_string($value)) { + throw new UnexpectedValueException($value, 'string'); + } + + /** @var $user TwoFactorInterface */ + $user = $this->tokenStorage->getToken()->getUser(); + + if (!$this->totpAuthenticator->checkCode($user, $value)) { + $this->context->buildViolation('form.twofactor-secret-invalid') + ->addViolation(); + + return false; + } + + return true; + } +} diff --git a/symfony.lock b/symfony.lock index fdffb01e..dccfa904 100644 --- a/symfony.lock +++ b/symfony.lock @@ -185,6 +185,19 @@ "ramsey/uuid": { "version": "3.8.0" }, + "scheb/2fa-bundle": { + "version": "5.13", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.0", + "ref": "0a83961ef50ff91812b229a6f0caf28431d94aec" + }, + "files": [ + "config/packages/scheb_2fa.yaml", + "config/routes/scheb_2fa.yaml" + ] + }, "sensio/framework-extra-bundle": { "version": "5.2", "recipe": { diff --git a/templates/Security/2fa_form.html.twig b/templates/Security/2fa_form.html.twig new file mode 100644 index 00000000..714c1feb --- /dev/null +++ b/templates/Security/2fa_form.html.twig @@ -0,0 +1,28 @@ +{% extends 'base.html.twig' %} + +{% block subtitle %}{{ "form.twofactor-login"|trans }}{% endblock %} + +{% block content %} + +
+
+ + {% if authenticationError %} + + {% endif %} + +
+ + + + {% if isCsrfProtectionEnabled %} + + {% endif %} + {{ "form.twofactor-login-desc"|trans }} + + {{ "form.twofactor-login-cancel"|trans() }} +
+
+
+ +{% endblock %} diff --git a/templates/Start/account.html.twig b/templates/Start/account.html.twig index 483cfe0d..14938181 100644 --- a/templates/Start/account.html.twig +++ b/templates/Start/account.html.twig @@ -13,6 +13,9 @@
{% include 'Start/change_password.html.twig' %}
+
+ {% include 'Start/twofactor.html.twig' %} +
{% include 'Start/recovery_token.html.twig' %}
diff --git a/templates/Start/twofactor.html.twig b/templates/Start/twofactor.html.twig new file mode 100644 index 00000000..4ddd2c0e --- /dev/null +++ b/templates/Start/twofactor.html.twig @@ -0,0 +1,9 @@ +

{{ "account.twofactor.headline"|trans }}

+

{{ "account.twofactor.lead"|trans }}

+{% if not twofactor_enabled %} +

+ + {{ "account.twofactor.unset"|trans }} +

+{% endif %} +

{{ "account.twofactor.button"|trans }}

diff --git a/templates/Twofactor/twofactor_notes.html.twig b/templates/Twofactor/twofactor_notes.html.twig new file mode 100644 index 00000000..587492ad --- /dev/null +++ b/templates/Twofactor/twofactor_notes.html.twig @@ -0,0 +1,9 @@ +
+

+

{{ "account.twofactor.lead"|trans }}

+
+ +
diff --git a/templates/User/twofactor.html.twig b/templates/User/twofactor.html.twig new file mode 100644 index 00000000..e7d7b373 --- /dev/null +++ b/templates/User/twofactor.html.twig @@ -0,0 +1,71 @@ +{% extends 'base.html.twig' %} + +{% block subtitle %}{{ "account.twofactor.headline"|trans }}{% endblock %} + +{% form_theme form 'Form/fields.html.twig' %} +{% form_theme disableForm 'Form/fields.html.twig' %} + +{% block breadcrumbs %} + +{% endblock %} +{% block content %} +
+
+

{{ "account.twofactor.headline"|trans }}

+
+
+
+
+ {% if twofactor_enable and twofactor_backup_codes is defined %} + {% include 'User/twofactor_backup_ack.html.twig' %} + {% elseif twofactor_enable %} + {% include 'User/twofactor_enable.html.twig' %} + {% else %} + {% if not twofactor_enabled %} +
+ + {{ "account.twofactor.unset"|trans }} + {{ "account.twofactor.unset-extra"|trans }} +
+ + {{ form_start(form) }} + {{ form_errors(form) }} +
+ {{ form_label(form.password) }} + {{ form_errors(form.password) }} + {{ form_widget(form.password, {'attr': {'class': 'form-control' }}) }} +
+ +
+ {{ form_widget(form.submit, {'attr': {'class': 'btn btn-primary' }}) }} +
+ {{ form_end(form) }} + {% else %} +
+ + {{ "account.twofactor.set"|trans }} +
+ {{ form_start(disableForm) }} + {{ form_errors(disableForm) }} +
+ {{ form_label(disableForm.password) }} + {{ form_errors(disableForm.password) }} + {{ form_widget(disableForm.password, {'attr': {'class': 'form-control' }}) }} +
+ +
+ {{ form_widget(disableForm.submit, {'attr': {'class': 'btn btn-primary btn-danger'}, 'label': 'form.twofactor-disable'}) }} +
+ {{ form_end(disableForm) }} + {% endif %} + {% endif %} +
+
+ {% include 'Twofactor/twofactor_notes.html.twig' %} +
+
+{% endblock %} diff --git a/templates/User/twofactor_backup_ack.html.twig b/templates/User/twofactor_backup_ack.html.twig new file mode 100644 index 00000000..30bef570 --- /dev/null +++ b/templates/User/twofactor_backup_ack.html.twig @@ -0,0 +1,23 @@ +{% form_theme backupAckForm 'Form/fields.html.twig' %} + +
+ {{ "account.twofactor.backup-ack-lead"|trans }} +
+
+
+        {% for key, value in twofactor_backup_codes %}
+            {{ value }}
+        {% endfor %}
+    
+
+{{ form_start(backupAckForm) }} +{{ form_errors(backupAckForm) }} +
+ {{ form_errors(backupAckForm.ack) }} + {{ form_widget(backupAckForm.ack) }} + {{ form_label(backupAckForm.ack) }} +
+
+ {{ form_widget(backupAckForm.submit, {'attr': {'class': 'btn btn-primary' } }) }} +
+{{ form_end(backupAckForm) }} diff --git a/templates/User/twofactor_enable.html.twig b/templates/User/twofactor_enable.html.twig new file mode 100644 index 00000000..cf787e17 --- /dev/null +++ b/templates/User/twofactor_enable.html.twig @@ -0,0 +1,24 @@ +{% form_theme confirmForm 'Form/fields.html.twig' %} + +
+ {{ "account.twofactor.enable-lead"|trans }} +
+
+ +
+{{ form_start(confirmForm) }} +{{ form_errors(confirmForm) }} +
+ {{ form_label(confirmForm.totpSecret) }} + {{ form_errors(confirmForm.totpSecret) }} + {{ form_widget(confirmForm.totpSecret, {'attr': { + 'class': 'form-control', + 'autofocus': '', + 'placeholder': 'form.twofactor-login-placeholder', + 'autocomplete': 'off' + }}) }} +
+
+ {{ form_widget(confirmForm.submit, {'attr': {'class': 'btn btn-primary' } }) }} +
+{{ form_end(confirmForm) }} diff --git a/tests/Command/MuninAccountCommandTest.php b/tests/Command/MuninAccountCommandTest.php index ced6d207..da673a14 100644 --- a/tests/Command/MuninAccountCommandTest.php +++ b/tests/Command/MuninAccountCommandTest.php @@ -20,6 +20,7 @@ public function testExecute(): void $userRepository->method('countDeletedUsers')->willReturn(3); $userRepository->method('countUsersWithRecoveryToken')->willReturn(5); $userRepository->method('countUsersWithMailCrypt')->willReturn(7); + $userRepository->method('countUsersWithTwofactor')->willReturn(9); $openPgpKeyRepository = $this->getMockBuilder(OpenPgpKeyRepository::class) ->disableOriginalConstructor() @@ -40,7 +41,7 @@ public function testExecute(): void $output = $commandTester->getDisplay(); - self::assertEquals("account.value 10\ndeleted.value 3\nrecovery_tokens.value 5\nmail_crypt_keys.value 7\nopenpgp_keys.value 2\n", $output); + self::assertEquals("account.value 10\ndeleted.value 3\nrecovery_tokens.value 5\nmail_crypt_keys.value 7\ntwofactor.value 9\nopenpgp_keys.value 2\n", $output); $commandTester->execute(['--autoconf' => true]); @@ -61,6 +62,7 @@ public function testExecute(): void self::assertStringContainsString('deleted.label Deleted accounts', $output); self::assertStringContainsString('recovery_tokens.label Active accounts with recovery token', $output); self::assertStringContainsString('mail_crypt_keys.label Active accounts with mailbox encryption', $output); + self::assertStringContainsString('twofactor.label Active accounts with two-factor authentication', $output); self::assertStringContainsString('openpgp_keys.label OpenPGP keys', $output); } } diff --git a/tests/Command/UsersResetCommandTest.php b/tests/Command/UsersResetCommandTest.php index 48f55080..e64d612e 100644 --- a/tests/Command/UsersResetCommandTest.php +++ b/tests/Command/UsersResetCommandTest.php @@ -3,7 +3,6 @@ namespace App\Tests\Command; use App\Command\UsersResetCommand; -use App\Command\VoucherUnlinkCommand; use App\Entity\User; use App\Handler\MailCryptKeyHandler; use App\Handler\RecoveryTokenHandler; @@ -17,21 +16,22 @@ class UsersResetCommandTest extends TestCase { - /** - * @var VoucherUnlinkCommand - */ - private $command; + private UsersResetCommand $command; + private User $user; public function setUp(): void { - $user = new User(); - $user->setEmail('user@example.org'); + $this->user = new User(); + $this->user->setEmail('user@example.org'); + $this->user->setTotpSecret('secret'); + $this->user->setTotpConfirmed(true); + $this->user->addBackupCode('123456'); $repository = $this->getMockBuilder(UserRepository::class) ->disableOriginalConstructor() ->getMock(); $repository->method('findByEmail') - ->willReturn($user); + ->willReturn($this->user); $manager = $this->getMockBuilder(EntityManagerInterface::class) ->disableOriginalConstructor() @@ -74,6 +74,11 @@ public function testExecute(): void // Test real run $commandTester->execute(['--user' => 'user@example.org']); + // Verify that user properties got reset + self::assertFalse($this->user->getTotpConfirmed()); + self::assertEmpty($this->user->getBackupCodes()); + self::assertFalse($this->user->isTotpAuthenticationEnabled()); + $output = $commandTester->getDisplay(); $this->assertStringContainsString('Resetting user user@example.org', $output); } diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php index fcc5c42a..52d13a75 100644 --- a/tests/Entity/UserTest.php +++ b/tests/Entity/UserTest.php @@ -5,6 +5,7 @@ use App\Entity\User; use App\Enum\Roles; use PHPUnit\Framework\TestCase; +use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration; /** * Class UserTest. @@ -94,4 +95,27 @@ public function testHasUpdatedTimeSet(): void $today = new \DateTime(); self::assertEquals($user->getUpdatedTime()->format('Y-m-d'), $today->format('Y-m-d')); } + + public function testTotp(): void + { + // totpSecret and totpConfirmed + $totpSecret = 'secret'; + $user = new User(); + self::assertEquals(false, $user->getTotpConfirmed()); + self::assertEquals(false, $user->isTotpAuthenticationEnabled()); + $user->setTotpSecret($totpSecret); + self::assertEquals(false, $user->isTotpAuthenticationEnabled()); + $user->setTotpConfirmed(true); + self::assertEquals(true, $user->getTotpConfirmed()); + self::assertEquals(true, $user->isTotpAuthenticationEnabled()); + + // getTotpAuthenticationUsername + $email = 'user@example.org'; + $user->setEmail($email); + self::assertEquals($email, $user->getTotpAuthenticationUsername()); + + // getTotpAuthenticationConfiguration + $totpConfiguration = new TotpConfiguration($totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6); + self::assertEquals($totpConfiguration, $user->getTotpAuthenticationConfiguration()); + } } diff --git a/tests/Form/TwofactorConfirmTypeTest.php b/tests/Form/TwofactorConfirmTypeTest.php new file mode 100644 index 00000000..9855f0f0 --- /dev/null +++ b/tests/Form/TwofactorConfirmTypeTest.php @@ -0,0 +1,34 @@ + $totpSecret]; + + $form = $this->factory->create(TwofactorConfirmType::class); + + $object = new TwofactorConfirm(); + $object->totpSecret = $totpSecret; + + // submit the data to the form directly + $form->submit($formData); + + $this->assertTrue($form->isSynchronized()); + $this->assertEquals($object, $form->getData()); + + $view = $form->createView(); + $children = $view->children; + + foreach (array_keys($formData) as $key) { + $this->assertArrayHasKey($key, $children); + } + } +} diff --git a/tests/Validator/TotpSecretValidatorTest.php b/tests/Validator/TotpSecretValidatorTest.php new file mode 100644 index 00000000..62919586 --- /dev/null +++ b/tests/Validator/TotpSecretValidatorTest.php @@ -0,0 +1,92 @@ +user = new User(); + $tokenInterface = $this->getMockBuilder(TokenInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $tokenInterface->method('getUser') + ->willReturn($this->user); + $tokenStorage = $this->getMockBuilder(TokenStorageInterface::class) + ->disableOriginalConstructor() + ->getMock(); + $tokenStorage->method('getToken') + ->willReturn($tokenInterface); + + $this->totpAuthenticator = $this->getMockBuilder(TotpAuthenticatorInterface::class) + ->disableOriginalConstructor() + ->getMock(); + + return new TotpSecretValidator($tokenStorage, $this->totpAuthenticator); + } + + public function testExpectsTotpSecretType(): void + { + $this->expectException(UnexpectedTypeException::class); + $this->validator->validate('string', new Valid()); + } + + public function testNullIsValid(): void + { + $this->validator->validate(null, new TotpSecret()); + $this->assertNoViolation(); + } + + public function testEmptyStringIsValid(): void + { + $this->validator->validate('', new TotpSecret()); + $this->assertNoViolation(); + } + + public function testExpectsStringCompatibleType(): void + { + $this->expectException(UnexpectedTypeException::class); + $this->validator->validate(new \stdClass(), new TotpSecret()); + } + + public function testValidateVoucherInvalid(): void + { + $totpSecret = 'invalid'; + $this->totpAuthenticator->expects(self::once()) + ->method('checkCode') + ->with($this->user, $totpSecret) + ->willReturn(false); + + $this->validator->validate($totpSecret, new TotpSecret()); + $this->buildViolation('form.twofactor-secret-invalid') + ->assertRaised(); + } + + public function testValidateValid(): void + { + $totpSecret = 'valid'; + $this->totpAuthenticator->expects(self::once()) + ->method('checkCode') + ->with($this->user, $totpSecret) + ->willReturn(true); + + $this->validator->validate($totpSecret, new TotpSecret()); + $this->assertNoViolation(); + } +}