├── .gitignore ├── Resources ├── Private │ ├── Fusion │ │ ├── Login │ │ │ └── Login.fusion │ │ ├── Root.fusion │ │ ├── Integration │ │ │ └── Controller │ │ │ │ ├── Login │ │ │ │ ├── AskForSecondFactor.fusion │ │ │ │ └── SetupSecondFactor.fusion │ │ │ │ └── Backend │ │ │ │ ├── Index.fusion │ │ │ │ └── New.fusion │ │ └── Presentation │ │ │ ├── Components │ │ │ ├── Footer.fusion │ │ │ ├── LoginFlashMessages.fusion │ │ │ ├── FlashMessages.fusion │ │ │ ├── LoginSecondFactorStep.fusion │ │ │ └── SecondFactorList.fusion │ │ │ ├── Pages │ │ │ ├── DefaultPage.fusion │ │ │ ├── AbstractPage.fusion │ │ │ ├── LoginSecondFactorPage.fusion │ │ │ └── SetupSecondFactorPage.fusion │ │ │ └── BodyLayout │ │ │ └── Default.fusion │ └── Translations │ │ ├── en │ │ ├── Main.xlf │ │ └── Backend.xlf │ │ └── de │ │ ├── Main.xlf │ │ └── Backend.xlf └── Public │ ├── index.js │ └── Styles │ └── Login.css ├── Classes ├── Domain │ ├── AuthenticationStatus.php │ ├── Model │ │ ├── Dto │ │ │ └── SecondFactorDto.php │ │ └── SecondFactor.php │ └── Repository │ │ └── SecondFactorRepository.php ├── Service │ ├── SecondFactorSessionStorageService.php │ ├── TOTPService.php │ └── SecondFactorService.php ├── Controller │ ├── BackendController.php │ └── LoginController.php └── Http │ └── Middleware │ └── SecondFactorMiddleware.php ├── Configuration ├── Views.yaml ├── Policy.yaml ├── Routes.yaml └── Settings.yaml ├── LICENSE ├── Migrations └── Mysql │ ├── Version20240812091514.php │ ├── Version20220207105522.php │ └── Version20231114151915.php ├── composer.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # 3rd party sources 2 | Packages/ 3 | vendor/ 4 | 5 | # composer 6 | composer.lock 7 | 8 | # IDEs 9 | .idea/ 10 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Login/Login.fusion: -------------------------------------------------------------------------------- 1 | prototype(Sandstorm.NeosTwoFactorAuthentication:Login) < prototype(Neos.Fusion:Component) { 2 | renderer = 'login comming soon' 3 | } 4 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Root.fusion: -------------------------------------------------------------------------------- 1 | # you need to include ALL packages you want to use here, there is no AutoInclude! 2 | include: resource://Neos.Fusion/Private/Fusion/Root.fusion 3 | include: resource://Neos.Fusion.Form/Private/Fusion/Root.fusion 4 | 5 | # include all files in here 6 | include: **/*.fusion 7 | -------------------------------------------------------------------------------- /Classes/Domain/AuthenticationStatus.php: -------------------------------------------------------------------------------- 1 | = 8.1 6 | class AuthenticationStatus 7 | { 8 | const AUTHENTICATION_NEEDED = 'AUTHENTICATION_NEEDED'; 9 | const AUTHENTICATED = 'AUTHENTICATED'; 10 | } 11 | -------------------------------------------------------------------------------- /Configuration/Views.yaml: -------------------------------------------------------------------------------- 1 | - 2 | requestFilter: 'isPackage("Sandstorm.NeosTwoFactorAuthentication") && isController("Login") && isAction("index") && isFormat("html")' 3 | viewObjectName: 'Neos\Fusion\View\FusionView' 4 | options: 5 | fusionPathPatterns: 6 | - 'resource://Sandstorm.NeosTwoFactorAuthentication/Private/Fusion/Login' 7 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Integration/Controller/Login/AskForSecondFactor.fusion: -------------------------------------------------------------------------------- 1 | Sandstorm.NeosTwoFactorAuthentication.LoginController.askForSecondFactor = Sandstorm.NeosTwoFactorAuthentication:Page.LoginSecondFactorPage { 2 | site = ${site} 3 | styles = ${styles} 4 | username = ${username} 5 | flashMessages = ${flashMessages} 6 | } 7 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Integration/Controller/Login/SetupSecondFactor.fusion: -------------------------------------------------------------------------------- 1 | Sandstorm.NeosTwoFactorAuthentication.LoginController.setupSecondFactor = Sandstorm.NeosTwoFactorAuthentication:Page.SetupSecondFactorPage { 2 | site = ${site} 3 | styles = ${styles} 4 | scripts = ${scripts} 5 | username = ${username} 6 | flashMessages = ${flashMessages} 7 | qrCode = ${qrCode} 8 | secret = ${secret} 9 | } 10 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Presentation/Components/Footer.fusion: -------------------------------------------------------------------------------- 1 | prototype(Sandstorm.NeosTwoFactorAuthentication:Component.Footer) < prototype(Neos.Fusion:Component) { 2 | primaryAction = '' 3 | title = '' 4 | 5 | renderer = afx` 6 | 11 | ` 12 | } 13 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Presentation/Components/LoginFlashMessages.fusion: -------------------------------------------------------------------------------- 1 | prototype(Sandstorm.NeosTwoFactorAuthentication:Component.LoginFlashMessages) < prototype(Neos.Fusion:Component) { 2 | flashMessages = ${[]} 3 | 4 | renderer = afx` 5 | 6 |
{flashMessage}
7 |
8 | ` 9 | } 10 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Presentation/Components/FlashMessages.fusion: -------------------------------------------------------------------------------- 1 | prototype(Sandstorm.NeosTwoFactorAuthentication:Component.FlashMessages) < prototype(Neos.Fusion:Component) { 2 | flashMessages = ${[]} 3 | 4 | renderer = afx` 5 | 10 | ` 11 | } 12 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Presentation/Pages/DefaultPage.fusion: -------------------------------------------------------------------------------- 1 | prototype(Sandstorm.NeosTwoFactorAuthentication:Page.DefaultPage) < prototype(Sandstorm.NeosTwoFactorAuthentication:Page.AbstractPage) { 2 | head { 3 | headTags = Neos.Fusion:Component { 4 | renderer = afx` 5 | 6 | 7 | ` 8 | } 9 | stylesheets { 10 | app = null 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Presentation/BodyLayout/Default.fusion: -------------------------------------------------------------------------------- 1 | prototype(Sandstorm.NeosTwoFactorAuthentication:BodyLayout.Default) < prototype(Neos.Fusion:Component) { 2 | content = '' 3 | teaserTitle = '' 4 | teaserText = '' 5 | 6 | flashMessages = ${[]} 7 | 8 | footer = '' 9 | 10 | renderer = afx` 11 |
12 | 13 | {props.teaserTitle} 14 | 15 |

16 | {props.teaserText} 17 |

18 | 19 | {props.content} 20 |
21 | 22 | 25 | ` 26 | } 27 | -------------------------------------------------------------------------------- /Classes/Domain/Model/Dto/SecondFactorDto.php: -------------------------------------------------------------------------------- 1 | user = $user; 17 | $this->secondFactor = $secondFactor; 18 | } 19 | 20 | /** 21 | * @return SecondFactor|string 22 | */ 23 | public function getSecondFactor(): SecondFactor 24 | { 25 | return $this->secondFactor; 26 | } 27 | 28 | /** 29 | * @return User 30 | */ 31 | public function getUser(): User 32 | { 33 | return $this->user; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Configuration/Policy.yaml: -------------------------------------------------------------------------------- 1 | privilegeTargets: 2 | 3 | 'Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege': 4 | 5 | 'Sandstorm.NeosTwoFactorAuthentication:LoginWithSecondFactor': 6 | matcher: 'method(Sandstorm\NeosTwoFactorAuthentication\Controller\LoginController->(.*)Action())' 7 | 8 | 'Sandstorm.NeosTwoFactorAuthentication:BackendModule': 9 | matcher: 'method(Sandstorm\NeosTwoFactorAuthentication\Controller\BackendController->(.*)Action())' 10 | 11 | roles: 12 | 'Neos.Neos:AbstractEditor': 13 | privileges: 14 | - 15 | privilegeTarget: 'Sandstorm.NeosTwoFactorAuthentication:LoginWithSecondFactor' 16 | permission: GRANT 17 | - 18 | privilegeTarget: 'Sandstorm.NeosTwoFactorAuthentication:BackendModule' 19 | permission: GRANT 20 | 21 | 'Neos.Neos:Administrator': 22 | privileges: 23 | - 24 | privilegeTarget: 'Sandstorm.NeosTwoFactorAuthentication:BackendModule' 25 | permission: GRANT 26 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Integration/Controller/Backend/Index.fusion: -------------------------------------------------------------------------------- 1 | Sandstorm.NeosTwoFactorAuthentication.BackendController.index = Sandstorm.NeosTwoFactorAuthentication:Page.DefaultPage { 2 | body = Sandstorm.NeosTwoFactorAuthentication:BodyLayout.Default { 3 | flashMessages = ${flashMessages} 4 | 5 | teaserTitle = ${I18n.id('module.index.title').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} 6 | 7 | content = Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList { 8 | factorsAndPerson = ${factorsAndPerson} 9 | } 10 | 11 | footer = Sandstorm.NeosTwoFactorAuthentication:Component.Footer { 12 | title = ${I18n.id('module.index.create').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} 13 | primaryAction = Neos.Fusion:UriBuilder { 14 | package = 'Sandstorm.NeosTwoFactorAuthentication' 15 | controller = 'Backend' 16 | action = 'new' 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sandstorm Media GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Classes/Domain/Repository/SecondFactorRepository.php: -------------------------------------------------------------------------------- 1 | 'ASC', 21 | 'creationDate' => 'DESC' 22 | ]; 23 | 24 | /** 25 | * @throws IllegalObjectTypeException 26 | */ 27 | public function createSecondFactorForAccount(string $secret, Account $account): void 28 | { 29 | $secondFactor = new SecondFactor(); 30 | $secondFactor->setAccount($account); 31 | $secondFactor->setSecret($secret); 32 | $secondFactor->setType(SecondFactor::TYPE_TOTP); 33 | $secondFactor->setCreationDate(new \DateTime()); 34 | $this->add($secondFactor); 35 | $this->persistenceManager->persistAll(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Configuration/Routes.yaml: -------------------------------------------------------------------------------- 1 | - name: 'Sandstorm Two Factor Authentication' 2 | uriPattern: 'neos/second-factor-login' 3 | httpMethods: ['GET'] 4 | defaults: 5 | '@package': 'Sandstorm.NeosTwoFactorAuthentication' 6 | '@controller': 'Login' 7 | '@action': 'askForSecondFactor' 8 | '@format': 'html' 9 | appendExceedingArguments: true 10 | 11 | - name: 'Sandstorm Two Factor Authentication - Validation' 12 | uriPattern: 'neos/second-factor-login' 13 | httpMethods: ['POST'] 14 | defaults: 15 | '@package': 'Sandstorm.NeosTwoFactorAuthentication' 16 | '@controller': 'Login' 17 | '@action': 'checkSecondFactor' 18 | '@format': 'html' 19 | appendExceedingArguments: true 20 | 21 | - name: 'Sandstorm Two Factor Authentication - Setup' 22 | uriPattern: 'neos/second-factor-setup' 23 | defaults: 24 | '@package': 'Sandstorm.NeosTwoFactorAuthentication' 25 | '@controller': 'Login' 26 | '@action': 'setupSecondFactor' 27 | '@format': 'html' 28 | httpMethods: ['GET'] 29 | appendExceedingArguments: true 30 | 31 | - name: 'Sandstorm Two Factor Authentication - Create 2FA' 32 | uriPattern: 'neos/second-factor-setup' 33 | defaults: 34 | '@package': 'Sandstorm.NeosTwoFactorAuthentication' 35 | '@controller': 'Login' 36 | '@action': 'createSecondFactor' 37 | '@format': 'html' 38 | httpMethods: ['POST'] 39 | -------------------------------------------------------------------------------- /Resources/Private/Translations/en/Main.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OTP from App 7 | 8 | 9 | Error 10 | 11 | 12 | The provided OTP is invalid. 13 | 14 | 15 | Submitted OTP was incorrect. 16 | 17 | 18 | OTP was registered successfully. 19 | 20 | 21 | Show Code 22 | 23 | 24 | Copy 25 | 26 | 27 | Close 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Presentation/Pages/AbstractPage.fusion: -------------------------------------------------------------------------------- 1 | # This is a trimmed-down version of Neos.Neos:Page, usable for standalone usage in Flow applications. 2 | # 3 | # The "public API", like "head.titleTag.content", "body", or "body.javascripts" is exactly the same as 4 | # in Neos.Neos:Page. 5 | prototype(Sandstorm.NeosTwoFactorAuthentication:Page.AbstractPage) < prototype(Neos.Fusion:Component) { 6 | # add the DocType as processor; because it will break AFX. 7 | @process.addDocType = ${'' + value} 8 | 9 | head = Neos.Fusion:Join { 10 | titleTag = Neos.Fusion:Tag { 11 | tagName = 'title' 12 | } 13 | # Script and CSS includes in the head should go here 14 | stylesheets = Neos.Fusion:Join 15 | javascripts = Neos.Fusion:Join 16 | } 17 | 18 | body { 19 | # Script includes before the closing body tag should go here 20 | javascripts = Neos.Fusion:Join 21 | # This processor appends the rendered javascripts Array to the rendered template 22 | @process.appendJavaScripts = ${value + this.javascripts} 23 | } 24 | 25 | renderer = afx` 26 | 27 | 28 | 29 | {props.head} 30 | 31 | 32 | {props.body} 33 | 34 | 35 | ` 36 | 37 | # enable Neos.Fusion:Debug helper 38 | @process.debugDump = Neos.Fusion:DebugDump 39 | } 40 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20240812091514.php: -------------------------------------------------------------------------------- 1 | abortIf( 24 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform, 25 | "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform,'." 26 | ); 27 | 28 | $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor ADD creationdate DATETIME DEFAULT NULL'); 29 | } 30 | 31 | public function down(Schema $schema): void 32 | { 33 | // this down() migration is auto-generated, please modify it to your needs 34 | $this->abortIf( 35 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform, 36 | "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform,'." 37 | ); 38 | 39 | $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor DROP creationdate'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "", 3 | "type": "neos-package", 4 | "name": "sandstorm/neostwofactorauthentication", 5 | "license": [ 6 | "MIT" 7 | ], 8 | "require": { 9 | "php": "^7.4 | ^8.0", 10 | "neos/neos": "^5.3 | ^7.0 | ^8.0 | ^9.0", 11 | "neos/fusion": "*", 12 | "neos/fusion-afx": "*", 13 | "neos/fusion-form": "*", 14 | "spomky-labs/otphp": "^10.0", 15 | "chillerlan/php-qrcode": "^4.3" 16 | }, 17 | "autoload": { 18 | "psr-4": { 19 | "Sandstorm\\NeosTwoFactorAuthentication\\": "Classes/" 20 | } 21 | }, 22 | "extra": { 23 | "neos": { 24 | "package-key": "Sandstorm.NeosTwoFactorAuthentication" 25 | }, 26 | "applied-flow-migrations": [ 27 | "Neos.Twitter.Bootstrap-20161124204912", 28 | "Neos.Party-20161124225257", 29 | "Neos.Imagine-20161124231742", 30 | "Neos.Seo-20170127154600", 31 | "Neos.Flow-20180415105700", 32 | "Neos.Neos-20180907103800", 33 | "Neos.Neos.Ui-20190319094900", 34 | "Neos.Flow-20190425144900", 35 | "Neos.Flow-20190515215000", 36 | "Neos.Flow-20200813181400", 37 | "Neos.Flow-20201003165200", 38 | "Neos.Flow-20201109224100", 39 | "Neos.Flow-20201205172733", 40 | "Neos.Flow-20201207104500", 41 | "Neos.Neos-20220318111600", 42 | "Neos.Flow-20220318174300", 43 | "Neos.Fusion-20220326120900" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Classes/Service/SecondFactorSessionStorageService.php: -------------------------------------------------------------------------------- 1 | sessionManager->getCurrentSession()->putData( 27 | self::SESSION_OBJECT_ID, 28 | [ 29 | self::SESSION_OBJECT_AUTH_STATUS => $authenticationStatus, 30 | ] 31 | ); 32 | } 33 | 34 | /** 35 | * @throws SessionNotStartedException 36 | */ 37 | public function getAuthenticationStatus(): string 38 | { 39 | $storageObject = $this->sessionManager->getCurrentSession()->getData(self::SESSION_OBJECT_ID); 40 | 41 | return $storageObject[self::SESSION_OBJECT_AUTH_STATUS]; 42 | } 43 | 44 | /** 45 | * @throws SessionNotStartedException 46 | */ 47 | public function initializeTwoFactorSessionObject(): void 48 | { 49 | if (!$this->sessionManager->getCurrentSession()->hasKey(self::SESSION_OBJECT_ID)) { 50 | self::setAuthenticationStatus(AuthenticationStatus::AUTHENTICATION_NEEDED); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Resources/Private/Translations/de/Main.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OTP from App 7 | OTP aus App 8 | 9 | 10 | Error 11 | Fehler 12 | 13 | 14 | The provided OTP is invalid. 15 | Das eingegebene OTP ist ungültig. 16 | 17 | 18 | Submitted OTP was incorrect. 19 | Das eingegebene OTP ist nicht korrekt. 20 | 21 | 22 | OTP was registered successfully. 23 | Das OTP wurde erfolgreich registriert. 24 | 25 | 26 | Show Code 27 | Code Anzeigen 28 | 29 | 30 | Copy 31 | Kopieren 32 | 33 | 34 | Close 35 | Schließen 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20220207105522.php: -------------------------------------------------------------------------------- 1 | abortIf( 24 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform, 25 | "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform'." 26 | ); 27 | 28 | $this->addSql('CREATE TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor (persistence_object_identifier VARCHAR(40) NOT NULL, account VARCHAR(40) DEFAULT NULL, type INT NOT NULL, secret VARCHAR(255) NOT NULL, INDEX IDX_29EF8A7F7D3656A4 (account), PRIMARY KEY(persistence_object_identifier)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); 29 | $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor ADD CONSTRAINT FK_29EF8A7F7D3656A4 FOREIGN KEY (account) REFERENCES neos_flow_security_account (persistence_object_identifier)'); 30 | } 31 | 32 | public function down(Schema $schema): void 33 | { 34 | // this down() migration is auto-generated, please modify it to your needs 35 | $this->abortIf( 36 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform, 37 | "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform'." 38 | ); 39 | 40 | $this->addSql('DROP TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Migrations/Mysql/Version20231114151915.php: -------------------------------------------------------------------------------- 1 | abortIf( 24 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform, 25 | "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform,'." 26 | ); 27 | 28 | $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor DROP FOREIGN KEY FK_29EF8A7F7D3656A4'); 29 | $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor ADD CONSTRAINT FK_29EF8A7F7D3656A4 FOREIGN KEY (account) REFERENCES neos_flow_security_account (persistence_object_identifier) ON DELETE CASCADE'); 30 | } 31 | 32 | public function down(Schema $schema): void 33 | { 34 | // this down() migration is auto-generated, please modify it to your needs 35 | $this->abortIf( 36 | !$this->connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\MySqlPlatform, 37 | "Migration can only be executed safely on '\Doctrine\DBAL\Platforms\MySqlPlatform,'." 38 | ); 39 | 40 | $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor DROP FOREIGN KEY FK_29EF8A7F7D3656A4'); 41 | $this->addSql('ALTER TABLE sandstorm_neostwofactorauthentication_domain_model_secondfactor ADD CONSTRAINT FK_29EF8A7F7D3656A4 FOREIGN KEY (account) REFERENCES neos_flow_security_account (persistence_object_identifier)'); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Classes/Service/TOTPService.php: -------------------------------------------------------------------------------- 1 | verify($submittedOtp); 42 | } 43 | 44 | public function generateQRCodeForTokenAndAccount(TOTP $otp, Account $account): string 45 | { 46 | $secret = $otp->getSecret(); 47 | $currentDomain = $this->domainRepository->findOneByActiveRequest(); 48 | $currentSite = $currentDomain !== null ? $currentDomain->getSite() : $this->siteRepository->findDefault(); 49 | $currentSiteName = $currentSite->getName(); 50 | $urlEncodedSiteName = urlencode($currentSiteName); 51 | $userIdentifier = $account->getAccountIdentifier(); 52 | // If the issuerName is set in the configuration, use that. Else fall back to the default. 53 | $issuer = !empty($this->issuerName) ? urlencode($this->issuerName) : $urlEncodedSiteName; 54 | $oauthData = "otpauth://totp/$userIdentifier?secret=$secret&period=30&issuer=$issuer"; 55 | $qrCode = (new QRCode(new QROptions([ 56 | 'outputType' => QRCode::OUTPUT_MARKUP_SVG 57 | ])))->render($oauthData); 58 | 59 | return $qrCode; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Configuration/Settings.yaml: -------------------------------------------------------------------------------- 1 | Neos: 2 | Neos: 3 | fusion: 4 | autoInclude: 5 | 'Sandstorm.NeosTwoFactorAuthentication': true 6 | modules: 7 | management: 8 | submodules: 9 | twoFactorAuthentication: 10 | controller: 'Sandstorm\NeosTwoFactorAuthentication\Controller\BackendController' 11 | label: 'Sandstorm.NeosTwoFactorAuthentication:Backend:module.label' 12 | description: 'Sandstorm.NeosTwoFactorAuthentication:Backend:module.description' 13 | icon: 'fas fa-qrcode' 14 | additionalResources: 15 | styleSheets: 16 | - 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/Styles/Login.css' 17 | javaScripts: 18 | - 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/index.js' 19 | 20 | userInterface: 21 | translation: 22 | autoInclude: 23 | 'Sandstorm.NeosTwoFactorAuthentication': 24 | - '*' 25 | 26 | backendLoginForm: 27 | stylesheets: 28 | 'Sandstorm.NeosTwoFactorAuthentication:AdditionalStyles': 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/Styles/Login.css' 29 | scripts: 30 | 'Sandstorm.NeosTwoFactorAuthentication:AdditionalScripts': 'resource://Sandstorm.NeosTwoFactorAuthentication/Public/index.js' 31 | 32 | Flow: 33 | http: 34 | middlewares: 35 | 'secondFactorRedirectMiddleware': 36 | middleware: 'Sandstorm\NeosTwoFactorAuthentication\Http\Middleware\SecondFactorMiddleware' 37 | position: 'after securityEntryPoint' 38 | mvc: 39 | routes: 40 | 'Sandstorm.NeosTwoFactorAuthentication': true 41 | 42 | security: 43 | authentication: 44 | providers: 45 | 'Neos.Neos:Backend': 46 | requestPatterns: 47 | 'Sandstorm.NeosTwoFactorAuthentication:SecondFactor': 48 | pattern: 'ControllerObjectName' 49 | patternOptions: 50 | controllerObjectNamePattern: 'Sandstorm\NeosTwoFactorAuthentication\Controller\(LoginController|BackendController)' 51 | 52 | Sandstorm: 53 | NeosTwoFactorAuthentication: 54 | # enforce 2FA for all users 55 | enforceTwoFactorAuthentication: false 56 | # enforce 2FA for specific authentication providers 57 | enforce2FAForAuthenticationProviders : [] 58 | # enforce 2FA for specific roles 59 | enforce2FAForRoles: [] 60 | # (optional) if set this will be used as a naming convention for the TOTP. If empty the Site name will be used 61 | issuerName: '' 62 | -------------------------------------------------------------------------------- /Classes/Service/SecondFactorService.php: -------------------------------------------------------------------------------- 1 | enforceTwoFactorAuthentication; 46 | $isEnforcedForRoles = count(array_intersect( 47 | array_map(fn($item) => $item->getIdentifier(), $account->getRoles()), 48 | $this->enforce2FAForRoles 49 | )); 50 | $isEnforcedForAuthenticationProviders = in_array( 51 | $account->getAuthenticationProviderName(), 52 | $this->enforce2FAForAuthenticationProviders 53 | ); 54 | 55 | return $isEnforcedForAll || $isEnforcedForRoles || $isEnforcedForAuthenticationProviders; 56 | } 57 | 58 | /** 59 | * Check if the account has setup at least 1 second factor. 60 | */ 61 | public function isSecondFactorEnabledForAccount(Account $account): bool 62 | { 63 | $factors = $this->secondFactorRepository->findByAccount($account); 64 | return count($factors) > 0; 65 | } 66 | 67 | /** 68 | * Check if the account can delete 1 second factor. 69 | * 70 | * Second factor can only be deleted if it is not enforced for the account or if the account has multiple factors. 71 | */ 72 | public function canOneSecondFactorBeDeletedForAccount(Account $account): bool 73 | { 74 | $isEnforcedForAccount = $this->isSecondFactorEnforcedForAccount($account); 75 | $hasMultipleFactors = count($this->secondFactorRepository->findByAccount($account)) > 1; 76 | 77 | return !$isEnforcedForAccount || $hasMultipleFactors; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Presentation/Components/LoginSecondFactorStep.fusion: -------------------------------------------------------------------------------- 1 | prototype(Sandstorm.NeosTwoFactorAuthentication:Component.LoginSecondFactorStep) < prototype(Neos.Fusion:Component) { 2 | flashMessages = ${[]} 3 | 4 | severityMapping = Neos.Fusion:DataStructure { 5 | OK = 'success' 6 | Notice = 'notice' 7 | Warning = 'warning' 8 | Error = 'error' 9 | } 10 | 11 | renderer = afx` 12 | 13 |
14 |
15 | 25 |
26 | 27 |
28 | 29 | {I18n.id('login').value('Login').package('Neos.Neos').source('Main').translate()} 30 | 31 | 32 | {I18n.id('authenticating').value('Authenticating').package('Neos.Neos').source('Main').translate()} 33 | 34 |
35 | 36 | 37 |
39 | 44 |
45 | 48 |
49 |
50 |
51 |
52 | ` 53 | } 54 | -------------------------------------------------------------------------------- /Classes/Domain/Model/SecondFactor.php: -------------------------------------------------------------------------------- 1 | account; 57 | } 58 | 59 | /** 60 | * @param Account $account 61 | */ 62 | public function setAccount(Account $account): void 63 | { 64 | $this->account = $account; 65 | } 66 | 67 | /** 68 | * @return int 69 | */ 70 | public function getType(): int 71 | { 72 | return $this->type; 73 | } 74 | 75 | /** 76 | * @return string 77 | */ 78 | public function getTypeAsName(): string 79 | { 80 | return self::typeToString($this->getType()); 81 | } 82 | 83 | /** 84 | * @param int $type 85 | */ 86 | public function setType(int $type): void 87 | { 88 | $this->type = $type; 89 | } 90 | 91 | /** 92 | * @return string 93 | */ 94 | public function getSecret(): string 95 | { 96 | return $this->secret; 97 | } 98 | 99 | /** 100 | * @param string $secret 101 | */ 102 | public function setSecret(string $secret): void 103 | { 104 | $this->secret = $secret; 105 | } 106 | 107 | public function getCreationDate(): DateTime|null 108 | { 109 | return $this->creationDate; 110 | } 111 | 112 | public function setCreationDate(DateTime $creationDate): void 113 | { 114 | $this->creationDate = $creationDate; 115 | } 116 | 117 | public function __toString(): string 118 | { 119 | return $this->account->getAccountIdentifier() . " with " . self::typeToString($this->type); 120 | } 121 | 122 | public static function typeToString(int $type): string 123 | { 124 | switch ($type) { 125 | case self::TYPE_TOTP: 126 | return 'OTP'; 127 | case self::TYPE_PUBLIC_KEY: 128 | return 'Public Key'; 129 | default: 130 | throw new InvalidArgumentException('Unsupported second factor type with index ' . $type); 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Resources/Public/index.js: -------------------------------------------------------------------------------- 1 | // Progressively enhance the secret form element on load 2 | window.addEventListener('load', function () { 3 | document.querySelectorAll('.neos-two-factor__secret-wrapper') 4 | .forEach(progressivelyEnhanceSecretFormElement) 5 | }) 6 | 7 | function progressivelyEnhanceSecretFormElement(secretFormElement) { 8 | // Collect inner elements (scoped to the secretFormElement) 9 | const secretInput = secretFormElement.querySelector('input#secret') 10 | const secretDialog = secretFormElement.querySelector('dialog') 11 | const showSecretButton = secretFormElement.querySelector('.neos-two-factor__secret__show__button') 12 | const secretDialogCloseButton = secretDialog.querySelector('.neos-two-factor__secret__close__button') 13 | const secretDisplay = secretDialog.querySelector('.neos-two-factor__secret') 14 | 15 | // Init secret modal buttons 16 | showSecretButton.onclick = function () { 17 | secretDialog.showModal() 18 | } 19 | 20 | secretDialogCloseButton.onclick = function () { 21 | secretDialog.close() 22 | } 23 | 24 | // Init overflow indicators 25 | // Each character are wrapped in a span element (so we can have different styles for numbers and letters) 26 | const allCharElements = Array.from(secretDisplay.querySelectorAll('span')) 27 | const firstCharElement = allCharElements[0] 28 | const lastCharElement = allCharElements[allCharElements.length - 1] 29 | 30 | const overflowIndicatorLeft = secretDialog.querySelector('.neos-two-factor__secret__overflow-indicator--left') 31 | const overflowIndicatorRight = secretDialog.querySelector('.neos-two-factor__secret__overflow-indicator--right') 32 | 33 | const intersectionObserverOptions = { 34 | threshold: 0.9 35 | } 36 | 37 | const firstCharIntersectionObserver = new IntersectionObserver(function (entries) { 38 | // Hide or show indicator when first character is visible or not visible respectively 39 | if (entries[0].isIntersecting) { 40 | overflowIndicatorLeft.classList.add('neos-two-factor__hidden') 41 | } else { 42 | overflowIndicatorLeft.classList.remove('neos-two-factor__hidden') 43 | } 44 | }, intersectionObserverOptions) 45 | 46 | const lastCharIntersectionObserver = new IntersectionObserver(function (entries) { 47 | // Hide or show indicator when last character is visible or not visible respectively 48 | if (entries[0].isIntersecting) { 49 | overflowIndicatorRight.classList.add('neos-two-factor__hidden') 50 | } else { 51 | overflowIndicatorRight.classList.remove('neos-two-factor__hidden') 52 | } 53 | }, intersectionObserverOptions) 54 | 55 | firstCharIntersectionObserver.observe(firstCharElement) 56 | lastCharIntersectionObserver.observe(lastCharElement) 57 | 58 | // Init copy secret button 59 | const copySecretButton = secretFormElement.querySelector('.neos-two-factor__secret__copy__button') 60 | copySecretButton.onclick = async function () { 61 | try { 62 | // Copy secret to clipboard 63 | await navigator.clipboard.writeText(secretInput.value) 64 | // Disable button and show success indicator 65 | copySecretButton.setAttribute('disabled', 'disabled') 66 | copySecretButton.querySelectorAll('span').forEach(element => { 67 | element.classList.toggle('neos-two-factor__hidden') 68 | }) 69 | 70 | // Wait for 1 second 71 | await new Promise(function (resolve) { 72 | setTimeout(resolve, 1000) 73 | }) 74 | 75 | } finally { 76 | // Re-enable button and hide success indicator 77 | copySecretButton.removeAttribute('disabled') 78 | copySecretButton.querySelectorAll('span').forEach(element => { 79 | element.classList.toggle('neos-two-factor__hidden') 80 | }) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Resources/Private/Translations/en/Backend.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Two-Factor Authentication 7 | 8 | 9 | This module allows registering and managing a second factor for the backend login. 10 | 11 | 12 | 13 | List of all registered second factors 14 | 15 | 16 | Name 17 | 18 | 19 | Type 20 | 21 | 22 | Creation Date 23 | 24 | 25 | Delete second factor 26 | 27 | 28 | Create second factor 29 | 30 | 31 | 32 | Do you really want to delete the second factor? 33 | 34 | 35 | Deleting a second factor can not be undone. 36 | 37 | 38 | Cancel 39 | 40 | 41 | Delete second factor 42 | 43 | 44 | Error 45 | 46 | 47 | The second factor was deleted. 48 | 49 | 50 | Cannot remove the last second factor because the two-factor authentication is enforced. 51 | 52 | 53 | 54 | Register new second factor 55 | 56 | 57 | To register a new second factor, scan the QR code with any TOTP app (e.g. Google Authenticator, Microsoft Authenticator, Authy) and enter the code. 58 | 59 | 60 | Register new OTP 61 | 62 | 63 | Submitted OTP was incorrect. 64 | 65 | 66 | OTP was registered successfully. 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /Resources/Public/Styles/Login.css: -------------------------------------------------------------------------------- 1 | .neos-two-factor-flashmessage { 2 | font-size: 13px; 3 | line-height: 1.4; 4 | padding: 8px; 5 | color: #fff; 6 | text-align: center; 7 | margin-bottom: 10px; 8 | } 9 | 10 | .neos-two-factor-flashmessage-warning { 11 | background-color: #F89406; 12 | } 13 | 14 | .neos-two-factor-flashmessage-info { 15 | background-color: #5bc0de; 16 | } 17 | 18 | .neos-two-factor__secret-wrapper { 19 | /* specificity hack */ 20 | display: flex !important; 21 | } 22 | 23 | .neos-two-factor__secret-wrapper dialog { 24 | padding: 8px; 25 | 26 | border: none; 27 | } 28 | 29 | .neos-two-factor__secret-wrapper dialog::backdrop { 30 | background-color: rgba(0, 0, 0, 0.5); 31 | } 32 | 33 | .neos-two-factor__secret-wrapper dialog > div { 34 | display: flex; 35 | flex-direction: column; 36 | align-items: center; 37 | gap: 8px; 38 | } 39 | 40 | .neos-two-factor__secret-wrapper dialog .neos-two-factor__dialog__actions { 41 | display: flex; 42 | gap: 8px; 43 | } 44 | 45 | .neos-two-factor__secret-wrapper dialog > div > .neos-actions { 46 | max-width: 200px; 47 | } 48 | 49 | .neos-two-factor__secret__copy__button { 50 | /* specificity hack */ 51 | display: flex !important; 52 | gap: 8px; 53 | align-items: center; 54 | justify-content: center; 55 | } 56 | 57 | .neos-two-factor__secret__copy__button span, 58 | .neos-two-factor__secret__copy__button span i { 59 | display: flex; 60 | align-items: center; 61 | } 62 | 63 | .neos-two-factor__secret__copy__button svg { 64 | height: 16px; 65 | width: 16px; 66 | 67 | fill: #fff; 68 | color: #fff; 69 | } 70 | 71 | .neos-two-factor__hidden { 72 | /* specificity hack */ 73 | display: none !important; 74 | } 75 | 76 | .neos-two-factor__secret { 77 | position: relative; 78 | display: block; 79 | width: 100%; 80 | overflow: hidden; 81 | 82 | font-size: 14px; 83 | line-height: 1.6em; 84 | } 85 | 86 | .neos-two-factor__secret div, 87 | .neos-two-factor__secret p, 88 | .neos-two-factor__secret svg { 89 | box-sizing: content-box; 90 | margin: 0 !important; 91 | padding: 0 !important; 92 | } 93 | 94 | .neos-two-factor__secret p { 95 | overflow: scroll; 96 | 97 | color: #0f0f0f; 98 | font-family: monospace; 99 | } 100 | 101 | .neos-two-factor__secret span:nth-child(3n) { 102 | margin-right: 4px; 103 | } 104 | 105 | .neos-two-factor__secret .neos-two-factor__secret__number { 106 | color: #007ead; 107 | } 108 | 109 | .neos-two-factor__secret__overflow-indicator--left, 110 | .neos-two-factor__secret__overflow-indicator--right { 111 | position: absolute; 112 | top: 0; 113 | width: 10em; 114 | height: 1.6em; 115 | 116 | display: flex; 117 | align-items: center; 118 | 119 | pointer-events: none; 120 | } 121 | 122 | .neos-two-factor__secret__overflow-indicator--left svg, 123 | .neos-two-factor__secret__overflow-indicator--right svg { 124 | height: 1.2em; 125 | 126 | fill: #3f3f3f; 127 | } 128 | 129 | .neos-two-factor__secret__overflow-indicator--left { 130 | left: 0; 131 | 132 | justify-content: left; 133 | 134 | background: linear-gradient(to left, rgba(255, 255, 255, 0), #fff); 135 | } 136 | 137 | .neos-two-factor__secret__overflow-indicator--right { 138 | right: 0; 139 | 140 | justify-content: right; 141 | 142 | background: linear-gradient(to right, rgba(255, 255, 255, 0), #fff); 143 | } 144 | 145 | /* override custom neos backend scrollbar styles */ 146 | .neos-two-factor__secret-wrapper ::-webkit-scrollbar { 147 | width: 8px; 148 | height: 8px; 149 | } 150 | 151 | .neos-two-factor__secret-wrapper ::-webkit-scrollbar-corner { 152 | background-color: initial; 153 | } 154 | 155 | .neos-two-factor__secret-wrapper ::-webkit-scrollbar-thumb { 156 | background-color: initial; 157 | border: initial; 158 | } 159 | .neos-two-factor__secret-wrapper ::-webkit-scrollbar-track { 160 | background-color: initial; 161 | } 162 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Presentation/Components/SecondFactorList.fusion: -------------------------------------------------------------------------------- 1 | prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList) < prototype(Neos.Fusion:Component) { 2 | factorsAndPerson = '' 3 | 4 | renderer = afx` 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 21 | 25 | 26 | 27 |
{I18n.id('module.index.list.header.name').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}{I18n.id('module.index.list.header.type').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()}{I18n.id('module.index.list.header.creationDate').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} 
28 | ` 29 | } 30 | 31 | prototype(Sandstorm.NeosTwoFactorAuthentication:Component.SecondFactorList.Entry) < prototype(Neos.Fusion:Component) { 32 | factorAndPerson = '' 33 | iterator = '' 34 | 35 | editUri = Neos.Fusion:UriBuilder { 36 | action = 'edit' 37 | arguments = Neos.Fusion:DataStructure { 38 | secondFactor = ${factorAndPerson.secondFactor} 39 | } 40 | } 41 | 42 | renderer = afx` 43 | 44 | {props.factorAndPerson.user.name.fullName} ({props.factorAndPerson.secondFactor.account.accountIdentifier}) 45 | {props.factorAndPerson.secondFactor.typeAsName} 46 | {props.factorAndPerson.secondFactor.creationDate == null ? '-' : Date.format(props.factorAndPerson.secondFactor.creationDate, 'Y-m-d H:i')} 47 | 48 | 52 | 53 |
54 |
55 |
56 |
57 | 58 |
59 | {I18n.id('module.index.delete.header').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} 60 |
61 |
62 |
63 |

64 | {I18n.id('module.index.delete.text').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} 65 |

66 |
67 |
68 |
69 | 79 |
80 |
81 |
82 |
83 | 84 | 85 | ` 86 | } 87 | -------------------------------------------------------------------------------- /Resources/Private/Translations/de/Backend.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Two-Factor Authentication 7 | Zwei-Faktor-Authentifizierung 8 | 9 | 10 | This module allows registering and managing a second factor for the backend login. 11 | Dieses Modul erlaubt die Einrichtung und Verwaltung eines zweiten Faktors für das Backend-Login. 12 | 13 | 14 | 15 | List of all registered second factors 16 | Liste aller registrierten zweiten Faktoren 17 | 18 | 19 | Name 20 | Name 21 | 22 | 23 | Type 24 | Typ 25 | 26 | 27 | Creation Date 28 | Erstellungsdatum 29 | 30 | 31 | Delete second factor 32 | Zweiten Faktor löschen 33 | 34 | 35 | Create second factor 36 | Zweiten Faktor erstellen 37 | 38 | 39 | 40 | Do you really want to delete the second factor? 41 | Wollen Sie den zweiten Faktor wirklich löschen? 42 | 43 | 44 | Deleting a second factor can not be undone. 45 | Das Löschen eines zweiten Faktors kann nicht rückgängig gemacht werden. 46 | 47 | 48 | Cancel 49 | Abbrechen 50 | 51 | 52 | Delete second factor 53 | Zweiten Faktor löschen 54 | 55 | 56 | Error 57 | Fehler 58 | 59 | 60 | The second factor was deleted. 61 | Der zweite Faktor wurde gelöscht. 62 | 63 | 64 | Cannot remove the last second factor because the two-factor authentication is enforced. 65 | Der letzte zweite Faktor kann nicht gelöscht werden, da die Zwei-Faktor-Authentifizierung erzwungen wird. 66 | 67 | 68 | 69 | Register new second factor 70 | Neuen zweiten Faktor registrieren 71 | 72 | 73 | To register a new second factor, scan the QR code with any TOTP app (e.g. Google Authenticator, Microsoft Authenticator, Authy) and enter the code. 74 | Scannen Sie den QR-Code in Ihrer TOTP-App (z.B. Google Authenticator, Microsoft Authenticator, Authy) und geben Sie den Code ein, um einen neuen zweiten Faktor zu registrieren. 75 | 76 | 77 | Register new OTP 78 | Neues OTP registrieren 79 | 80 | 81 | Submitted OTP was incorrect. 82 | Das eingegebene OTP ist nicht korrekt. 83 | 84 | 85 | OTP was registered successfully. 86 | Das OTP wurde erfolgreich registriert. 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Presentation/Pages/LoginSecondFactorPage.fusion: -------------------------------------------------------------------------------- 1 | prototype(Sandstorm.NeosTwoFactorAuthentication:Page.LoginSecondFactorPage) < prototype(Neos.Fusion:Component) { 2 | site = null 3 | styles = ${[]} 4 | username = '' 5 | flashMessages = ${[]} 6 | 7 | renderer = Neos.Fusion:Join { 8 | doctype = '' 9 | doctype.@position = 'start 100' 10 | 11 | content = Neos.Fusion:Component { 12 | @apply.props = ${props} 13 | 14 | backgroundImageSource = Neos.Fusion:Component { 15 | @if.set = ${this.path} 16 | path = ${Configuration.setting('Neos.Neos.userInterface.backendLoginForm.backgroundImage')} 17 | 18 | renderer = Neos.Fusion:Case { 19 | resourcePath { 20 | condition = ${String.indexOf(props.path, 'resource://') == 0} 21 | renderer = Neos.Fusion:ResourceUri { 22 | path = ${props.path} 23 | } 24 | } 25 | 26 | default { 27 | condition = true 28 | renderer = ${props.path} 29 | } 30 | } 31 | } 32 | backgroundImageSourceIsWebp = ${this.backgroundImageSource ? String.endsWith(this.backgroundImageSource, '.webp') : null} 33 | 34 | headerComment = ${Configuration.setting('Neos.Neos.headerComment')} 35 | 36 | renderer = afx` 37 | 38 | {props.headerComment} 39 | 40 | 41 | 42 | {I18n.translate('login.index.title', 'Login to')} {props.site.name} 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 57 | 61 | 62 | 63 | 64 |
65 |
66 | 81 |
82 | 90 |
91 | 92 | 101 | 102 | 103 | ` 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Integration/Controller/Backend/New.fusion: -------------------------------------------------------------------------------- 1 | Sandstorm.NeosTwoFactorAuthentication.BackendController.new = Sandstorm.NeosTwoFactorAuthentication:Page.DefaultPage { 2 | body = Sandstorm.NeosTwoFactorAuthentication:BodyLayout.Default { 3 | flashMessages = ${flashMessages} 4 | 5 | teaserTitle = ${I18n.id('module.new.title').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} 6 | teaserText = ${I18n.id('module.new.description').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} 7 | 8 | content = Neos.Fusion:Component { 9 | renderer = afx` 10 | 11 |
12 | 13 |
14 | 15 |
16 | 17 | 18 |
19 | 22 |
23 | 24 | 25 |
26 |
27 |

28 | { 29 | Array.join( 30 | Array.map( 31 | String.split(secret, ''), 32 | char => Type.isNumeric(char) ? '' + char + '' : '' + char + '' 33 | ), 34 | '' 35 | ) 36 | } 37 |

38 | 39 | 45 | 51 |
52 | 53 |
54 | 73 | 76 |
77 |
78 |
79 |
80 | 81 |
82 | 90 |
91 | 92 |
93 | 94 | {I18n.id('module.new.submit-otp').package('Sandstorm.NeosTwoFactorAuthentication').source('Backend').translate()} 95 | 96 |
97 |
98 | 99 | 100 | 103 | 104 | ` 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Neos Backend 2FA 2 | 3 | Extend the Neos backend login to support second factors. At the moment we only support TOTP tokens. 4 | 5 | Support for WebAuthn is planed! 6 | 7 | ## What this package does 8 | 9 | https://user-images.githubusercontent.com/12086990/153027757-ac715746-0575-4555-bce1-c44603747945.mov 10 | 11 | This package allows all users to register their personal TOTP token (Authenticator App). As an Administrator you are 12 | able to delete those token for the users again, in case they locked them self out. 13 | 14 | ![Screenshot 2022-02-08 at 17 11 01](https://user-images.githubusercontent.com/12086990/153028043-93e9220e-cc22-4879-9edb-3e156c9accc8.png) 15 | 16 | ## Settings 17 | ### Enforce 2FA 18 | To enforce the setup and usage of 2FA you can add the following to your `Settings.yaml`. 19 | ```yml 20 | Sandstorm: 21 | NeosTwoFactorAuthentication: 22 | # enforce 2FA for all users 23 | enforceTwoFactorAuthentication: true 24 | ``` 25 | With this setting, no user can login into the CMS without setting up a second factor first. 26 | 27 | In addition, you can enforce 2FA for specific authentication providers and/or roles by adding following to your `Settings.yaml` 28 | ```yml 29 | Sandstorm: 30 | NeosTwoFactorAuthentication: 31 | # enforce 2FA for specific authentication providers 32 | enforce2FAForAuthenticationProviders : ['Neos.Neos:Backend'] 33 | # enforce 2FA for specific roles 34 | enforce2FAForRoles: ['Neos.Neos:Administrator'] 35 | ``` 36 | 37 | ### Issuer Naming 38 | To override the default sitename as issuer label, you can define one via the configuration settings: 39 | ```yml 40 | Sandstorm: 41 | NeosTwoFactorAuthentication: 42 | # (optional) if set this will be used as a naming convention for the TOTP. If empty the Site name will be used 43 | issuerName: '' 44 | ``` 45 | 46 | ## Tested 2FA apps 47 | 48 | Thx to @Sebobo @Benjamin-K for creating a list of supported and testet apps! 49 | 50 | **iOS**: 51 | * Google Authenticator (used for development) ✅ 52 | * Authy ✅ 53 | * Microsoft Authenticator ✅ 54 | * 1Password ✅ 55 | 56 | **Android**: 57 | * Google Authenticator ✅ 58 | * Microsoft Authenticator ✅ 59 | * Authy ✅ 60 | 61 | ## How we did it 62 | * We introduced a new middleware `SecondFactorMiddleware` which handles 2FA on a Neos `Session` basis. 63 | * This is an overview of the checks the `SecondFactorMiddleware` does for any request: 64 | ``` 65 | ┌─────────────────────────────┐ 66 | │ Request │ 67 | └─────────────────────────────┘ 68 | ▼ 69 | ... middleware chain ... 70 | ▼ 71 | ┌─────────────────────────────┐ 72 | │ SecurityEndpointMiddleware │ 73 | └─────────────────────────────┘ 74 | ▼ 75 | ┌───────────────────────────────────────────────────────────────────┐ 76 | │ SecondFactorMiddleware │ 77 | │ │ 78 | │ ┌─────────────────────────────────────────────────────────────┐ │ 79 | │ │ 1. Skip, if no authentication tokens are present, because │ │ 80 | │ │ we're not on a secured route. │ │ 81 | │ └─────────────────────────────────────────────────────────────┘ │ 82 | │ ┌─────────────────────────────────────────────────────────────┐ │ 83 | │ │ 2. Skip, if 'Neos.Backend:Backend' authentication token not │ │ 84 | │ │ present, because we only support second factors for Neos │ │ 85 | │ │ backend. │ │ 86 | │ └─────────────────────────────────────────────────────────────┘ │ 87 | │ ┌─────────────────────────────────────────────────────────────┐ │ 88 | │ │ 3. Skip, if 'Neos.Backend:Backend' authentication token is │ │ 89 | │ │ not authenticated, because we need to be authenticated │ │ 90 | │ │ with the authentication provider of │ │ 91 | │ │ 'Neos.Backend:Backend' first. │ │ 92 | │ └─────────────────────────────────────────────────────────────┘ │ 93 | │ ┌─────────────────────────────────────────────────────────────┐ │ 94 | │ │ 4. Skip, if second factor is not set up for account and not │ │ 95 | │ │ enforced via settings. │ │ 96 | │ └─────────────────────────────────────────────────────────────┘ │ 97 | │ ┌─────────────────────────────────────────────────────────────┐ │ 98 | │ │ 5. Skip, if second factor is already authenticated. │ │ 99 | │ └─────────────────────────────────────────────────────────────┘ │ 100 | │ ┌─────────────────────────────────────────────────────────────┐ │ 101 | │ │ 6. Redirect to 2FA login, if second factor is set up for │ │ 102 | │ │ account but not authenticated. │ │ 103 | │ │ Skip, if already on 2FA login route. │ │ 104 | │ └─────────────────────────────────────────────────────────────┘ │ 105 | │ ┌─────────────────────────────────────────────────────────────┐ │ 106 | │ │ 7. Redirect to 2FA setup, if second factor is not set up for│ │ 107 | │ │ account but is enforced by system. │ │ 108 | │ │ Skip, if already on 2FA setup route. │ │ 109 | │ └─────────────────────────────────────────────────────────────┘ │ 110 | │ ┌─────────────────────────────────────────────────────────────┐ │ 111 | │ │ X. Throw an error, because any check before should have │ │ 112 | │ │ succeeded. │ │ 113 | │ └─────────────────────────────────────────────────────────────┘ │ 114 | └───────────────────────────────────────────────────────────────────┘ 115 | ▼ 116 | ... middlewares ... 117 | 118 | ``` 119 | 120 | 121 | ## When updating Neos, those part will likely crash: 122 | 123 | * the login screen for the second factor is a hard copy of the login screen from the `Neos.Neos` package 124 | * just replaced the username/password form with the form for the second factor 125 | * maybe has to be replaced when neos gets updated 126 | * hopefully the rest of this package is solid enough to survive the next mayor Neos versions ;) 127 | 128 | ## Why not ...? 129 | 130 | ### Enhance the `UsernamePassword` authentication token 131 | 132 | > This actually has been the approach up until version 1.0.5. 133 | 134 | One issue with this is the fact, that we _want_ the user to be logged in with that token via the 135 | `PersistedUsernamePasswordProvider`, but at the same time to _not be logged in_ with that token as long as 2FA is 136 | not authenticated as well. 137 | We found it hard to find a secure way to model the 2FA setup solution when 2FA is enforced, but the user does not have a 138 | second factor enabled, yet. 139 | 140 | The middleware approach makes a clear distinction between "Logging in" and "Second Factor Authentication", while still 141 | being session based and unable to bypass. 142 | 143 | ### Set the authenticationStrategy to `allTokens` 144 | 145 | The AuthenticationProviderManager requires to authorize all tokens at the same time otherwise, it will throw 146 | an Exception (see AuthenticationProviderManager Line 181 147 | 148 | ```php 149 | if ($this->authenticationStrategy === Context::AUTHENTICATE_ALL_TOKENS) { 150 | throw new AuthenticationRequiredException('Could not authenticate all tokens, but authenticationStrategy was set to "all".', 1222203912); 151 | } 152 | ``` 153 | ) 154 | 155 | This leads to an error where the `AuthenticationProviderManager` throws exceptions before the user is able to enter any 156 | credentials. The `SecurityEntryPointMiddleware` catches those exceptions and redirects to the Neos Backend Login, which 157 | causes the same exception again. We get caught in an endless redirect. 158 | 159 | The [Neos Flow Security Documentation](https://flowframework.readthedocs.io/en/stable/TheDefinitiveGuide/PartIII/Security.html#multi-factor-authentication-strategy) 160 | suggests how to implement a multi-factor-authentication, but this method seems like it was never tested. At the moment of writing 161 | it seems like the `authenticationStrategy: allTokens` flag is broken and not usable. 162 | -------------------------------------------------------------------------------- /Classes/Controller/BackendController.php: -------------------------------------------------------------------------------- 1 | securityContext->getAccount(); 88 | 89 | if ($this->securityContext->hasRole('Neos.Neos:Administrator')) { 90 | $factors = $this->secondFactorRepository->findAll(); 91 | } else { 92 | $factors = $this->secondFactorRepository->findByAccount($account); 93 | } 94 | 95 | $factorsAndPerson = array_map(function ($factor) { 96 | /** @var SecondFactor $factor */ 97 | $party = $this->partyService->getAssignedPartyOfAccount($factor->getAccount()); 98 | $user = null; 99 | if ($party instanceof User) { 100 | $user = $party; 101 | } 102 | return new SecondFactorDto($factor, $user); 103 | }, $factors->toArray()); 104 | 105 | $this->view->assignMultiple([ 106 | 'factorsAndPerson' => $factorsAndPerson, 107 | 'flashMessages' => $this->flashMessageService 108 | ->getFlashMessageContainerForRequest($this->request) 109 | ->getMessagesAndFlush(), 110 | ]); 111 | } 112 | 113 | /** 114 | * show the form to register a new second factor 115 | */ 116 | public function newAction(): void 117 | { 118 | $otp = TOTPService::generateNewTotp(); 119 | $secret = $otp->getSecret(); 120 | $qrCode = $this->tOTPService->generateQRCodeForTokenAndAccount($otp, $this->securityContext->getAccount()); 121 | 122 | $this->view->assignMultiple([ 123 | 'styles' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['stylesheets']), 124 | 'scripts' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['scripts']), 125 | 'secret' => $secret, 126 | 'qrCode' => $qrCode, 127 | 'flashMessages' => $this->flashMessageService 128 | ->getFlashMessageContainerForRequest($this->request) 129 | ->getMessagesAndFlush(), 130 | ]); 131 | } 132 | 133 | /** 134 | * save the registered second factor 135 | * 136 | * @throws SessionNotStartedException 137 | * @throws IllegalObjectTypeException 138 | * @throws StopActionException 139 | */ 140 | public function createAction(string $secret, string $secondFactorFromApp): void 141 | { 142 | $isValid = TOTPService::checkIfOtpIsValid($secret, $secondFactorFromApp); 143 | 144 | if (!$isValid) { 145 | $this->addFlashMessage( 146 | $this->translator->translateById( 147 | 'module.new.flashMessage.submittedOtpIncorrect', 148 | [], 149 | null, 150 | null, 151 | 'Backend', 152 | 'Sandstorm.NeosTwoFactorAuthentication' 153 | ), 154 | '', 155 | Message::SEVERITY_WARNING 156 | ); 157 | $this->redirect('new'); 158 | } 159 | 160 | $this->secondFactorRepository->createSecondFactorForAccount($secret, $this->securityContext->getAccount()); 161 | 162 | $this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED); 163 | 164 | $this->addFlashMessage( 165 | $this->translator->translateById( 166 | 'module.new.flashMessage.successfullyRegisteredOtp', 167 | [], 168 | null, 169 | null, 170 | 'Backend', 171 | 'Sandstorm.NeosTwoFactorAuthentication' 172 | ) 173 | ); 174 | $this->redirect('index'); 175 | } 176 | 177 | /** 178 | * @param SecondFactor $secondFactor 179 | * @return void 180 | */ 181 | public function deleteAction(SecondFactor $secondFactor): void 182 | { 183 | $account = $this->securityContext->getAccount(); 184 | 185 | $isAdministrator = $this->securityContext->hasRole('Neos.Neos:Administrator'); 186 | $isOwner = $secondFactor->getAccount() === $account; 187 | 188 | // Check, if user is allowed to remove second factor 189 | if ($isAdministrator || ($isOwner && $this->secondFactorService->canOneSecondFactorBeDeletedForAccount($account))) { 190 | // User is admin or has more than one second factor 191 | $this->secondFactorRepository->remove($secondFactor); 192 | $this->persistenceManager->persistAll(); 193 | $this->addFlashMessage( 194 | $this->translator->translateById( 195 | 'module.index.delete.flashMessage.secondFactorDeleted', 196 | [], 197 | null, 198 | null, 199 | 'Backend', 200 | 'Sandstorm.NeosTwoFactorAuthentication' 201 | ) 202 | ); 203 | } elseif ($isOwner) { 204 | // User is owner (but not admin) and has only one second factor -> factor can not be deleted 205 | $this->addFlashMessage( 206 | $this->translator->translateById( 207 | 'module.index.delete.flashMessage.cannotRemoveLastSecondFactor', 208 | [], 209 | null, 210 | null, 211 | 'Backend', 212 | 'Sandstorm.NeosTwoFactorAuthentication' 213 | ), 214 | $this->translator->translateById( 215 | 'module.index.delete.flashMessage.errorHeader', 216 | [], 217 | null, 218 | null, 219 | 'Backend', 220 | 'Sandstorm.NeosTwoFactorAuthentication' 221 | ), 222 | Message::SEVERITY_ERROR 223 | ); 224 | } 225 | 226 | $this->redirect('index'); 227 | } 228 | 229 | /** 230 | * @return array 231 | * @throws InvalidConfigurationTypeException 232 | */ 233 | protected function getNeosSettings(): array 234 | { 235 | $configurationManager = $this->objectManager->get(ConfigurationManager::class); 236 | return $configurationManager->getConfiguration( 237 | ConfigurationManager::CONFIGURATION_TYPE_SETTINGS, 238 | 'Neos.Neos' 239 | ); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /Classes/Controller/LoginController.php: -------------------------------------------------------------------------------- 1 | domainRepository->findOneByActiveRequest(); 94 | $currentSite = $currentDomain !== null ? $currentDomain->getSite() : $this->siteRepository->findDefault(); 95 | 96 | $this->view->assignMultiple([ 97 | 'styles' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['stylesheets']), 98 | 'scripts' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['scripts']), 99 | 'username' => $username, 100 | 'site' => $currentSite, 101 | 'flashMessages' => $this->flashMessageService 102 | ->getFlashMessageContainerForRequest($this->request) 103 | ->getMessagesAndFlush(), 104 | ]); 105 | } 106 | 107 | /** 108 | * @throws StopActionException 109 | * @throws SessionNotStartedException 110 | */ 111 | public function checkSecondFactorAction(string $otp): void 112 | { 113 | $account = $this->securityContext->getAccount(); 114 | 115 | $isValidOtp = $this->enteredTokenMatchesAnySecondFactor($otp, $account); 116 | 117 | if ($isValidOtp) { 118 | $this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED); 119 | } else { 120 | $this->addFlashMessage( 121 | $this->translator->translateById( 122 | 'login.flashMessage.invalidOtp', 123 | [], 124 | null, 125 | null, 126 | 'Main', 127 | 'Sandstorm.NeosTwoFactorAuthentication' 128 | ), 129 | $this->translator->translateById( 130 | 'login.flashMessage.errorHeader', 131 | [], 132 | null, 133 | null, 134 | 'Main', 135 | 'Sandstorm.NeosTwoFactorAuthentication' 136 | ), 137 | Message::SEVERITY_ERROR 138 | ); 139 | } 140 | 141 | $originalRequest = $this->securityContext->getInterceptedRequest(); 142 | if ($originalRequest !== null) { 143 | $this->redirectToRequest($originalRequest); 144 | } 145 | 146 | $this->redirect('index', 'Backend\Backend', 'Neos.Neos'); 147 | } 148 | 149 | /** 150 | * This action decides which tokens are already authenticated 151 | * and decides which is next to authenticate 152 | * 153 | * ATTENTION: this code is copied from the Neos.Neos:LoginController 154 | */ 155 | public function setupSecondFactorAction(?string $username = null): void 156 | { 157 | $otp = TOTPService::generateNewTotp(); 158 | $secret = $otp->getSecret(); 159 | $qrCode = $this->tOTPService->generateQRCodeForTokenAndAccount($otp, $this->securityContext->getAccount()); 160 | 161 | $currentDomain = $this->domainRepository->findOneByActiveRequest(); 162 | $currentSite = $currentDomain !== null ? $currentDomain->getSite() : $this->siteRepository->findDefault(); 163 | 164 | $this->view->assignMultiple([ 165 | 'styles' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['stylesheets']), 166 | 'scripts' => array_filter($this->getNeosSettings()['userInterface']['backendLoginForm']['scripts']), 167 | 'username' => $username, 168 | 'site' => $currentSite, 169 | 'secret' => $secret, 170 | 'qrCode' => $qrCode, 171 | 'flashMessages' => $this->flashMessageService 172 | ->getFlashMessageContainerForRequest($this->request) 173 | ->getMessagesAndFlush(), 174 | ]); 175 | } 176 | 177 | /** 178 | * @param string $secret 179 | * @param string $secondFactorFromApp 180 | * @return void 181 | * @throws IllegalObjectTypeException 182 | * @throws SessionNotStartedException 183 | * @throws StopActionException 184 | */ 185 | public function createSecondFactorAction(string $secret, string $secondFactorFromApp): void 186 | { 187 | $isValid = TOTPService::checkIfOtpIsValid($secret, $secondFactorFromApp); 188 | 189 | if (!$isValid) { 190 | $this->addFlashMessage( 191 | $this->translator->translateById( 192 | 'login.flashMessage.submittedOtpIncorrect', 193 | [], 194 | null, 195 | null, 196 | 'Main', 197 | 'Sandstorm.NeosTwoFactorAuthentication' 198 | ), 199 | '', 200 | Message::SEVERITY_WARNING 201 | ); 202 | $this->redirect('setupSecondFactor'); 203 | } 204 | 205 | $account = $this->securityContext->getAccount(); 206 | 207 | $this->secondFactorRepository->createSecondFactorForAccount($secret, $account); 208 | 209 | $this->addFlashMessage( 210 | $this->translator->translateById( 211 | 'login.flashMessage.successfullyRegisteredOtp', 212 | [], 213 | null, 214 | null, 215 | 'Main', 216 | 'Sandstorm.NeosTwoFactorAuthentication' 217 | ), 218 | ); 219 | 220 | $this->secondFactorSessionStorageService->setAuthenticationStatus(AuthenticationStatus::AUTHENTICATED); 221 | 222 | $originalRequest = $this->securityContext->getInterceptedRequest(); 223 | if ($originalRequest !== null) { 224 | $this->redirectToRequest($originalRequest); 225 | } 226 | 227 | $this->redirect('index', 'Backend\Backend', 'Neos.Neos'); 228 | } 229 | 230 | /** 231 | * Check if the given token matches any registered second factor 232 | * 233 | * @param string $enteredSecondFactor 234 | * @param Account $account 235 | * @return bool 236 | */ 237 | private function enteredTokenMatchesAnySecondFactor(string $enteredSecondFactor, Account $account): bool 238 | { 239 | /** @var SecondFactor[] $secondFactors */ 240 | $secondFactors = $this->secondFactorRepository->findByAccount($account); 241 | foreach ($secondFactors as $secondFactor) { 242 | $isValid = TOTPService::checkIfOtpIsValid($secondFactor->getSecret(), $enteredSecondFactor); 243 | if ($isValid) { 244 | return true; 245 | } 246 | } 247 | 248 | return false; 249 | } 250 | 251 | /** 252 | * @return array 253 | * @throws InvalidConfigurationTypeException 254 | */ 255 | protected function getNeosSettings(): array 256 | { 257 | $configurationManager = $this->objectManager->get(ConfigurationManager::class); 258 | return $configurationManager->getConfiguration( 259 | ConfigurationManager::CONFIGURATION_TYPE_SETTINGS, 260 | 'Neos.Neos' 261 | ); 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /Classes/Http/Middleware/SecondFactorMiddleware.php: -------------------------------------------------------------------------------- 1 | securityContext->getAuthenticationTokens(); 123 | 124 | // 1. Skip, if no authentication tokens are present, because we're not on a secured route. 125 | if (empty($authenticationTokens)) { 126 | $this->log('No authentication tokens found, skipping second factor.'); 127 | 128 | return $handler->handle($request); 129 | } 130 | 131 | // 2. Skip, if 'Neos.Backend:Backend' authentication token not present, because we only support second factors 132 | // for Neos backend. 133 | if (!array_key_exists('Neos.Neos:Backend', $authenticationTokens)) { 134 | $this->log('No authentication token for "Neos.Neos:Backend" found, skipping second factor.'); 135 | 136 | return $handler->handle($request); 137 | } 138 | 139 | $isAuthenticated = $authenticationTokens['Neos.Neos:Backend']->isAuthenticated(); 140 | 141 | // 3. Skip, if 'Neos.Backend:Backend' authentication token is not authenticated, because we need to be 142 | // authenticated with the authentication provider of 'Neos.Backend:Backend' first. 143 | if (!$isAuthenticated) { 144 | $this->log('Not authenticated on "Neos.Neos:Backend" authentication provider, skipping second factor.'); 145 | 146 | return $handler->handle($request); 147 | } 148 | 149 | $account = $this->securityContext->getAccount(); 150 | 151 | $isEnabledForAccount = $this->secondFactorService->isSecondFactorEnabledForAccount($account); 152 | $isEnforcedForAccount = $this->secondFactorService->isSecondFactorEnforcedForAccount($account); 153 | 154 | // 4. Skip, if second factor is not set up for account and not enforced via settings. 155 | if (!$isEnabledForAccount && !$isEnforcedForAccount) { 156 | $this->log('Second factor not enabled for account and not enforced by system, skipping second factor.'); 157 | 158 | return $handler->handle($request); 159 | } 160 | 161 | $this->secondFactorSessionStorageService->initializeTwoFactorSessionObject(); 162 | 163 | $authenticationStatus = $this->secondFactorSessionStorageService->getAuthenticationStatus(); 164 | 165 | // 5. Skip, if second factor is already authenticated. 166 | if ($authenticationStatus === AuthenticationStatus::AUTHENTICATED) { 167 | $this->log('Second factor already authenticated.'); 168 | 169 | return $handler->handle($request); 170 | } 171 | 172 | // 6. Redirect to 2FA login, if second factor is set up for account but not authenticated. 173 | // Skip, if already on 2FA login route. 174 | if ( 175 | $isEnabledForAccount 176 | && $authenticationStatus === AuthenticationStatus::AUTHENTICATION_NEEDED 177 | ) { 178 | // WHY: We use the request URI as state here to keep the middleware from entering a redirect loop. 179 | $isAskingForOTP = str_ends_with($request->getUri()->getPath(), self::SECOND_FACTOR_LOGIN_URI); 180 | if ($isAskingForOTP) { 181 | return $handler->handle($request); 182 | } 183 | 184 | $this->log('Second factor enabled and not authenticated, redirecting to 2FA login.'); 185 | 186 | // WHY: Set intercepted request to be able to redirect after 2FA login. 187 | // See Sandstorm/NeosTwoFactorAuthentication/LoginController 188 | $this->registerOriginalRequestForRedirect($request); 189 | 190 | return new Response(303, ['Location' => self::SECOND_FACTOR_LOGIN_URI]); 191 | } 192 | 193 | // 7. Redirect to 2FA setup, if second factor is not set up for account but is enforced by system. 194 | // Skip, if already on 2FA setup route. 195 | if ($isEnforcedForAccount && !$isEnabledForAccount) { 196 | // WHY: We use the request URI as state here to keep the middleware from entering a redirect loop. 197 | $isSettingUp2FA = str_ends_with($request->getUri()->getPath(), self::SECOND_FACTOR_SETUP_URI); 198 | if ($isSettingUp2FA) { 199 | return $handler->handle($request); 200 | } 201 | 202 | $this->log('Second factor enforced and not enabled for account, redirecting to 2FA setup.'); 203 | 204 | // WHY: Set intercepted request to be able to redirect after 2FA setup. 205 | // See Sandstorm/NeosTwoFactorAuthentication/LoginController 206 | $this->registerOriginalRequestForRedirect($request); 207 | 208 | return new Response(303, ['Location' => self::SECOND_FACTOR_SETUP_URI]); 209 | } 210 | 211 | // X. Throw an error, because any check before should have succeeded. 212 | throw new AuthenticationRequiredException("Second factor authentication failed!"); 213 | } 214 | 215 | private function registerOriginalRequestForRedirect(ServerRequestInterface $request): void 216 | { 217 | $routingMatchResults = $request->getAttribute(ServerRequestAttributes::ROUTING_RESULTS) ?? []; 218 | $actionRequest = $this->actionRequestFactory->createActionRequest($request, $routingMatchResults); 219 | 220 | $this->securityContext->setInterceptedRequest($actionRequest); 221 | } 222 | 223 | private function log(string $message, array $context = []): void 224 | { 225 | $this->securityLogger->debug(self::LOGGING_PREFIX . $message, $context); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /Resources/Private/Fusion/Presentation/Pages/SetupSecondFactorPage.fusion: -------------------------------------------------------------------------------- 1 | prototype(Sandstorm.NeosTwoFactorAuthentication:Page.SetupSecondFactorPage) < prototype(Neos.Fusion:Component) { 2 | site = null 3 | styles = ${[]} 4 | scripts = ${[]} 5 | username = '' 6 | flashMessages = ${[]} 7 | 8 | renderer = Neos.Fusion:Join { 9 | doctype = '' 10 | doctype.@position = 'start 100' 11 | 12 | content = Neos.Fusion:Component { 13 | @apply.props = ${props} 14 | 15 | backgroundImageSource = Neos.Fusion:Component { 16 | @if.set = ${this.path} 17 | path = ${Configuration.setting('Neos.Neos.userInterface.backendLoginForm.backgroundImage')} 18 | 19 | renderer = Neos.Fusion:Case { 20 | resourcePath { 21 | condition = ${String.indexOf(props.path, 'resource://') == 0} 22 | renderer = Neos.Fusion:ResourceUri { 23 | path = ${props.path} 24 | } 25 | } 26 | 27 | default { 28 | condition = true 29 | renderer = ${props.path} 30 | } 31 | } 32 | } 33 | backgroundImageSourceIsWebp = ${this.backgroundImageSource ? String.endsWith(this.backgroundImageSource, '.webp') : null} 34 | 35 | headerComment = ${Configuration.setting('Neos.Neos.headerComment')} 36 | 37 | renderer = afx` 38 | 39 | {props.headerComment} 40 | 41 | 42 | 43 | {I18n.translate('login.index.title', 'Login to')} {props.site.name} 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 58 | 62 | 63 | 64 | 65 |
66 |
67 | 169 |
170 | 178 |
179 | 180 | 181 | 184 | 185 | 186 | 187 | ` 188 | } 189 | } 190 | } 191 | --------------------------------------------------------------------------------