Two Factor

This commit is contained in:
Jack Anderson 2024-10-08 15:45:21 +01:00
parent 50e5c54c26
commit ff4ba2bdd2
28 changed files with 1354 additions and 125 deletions

479
composer.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <http://www.gnu.org/licenses/>.
*
* 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 {
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*
* 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);
}
}

View file

@ -68,7 +68,7 @@ export class AuthGuard {
protected authorizeUser(route: ActivatedRouteSnapshot, snapshot: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | 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<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree
* @param {ActivatedRouteSnapshot} route information about the current route
* @param snapshot
*/
protected authorizeUserSession(route: ActivatedRouteSnapshot, snapshot: RouterStateSnapshot):
Observable<boolean | UrlTree> {
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);
}
})
);
}
}

View file

@ -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<any> {
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<any> {
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<any> {
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<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree
* @param {ActivatedRouteSnapshot} route information about the current route
* @param snapshot
*/
public authorizeUserSession(route: ActivatedRouteSnapshot, snapshot: RouterStateSnapshot):
Observable<boolean | UrlTree> {
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

View file

@ -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 <http://www.gnu.org/licenses/>.
*
* 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<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
return this.authService.authorizeUserSession(route, snapshot);
}
}

View file

@ -35,7 +35,7 @@
<scrm-logo-ui></scrm-logo-ui>
</div>
</div>
<div class="form-row" *ngIf="vm.showLanguages">
<div class="form-row" *ngIf="vm.showLanguages && cardState !== '2fa'">
<div class="col">
<label class="" for="languages">{{vm.appStrings['LBL_LANGUAGE']}}</label>
</div>
@ -151,6 +151,20 @@
</a>
</div>
</div>
<!-- 2fa Card-->
<div class="fade-card-back col"
*ngIf="cardState ==='2fa'"
[@fade]>
<div class="inner-addon left-addon">
<scrm-2fa-check [class]="'login-button'"></scrm-2fa-check>
</div>
<div>
<a class="back-link forgotten-password-link" (click)="returnToLogin()">
{{vm.appStrings['LBL_BACK']}}
</a>
</div>
</div>
</div>
</form>

View file

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

View file

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

View file

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

View file

@ -108,6 +108,7 @@
@import 'layout/install';
@import 'layout/admin';
@import 'layout/scrollbar';
@import 'layout/two-factor';
@import 'fields/email';
@import 'fields/composite-mobile';

View file

@ -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']]
);
}
}

View file

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

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\ORM\EntityManagerInterface;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241001074858 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add Totp Secret Field to DB';
}
public function up(Schema $schema): void
{
/** @var EntityManagerInterface $entityManager */
$entityManager = $this->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
{
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Security\TwoFactor;
use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorTokenInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
class AuthenticationSuccessHandler implements AuthenticationSuccessHandlerInterface
{
public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
{
if ($token instanceof TwoFactorTokenInterface) {
// Return the response to tell the client two-factor authentication is required.
return new Response('{"login_success": "true", "two_factor_complete": "false"}');
}
// $data = [
// 'appStatus' => '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));
}
}

View file

@ -0,0 +1,42 @@
<?php
/**
* 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 <http://www.gnu.org/licenses/>.
*
* 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;
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Security\TwoFactor;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
class TwoFactorAuthenticationFailureHandler implements AuthenticationFailureHandlerInterface
{
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
error_log(' in onAuthenticationFailure');
// Return the response to tell the client that 2fa failed. You may want to add more details
// from the $exception.
return new Response('{"error": "2fa_failed", "two_factor_complete": false}');
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Security\TwoFactor;
use Scheb\TwoFactorBundle\Security\Http\Authentication\AuthenticationRequiredHandlerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
class TwoFactorAuthenticationRequiredHandler implements AuthenticationRequiredHandlerInterface
{
public function onAuthenticationRequired(Request $request, TokenInterface $token): Response
{
error_log(' in onAuthenticationRequired');
// Return the response to tell the client that authentication hasn't completed yet and
// two-factor authentication is required.
return new Response('{"error": "access_denied", "two_factor_complete": false}');
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Security\TwoFactor;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
class TwoFactorAuthenticationSuccessHandler implements AuthenticationSuccessHandlerInterface
{
public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
{
error_log(' in onAuthenticationSuccess');
// Return the response to tell the client that authentication including two-factor
// authentication is complete now.
return new Response('{"login_success": true, "two_factor_complete": true}');
}
}

View file

@ -35,6 +35,10 @@ use App\Module\Users\Repository\UserRepository;
use DateTime;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration;
use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface;
use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface;
use Scheb\TwoFactorBundle\Model\BackupCodeInterface;
use Symfony\Component\Security\Core\User\EquatableInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
@ -55,7 +59,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
name: "idx_user_name",
options: ['lengths' => [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;
}
}
}

View file

@ -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"
},