├── .eslintrc ├── jsconfig.json ├── .stylelintrc.json ├── .gitignore ├── test ├── modules │ └── @apostrophecms │ │ └── home-page │ │ └── views │ │ └── page.html ├── package.json └── test.js ├── CHANGELOG.md ├── LICENSE.md ├── i18n └── aposTotp │ ├── en.json │ ├── sk.json │ ├── pt-BR.json │ ├── it.json │ ├── es.json │ ├── fr.json │ └── de.json ├── package.json ├── .github └── workflows │ └── main.yml ├── modules └── @apostrophecms │ └── user-totp │ └── index.js ├── README.md ├── ui └── apos │ └── components │ ├── AposTotp.scss │ └── AposTotp.vue └── index.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { "extends": "apostrophe" } 2 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | // tsconfig.json 2 | { 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-apostrophe", 3 | "rules": { 4 | "scale-unlimited/declaration-strict-value": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore MacOS X metadata forks (fusefs) 2 | ._* 3 | package-lock.json 4 | *.DS_Store 5 | node_modules 6 | 7 | # Never commit a CSS map file, anywhere 8 | *.css.map 9 | 10 | # vim swp files 11 | .*.sw* 12 | 13 | test/data 14 | test/public 15 | -------------------------------------------------------------------------------- /test/modules/@apostrophecms/home-page/views/page.html: -------------------------------------------------------------------------------- 1 | {{ data.page.title }} 2 |

Home Page Template

3 | {# Used for the login tests. #} 4 | {% if data.user %} 5 | logged in 6 | {% else %} 7 | logged out 8 | {% endif %} 9 | 10 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "//": "This package.json file is not actually installed.", 3 | "//": "Apostrophe requires that all npm modules to be loaded by moog", 4 | "//": "exist in package.json at project level, which for a test is here", 5 | "dependencies": { 6 | "apostrophe": "^3.13.0", 7 | "@apostrophecms/login-totp": "git://github.com/apostrophecms/login-totp.git" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## UNRELEASED 4 | 5 | ### Changes 6 | 7 | * Bumbs `eslint-config-apostrophe` to `5`, fixes errors, removes unused dependencies. 8 | 9 | ## 1.3.2 (2025-03-19) 10 | 11 | * Fix pasting on MacOS. 12 | * Add stylelint. 13 | * Add data-test attributes. 14 | 15 | ## 1.3.1 (2024-10-31) 16 | 17 | * Adds AI-generated and community-reviewed missing translations 18 | 19 | ## 1.3.0 (2024-07-10) 20 | 21 | ### Adds 22 | 23 | * Add missing UI translation keys. 24 | 25 | ## 1.2.0 (2024-03-12) 26 | 27 | ### Changes 28 | 29 | * Compatible with both Apostrophe 3.x and Apostrophe 4.x (both Vue 2 and Vue 3). 30 | 31 | ## 1.1.0 (2023-08-16) 32 | 33 | ### Adds 34 | 35 | - Add `totp-complete` and `totp-invalid-token` structured logging events. 36 | 37 | ## 1.0.1 (2023-02-17) 38 | 39 | - Remove `apostrophe` as a peer dependency. 40 | 41 | ## 1.0.0 (2023-01-16) 42 | 43 | - Declared stable. No code changes. 44 | 45 | ## 1.0.0-beta (2022-02-04) 46 | 47 | - Builds out the beta release of a TOTP login requirement module. 48 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Apostrophe Technologies 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /i18n/aposTotp/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "missingSecret": "You should provide a secret of a length of 10 characters in your @apostrophecms/login module config.", 3 | "badSecretSize": "Your secret should be 10 characters in length.", 4 | "invalidToken": "Invalid code, try again.", 5 | "updateError": "An error occurred while setting up your TOTP account.", 6 | "verify": "Verify", 7 | "loginTitleSetup": "Enable Authenticator Support", 8 | "loginTitle": "Enter the six digit code from your authenticator app.", 9 | "setupText1": "Install Google Authenticator or similar TOTP app, then", 10 | "setupText2": "Scan this QR code", 11 | "setupText3": "Or manually enter this key", 12 | "loginText": "Then enter the verification code from your authenticator app", 13 | "copyKey": "Copy key", 14 | "successMessage": "Success! Completing the login process...", 15 | "notConfigured": "Not configured, refresh the login page to configure TOTP", 16 | "resetTotpLabel": "Reset TOTP", 17 | "resetTotpHelp": "Select this option and save to reset the user's TOTP (Google Authenticator) configuration once, so they can set it up again.", 18 | "totpLabel": "TOTP" 19 | } 20 | -------------------------------------------------------------------------------- /i18n/aposTotp/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "missingSecret": "Mali by ste poskytnúť tajomstvo s dĺžkou 10 znakov vo svojej konfigurácii modulu @apostrophecms/login.", 3 | "badSecretSize": "Vaše tajomstvo by malo mať dĺžku 10 znakov.", 4 | "invalidToken": "Neplatný kód, skúste znova.", 5 | "updateError": "Pri nastavovaní vášho TOTP účtu sa vyskytla chyba.", 6 | "verify": "Overiť", 7 | "loginTitleSetup": "Povoliť podporu autentifikátora", 8 | "loginTitle": "Zadajte šesť miestny kód zo svojho autentifikačného aplikácie.", 9 | "setupText1": "Nainštalujte Google Authenticator alebo podobnú aplikáciu TOTP, potom", 10 | "setupText2": "Oscanovať tento QR kód", 11 | "setupText3": "Alebo zadajte tento kľúč ručne", 12 | "loginText": "Potom zadajte overovací kód zo svojho autentifikačného aplikácie", 13 | "copyKey": "Skopírovať kľúč", 14 | "successMessage": "Úspech! Dokončovanie procesu prihlásenia...", 15 | "notConfigured": "Nastavené nie je, obnovte prihlasovaciu stránku na konfiguráciu TOTP", 16 | "resetTotpLabel": "Obnoviť TOTP", 17 | "resetTotpHelp": "Vyberte túto možnosť a uložte ju na obnovenie TOTP (Google Authenticator) konfigurácie používateľa jedenkrát, aby si ju mohli nastaviť znova.", 18 | "totpLabel": "TOTP" 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apostrophecms/login-totp", 3 | "version": "1.3.2", 4 | "description": "Adds totp (time-based one-time password) to Apostrophe login pages", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "npm run eslint && npm run stylelint", 8 | "eslint": "eslint --ext .js,.vue .", 9 | "stylelint": "stylelint ui/**/*.{scss,vue}", 10 | "test": "npm run lint && mocha" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/apostrophecms/login-totp.git" 15 | }, 16 | "keywords": [ 17 | "apostrophe", 18 | "login", 19 | "totp" 20 | ], 21 | "author": "Apostrophe Technologies", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/apostrophecms/login-totp/issues" 25 | }, 26 | "homepage": "https://github.com/apostrophecms/login-totp#readme", 27 | "devDependencies": { 28 | "apostrophe": "github:apostrophecms/apostrophe", 29 | "eslint-config-apostrophe": "^5.0.0", 30 | "mocha": "^7.2.0", 31 | "stylelint": "^16.0.0", 32 | "stylelint-config-apostrophe": "^4.1.0" 33 | }, 34 | "dependencies": { 35 | "qrcode": "^1.5.0", 36 | "thirty-two": "^1.0.2", 37 | "totp-generator": "0.0.13" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /i18n/aposTotp/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "missingSecret": "Você deve fornecer um segredo com comprimento de 10 caracteres na configuração do seu módulo @apostrophecms/login.", 3 | "badSecretSize": "Seu segredo deve ter 10 caracteres de comprimento.", 4 | "invalidToken": "Código inválido, tente novamente.", 5 | "updateError": "Ocorreu um erro ao configurar sua conta TOTP.", 6 | "verify": "Verificar", 7 | "loginTitleSetup": "Habilitar Suporte ao Authenticator", 8 | "loginTitle": "Insira o código de seis dígitos do seu aplicativo autenticador.", 9 | "setupText1": "Instale o Google Authenticator ou um aplicativo TOTP semelhante, então", 10 | "setupText2": "Escaneie este código QR", 11 | "setupText3": "Ou insira esta chave manualmente", 12 | "loginText": "Em seguida, insira o código de verificação do seu aplicativo autenticador", 13 | "copyKey": "Copiar chave", 14 | "successMessage": "Sucesso! Finalizando o processo de login...", 15 | "notConfigured": "Não configurado, atualize a página de login para configurar o TOTP", 16 | "resetTotpLabel": "Redefinir TOTP", 17 | "resetTotpHelp": "Selecione esta opção e salve para redefinir a configuração do TOTP (Google Authenticator) do usuário uma vez, para que ele possa configurá-la novamente.", 18 | "totpLabel": "TOTP" 19 | } 20 | -------------------------------------------------------------------------------- /i18n/aposTotp/it.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | "missingSecret": "Devi fornire un segreto di lunghezza 10 caratteri nella configurazione del tuo modulo @apostrophecms/login.", 4 | "badSecretSize": "Il tuo segreto deve avere 10 caratteri di lunghezza.", 5 | "invalidToken": "Codice non valido, riprova.", 6 | "updateError": "Si è verificato un errore durante la configurazione del tuo account TOTP.", 7 | "verify": "Verifica", 8 | "loginTitleSetup": "Abilita il supporto Authenticator", 9 | "loginTitle": "Inserisci il codice a sei cifre dalla tua app di autenticazione.", 10 | "setupText1": "Installa Google Authenticator o un'app TOTP simile, quindi", 11 | "setupText2": "Scansiona questo codice QR", 12 | "setupText3": "Oppure inserisci manualmente questa chiave", 13 | "loginText": "Poi inserisci il codice di verifica dalla tua app di autenticazione", 14 | "copyKey": "Copia chiave", 15 | "successMessage": "Successo! Completando il processo di accesso...", 16 | "notConfigured": "Non configurato, aggiorna la pagina di accesso per configurare TOTP", 17 | "resetTotpLabel": "Reimposta TOTP", 18 | "resetTotpHelp": "Seleziona questa opzione e salva per reimpostare la configurazione TOTP dell'utente (Google Authenticator) una sola volta, in modo che possano configurarlo di nuovo.", 19 | "totpLabel": "TOTP" 20 | } 21 | -------------------------------------------------------------------------------- /i18n/aposTotp/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "missingSecret": "Deberías proporcionar un secreto de una longitud de 10 caracteres en la configuración de tu módulo @apostrophecms/login.", 3 | "badSecretSize": "Tu secreto debería tener 10 caracteres de longitud.", 4 | "invalidToken": "Código inválido, intenta de nuevo.", 5 | "updateError": "Ocurrió un error mientras configurabas tu cuenta TOTP.", 6 | "verify": "Verificar", 7 | "loginTitleSetup": "Habilitar soporte para autenticador", 8 | "loginTitle": "Introduce el código de seis dígitos de tu aplicación de autenticador.", 9 | "setupText1": "Instala Google Authenticator o una aplicación TOTP similar, luego", 10 | "setupText2": "Escanea este código QR", 11 | "setupText3": "O introduce manualmente esta clave", 12 | "loginText": "Luego introduce el código de verificación de tu aplicación de autenticador", 13 | "copyKey": "Copiar clave", 14 | "successMessage": "¡Éxito! Completar el proceso de inicio de sesión...", 15 | "notConfigured": "No configurado, actualiza la página de inicio de sesión para configurar TOTP", 16 | "resetTotpLabel": "Restablecer TOTP", 17 | "resetTotpHelp": "Selecciona esta opción y guarda para restablecer la configuración de TOTP (Google Authenticator) del usuario una vez, para que puedan configurarlo de nuevo.", 18 | "totpLabel": "TOTP" 19 | } 20 | -------------------------------------------------------------------------------- /i18n/aposTotp/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "missingSecret": "Vous devez fournir un secret d'une longueur de 10 caractères dans la configuration de votre module @apostrophecms/login.", 3 | "badSecretSize": "Votre secret doit avoir 10 caractères.", 4 | "invalidToken": "Code invalide, réessayez.", 5 | "updateError": "Une erreur est survenue lors de la configuration de votre compte TOTP.", 6 | "verify": "Vérifier", 7 | "loginTitleSetup": "Activer le support d'Authenticator", 8 | "loginTitle": "Entrez le code à six chiffres de votre application d'authentification.", 9 | "setupText1": "Installez Google Authenticator ou une application TOTP similaire, puis", 10 | "setupText2": "Scannez ce code QR", 11 | "setupText3": "Ou entrez manuellement cette clé", 12 | "loginText": "Puis entrez le code de vérification de votre application d'authentification", 13 | "copyKey": "Copier la clé", 14 | "successMessage": "Succès ! Complétez le processus de connexion...", 15 | "notConfigured": "Non configuré, rafraîchissez la page de connexion pour configurer TOTP", 16 | "resetTotpLabel": "Réinitialiser TOTP", 17 | "resetTotpHelp": "Sélectionnez cette option et enregistrez pour réinitialiser la configuration TOTP (Google Authenticator) de l'utilisateur une fois, afin qu'il puisse la configurer à nouveau.", 18 | "totpLabel": "TOTP" 19 | } 20 | -------------------------------------------------------------------------------- /i18n/aposTotp/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "missingSecret": "Sie sollten ein Geheimnis mit einer Länge von 10 Zeichen in Ihrer @apostrophecms/login-Modulkonfiguration angeben.", 3 | 4 | "badSecretSize": "Ihr Geheimnis sollte 10 Zeichen lang sein.", 5 | "invalidToken": "Ungültiger Code, versuchen Sie es erneut.", 6 | "updateError": "Beim Einrichten Ihres TOTP-Kontos ist ein Fehler aufgetreten.", 7 | "verify": "Überprüfen", 8 | "loginTitleSetup": "Authenticator-Unterstützung aktivieren", 9 | "loginTitle": "Geben Sie den sechsstelligen Code aus Ihrer Authenticator-App ein.", 10 | "setupText1": "Installieren Sie Google Authenticator oder eine ähnliche TOTP-App, dann", 11 | "setupText2": "Scannen Sie diesen QR-Code", 12 | "setupText3": "Oder geben Sie diesen Schlüssel manuell ein", 13 | "loginText": "Geben Sie dann den Verifizierungscode aus Ihrer Authenticator-App ein", 14 | "copyKey": "Schlüssel kopieren", 15 | "successMessage": "Erfolg! Beenden des Anmeldevorgangs...", 16 | "notConfigured": "Nicht konfiguriert, aktualisieren Sie die Anmeldeseite, um TOTP zu konfigurieren", 17 | "resetTotpLabel": "TOTP zurücksetzen", 18 | "resetTotpHelp": "Wählen Sie diese Option und speichern Sie, um die TOTP (Google Authenticator)-Konfiguration des Benutzers einmal zurückzusetzen, damit sie sie neu einrichten können.", 19 | "totpLabel": "TOTP" 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: tests 4 | 5 | # Controls when the action will run. 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["*"] 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 16 | jobs: 17 | # This workflow contains a single job called "build" 18 | build: 19 | # The type of runner that the job will run on 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | # current LTS 24 | node-version: [18, 20] 25 | mongodb-version: [6.0, 7.0, 8.0] 26 | 27 | # Steps represent a sequence of tasks that will be executed as part of the job 28 | steps: 29 | - name: Git checkout 30 | uses: actions/checkout@v2 31 | 32 | - name: Use Node.js ${{ matrix.node-version }} 33 | uses: actions/setup-node@v1 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | 37 | - name: Start MongoDB 38 | uses: supercharge/mongodb-github-action@1.3.0 39 | with: 40 | mongodb-version: ${{ matrix.mongodb-version }} 41 | 42 | - run: npm install 43 | 44 | - run: npm test 45 | env: 46 | CI: true 47 | -------------------------------------------------------------------------------- /modules/@apostrophecms/user-totp/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | improve: '@apostrophecms/user', 3 | fields: { 4 | add: { 5 | resetTotp: { 6 | label: 'aposTotp:resetTotpLabel', 7 | type: 'boolean', 8 | help: 'aposTotp:resetTotpHelp' 9 | } 10 | }, 11 | group: { 12 | totp: { 13 | label: 'aposTotp:totpLabel', 14 | fields: [ 'resetTotp' ] 15 | } 16 | } 17 | }, 18 | tasks(self) { 19 | return { 20 | 'reset-totp': { 21 | usage: 'Invoke this task with a username or email address to reset TOTP (Google Authenticator) so they can set it up again.', 22 | async task(argv) { 23 | const username = argv._[1]; 24 | const result = await self.safe.updateOne({ 25 | $or: [ 26 | { 27 | username 28 | }, 29 | { 30 | email: username 31 | } 32 | ] 33 | }, { 34 | $unset: { 35 | totp: 1 36 | } 37 | }); 38 | if (!result.modifiedCount) { 39 | throw 'User not found.'; 40 | } 41 | } 42 | } 43 | }; 44 | }, 45 | handlers(self) { 46 | return { 47 | beforeSave: { 48 | async resetTotp(req, doc) { 49 | if (doc.resetTotp) { 50 | doc.resetTotp = false; 51 | await self.apos.user.safe.updateOne({ 52 | _id: doc._id 53 | }, { 54 | $unset: { 55 | totp: 1 56 | } 57 | }); 58 | } 59 | } 60 | } 61 | }; 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | ApostropheCMS logo 3 | 4 |

Apostrophe TOTP Login Verification

5 |

6 | 7 | 8 | 9 | 10 | GitHub Workflow Status (branch) 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |

19 |
20 | 21 | This login verification module adds a [TOTP (Time-based One-Time Password)](https://en.wikipedia.org/wiki/Time-based_one-time_password) check when any user logs into the site, compatible with Google Authenticator or any TOTP app. 22 | When activated, it will ask unregistered users to add a token to their app through a QR code. Once done, it will ask users to enter the code provided by their app after the initial login step. 23 | 24 | ## Installation 25 | 26 | To install the module, use the command line to run this command in an Apostrophe project's root directory: 27 | 28 | ``` 29 | npm install @apostrophecms/login-totp 30 | ``` 31 | 32 | ## Usage 33 | 34 | Instantiate the TOTP login module in the `app.js` file: 35 | 36 | ```javascript 37 | require('apostrophe')({ 38 | shortName: 'my-project', 39 | modules: { 40 | '@apostrophecms/login-totp': {} 41 | } 42 | }); 43 | ``` 44 | 45 | You must configure the `@apostrophecms/login` module with a TOTP secret, as shown. The secret must be **exactly 10 characters long.** 46 | 47 | ```javascript 48 | // modules/@apostrophecms/login/index.js 49 | module.exports = { 50 | options: { 51 | totp: { 52 | // Should be a random string, exactly 10 characters long 53 | secret: 'totpsecret' 54 | } 55 | } 56 | }; 57 | ``` 58 | 59 | > ⚠️ All configuration of TOTP related options is done on the `@apostrophecms/login` module. The `@apostrophecms/login-totp` module is just an "improvement" to that module, so it has no configuration options of its own. 60 | 61 | ### Resetting TOTP when a user loses their device 62 | 63 | If a user loses their device, an admin can edit the appropriate user via the admin bar. Select "Yes" for the "Reset TOTP" field and save the user. 64 | 65 | If an admin user loses their own device, they can reset TOTP via a command line task. Pass the username as the sole argument: 66 | 67 | ``` 68 | node app @apostrophecms/user:reset-totp username-goes-here 69 | ``` 70 | 71 | Once TOTP is reset, the user is able to set it up again on their next login. 72 | -------------------------------------------------------------------------------- /ui/apos/components/AposTotp.scss: -------------------------------------------------------------------------------- 1 | .apos-totp { 2 | $danger-color: #EA433A; 3 | $block-width: 530px; 4 | 5 | position: relative; 6 | left: calc((330px - $block-width) / 2); // 330 being the login wrapper size 7 | width: $block-width; 8 | 9 | &__setup { 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | padding-bottom: 30px; 14 | border-bottom: 1px solid #212121; 15 | } 16 | 17 | &__title { 18 | @include type-display; 19 | 20 | & { 21 | margin-top: 0; 22 | margin-bottom: 16px; 23 | text-align: center; 24 | } 25 | 26 | &--success { 27 | margin-bottom: 32px; 28 | } 29 | } 30 | 31 | &__text { 32 | @include type-large; 33 | } 34 | 35 | &__scan-title { 36 | @include type-title; 37 | } 38 | 39 | &__qrcode { 40 | width: 100%; 41 | border-radius: 10px; 42 | margin-bottom: 20px; 43 | } 44 | 45 | &__text-grey { 46 | @include type-large; 47 | 48 | & { 49 | margin: 0; 50 | color: var(--a-base-5); 51 | } 52 | } 53 | 54 | &__token-container { 55 | position: relative; 56 | margin-top: 10px; 57 | text-align: right; 58 | } 59 | 60 | &__token { 61 | @include type-base; 62 | 63 | & { 64 | margin: 0 0 5px; 65 | padding: 5px; 66 | border: solid 1px #414141; 67 | color: #F9F9F9; 68 | background-color: #0f0f0f; 69 | } 70 | } 71 | 72 | &__copy-token-btn { 73 | display: flex; 74 | align-items: center; 75 | border: none; 76 | background-color: transparent; 77 | cursor: pointer; 78 | float: right; 79 | } 80 | 81 | &__copy-token-text { 82 | margin-left: 5px; 83 | } 84 | 85 | &__token-copied { 86 | position: absolute; 87 | right: -20px; 88 | bottom: 0; 89 | } 90 | 91 | &__login-text { 92 | margin-top: 20px; 93 | text-align: center; 94 | } 95 | 96 | &__login-form { 97 | display: flex; 98 | align-items: center; 99 | justify-content: space-between; 100 | } 101 | 102 | &__login-input { 103 | box-sizing: border-box; 104 | width: 47px; 105 | height: 56px; 106 | margin-right: 15px; 107 | border: solid 1px #8895a7; 108 | color: #fff; 109 | font-size: var(--a-type-display); 110 | text-align: center; 111 | border-radius: 5px; 112 | background-color: #14171e; 113 | caret-color: transparent; 114 | appearance: textfield; 115 | 116 | &:focus { 117 | border: solid 1px #fff; 118 | outline: none; 119 | } 120 | 121 | &::-webkit-inner-spin-button { 122 | appearance: none; 123 | margin: 0; 124 | } 125 | 126 | &--filled { 127 | border-color: var(--a-success); 128 | } 129 | 130 | &--error { 131 | border-color: $danger-color; 132 | } 133 | 134 | } 135 | 136 | &__login-submit { 137 | flex-grow: 1; 138 | height: 56px; 139 | 140 | &--disabled { 141 | opacity: 0.5; 142 | } 143 | 144 | :deep(.apos-button) { 145 | height: 100%; 146 | } 147 | } 148 | 149 | &__error { 150 | @include type-large; 151 | 152 | & { 153 | height: 15px; 154 | color: $danger-color; 155 | text-transform: uppercase; 156 | } 157 | } 158 | 159 | &__success { 160 | text-align: center; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const testUtil = require('apostrophe/test-lib/test'); 3 | const totp = require('totp-generator'); 4 | 5 | describe('totp module', function () { 6 | let apos; 7 | 8 | this.timeout(25000); 9 | 10 | after(async function () { 11 | testUtil.destroy(apos); 12 | }); 13 | 14 | // Improving 15 | it('should improve the login module', async function () { 16 | apos = await testUtil.create({ 17 | shortname: 'loginTest', 18 | testModule: true, 19 | modules: { 20 | '@apostrophecms/express': { 21 | options: { 22 | port: 4242, 23 | // csrf: { 24 | // exceptions: [ '/api/v1/@apostrophecms/form/submit' ] 25 | // }, 26 | session: { 27 | secret: 'test-this-module' 28 | }, 29 | apiKeys: { 30 | skeleton_key: { role: 'admin' } 31 | } 32 | } 33 | }, 34 | '@apostrophecms/login-totp': { 35 | options: { 36 | testOption: 'suprise' 37 | } 38 | }, 39 | '@apostrophecms/login': { 40 | options: { 41 | totp: { 42 | // Should be a random string, exactly 10 characters long 43 | secret: 'totpsecret' 44 | } 45 | } 46 | } 47 | } 48 | }); 49 | const login = apos.modules['@apostrophecms/login']; 50 | assert(login.options.testOption === 'suprise'); 51 | }); 52 | 53 | const mary = { 54 | username: 'marygold', 55 | pw: 'asdfjkl;', 56 | hash: '1234567890' 57 | }; 58 | 59 | it('should be able to insert test user', async function() { 60 | assert(apos.user.newInstance); 61 | const user = apos.user.newInstance(); 62 | assert(user); 63 | 64 | user.title = 'Mary Gold'; 65 | user.username = mary.username; 66 | user.password = mary.pw; 67 | user.email = 'mary@gold.rocks'; 68 | user.role = 'editor'; 69 | 70 | const doc = await apos.user.insert(apos.task.getReq(), user); 71 | apos.user.safe.updateOne({ 72 | _id: doc._id 73 | }, { 74 | $set: { 75 | totp: { 76 | activated: true, 77 | hash: mary.hash 78 | } 79 | } 80 | }); 81 | assert(doc._id); 82 | mary._id = doc._id; 83 | }); 84 | 85 | it('should log success', async function() { 86 | const req = apos.task.getReq({ 87 | ip: '1.1.1.1' 88 | }); 89 | const totpToken = totp( 90 | apos.login.generateToken(mary.hash, apos.login.getSecret()) 91 | ); 92 | // intecept the logger 93 | let savedArgs = []; 94 | apos.login.logInfo = (...args) => { 95 | savedArgs = args; 96 | }; 97 | 98 | await apos.login.requirements.AposTotp.verify(req, totpToken, mary); 99 | 100 | // the fancy way to detect `req` 101 | assert.equal(typeof savedArgs[0].t, 'function'); 102 | assert.equal(savedArgs[1], 'totp-complete'); 103 | assert.deepEqual(savedArgs[2], { 104 | username: mary.username 105 | }); 106 | }); 107 | 108 | it('should log bad token request', async function () { 109 | const req = apos.task.getReq({ 110 | ip: '1.1.1.1' 111 | }); 112 | // intecept the logger 113 | let savedArgs = []; 114 | apos.login.logInfo = (...args) => { 115 | savedArgs = args; 116 | }; 117 | 118 | try { 119 | await apos.login.requirements.AposTotp.verify(req, 'bad', mary); 120 | } catch (err) { 121 | // 122 | } 123 | // the fancy way to detect `req` 124 | assert.equal(typeof savedArgs[0].t, 'function'); 125 | assert.equal(savedArgs[1], 'totp-invalid-token'); 126 | assert.deepEqual(savedArgs[2], { 127 | username: mary.username 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { randomBytes } = require('crypto'); 2 | const base32 = require('thirty-two'); 3 | const totp = require('totp-generator'); 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | module.exports = { 8 | improve: '@apostrophecms/login', 9 | bundle: { 10 | directory: 'modules', 11 | modules: getBundleModuleNames() 12 | }, 13 | i18n: { 14 | aposTotp: { 15 | browser: true 16 | } 17 | }, 18 | init (self, { totp }) { 19 | if (!totp.secret) { 20 | self.apos.util.warn('You should provide a secret 10 characters in length in the login module\'s config.'); 21 | } else if (totp.secret.length !== 10) { 22 | self.apos.util.warn('Your secret should be exactly 10 characters in length.'); 23 | } 24 | }, 25 | requirements(self) { 26 | return { 27 | add: { 28 | AposTotp: { 29 | phase: 'afterPasswordVerified', 30 | askForConfirmation: true, 31 | async props(req, user) { 32 | const safe = await self.apos.user.safe.findOne({ 33 | _id: user._id 34 | }); 35 | if (!safe.totp || !safe.totp.activated) { 36 | const validSecret = self.getSecret(); 37 | const hash = randomBytes(validSecret ? 5 : 10).toString('hex'); 38 | const token = self.generateToken(hash, validSecret); 39 | const result = await self.apos.user.safe.updateOne({ 40 | _id: user._id 41 | }, { 42 | $set: { 43 | totp: { 44 | hash, 45 | activated: false 46 | } 47 | } 48 | }); 49 | if (!result.modifiedCount) { 50 | throw self.apos.error('notfound'); 51 | } 52 | return { 53 | token, 54 | // Allows multiple identities on the same site to be distinguished 55 | // in a TOTP app 56 | identity: `${user.username}@${self.apos.shortName}` 57 | }; 58 | } 59 | 60 | return {}; 61 | }, 62 | async verify(req, data, user) { 63 | const code = self.apos.launder.string(data); 64 | 65 | if (!code) { 66 | throw self.apos.error('invalid', req.t('aposTotp:invalidToken')); 67 | } 68 | 69 | const safe = await self.apos.user.safe.findOne({ 70 | _id: user._id 71 | }); 72 | if (!safe.totp) { 73 | throw self.apos.error('invalid', req.t('aposTotp:notConfigured')); 74 | } 75 | const userToken = self.generateToken(safe.totp.hash, self.getSecret()); 76 | const totpToken = totp(userToken); 77 | 78 | if (totpToken !== code) { 79 | self.logInfo(req, 'totp-invalid-token', { 80 | username: safe.username 81 | }); 82 | throw self.apos.error('invalid', req.t('aposTotp:invalidToken')); 83 | } 84 | 85 | if (!safe.totp.activated) { 86 | try { 87 | const result = await self.apos.user.safe.updateOne({ 88 | _id: user._id 89 | }, { 90 | $set: { 91 | 'totp.activated': true 92 | } 93 | }); 94 | if (!result.modifiedCount) { 95 | throw self.apos.error('notfound'); 96 | } 97 | } catch (err) { 98 | throw self.apos.error('unprocessable', req.t('aposTotp:updateError')); 99 | } 100 | } 101 | 102 | self.logInfo(req, 'totp-complete', { 103 | username: safe.username 104 | }); 105 | } 106 | } 107 | } 108 | }; 109 | }, 110 | methods(self) { 111 | return { 112 | generateToken (hash, secret) { 113 | const formattedSecret = secret 114 | ? secret.substring(0, 10) 115 | : ''; 116 | 117 | return base32.encode(hash + formattedSecret).toString(); 118 | }, 119 | getSecret () { 120 | const { secret } = self.options.totp; 121 | 122 | return secret; 123 | } 124 | }; 125 | } 126 | }; 127 | 128 | function getBundleModuleNames() { 129 | const source = path.join(__dirname, './modules/@apostrophecms'); 130 | return fs 131 | .readdirSync(source, { withFileTypes: true }) 132 | .filter(dirent => dirent.isDirectory()) 133 | .map(dirent => `@apostrophecms/${dirent.name}`); 134 | } 135 | -------------------------------------------------------------------------------- /ui/apos/components/AposTotp.vue: -------------------------------------------------------------------------------- 1 | 125 | 126 | 314 | 315 | --------------------------------------------------------------------------------