diff --git a/composer.lock b/composer.lock index 54292df1f..5f70e3459 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b00e8c43ab15eb49c8f498866f9b531d", + "content-hash": "e9f5e143fb7cfc803d711f5b63eb2e09", "packages": [ { "name": "api-platform/core", @@ -194,6 +194,60 @@ }, "time": "2024-05-29T05:48:47+00:00" }, + { + "name": "bacon/bacon-qr-code", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "510de6eca6248d77d31b339d62437cc995e2fb41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/510de6eca6248d77d31b339d62437cc995e2fb41", + "reference": "510de6eca6248d77d31b339d62437cc995e2fb41", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || 11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "squizlabs/php_codesniffer": "^3.9" + }, + "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/v3.0.0" + }, + "time": "2024-04-18T11:16:25+00:00" + }, { "name": "beberlei/assert", "version": "v3.3.2", @@ -452,6 +506,56 @@ ], "time": "2021-08-17T13:49:14+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.6", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/8dfd07c6d2cf31c8da90c53b83c026c7696dda90", + "reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "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.6" + }, + "time": "2024-08-09T14:30:48+00:00" + }, { "name": "defuse/php-encryption", "version": "v2.4.0", @@ -1980,6 +2084,78 @@ }, "time": "2023-04-21T15:31:12+00:00" }, + { + "name": "endroid/qr-code", + "version": "5.0.9", + "source": { + "type": "git", + "url": "https://github.com/endroid/qr-code.git", + "reference": "3dcdfab4c9122874f3915d8bf80a43b9df11852d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/3dcdfab4c9122874f3915d8bf80a43b9df11852d", + "reference": "3dcdfab4c9122874f3915d8bf80a43b9df11852d", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "php": "^8.1" + }, + "require-dev": { + "endroid/quality": "dev-main", + "ext-gd": "*", + "khanamiryan/qrcode-detector-decoder": "^2.0.2", + "setasign/fpdf": "^1.8.2" + }, + "suggest": { + "ext-gd": "Enables you to write PNG images", + "khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator", + "roave/security-advisories": "Makes sure package versions with known security issues are not installed", + "setasign/fpdf": "Enables you to use the PDF writer" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.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": [ + "code", + "endroid", + "php", + "qr", + "qrcode" + ], + "support": { + "issues": "https://github.com/endroid/qr-code/issues", + "source": "https://github.com/endroid/qr-code/tree/5.0.9" + }, + "funding": [ + { + "url": "https://github.com/endroid", + "type": "github" + } + ], + "time": "2024-05-08T08:09:28+00:00" + }, { "name": "ezimuel/guzzlestreams", "version": "3.1.0", @@ -5460,6 +5636,225 @@ }, "time": "2020-09-05T13:00:25+00:00" }, + { + "name": "scheb/2fa-backup-code", + "version": "v6.12.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-backup-code.git", + "reference": "1ad84e7eb26eb425c609e03097cac99387dde44c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-backup-code/zipball/1ad84e7eb26eb425c609e03097cac99387dde44c", + "reference": "1ad84e7eb26eb425c609e03097cac99387dde44c", + "shasum": "" + }, + "require": { + "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.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 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/v6.12.0" + }, + "time": "2023-12-03T15:44:26+00:00" + }, + { + "name": "scheb/2fa-bundle", + "version": "v6.12.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-bundle.git", + "reference": "6e51477c53070f27ac3e3d36be1a991870db415a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-bundle/zipball/6e51477c53070f27ac3e3d36be1a991870db415a", + "reference": "6e51477c53070f27ac3e3d36be1a991870db415a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0", + "symfony/config": "^5.4 || ^6.0", + "symfony/dependency-injection": "^5.4 || ^6.0", + "symfony/event-dispatcher": "^5.4 || ^6.0", + "symfony/framework-bundle": "^5.4 || ^6.0", + "symfony/http-foundation": "^5.4 || ^6.0", + "symfony/http-kernel": "^5.4 || ^6.0", + "symfony/property-access": "^5.4 || ^6.0", + "symfony/security-bundle": "^5.4 || ^6.0", + "symfony/twig-bundle": "^5.4 || ^6.0" + }, + "conflict": { + "scheb/two-factor-bundle": "*", + "symfony/security-core": "^7" + }, + "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-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/v6.12.0" + }, + "time": "2023-12-03T16:02:15+00:00" + }, + { + "name": "scheb/2fa-google-authenticator", + "version": "v6.12.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-google-authenticator.git", + "reference": "2c43bbe432fdc465d8f1d1b2d73ca9ea5276fe34" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-google-authenticator/zipball/2c43bbe432fdc465d8f1d1b2d73ca9ea5276fe34", + "reference": "2c43bbe432fdc465d8f1d1b2d73ca9ea5276fe34", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^2.4", + "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0", + "scheb/2fa-bundle": "self.version", + "spomky-labs/otphp": "^10.0 || ^11.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 Google Authenticator", + "homepage": "https://github.com/scheb/2fa", + "keywords": [ + "2fa", + "Authentication", + "google-authenticator", + "symfony", + "two-factor", + "two-step" + ], + "support": { + "source": "https://github.com/scheb/2fa-google-authenticator/tree/v6.12.0" + }, + "time": "2023-12-03T15:44:26+00:00" + }, + { + "name": "scheb/2fa-totp", + "version": "v6.12.0", + "source": { + "type": "git", + "url": "https://github.com/scheb/2fa-totp.git", + "reference": "a233f4638b75941e97f089c4c917f6101f2983e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/scheb/2fa-totp/zipball/a233f4638b75941e97f089c4c917f6101f2983e3", + "reference": "a233f4638b75941e97f089c4c917f6101f2983e3", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^2.4", + "php": "~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0", + "scheb/2fa-bundle": "self.version", + "spomky-labs/otphp": "^10.0 || ^11.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/v6.12.0" + }, + "time": "2023-12-03T15:44:26+00:00" + }, { "name": "shivas/versioning-bundle", "version": "4.1.0", @@ -5728,6 +6123,88 @@ }, "time": "2017-04-19T22:01:50+00:00" }, + { + "name": "spomky-labs/otphp", + "version": "11.3.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/otphp.git", + "reference": "2d8ccb5fc992b9cc65ef321fa4f00fefdb3f4b33" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/2d8ccb5fc992b9cc65ef321fa4f00fefdb3f4b33", + "reference": "2d8ccb5fc992b9cc65ef321fa4f00fefdb3f4b33", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "paragonie/constant_time_encoding": "^2.0 || ^3.0", + "php": ">=8.1", + "psr/clock": "^1.0", + "symfony/deprecation-contracts": "^3.2" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0", + "infection/infection": "^0.26|^0.27|^0.28|^0.29", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^9.5.26|^10.0|^11.0", + "qossmic/deptrac-shim": "^1.0", + "rector/rector": "^1.0", + "symfony/phpunit-bridge": "^6.1|^7.0", + "symplify/easy-coding-standard": "^12.0" + }, + "type": "library", + "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/11.3.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2024-06-12T11:22:32+00:00" + }, { "name": "symfony/asset", "version": "v6.4.8", diff --git a/config/bundles.php b/config/bundles.php index 1e4c45100..44978178a 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -17,4 +17,5 @@ return [ Nbgrp\OneloginSamlBundle\NbgrpOneloginSamlBundle::class => ['all' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], + Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true], ]; diff --git a/config/core_services.yaml b/config/core_services.yaml index 6d7fc3c71..6ee270acc 100644 --- a/config/core_services.yaml +++ b/config/core_services.yaml @@ -214,7 +214,7 @@ services: tags: [ { name: kernel.event_listener, event: kernel.response, method: onKernelResponse } ] arguments: $routes: - - path: '(/login$|/$|/auth$|/logout$|/logged-out$|/session-status|/auth/logout|/auth/login|/auth/session-status)' + - path: '(/login$|/profile-auth/2fa/enable|/profile-auth/2fa/enable-finalize|/profile-auth|/2fa|/$|/auth$|/logout$|/logged-out$|/session-status|/auth/logout|/auth/login|/auth/session-status)' $cookieName: 'XSRF-TOKEN' $cookieExpire: 0 $cookiePath: / @@ -330,6 +330,22 @@ services: alias: App\Security\AppSecretGenerator public: true + api_success_handler: + alias: App\Security\TwoFactor\AuthenticationSuccessHandler + public: true + + 2fa_required: + alias: App\Security\TwoFactor\TwoFactorAuthenticationRequiredHandler + public: true + + 2fa_success: + alias: App\Security\TwoFactor\TwoFactorAuthenticationSuccessHandler + public: true + + 2fa_failed: + alias: App\Security\TwoFactor\TwoFactorAuthenticationFailureHandler + public: true + App\Process\Service\ActionNameMapperInterface: '@App\Engine\LegacyHandler\ActionNameMapperHandler' App\Process\Service\BaseActionDefinitionProviderInterface: '@App\Process\Service\BaseActionDefinitionProvider' App\Process\Service\BulkActionDefinitionProviderInterface: '@App\Process\Service\BulkActionDefinitionProvider' diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 1180fa65d..0c6cd8d57 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -3,7 +3,7 @@ doctrine: url: '%env(resolve:DATABASE_URL)%' driver: pdo_mysql charset: UTF8 - auto_commit: false + auto_commit: true schema_filter: '/^(users|migration_versions|)$/' default_table_options: charset: utf8 diff --git a/config/packages/scheb_2fa.yaml b/config/packages/scheb_2fa.yaml new file mode 100644 index 000000000..bae459817 --- /dev/null +++ b/config/packages/scheb_2fa.yaml @@ -0,0 +1,13 @@ +# See the configuration reference at https://symfony.com/bundles/SchebTwoFactorBundle/6.x/configuration.html +scheb_two_factor: +# two_factor_condition: acme.custom_two_factor_condition + totp: + enabled: true + server_name: SuiteCRM # Server name used in QR code + issuer: SuiteCRM # Issuer name used in QR code + leeway: 0 + backup_codes: + enabled: true + security_tokens: + - Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken + - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken diff --git a/config/packages/security.php b/config/packages/security.php index f398310f7..1cde69e26 100644 --- a/config/packages/security.php +++ b/config/packages/security.php @@ -87,10 +87,14 @@ return static function (ContainerConfigurator $containerConfig) { //Note: Only the *first* access control that matches will be used $baseAccessControl = [ + ['path' => '^/logged-out', 'roles' => 'PUBLIC_ACCESS'], + ['path' => '^/logout$', 'roles' => 'PUBLIC_ACCESS'], ['path' => '^/login$', 'roles' => 'PUBLIC_ACCESS'], ['path' => '^/session-status$', 'roles' => 'PUBLIC_ACCESS'], - ['path' => '^/logout$', 'roles' => 'PUBLIC_ACCESS'], - ['path' => '^/logged-out', 'roles' => 'PUBLIC_ACCESS'], + ['path' => '^/profile-auth/2fa/enable-finalize', 'roles' => 'ROLE_USER'], + ['path' => '^/profile-auth/2fa/check', 'roles' => 'IS_AUTHENTICATED_2FA_IN_PROGRESS'], + ['path' => '^/profile-auth/2fa/enable', 'roles' => 'ROLE_USER'], + ['path' => '^/profile-auth/2fa/disable', 'roles' => 'ROLE_USER'], ['path' => '^/$', 'roles' => 'PUBLIC_ACCESS'], ['path' => '^/api', 'roles' => 'PUBLIC_ACCESS'], ['path' => '^/api/graphql', 'roles' => 'PUBLIC_ACCESS'], @@ -116,8 +120,10 @@ return static function (ContainerConfigurator $containerConfig) { $containerConfig->extension('security', [ 'firewalls' => array_merge_recursive($baseFirewall, [ 'main' => [ + 'stateless' => false, 'json_login' => [ 'check_path' => 'app_login', + 'success_handler' => 'api_success_handler' ], 'login_throttling' => [ 'limiter' => 'app.login_rate_limiter' @@ -125,6 +131,18 @@ return static function (ContainerConfigurator $containerConfig) { 'logout' => [ 'path' => 'app_logout', ], + 'two_factor' => [ + 'auth_form_path' => 'app_2fa_enable', + 'check_path' => 'app_2fa_check', + 'prepare_on_login' => true, + 'prepare_on_access_denied' => true, + 'auth_code_parameter_name' => '_auth_code', + 'default_target_path' => '/', + 'provider' => 'app_user_provider', + 'authentication_required_handler' => '2fa_required', + 'success_handler' => '2fa_success', + 'failure_handler' => '2fa_failed' + ] ], ]), 'access_control' => $baseAccessControl diff --git a/config/routes.yaml b/config/routes.yaml index b78fbb073..4fb920cb7 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -7,12 +7,12 @@ controllers: auth_controllers: resource: path: ../core/backend/Authentication/Controller/ - namespace: App\Authentication\Controller\ + namespace: App\Authentication\Controller type: attribute engine_controllers: resource: path: ../core/backend/Engine/Controller/ - namespace: App\Engine\Controller\ + namespace: App\Engine\Controller type: attribute diff --git a/core/app/core/src/lib/core.ts b/core/app/core/src/lib/core.ts index f19c811cf..572b37255 100644 --- a/core/app/core/src/lib/core.ts +++ b/core/app/core/src/lib/core.ts @@ -491,6 +491,7 @@ export * from './services/auth/auth.service'; export * from './services/auth/error.interceptor'; export * from './services/auth/install-auth-guard.service'; export * from './services/auth/login-auth-guard.service'; +export * from './services/auth/two-factor-auth-guard.service'; export * from './services/base-route/base-route.service'; export * from './services/condition-operators/active-fields-checker.service'; export * from './services/condition-operators/condition-operator.action'; @@ -720,3 +721,7 @@ export * from './views/record/store/record-pagination/record-pagination.service' export * from './views/record/store/record-pagination/record-pagination.store'; export * from './views/record/store/record-view/record-view.store.model'; export * from './views/record/store/record-view/record-view.store'; +export * from './views/2fa/components/2fa/2fa.component'; +export * from './views/2fa/components/2fa/2fa.module'; +export * from './views/2fa/components/2fa-check/2fa-check.component'; +export * from './views/2fa/components/2fa-check/2fa-check.module'; diff --git a/core/app/core/src/lib/pipes/trust-html/trust-html.module.ts b/core/app/core/src/lib/pipes/trust-html/trust-html.module.ts new file mode 100644 index 000000000..41f5a1e79 --- /dev/null +++ b/core/app/core/src/lib/pipes/trust-html/trust-html.module.ts @@ -0,0 +1,44 @@ +/** + * SuiteCRM is a customer relationship management program developed by SalesAgility Ltd. + * Copyright (C) 2021 SalesAgility Ltd. + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License version 3 as published by the + * Free Software Foundation with the addition of the following permission added + * to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK + * IN WHICH THE COPYRIGHT IS OWNED BY SALESAGILITY, SALESAGILITY DISCLAIMS THE + * WARRANTY OF NON INFRINGEMENT OF THIRD PARTY RIGHTS. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * In accordance with Section 7(b) of the GNU Affero General Public License + * version 3, these Appropriate Legal Notices must retain the display of the + * "Supercharged by SuiteCRM" logo. If the display of the logos is not reasonably + * feasible for technical reasons, the Appropriate Legal Notices must display + * the words "Supercharged by SuiteCRM". + */ + +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {TrustHtmlPipe} from './trust-html.pipe'; + + +@NgModule({ + declarations: [ + TrustHtmlPipe + ], + exports: [ + TrustHtmlPipe + ], + imports: [ + CommonModule + ] +}) +export class TrustHtmlModule { +} diff --git a/core/app/core/src/lib/pipes/trust-html/trust-html.pipe.ts b/core/app/core/src/lib/pipes/trust-html/trust-html.pipe.ts new file mode 100644 index 000000000..292ed67b8 --- /dev/null +++ b/core/app/core/src/lib/pipes/trust-html/trust-html.pipe.ts @@ -0,0 +1,41 @@ +/** + * SuiteCRM is a customer relationship management program developed by SalesAgility Ltd. + * Copyright (C) 2021 SalesAgility Ltd. + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License version 3 as published by the + * Free Software Foundation with the addition of the following permission added + * to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK + * IN WHICH THE COPYRIGHT IS OWNED BY SALESAGILITY, SALESAGILITY DISCLAIMS THE + * WARRANTY OF NON INFRINGEMENT OF THIRD PARTY RIGHTS. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * In accordance with Section 7(b) of the GNU Affero General Public License + * version 3, these Appropriate Legal Notices must retain the display of the + * "Supercharged by SuiteCRM" logo. If the display of the logos is not reasonably + * feasible for technical reasons, the Appropriate Legal Notices must display + * the words "Supercharged by SuiteCRM". + */ + +import {Pipe, PipeTransform} from '@angular/core'; +import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; + +@Pipe({ + name: 'trustHtml' +}) +export class TrustHtmlPipe implements PipeTransform { + + constructor(private sanitizer: DomSanitizer) { + } + + transform(data): SafeHtml { + return this.sanitizer.bypassSecurityTrustHtml(data); + } +} diff --git a/core/app/core/src/lib/services/auth/auth-guard.service.ts b/core/app/core/src/lib/services/auth/auth-guard.service.ts index 0e9553caf..1a5fc0c19 100644 --- a/core/app/core/src/lib/services/auth/auth-guard.service.ts +++ b/core/app/core/src/lib/services/auth/auth-guard.service.ts @@ -68,7 +68,7 @@ export class AuthGuard { protected authorizeUser(route: ActivatedRouteSnapshot, snapshot: RouterStateSnapshot): Observable | Promise | boolean | UrlTree { // Note: this session and acl are not always booleans return forkJoin([ - this.authorizeUserSession(route, snapshot), + this.authService.authorizeUserSession(route, snapshot), this.authorizeUserACL(route) ]).pipe(map(([session, acl]: any) => { @@ -141,87 +141,6 @@ export class AuthGuard { catchError(() => of(homeUrlTree)) ); } - - /** - * Authorize user session - * - * @returns {object} Observable | Promise | boolean | UrlTree - * @param {ActivatedRouteSnapshot} route information about the current route - * @param snapshot - */ - protected authorizeUserSession(route: ActivatedRouteSnapshot, snapshot: RouterStateSnapshot): - Observable { - - if (this.authService.isUserLoggedIn.value && route.data.checkSession !== true) { - return of(true); - } - - let sessionExpiredUrl = this.authService.getSessionExpiredRoute(); - const redirect = this.authService.sessionExpiredRedirect(); - - const sessionExpiredUrlTree: UrlTree = this.router.parseUrl(sessionExpiredUrl); - - return this.authService.fetchSessionStatus() - .pipe( - take(1), - map((user: SessionStatus) => { - - if (user && user.appStatus.installed === false) { - return this.router.parseUrl('install'); - } - - if (user && user.active === true) { - const wasLoggedIn = !!this.appState.getCurrentUser(); - this.authService.setCurrentUser(user); - - if (!wasLoggedIn) { - this.language.appStrings$.pipe( - filter(appStrings => appStrings && !emptyObject(appStrings)), - tap(() => { - setTimeout(() => { - this.notificationStore.enableNotifications(); - this.notificationStore.refreshNotifications(); - }, 2000); - }), - take(1) - ).subscribe(); - } - - if (user?.redirect?.route && (!snapshot.url.includes(user.redirect.route))) { - const redirectUrlTree: UrlTree = this.router.parseUrl(user.redirect.route); - redirectUrlTree.queryParams = user?.redirect?.queryParams ?? {} - return redirectUrlTree; - } - - return true; - } - this.appState.setPreLoginUrl(snapshot.url); - this.authService.resetState(); - - if (redirect) { - this.authService.handleSessionExpiredRedirect(); - return false; - } - - // Re-direct to login - return sessionExpiredUrlTree; - }), - catchError(() => { - if (redirect) { - this.authService.handleSessionExpiredRedirect(); - return of(false); - } - - this.authService.logout('LBL_SESSION_EXPIRED', false); - return of(sessionExpiredUrlTree); - }), - tap((result: boolean | UrlTree) => { - if (result === true) { - this.authService.isUserLoggedIn.next(true); - } - }) - ); - } } diff --git a/core/app/core/src/lib/services/auth/auth.service.ts b/core/app/core/src/lib/services/auth/auth.service.ts index e34d90399..36f672211 100644 --- a/core/app/core/src/lib/services/auth/auth.service.ts +++ b/core/app/core/src/lib/services/auth/auth.service.ts @@ -25,12 +25,12 @@ */ import {Injectable} from '@angular/core'; -import {Router} from '@angular/router'; +import {ActivatedRouteSnapshot, Router, RouterStateSnapshot, UrlTree} from '@angular/router'; import {HttpClient, HttpErrorResponse, HttpHeaders, HttpParams} from '@angular/common/http'; -import {BehaviorSubject, Observable, Subscription, throwError} from 'rxjs'; -import {catchError, distinctUntilChanged, filter, finalize, take} from 'rxjs/operators'; +import {BehaviorSubject, Observable, of, Subscription, throwError} from 'rxjs'; +import {catchError, distinctUntilChanged, filter, finalize, map, take, tap} from 'rxjs/operators'; import {User} from '../../common/types/user'; -import {isTrue, isEmptyString} from '../../common/utils/value-utils'; +import {emptyObject, isEmptyString, isTrue} from '../../common/utils/value-utils'; import {MessageService} from '../message/message.service'; import {StateManager} from '../../store/state-manager'; import {LanguageStore} from '../../store/language/language.store'; @@ -96,7 +96,8 @@ export class AuthService { username: string, password: string, onSuccess: (response: string) => void, - onError: (error: HttpErrorResponse) => void + onError: (error: HttpErrorResponse) => void, + onTwoFactor: (error: HttpErrorResponse) => void ): Subscription { let loginUrl = 'login'; loginUrl = this.baseRoute.appendNativeAuth(loginUrl); @@ -114,6 +115,12 @@ export class AuthService { {headers} ).subscribe((response: any) => { + if (response?.two_factor_complete === 'false') { + this.isUserLoggedIn.next(false); + onTwoFactor(response); + return; + } + if (this.baseRoute.isNativeAuth()) { window.location.href = this.baseRoute.removeNativeAuth(); } @@ -170,6 +177,79 @@ export class AuthService { } } + public enable2fa(): Observable { + let route = './profile-auth/2fa/enable'; + route = this.baseRoute.appendNativeAuth(route); + + route = this.baseRoute.calculateRoute(route); + + const headers = new HttpHeaders({ + 'Content-Type': 'application/json', + }); + + return this.http.get(route, {headers}); + } + + public check2fa(code: string): Observable { + + let route = './profile-auth/2fa/check'; + + route = this.baseRoute.appendNativeAuth(route); + route = this.baseRoute.calculateRoute(route); + + const headers = new HttpHeaders({ + 'Content-Type': 'application/json; charset=utf-8', + }); + + return this.http.post(route, {_auth_code: code}, {headers: headers}); + } + + public finalize2fa(code: string): Observable { + + let route = './profile-auth/2fa/enable-finalize'; + + route = this.baseRoute.appendNativeAuth(route); + route = this.baseRoute.calculateRoute(route); + + const headers = new HttpHeaders({ + 'Content-Type': 'application/json', + }); + + const body = JSON.stringify({_auth_code: code}); + + return this.http.post(route, {auth_code: code}, {headers: headers}); + } + + setLanguage(result: any): void { + this.languageStore.setSessionLanguage() + .pipe(catchError(() => of({}))) + .subscribe(() => { + if (result && result.redirect && result.redirect.route) { + this.router.navigate( + [result.redirect.route], + { + queryParams: result.redirect.queryParams ?? {} + }).then(); + return; + } + + if (this.appStateStore.getPreLoginUrl()) { + this.router.navigateByUrl(this.appStateStore.getPreLoginUrl()).then(() => { + this.appStateStore.setPreLoginUrl(''); + }); + return; + } + + const defaultModule = this.configs.getHomePage(); + this.router.navigate(['/' + defaultModule]).then(); + }); + + if (this.configs.getConfigValue('login_language')) { + this.languageStore.setUserLanguage().subscribe(); + } + return; + } + /** * Call logout * @param logoutUrl @@ -253,6 +333,85 @@ export class AuthService { return this.http.get(url, {headers}); } + /** + * Authorize user session + * + * @returns {object} Observable | Promise | boolean | UrlTree + * @param {ActivatedRouteSnapshot} route information about the current route + * @param snapshot + */ + public authorizeUserSession(route: ActivatedRouteSnapshot, snapshot: RouterStateSnapshot): + Observable { + + if (this.isUserLoggedIn.value && route.data.checkSession !== true) { + return of(true); + } + + let sessionExpiredUrl = this.getSessionExpiredRoute(); + const redirect = this.sessionExpiredRedirect(); + + const sessionExpiredUrlTree: UrlTree = this.router.parseUrl(sessionExpiredUrl); + + return this.fetchSessionStatus() + .pipe( + take(1), + map((user: SessionStatus) => { + + if (user && user.appStatus.installed === false) { + return this.router.parseUrl('install'); + } + + if (user && user.active === true) { + const wasLoggedIn = !!this.appStateStore.getCurrentUser(); + this.setCurrentUser(user); + + if (!wasLoggedIn) { + this.languageStore.appStrings$.pipe( + filter(appStrings => appStrings && !emptyObject(appStrings)), + tap(() => { + this.notificationStore.enableNotifications(); + this.notificationStore.refreshNotifications(); + }), + take(1) + ).subscribe(); + } + + if (user?.redirect?.route && (!snapshot.url.includes(user.redirect.route))) { + const redirectUrlTree: UrlTree = this.router.parseUrl(user.redirect.route); + redirectUrlTree.queryParams = user?.redirect?.queryParams ?? {} + return redirectUrlTree; + } + + return true; + } + this.appStateStore.setPreLoginUrl(snapshot.url); + this.resetState(); + + if (redirect) { + this.handleSessionExpiredRedirect(); + return false; + } + + // Re-direct to login + return sessionExpiredUrlTree; + }), + catchError(() => { + if (redirect) { + this.handleSessionExpiredRedirect(); + return of(false); + } + + this.logout('LBL_SESSION_EXPIRED', false); + return of(sessionExpiredUrlTree); + }), + tap((result: boolean | UrlTree) => { + if (result === true) { + this.isUserLoggedIn.next(true); + } + }) + ); + } + /** * Get route for session expired handling * @return string diff --git a/core/app/core/src/lib/services/auth/two-factor-auth-guard.service.ts b/core/app/core/src/lib/services/auth/two-factor-auth-guard.service.ts new file mode 100644 index 000000000..90cdbe9c1 --- /dev/null +++ b/core/app/core/src/lib/services/auth/two-factor-auth-guard.service.ts @@ -0,0 +1,44 @@ +/** + * SuiteCRM is a customer relationship management program developed by SalesAgility Ltd. + * Copyright (C) 2024 SalesAgility Ltd. + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License version 3 as published by the + * Free Software Foundation with the addition of the following permission added + * to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK + * IN WHICH THE COPYRIGHT IS OWNED BY SALESAGILITY, SALESAGILITY DISCLAIMS THE + * WARRANTY OF NON INFRINGEMENT OF THIRD PARTY RIGHTS. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * In accordance with Section 7(b) of the GNU Affero General Public License + * version 3, these Appropriate Legal Notices must retain the display of the + * "Supercharged by SuiteCRM" logo. If the display of the logos is not reasonably + * feasible for technical reasons, the Appropriate Legal Notices must display + * the words "Supercharged by SuiteCRM". + */ + +import {Injectable} from '@angular/core'; +import {ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree} from '@angular/router'; +import {Observable} from 'rxjs'; +import {AuthService} from './auth.service'; + +@Injectable({ + providedIn: 'root' +}) +export class TwoFactorAuthGuard { + constructor( + private authService: AuthService, + ) { + } + + canActivate(route: ActivatedRouteSnapshot, snapshot: RouterStateSnapshot): Observable | Promise | boolean | UrlTree { + return this.authService.authorizeUserSession(route, snapshot); + } +} diff --git a/core/app/core/src/lib/views/login/components/login/login.component.html b/core/app/core/src/lib/views/login/components/login/login.component.html index 42a0784a2..639f15e1b 100644 --- a/core/app/core/src/lib/views/login/components/login/login.component.html +++ b/core/app/core/src/lib/views/login/components/login/login.component.html @@ -35,7 +35,7 @@ -
+
@@ -151,6 +151,20 @@
+ + + diff --git a/core/app/core/src/lib/views/login/components/login/login.component.ts b/core/app/core/src/lib/views/login/components/login/login.component.ts index 02094ca4e..f7ac1eca1 100644 --- a/core/app/core/src/lib/views/login/components/login/login.component.ts +++ b/core/app/core/src/lib/views/login/components/login/login.component.ts @@ -157,9 +157,14 @@ export class LoginUiComponent implements OnInit { } } + returnToLogin(): void { + this.cardState = 'front'; + return; + } + doLogin(): void { this.loading = true; - this.auth.doLogin(this.uname, this.passw, this.onLoginSuccess.bind(this), this.onLoginError.bind(this)); + this.auth.doLogin(this.uname, this.passw, this.onLoginSuccess.bind(this), this.onLoginError.bind(this), this.onTwoFactor.bind(this)); } recoverPassword(): void { @@ -192,32 +197,7 @@ export class LoginUiComponent implements OnInit { this.message.log('Login success'); this.message.removeMessages(); - this.languageStore.setSessionLanguage() - .pipe(catchError(() => of({}))) - .subscribe(() => { - if (result && result.redirect && result.redirect.route) { - this.router.navigate( - [result.redirect.route], - { - queryParams: result.redirect.queryParams ?? {} - }).then(); - return; - } - - if (this.appState.getPreLoginUrl()) { - this.router.navigateByUrl(this.appState.getPreLoginUrl()).then(() => { - this.appState.setPreLoginUrl(''); - }); - return; - } - - const defaultModule = this.configs.getHomePage(); - this.router.navigate(['/' + defaultModule]).then(); - }); - - if (this.configs.getConfigValue('login_language')) { - this.languageStore.setUserLanguage().subscribe(); - } + this.auth.setLanguage(result); return; } @@ -241,6 +221,10 @@ export class LoginUiComponent implements OnInit { this.message.addDangerMessage(message); } + onTwoFactor(result: any): void { + this.cardState = '2fa'; + } + protected getTooManyFailedMessage(defaultTooManyFailedMessage: string): string { let tooManyFailedMessage = this.languageStore.getFieldLabel('LOGIN_TOO_MANY_FAILED'); diff --git a/core/app/core/src/lib/views/login/components/login/login.module.ts b/core/app/core/src/lib/views/login/components/login/login.module.ts index 420fe1584..4da00e9b9 100644 --- a/core/app/core/src/lib/views/login/components/login/login.module.ts +++ b/core/app/core/src/lib/views/login/components/login/login.module.ts @@ -34,6 +34,7 @@ import {AngularSvgIconModule} from 'angular-svg-icon'; import {ButtonLoadingUiModule} from '../../../../directives/button-loading/button-loading.module'; import {LogoUiModule} from '../../../../components/logo/logo.module'; import {ImageModule} from '../../../../components/image/image.module'; +import {TwoFactorCheckModule} from "../../../2fa/components/2fa-check/2fa-check.module"; @NgModule({ declarations: [ @@ -49,7 +50,8 @@ import {ImageModule} from '../../../../components/image/image.module'; CommonModule, AngularSvgIconModule, ImageModule, - ButtonLoadingUiModule + ButtonLoadingUiModule, + TwoFactorCheckModule ] }) export class LoginUiModule { diff --git a/core/app/shell/src/themes/suite8/css/layout/_two-factor.scss b/core/app/shell/src/themes/suite8/css/layout/_two-factor.scss new file mode 100644 index 000000000..ba9b32b51 --- /dev/null +++ b/core/app/shell/src/themes/suite8/css/layout/_two-factor.scss @@ -0,0 +1,11 @@ +#two-factor { + .two-factor-border { + border-right: 0.03em solid grey; + } + + .backup-codes { + background-color: $midnight-blue; + color: white; + border: 2px solid black; + } +} diff --git a/core/app/shell/src/themes/suite8/css/style.scss b/core/app/shell/src/themes/suite8/css/style.scss index f6fc29235..258bfc652 100644 --- a/core/app/shell/src/themes/suite8/css/style.scss +++ b/core/app/shell/src/themes/suite8/css/style.scss @@ -108,6 +108,7 @@ @import 'layout/install'; @import 'layout/admin'; @import 'layout/scrollbar'; +@import 'layout/two-factor'; @import 'fields/email'; @import 'fields/composite-mobile'; diff --git a/core/backend/Authentication/Controller/SecurityController.php b/core/backend/Authentication/Controller/SecurityController.php index c5439a2c2..88bcf24c5 100644 --- a/core/backend/Authentication/Controller/SecurityController.php +++ b/core/backend/Authentication/Controller/SecurityController.php @@ -28,17 +28,29 @@ namespace App\Authentication\Controller; +use App\Data\LegacyHandler\PreparedStatementHandler; +use App\Security\TwoFactor\BackupCodeGenerator; +use Doctrine\DBAL\Exception; +use Doctrine\ORM\EntityManagerInterface; +use Endroid\QrCode\Builder\Builder; +use Endroid\QrCode\Encoding\Encoding; use App\Authentication\LegacyHandler\Authentication; use App\Module\Users\Entity\User; +use Endroid\QrCode\ErrorCorrectionLevel; +use Endroid\QrCode\RoundBlockSizeMode; +use Endroid\QrCode\Writer\SvgWriter; use RuntimeException; +use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Attribute\CurrentUser; +use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; /** @@ -57,20 +69,41 @@ class SecurityController extends AbstractController */ private $requestStack; + /** + * @var EntityManagerInterface + */ + private $entityManager; + private PreparedStatementHandler $preparedStatementHandler; + + private BackupCodeGenerator $backupCodeGenerator; + /** * SecurityController constructor. * @param Authentication $authentication * @param RequestStack $requestStack */ - public function __construct(Authentication $authentication, RequestStack $requestStack) + public function __construct( + Authentication $authentication, + RequestStack $requestStack, + EntityManagerInterface $entityManager, + PreparedStatementHandler $preparedStatementHandler, + BackupCodeGenerator $backupCodeGenerator + ) { $this->authentication = $authentication; $this->requestStack = $requestStack; + $this->entityManager = $entityManager; + $this->preparedStatementHandler = $preparedStatementHandler; + $this->backupCodeGenerator = $backupCodeGenerator; } #[Route('/login', name: 'app_login', methods: ["GET", "POST"])] public function login(AuthenticationUtils $authenticationUtils, #[CurrentUser] ?User $user): JsonResponse { +// if ($user->getIsTotpEnabled()) { +// return new Response('{"login_success": "true", "two_factor_complete": "false"}'); +// } + $error = $authenticationUtils->getLastAuthenticationError(); $isAppInstalled = $this->authentication->getAppInstallStatus(); $isAppInstallerLocked = $this->authentication->getAppInstallerLockStatus(); @@ -105,6 +138,108 @@ class SecurityController extends AbstractController return $this->json($data, Response::HTTP_OK); } + + /** + * @throws Exception + */ + #[Route('/profile-auth/2fa/enable', name: 'app_2fa_enable', methods: ["GET", "POST"])] + #[isGranted('IS_AUTHENTICATED_FULLY')] + public function enable2fa(#[CurrentUser] ?User $user, TotpAuthenticatorInterface $totpAuthenticator): Response + { +// if ($user->getIsTotpEnabled()){ +// return new Response(''); +// } + $secret = $totpAuthenticator->generateSecret(); + + $user->setTotpSecret($secret); + + $backupCodes = $this->backupCodeGenerator->generate(); + + $this->setupBackupCodes($user,$backupCodes); + + $this->preparedStatementHandler->update( + 'UPDATE users SET totp_secret = :totp_secret WHERE id = :id', + ['totp_secret' => $secret, 'id' => $user->getId()], + [['param' => 'totp_secret', 'type' => 'string'], ['param' => 'id', 'type' => 'string']] + ); + + $this->entityManager->flush(); + + $qrCodeUrl = $totpAuthenticator->getQRContent($user); + + $response = [ + 'url' => $qrCodeUrl, + 'svg' => $this->displayQRCode($qrCodeUrl), + 'backupCodes' => $backupCodes + ]; + + return new Response(json_encode($response), Response::HTTP_OK); + } + + #[Route('/profile-auth/2fa/disable', name: 'app_2fa_disable', methods: ["GET"])] + public function disable2fa(#[CurrentUser] ?User $user, TotpAuthenticatorInterface $totpAuthenticator): Response + { + error_log('inside disable 2fa'); + + $id = $user->getId(); + + $this->preparedStatementHandler->update( + 'UPDATE users SET totp_secret = NULL WHERE id = :id', + ['id' => $id], + [['param' => 'id', 'type' => 'string']] + ); + + return $this->redirect('../#/users/edit/'.$id); + } + + #[Route('/profile-auth/2fa/enable-finalize', name: 'app_2fa_enable_finalize', methods: ["GET", "POST"])] + public function enableFinalize2fa(#[CurrentUser] ?User $user, Security $security, Request $request, TotpAuthenticatorInterface $totpAuthenticator): Response + { + error_log('inside enableFinalize2fa'); + + $maybe = $this->getUser(); + + $auth_code = $request->getPayload()->get('auth_code') ?? ''; + + $user_no = $security->getToken()->getUser(); + + $user_diff = $security->getUser(); +// $auth_code = $_POST['auth_code'] ?? null; + + $correctCode = $totpAuthenticator->checkCode($user, $auth_code); + + + if ($correctCode){ + $this->preparedStatementHandler->update( + 'UPDATE users SET is_totp_enabled = true WHERE id = :id', + ['id' => $user->getId()], + [['param' => 'id', 'type' => 'string']] + ); + } + + $response = ['two_factor_setup_complete' => $correctCode]; + + return new Response(json_encode($response), Response::HTTP_OK); + } + + #[Route('/profile-auth/2fa/check', name: 'app_2fa_check', methods: ["GET", "POST"])] + public function check2fa(#[CurrentUser] ?User $user, Request $request, TotpAuthenticatorInterface $totpAuthenticator): Response + { + error_log('inside 2fa check'); + // request + $auth_code = $request->getPayload()->get('auth_code') ?? ''; + + $correctCode = $totpAuthenticator->checkCode($user, $auth_code); + + if (!$correctCode){ + $correctCode = $user->isBackupCode($auth_code); + } + + $response = ['two_factor_complete' => $correctCode]; + + return new Response(json_encode($response), Response::HTTP_OK); + } + #[Route('/logout', name: 'app_logout', methods: ["GET", "POST"])] public function logout(): void { @@ -210,4 +345,34 @@ class SecurityController extends AbstractController 'userName' => $userName ]; } + + private function displayQrCode(string $qrCodeContent): string + { + // ErrorCorrectionLevelHigh + $result = Builder::create() + ->writer(new SvgWriter()) + ->writerOptions(['exclude_xml_declaration' => true]) + ->data($qrCodeContent) + ->encoding(new Encoding('UTF-8')) + ->errorCorrectionLevel(ErrorCorrectionLevel::High) + ->size(200) + ->margin(0) + ->roundBlockSizeMode(RoundBlockSizeMode::Margin) + ->build(); + + return $result->getString(); + } + + protected function setupBackupCodes($user, $backupCodes): void + { + error_log(print_r($backupCodes, true)); + error_log(print_r(array_keys($backupCodes), true)); + +// $backupCodes = json_encode(); + + $this->preparedStatementHandler->update("UPDATE users SET backup_codes = '{':backup_codes'}' WHERE id = :id", + ['id' => $user->getId(), 'backup_codes' => $backupCodes], + [['param' => 'id', 'type' => 'string'], ['param' => 'backup_codes', 'type' => 'array']] + ); + } } diff --git a/core/backend/Data/LegacyHandler/PreparedStatementHandler.php b/core/backend/Data/LegacyHandler/PreparedStatementHandler.php index 3d7b37b06..55dfc2c43 100644 --- a/core/backend/Data/LegacyHandler/PreparedStatementHandler.php +++ b/core/backend/Data/LegacyHandler/PreparedStatementHandler.php @@ -96,4 +96,25 @@ class PreparedStatementHandler return $stmt->executeQuery($params)->fetchAssociative(); } + + /** + * @throws Exception + */ + public function update ( + string $query, + array $params, + array $binds + ): int { + $stmt = $this->entityManager->getConnection()->prepare($query); + + if (!empty($binds)) { + foreach ($binds as $bind) { + $stmt->bindValue($bind['param'], $params[$bind['param']], $bind['type']); + } + } + $result = $stmt->executeStatement(); + $this->entityManager->flush(); + + return $result; + } } diff --git a/core/backend/Migrations/Version20241001074858.php b/core/backend/Migrations/Version20241001074858.php new file mode 100644 index 000000000..ab2a3c898 --- /dev/null +++ b/core/backend/Migrations/Version20241001074858.php @@ -0,0 +1,36 @@ +container->get('entity_manager'); + + try { + $entityManager->getConnection()->executeQuery('ALTER TABLE users ADD COLUMN `totp_secret` varchar NULL'); + } catch (\Exception $e) { + } + + } + + public function down(Schema $schema): void + { + } +} diff --git a/core/backend/Security/TwoFactor/AuthenticationSuccessHandler.php b/core/backend/Security/TwoFactor/AuthenticationSuccessHandler.php new file mode 100644 index 000000000..a0ab219de --- /dev/null +++ b/core/backend/Security/TwoFactor/AuthenticationSuccessHandler.php @@ -0,0 +1,42 @@ + 'installed', +// 'active' => true, +// 'id' => 'seed_will_id', +// 'firstName' => 'will', +// 'lastName' => 'will', +// 'userName' => 'will', +// ]; + + $data = [ + 'appStatus' => 'installed', + 'active' => true, + 'id' => '1', + 'firstName' => '', + 'lastName' => '', + 'userName' => 'admin', + ]; + + return new Response(json_encode($data)); + } +} diff --git a/core/backend/Security/TwoFactor/BackupCodeGenerator.php b/core/backend/Security/TwoFactor/BackupCodeGenerator.php new file mode 100644 index 000000000..e9ecfd322 --- /dev/null +++ b/core/backend/Security/TwoFactor/BackupCodeGenerator.php @@ -0,0 +1,42 @@ +. + * + * In accordance with Section 7(b) of the GNU Affero General Public License + * version 3, these Appropriate Legal Notices must retain the display of the + * "Supercharged by SuiteCRM" logo. If the display of the logos is not reasonably + * feasible for technical reasons, the Appropriate Legal Notices must display + * the words "Supercharged by SuiteCRM". + */ + +namespace App\Security\TwoFactor; + +class BackupCodeGenerator +{ + public function generate($len = 10): array + { + $codes = []; + + for ($i = 0; $i < $len; $i++) { + $codes[] = bin2hex(random_bytes(8)); + } + + return $codes; + } +} diff --git a/core/backend/Security/TwoFactor/TwoFactorAuthenticationFailureHandler.php b/core/backend/Security/TwoFactor/TwoFactorAuthenticationFailureHandler.php new file mode 100644 index 000000000..73b690747 --- /dev/null +++ b/core/backend/Security/TwoFactor/TwoFactorAuthenticationFailureHandler.php @@ -0,0 +1,19 @@ + [null, null, null, 30, 30]] )] -class User implements UserInterface, EquatableInterface, PasswordAuthenticatedUserInterface +class User implements UserInterface, EquatableInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface, BackupCodeInterface { #[ApiProperty( identifier: true, @@ -659,6 +663,28 @@ class User implements UserInterface, EquatableInterface, PasswordAuthenticatedUs )] private ?string $factorAuthInterface; + #[ApiProperty( + openapiContext: [ + 'type' => 'string', + 'description' => 'totp secret', + ] + )] + #[ORM\Column(name: "totp_secret", type: 'string', length: 255, nullable: true, options: ["collation" => "utf8_general_ci"])] + private ?string $totpSecret; + + #[ORM\Column( + name: "is_totp_enabled", + type: "boolean", + nullable: true + )] + private ?bool $isTotpEnabled = false; + + #[ORM\Column( + name: "backup_codes", + type: "json", + )] + private ?array $backupCodes = []; + /** * @see UserInterface */ @@ -1218,4 +1244,82 @@ class User implements UserInterface, EquatableInterface, PasswordAuthenticatedUs { return $this->getUserName(); } + + public function isTotpAuthenticationEnabled(): bool + { + return $this->totpSecret ? true : false; + } + + public function getTotpAuthenticationUsername(): string + { + return $this->getUserName(); + } + + public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface + { + error_log('inside getTotpAuthenticationconfig'); + error_log($this->getTotpSecret()); + + // You could persist the other configuration options in the user entity to make it individual per user. + return new TotpConfiguration($this->getTotpSecret(), TotpConfiguration::ALGORITHM_SHA1, 30, 6); + } + + public function getTotpSecret(): ?string + { + return $this->totpSecret; + } + + public function setTotpSecret(?string $totpSecret): self + { + $this->totpSecret = $totpSecret; + + return $this; + } + + public function getIsTotpEnabled(): ?bool + { + return $this->isTotpEnabled; + } + + public function setIsTotpEnabled(?bool $isTotpEnabled): self + { + $this->isTotpEnabled = $isTotpEnabled; + + return $this; + } + + /** + * Check if it is a valid backup code. + */ + public function isBackupCode(string $code): bool + { + $correctCode = false; + if (in_array($code, $backup)){ + $correctCode = true; + $this->invalidateBackupCode($code); + } + return $correctCode; + } + + /** + * Invalidate a backup code + */ + public function invalidateBackupCode(string $code): void + { + $key = array_search($code, $this->backupCodes); + if ($key !== false){ + unset($this->backupCodes[$key]); + } + } + + /** + * Add a backup code + */ + public function addBackUpCode(string $backUpCode): void + { + if (!in_array($backUpCode, $this->backupCodes)) { + $this->backupCodes[] = $backUpCode; + } + } + } diff --git a/symfony.lock b/symfony.lock index c88812a77..b755169db 100644 --- a/symfony.lock +++ b/symfony.lock @@ -504,6 +504,19 @@ "robrichards/xmlseclibs": { "version": "3.1.1" }, + "scheb/2fa-bundle": { + "version": "6.12", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "1e6f68089146853a790b5da9946fc5974f6fcd49" + }, + "files": [ + "config/packages/scheb_2fa.yaml", + "config/routes/scheb_2fa.yaml" + ] + }, "scssphp/scssphp": { "version": "v1.7.0" },