├── .eslintrc.json ├── .github └── workflows │ ├── codeql-analysis.yml │ └── node.js.yml ├── .gitignore ├── .npmignore ├── CHANGELOG ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── test └── totp.test.js └── totp.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es2020": true 6 | }, 7 | "extends": [ 8 | "standard" 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": 11 12 | }, 13 | "rules": { 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '19 4 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [10.x, 12.x, 14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .nyc_output -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.1.0] - 2020-10-28 4 | - refactor: use sync methods where possible 5 | 6 | ## [0.0.1] - 2020-09-11 7 | - Initial release -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Heply 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastify-totp 2 | 3 | A plugin to handle TOTP (e.g. for 2FA) 4 | 5 | ![Node.js CI](https://github.com/heply/fastify-totp/workflows/Node.js%20CI/badge.svg) 6 | 7 | ## Install 8 | 9 | ```bash 10 | npm i --save fastify-totp 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```js 16 | fastify.register(require('fastify-totp')) 17 | 18 | // ... 19 | 20 | secret = fastify.totp.generateSecret() 21 | 22 | // You should now store secret.ascii in order to verify the TOTP. 23 | 24 | const token = req.body.token 25 | 26 | isVerified = fastify.totp.verify({ secret: secret.ascii, token }) 27 | ``` 28 | 29 | The plugin includes also a facility to generate a **QRCode** that can be used 30 | to quickly configure third-party authenticators (*e.g. Google Authenticator*) 31 | 32 | ```js 33 | const qrcode = await fastify.totp.generateQRCode({ secret: secret.ascii }) 34 | ``` 35 | 36 | ## Methods 37 | 38 | | Name | Description | 39 | |-------------------------------------|------------------------------------------------------------------------------------| 40 | | `generateSecret (length)` | Generate a new secret with the provided `length` (or use default one otherwise) | 41 | | `generateToken (options)` | Generate a TOTP token based on given `options`. | 42 | | `generateAuthURL (options)` | Generate an *auth URL** that can be used to configure a third-party authenticator. | 43 | | `generateQRCode (options) [async]` | Genereate a data-URI of a *QRCode* to share the *auth URL*. | 44 | | `verify (options)` | Verify a TOTP token with the original secret. | 45 | 46 | ## Request 47 | 48 | | Name | Description | 49 | |---------------------------------|---------------------------------------------------------------| 50 | | `request.totpVerify (options)` | See `verify`. | 51 | 52 | ## Options 53 | 54 | | Name | Description | 55 | |--------------------|----------------------------------------------------------------------------------------------| 56 | | `secretLength` | The length of the generated secret. *Default: 20* | 57 | | `totpLabel` | The label to show in third-party authenticators. Usually the app name. *Default: "Fastify"* | 58 | | `totpWindow` | The allowable previous or future "time-windows" to check against of. *Default: 1* | 59 | | `totpAlg` | The algorithm to use for hash generation. *Default: "sha512"* | 60 | | `totpStep` | Time step in seconds. *Default: 30* | 61 | 62 | **NOTE:** for more details, please take a look at [Speakeasy docs](https://www.npmjs.com/package/speakeasy#documentation). 63 | 64 | ## Test 65 | 66 | ```bash 67 | npm test 68 | ``` 69 | 70 | ## Acknowledgements 71 | 72 | This project is kindly sponsored by: 73 | 74 | [![Beliven](https://assets.beliven.com/brand/logo_pos_color.svg)](https://www.beliven.com) 75 | 76 | ## License 77 | 78 | Licensed under [MIT](./LICENSE) 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-totp", 3 | "version": "1.0.0", 4 | "description": "A plugin to handle TOTP (e.g. for 2FA)", 5 | "main": "totp.js", 6 | "scripts": { 7 | "test": "npx tap -Rspec --coverage test/**/*.test.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/beliven-it/fastify-totp.git" 12 | }, 13 | "keywords": [ 14 | "fastify", 15 | "plugin", 16 | "totp", 17 | "2fa", 18 | "speakeasy", 19 | "auth" 20 | ], 21 | "author": "Beliven", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/beliven-it/fastify-totp/issues" 25 | }, 26 | "homepage": "https://github.com/beliven-it/fastify-totp#readme", 27 | "devDependencies": { 28 | "eslint": "^7.27.0", 29 | "eslint-config-standard": "^16.0.3", 30 | "eslint-plugin-import": "^2.23.4", 31 | "eslint-plugin-node": "^11.1.0", 32 | "eslint-plugin-promise": "^5.1.0", 33 | "eslint-plugin-standard": "^5.0.0", 34 | "fastify": "^3.17.0", 35 | "fastify-sensible": "^3.1.1", 36 | "tap": "^16.0.0" 37 | }, 38 | "dependencies": { 39 | "fastify-plugin": "^3.0.0", 40 | "qrcode": "^1.4.4", 41 | "speakeasy": "^2.0.0" 42 | }, 43 | "tap": { 44 | "check-coverage": false 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/totp.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('tap') 4 | const Fastify = require('fastify') 5 | 6 | const SECRET_LENGHT = 30 7 | 8 | const buildApp = function (t) { 9 | const fastify = Fastify({ logger: { level: 'error' } }) 10 | 11 | t.teardown(() => fastify.close()) 12 | 13 | return fastify.register(require('../totp'), { secretLength: SECRET_LENGHT }) 14 | } 15 | 16 | test('totp.generateSecret', async t => { 17 | const fastify = await buildApp(t) 18 | 19 | t.test('is registered', t => { 20 | t.plan(1) 21 | t.equal(typeof fastify.totp.generateSecret, 'function', 'as a function') 22 | }) 23 | 24 | t.test('without args', async t => { 25 | t.plan(1) 26 | const secret = fastify.totp.generateSecret() 27 | t.equal(secret.ascii.length, SECRET_LENGHT, 'should generate a secret with default length') 28 | }) 29 | 30 | t.test('with a custom length', async t => { 31 | t.plan(1) 32 | const length = 54 33 | const secret = fastify.totp.generateSecret(length) 34 | t.equal(secret.ascii.length, length, 'should generate a secret with given length') 35 | }) 36 | }) 37 | 38 | test('totp.generateToken', async t => { 39 | const fastify = await buildApp(t) 40 | 41 | t.test('is registered', t => { 42 | t.plan(1) 43 | t.equal(typeof fastify.totp.generateToken, 'function', 'as a function') 44 | }) 45 | 46 | t.test('without args', async t => { 47 | t.plan(1) 48 | const result = fastify.totp.generateToken() 49 | t.equal(result, null, 'should return null') 50 | }) 51 | 52 | t.test('with a secret', async t => { 53 | t.plan(1) 54 | const result = fastify.totp.generateToken({ secret: 'abcdefg' }) 55 | t.ok(result && result.length > 0, 'should return a valid token') 56 | }) 57 | }) 58 | 59 | test('totp.generateAuthURL', async t => { 60 | const fastify = await buildApp(t) 61 | 62 | t.test('is registered', t => { 63 | t.plan(1) 64 | t.equal(typeof fastify.totp.generateAuthURL, 'function', 'as a function') 65 | }) 66 | 67 | t.test('without args', async t => { 68 | t.plan(1) 69 | const result = fastify.totp.generateAuthURL() 70 | t.equal(result, null, 'should return null') 71 | }) 72 | 73 | t.test('with a secret', async t => { 74 | t.plan(1) 75 | const result = fastify.totp.generateAuthURL({ secret: 'abcdefg' }) 76 | const isURL = (result.indexOf('otpauth://totp') === 0) 77 | t.equal(isURL, true, 'should return an auth URL') 78 | }) 79 | 80 | t.test('with a secret and a label', async t => { 81 | t.plan(1) 82 | const label = 'test-url' 83 | const result = fastify.totp.generateAuthURL({ secret: 'abcdefg', label }) 84 | const isURL = (result.indexOf(`otpauth://totp/${label}`) === 0) 85 | t.equal(isURL, true, 'should return an auth URL with given label') 86 | }) 87 | }) 88 | 89 | test('totp.generateQRCode', async t => { 90 | const fastify = await buildApp(t) 91 | 92 | t.test('is registered', t => { 93 | t.plan(1) 94 | t.equal(typeof fastify.totp.generateQRCode, 'function', 'as a function') 95 | }) 96 | 97 | t.test('without args', async t => { 98 | t.plan(1) 99 | const result = await fastify.totp.generateQRCode() 100 | t.equal(result, null, 'should return null') 101 | }) 102 | 103 | t.test('with a secret', async t => { 104 | t.plan(1) 105 | const result = await fastify.totp.generateQRCode({ secret: 'abcdefg' }) 106 | const isQRCode = (result.indexOf('data:image/png;base64') === 0) 107 | t.equal(isQRCode, true, 'should return a data URL with QRCode') 108 | }) 109 | 110 | t.test('with a secret and a label', async t => { 111 | t.plan(1) 112 | const result = await fastify.totp.generateQRCode({ secret: 'abcdefg', label: 'test-qrcode' }) 113 | const isQRCode = (result.indexOf('data:image/png;base64') === 0) 114 | t.equal(isQRCode, true, 'should return a data URL with QRCode') 115 | }) 116 | }) 117 | 118 | test('totp.verify', async t => { 119 | const fastify = await buildApp(t) 120 | 121 | t.test('is registered', t => { 122 | t.plan(1) 123 | t.equal(typeof fastify.totp.verify, 'function', 'as a function') 124 | }) 125 | 126 | t.test('passing a valid token for a secret', async t => { 127 | t.plan(1) 128 | const secret = 'HGOp]VSO[bV:T6?vgNe&' 129 | const algorithm = 'sha512' 130 | const token = fastify.totp.generateToken({ secret, algorithm }) 131 | const result = fastify.totp.verify({ secret, token, algorithm }) 132 | t.equal(result, true, 'should return true') 133 | }) 134 | }) 135 | 136 | test('request.totpVerify', async t => { 137 | const fastify = await buildApp(t) 138 | 139 | t.test('verifying a valid token for a secret', async t => { 140 | t.plan(1) 141 | const secret = 'HGOp]VSO[bV:T6?vgNe&' 142 | function handler (req, reply) { 143 | const algorithm = 'sha512' 144 | const token = fastify.totp.generateToken({ secret, algorithm }) 145 | const doesMatch = req.totpVerify({ secret, token, algorithm }) 146 | return reply.send(doesMatch ? 'ok' : 'ko') 147 | } 148 | fastify.route({ 149 | method: 'GET', 150 | url: '/', 151 | handler 152 | }) 153 | const result = await fastify.inject('/') 154 | t.equal(result.payload, 'ok', 'should return a positive response') 155 | }) 156 | }) 157 | -------------------------------------------------------------------------------- /totp.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fp = require('fastify-plugin') 4 | const speakeasy = require('speakeasy') 5 | const qrcode = require('qrcode') 6 | 7 | const DEFAULT_TOTP_SECRET_LENGTH = 20 8 | const DEFAULT_TOTP_LABEL = 'Fastify' 9 | const DEFAULT_TOTP_WINDOW = 1 10 | const DEFAULT_TOTP_ALG = 'sha512' 11 | const DEFAULT_TOTP_STEP = 30 12 | 13 | module.exports = fp(function (fastify, opts, next) { 14 | const TOTP_SECRET_LENGHT = opts.secretLength || DEFAULT_TOTP_SECRET_LENGTH 15 | const TOTP_LABEL = opts.totpLabel || DEFAULT_TOTP_LABEL 16 | const TOTP_WINDOW = opts.totpWindow || DEFAULT_TOTP_WINDOW 17 | const TOTP_ALG = opts.totpAlg || DEFAULT_TOTP_ALG 18 | const TOTP_STEP = opts.totpStep || DEFAULT_TOTP_STEP 19 | 20 | function generateTOTPSecret (length) { 21 | const secret = speakeasy.generateSecret({ 22 | length: length || TOTP_SECRET_LENGHT 23 | }) 24 | // WARNING: secret is NOT a string, but an object providing 25 | // the secret key encoded in several ways (ascii, base32, etc.) 26 | return secret 27 | } 28 | 29 | function generateTOTPToken (options = {}) { 30 | if (!options.secret) return null 31 | 32 | const token = speakeasy.totp({ 33 | encoding: options.encoding || 'ascii', 34 | algorithm: options.algorithm || TOTP_ALG, 35 | step: options.step || TOTP_STEP, 36 | ...options 37 | }) 38 | 39 | return token 40 | } 41 | 42 | function generateAuthURLFromSecret (options = {}) { 43 | if (!options.secret) return null 44 | 45 | const url = speakeasy.otpauthURL({ 46 | label: options.label || TOTP_LABEL, 47 | algorithm: options.algorithm || TOTP_ALG, 48 | ...options 49 | }) 50 | 51 | return url 52 | } 53 | 54 | async function generateQRCodeFromSecret (secret, label, algorithm) { 55 | const url = fastify.totp.generateAuthURL(secret, label, algorithm) 56 | 57 | if (!url) return null 58 | 59 | return qrcode.toDataURL(url) 60 | } 61 | 62 | function verifyTOTP (options = {}) { 63 | const result = speakeasy.totp.verifyDelta({ 64 | encoding: options.encoding || 'ascii', 65 | window: options.window || TOTP_WINDOW, 66 | step: options.step || TOTP_STEP, 67 | ...options 68 | }) 69 | return !!result 70 | } 71 | 72 | fastify.decorate('totp', { 73 | generateSecret: generateTOTPSecret, 74 | generateToken: generateTOTPToken, 75 | generateAuthURL: generateAuthURLFromSecret, 76 | generateQRCode: generateQRCodeFromSecret, 77 | verify: verifyTOTP 78 | }) 79 | 80 | fastify.decorateRequest('totpVerify', verifyTOTP) 81 | 82 | next() 83 | }, { 84 | fastify: '>=2.x', 85 | name: 'fastify-totp' 86 | }) 87 | --------------------------------------------------------------------------------