├── CHANGELOG.md ├── .gitignore ├── server.json ├── ModuleConfig.cfc ├── .github └── workflows │ ├── cfformat.yml │ ├── cron.yml │ ├── pr.yml │ ├── prerelease.yml │ └── release.yml ├── tests ├── runner.cfm ├── Application.cfc ├── index.cfm └── specs │ └── unit │ └── TOTPSpec.cfc ├── Application.cfc ├── LICENSE ├── box.json ├── .cfformat.json ├── index.cfm ├── README.md └── models ├── Base32.cfc └── TOTP.cfc /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /testbox 2 | /tests/results 3 | /tests/resources/app/coldbox 4 | /node_modules 5 | /modules 6 | jmimemagic.log 7 | 8 | .vscode -------------------------------------------------------------------------------- /server.json: -------------------------------------------------------------------------------- 1 | { 2 | "app":{ 3 | "cfengine":"adobe@2016" 4 | }, 5 | "web":{ 6 | "http":{ 7 | "port":"8500" 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /ModuleConfig.cfc: -------------------------------------------------------------------------------- 1 | component { 2 | 3 | this.name = "totp"; 4 | this.author = "Eric Peterson "; 5 | this.webUrl = "https://github.com/coldbox-modules/totp"; 6 | this.cfmapping = "totp"; 7 | this.autoMapModels = false; 8 | 9 | function configure() { 10 | binder.map( "TOTP@totp" ).to( "#moduleMapping#.models.TOTP" ); 11 | binder.map( "@totp" ).to( "#moduleMapping#.models.TOTP" ); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/cfformat.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/format.yml 2 | name: CFFormat 3 | 4 | on: 5 | push: 6 | branches-ignore: 7 | - "main" 8 | - "master" 9 | - "development" 10 | pull_request: 11 | branches: 12 | - main 13 | - master 14 | - development 15 | 16 | jobs: 17 | format: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout repo 21 | uses: actions/checkout@v2 22 | 23 | - name: Run CFFormat 24 | uses: Ortus-Solutions/commandbox-action@v1.0.2 25 | with: 26 | cmd: run-script format 27 | 28 | - name: Commit Format Changes 29 | uses: stefanzweifel/git-auto-commit-action@v4 30 | with: 31 | commit_message: "chore: Auto-format cfcs via cfformat" -------------------------------------------------------------------------------- /tests/runner.cfm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Application.cfc: -------------------------------------------------------------------------------- 1 | component { 2 | 3 | this.name = "TOTP Example"; 4 | this.sessionManagement = true; 5 | this.setClientCookies = true; 6 | this.sessionTimeout = createTimeSpan( 0, 0, 15, 0 ); 7 | this.applicationTimeout = createTimeSpan( 0, 0, 15, 0 ); 8 | 9 | rootPath = getDirectoryFromPath( getCurrentTemplatePath() ); 10 | this.mappings[ "/root" ] = rootPath; 11 | this.mappings[ "/totp" ] = rootPath; 12 | this.mappings[ "/CFzxing" ] = rootPath & "/modules/CFzxing"; 13 | 14 | this.javaSettings = { 15 | loadPaths: directoryList( 16 | this.mappings[ "/CFzxing" ] & "/lib", 17 | true, 18 | "array", 19 | "*jar" 20 | ), 21 | loadColdFusionClassPath: true, 22 | reloadOnChange: false 23 | }; 24 | 25 | } -------------------------------------------------------------------------------- /tests/Application.cfc: -------------------------------------------------------------------------------- 1 | component { 2 | 3 | this.name = "TOTP Tests"; 4 | this.sessionManagement = true; 5 | this.setClientCookies = true; 6 | this.sessionTimeout = createTimeSpan( 0, 0, 15, 0 ); 7 | this.applicationTimeout = createTimeSpan( 0, 0, 15, 0 ); 8 | 9 | testsPath = getDirectoryFromPath( getCurrentTemplatePath() ); 10 | this.mappings[ "/tests" ] = testsPath; 11 | rootPath = REReplaceNoCase( this.mappings[ "/tests" ], "tests(\\|/)", "" ); 12 | this.mappings[ "/root" ] = rootPath; 13 | this.mappings[ "/totp" ] = rootPath; 14 | this.mappings[ "/CFzxing" ] = rootPath & "/modules/CFzxing"; 15 | this.mappings[ "/testingModuleRoot" ] = listDeleteAt( rootPath, listLen( rootPath, '\/' ), "\/" ); 16 | this.mappings[ "/testbox" ] = rootPath & "/testbox"; 17 | 18 | this.javaSettings = { 19 | loadPaths: directoryList( 20 | this.mappings[ "/CFzxing" ] & "/lib", 21 | true, 22 | "array", 23 | "*jar" 24 | ), 25 | loadColdFusionClassPath: true, 26 | reloadOnChange: false 27 | }; 28 | 29 | } -------------------------------------------------------------------------------- /.github/workflows/cron.yml: -------------------------------------------------------------------------------- 1 | name: Cron 2 | 3 | on: 4 | schedule: 5 | - cron: 0 0 * * 1 6 | 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | name: Tests 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | cfengine: ["lucee@5", "lucee@be", "adobe@2016", "adobe@2018", "adobe@2021", "adobe@be"] 15 | coldbox: ["coldbox@6", "coldbox@be"] 16 | javaVersion: ["openjdk8", "openjdk11"] 17 | steps: 18 | - name: Checkout Repository 19 | uses: actions/checkout@v2 20 | 21 | - name: Setup Java JDK 22 | uses: actions/setup-java@v1.4.3 23 | with: 24 | java-version: 11 25 | 26 | - name: Set Up CommandBox 27 | uses: elpete/setup-commandbox@v1.0.0 28 | 29 | - name: Install dependencies 30 | run: | 31 | box install 32 | box install ${{ matrix.coldbox }} --noSave 33 | 34 | - name: Start server 35 | run: box server start cfengine=${{ matrix.cfengine }} javaVersion=${{ matrix.javaVersion }} --noSaveSettings 36 | 37 | - name: Run TestBox Tests 38 | run: box testbox run -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ortus Solutions 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 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PRs and Branches 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - "main" 7 | - "master" 8 | - "development" 9 | pull_request: 10 | branches: 11 | - main 12 | - master 13 | - development 14 | 15 | jobs: 16 | tests: 17 | runs-on: ubuntu-latest 18 | name: Tests 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | cfengine: ["lucee@5", "adobe@2016", "adobe@2018", "adobe@2021"] 23 | coldbox: ["coldbox@6"] 24 | steps: 25 | - name: Checkout Repository 26 | uses: actions/checkout@v2 27 | 28 | - name: Setup Java JDK 29 | uses: actions/setup-java@v1.4.3 30 | with: 31 | java-version: 11 32 | 33 | - name: Set Up CommandBox 34 | uses: elpete/setup-commandbox@v1.0.0 35 | 36 | - name: Install dependencies 37 | run: | 38 | box install 39 | box install ${{ matrix.coldbox }} --noSave 40 | 41 | - name: Start server 42 | run: box server start cfengine=${{ matrix.cfengine }} --noSaveSettings 43 | 44 | - name: Run TestBox Tests 45 | run: box testbox run -------------------------------------------------------------------------------- /box.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"totp", 3 | "version":"0.0.0", 4 | "author":"Eric Peterson ", 5 | "location":"forgeboxStorage", 6 | "homepage":"https://github.com/coldbox-modules/totp", 7 | "documentation":"https://github.com/coldbox-modules/totp", 8 | "repository":{ 9 | "type":"git", 10 | "URL":"https://github.com/coldbox-modules/totp" 11 | }, 12 | "bugs":"https://github.com/coldbox-modules/totp/issues", 13 | "slug":"totp", 14 | "shortDescription":"A CFML implementation of Time-based One-time Passwords", 15 | "description":"A CFML implementation of Time-based One-time Passwords", 16 | "type":"modules", 17 | "dependencies":{ 18 | "CFzxing":"^1.1.0" 19 | }, 20 | "devDependencies":{ 21 | "testbox":"^4.0.0" 22 | }, 23 | "installPaths":{ 24 | "testbox":"testbox/", 25 | "CFzxing":"modules/CFzxing/" 26 | }, 27 | "scripts":{ 28 | "format":"cfformat run ModuleConfig.cfc,models/**/*.cfc,tests/specs/**/*.cfc,tests/resources/app/handlers/**/*.cfc,tests/resources/app/config/**/*.cfc --overwrite", 29 | "format:check":"cfformat check ModuleConfig.cfc,models/**/*.cfc,tests/specs/**/*.cfc,tests/resources/app/handlers/**/*.cfc,tests/resources/app/config/**/*.cfc --verbose", 30 | "format:watch":"cfformat watch ModuleConfig.cfc,models/**/*.cfc,tests/specs/**/*.cfc,tests/resources/app/handlers/**/*.cfc,tests/resources/app/config/**/*.cfc", 31 | "onServerInstall": "#listfirst ${interceptData.installDetails.version} '.' | #comparenocase '2021' | assertTrue || run-script install:2021", 32 | "install:2021": "cfpm install image" 33 | }, 34 | "ignore":[ 35 | "**/.*", 36 | "test", 37 | "tests" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.cfformat.json: -------------------------------------------------------------------------------- 1 | { 2 | "alignment.consecutive.assignments":false, 3 | "alignment.consecutive.params":false, 4 | "alignment.consecutive.properties":false, 5 | "array.empty_padding":false, 6 | "array.multiline.element_count":4, 7 | "array.multiline.leading_comma":false, 8 | "array.multiline.leading_comma.padding":true, 9 | "array.multiline.min_length":40, 10 | "array.padding":true, 11 | "binary_operators.padding":true, 12 | "brackets.padding":true, 13 | "comment.asterisks":"align", 14 | "for_loop_semicolons.padding":true, 15 | "function_anonymous.empty_padding":false, 16 | "function_anonymous.group_to_block_spacing":"spaced", 17 | "function_anonymous.multiline.element_count":4, 18 | "function_anonymous.multiline.leading_comma":false, 19 | "function_anonymous.multiline.leading_comma.padding":true, 20 | "function_anonymous.multiline.min_length":40, 21 | "function_anonymous.padding":true, 22 | "function_call.casing.builtin":"cfdocs", 23 | "function_call.casing.userdefined":"camel", 24 | "function_call.empty_padding":false, 25 | "function_call.multiline.element_count":4, 26 | "function_call.multiline.leading_comma":false, 27 | "function_call.multiline.leading_comma.padding":true, 28 | "function_call.multiline.min_length":40, 29 | "function_call.padding":true, 30 | "function_declaration.empty_padding":false, 31 | "function_declaration.group_to_block_spacing":"spaced", 32 | "function_declaration.multiline.element_count":4, 33 | "function_declaration.multiline.leading_comma":false, 34 | "function_declaration.multiline.leading_comma.padding":true, 35 | "function_declaration.multiline.min_length":40, 36 | "function_declaration.padding":true, 37 | "indent_size":4, 38 | "keywords.block_to_keyword_spacing":"spaced", 39 | "keywords.empty_group_spacing":false, 40 | "keywords.group_to_block_spacing":"spaced", 41 | "keywords.padding_inside_group":true, 42 | "keywords.spacing_to_block":"spaced", 43 | "keywords.spacing_to_group":true, 44 | "max_columns":120, 45 | "method_call.chain.multiline":3, 46 | "newline":"\n", 47 | "parentheses.padding":true, 48 | "strings.attributes.quote":"double", 49 | "strings.quote":"double", 50 | "struct.empty_padding":false, 51 | "struct.multiline.element_count":4, 52 | "struct.multiline.leading_comma":false, 53 | "struct.multiline.leading_comma.padding":true, 54 | "struct.multiline.min_length":40, 55 | "struct.padding":true, 56 | "struct.separator":": ", 57 | "tab_indent":false 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/prerelease.yml: -------------------------------------------------------------------------------- 1 | name: Prerelease 2 | 3 | on: 4 | push: 5 | branches: 6 | - development 7 | 8 | jobs: 9 | tests: 10 | name: Tests 11 | if: "!contains(github.event.head_commit.message, '__SEMANTIC RELEASE VERSION UPDATE__')" 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | cfengine: ["lucee@5", "adobe@2016", "adobe@2018", "adobe@2021"] 17 | coldbox: ["coldbox@6"] 18 | steps: 19 | - name: Checkout Repository 20 | uses: actions/checkout@v2 21 | 22 | - name: Setup Java JDK 23 | uses: actions/setup-java@v1.4.3 24 | with: 25 | java-version: 11 26 | 27 | - name: Set Up CommandBox 28 | uses: elpete/setup-commandbox@v1.0.0 29 | 30 | - name: Install dependencies 31 | run: | 32 | box install 33 | box install ${{ matrix.coldbox }} --noSave 34 | 35 | - name: Start server 36 | run: box server start cfengine=${{ matrix.cfengine }} --noSaveSettings 37 | 38 | - name: Run TestBox Tests 39 | run: box testbox run 40 | 41 | # release: 42 | # name: Semantic Release 43 | # if: "!contains(github.event.head_commit.message, '__SEMANTIC RELEASE VERSION UPDATE__')" 44 | # needs: tests 45 | # runs-on: ubuntu-latest 46 | # env: 47 | # GA_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} 48 | # steps: 49 | # - name: Checkout Repository 50 | # uses: actions/checkout@v2 51 | # with: 52 | # fetch-depth: 0 53 | 54 | # - name: Setup Java JDK 55 | # uses: actions/setup-java@v1.4.3 56 | # with: 57 | # java-version: 11 58 | 59 | # - name: Set Up CommandBox 60 | # uses: elpete/setup-commandbox@v1.0.0 61 | 62 | # - name: Install and Configure Semantic Release 63 | # run: | 64 | # box install commandbox-semantic-release 65 | # box config set endpoints.forgebox.APIToken=${{ secrets.FORGEBOX_TOKEN }} 66 | # box config set modules.commandbox-semantic-release.plugins='{ "VerifyConditions": "GitHubActionsConditionsVerifier@commandbox-semantic-release", "FetchLastRelease": "ForgeBoxReleaseFetcher@commandbox-semantic-release", "RetrieveCommits": "JGitCommitsRetriever@commandbox-semantic-release", "ParseCommit": "ConventionalChangelogParser@commandbox-semantic-release", "FilterCommits": "DefaultCommitFilterer@commandbox-semantic-release", "AnalyzeCommits": "DefaultCommitAnalyzer@commandbox-semantic-release", "VerifyRelease": "NullReleaseVerifier@commandbox-semantic-release", "GenerateNotes": "GitHubMarkdownNotesGenerator@commandbox-semantic-release", "UpdateChangelog": "FileAppendChangelogUpdater@commandbox-semantic-release", "CommitArtifacts": "NullArtifactsCommitter@commandbox-semantic-release", "PublishRelease": "ForgeBoxReleasePublisher@commandbox-semantic-release", "PublicizeRelease": "GitHubReleasePublicizer@commandbox-semantic-release" }' 67 | 68 | # - name: Run Semantic Release 69 | # env: 70 | # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | # run: box semantic-release --prerelease -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | tests: 11 | name: Tests 12 | if: "!contains(github.event.head_commit.message, '__SEMANTIC RELEASE VERSION UPDATE__')" 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | cfengine: ["lucee@5", "adobe@2016", "adobe@2018", "adobe@2021"] 18 | coldbox: ["coldbox@6"] 19 | steps: 20 | - name: Checkout Repository 21 | uses: actions/checkout@v2 22 | 23 | - name: Setup Java JDK 24 | uses: actions/setup-java@v1.4.3 25 | with: 26 | java-version: 11 27 | 28 | - name: Set Up CommandBox 29 | uses: elpete/setup-commandbox@v1.0.0 30 | 31 | - name: Install dependencies 32 | run: | 33 | box install 34 | box install ${{ matrix.coldbox }} --noSave 35 | 36 | - name: Start server 37 | run: box server start cfengine=${{ matrix.cfengine }} --noSaveSettings 38 | 39 | - name: Run TestBox Tests 40 | run: box testbox run 41 | 42 | release: 43 | name: Semantic Release 44 | if: "!contains(github.event.head_commit.message, '__SEMANTIC RELEASE VERSION UPDATE__')" 45 | needs: tests 46 | runs-on: ubuntu-latest 47 | env: 48 | GA_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} 49 | steps: 50 | - name: Checkout Repository 51 | uses: actions/checkout@v2 52 | with: 53 | fetch-depth: 0 54 | 55 | - name: Setup Java JDK 56 | uses: actions/setup-java@v1.4.3 57 | with: 58 | java-version: 11 59 | 60 | - name: Set Up CommandBox 61 | uses: elpete/setup-commandbox@v1.0.0 62 | 63 | - name: Install and Configure Semantic Release 64 | run: | 65 | box install commandbox-semantic-release 66 | box config set endpoints.forgebox.APIToken=${{ secrets.FORGEBOX_TOKEN }} 67 | box config set modules.commandbox-semantic-release.targetBranch=main 68 | box config set modules.commandbox-semantic-release.plugins='{ "VerifyConditions": "GitHubActionsConditionsVerifier@commandbox-semantic-release", "FetchLastRelease": "ForgeBoxReleaseFetcher@commandbox-semantic-release", "RetrieveCommits": "JGitCommitsRetriever@commandbox-semantic-release", "ParseCommit": "ConventionalChangelogParser@commandbox-semantic-release", "FilterCommits": "DefaultCommitFilterer@commandbox-semantic-release", "AnalyzeCommits": "DefaultCommitAnalyzer@commandbox-semantic-release", "VerifyRelease": "NullReleaseVerifier@commandbox-semantic-release", "GenerateNotes": "GitHubMarkdownNotesGenerator@commandbox-semantic-release", "UpdateChangelog": "FileAppendChangelogUpdater@commandbox-semantic-release", "CommitArtifacts": "NullArtifactsCommitter@commandbox-semantic-release", "PublishRelease": "ForgeBoxReleasePublisher@commandbox-semantic-release", "PublicizeRelease": "GitHubReleasePublicizer@commandbox-semantic-release" }' 69 | 70 | - name: Run Semantic Release 71 | env: 72 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | run: box semantic-release -------------------------------------------------------------------------------- /index.cfm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | TOTP Tester 9 | 10 | 11 | 12 | 13 | param url.email = "john@example.com"; 14 | param url.issuer = "Example Company"; 15 | 16 | variables.totp = new models.TOTP(); 17 | variables.barcodeService = new modules.CFzxing.models.Barcode(); 18 | variables.barcodeService.setJavaloader( { "create": function( path ) { 19 | return createObject( "java", path ); 20 | } } ); 21 | variables.totp.setBarcodeService( variables.barcodeService ); 22 | 23 | if ( form.keyExists( "regenerateSecret" ) ) { 24 | structDelete( application, "totpConfig" ); 25 | } 26 | param application.totpConfig = variables.totp.generate( url.email, url.issuer, 32, 512, 512 ); 27 | 28 | param form.token = ""; 29 | param form.valid = true; 30 | if ( form.keyExists( "validateToken" ) ) { 31 | form.valid = variables.totp.verifyCode( application.totpConfig.secret, form.token ); 32 | } 33 | 34 | 35 |

TOTP Example

36 | 37 |
38 | #application.totpConfig.url# 39 |
#application.totpConfig.url#
40 |
41 | 42 |
43 | 46 |
47 | 48 |
49 | 50 |
51 |
52 |
53 |
54 | 66 | aria-invalid="true" 67 | aria-describedby="token-error" 68 | 69 | aria-describedby="token-success" 70 | 71 | > 72 |
73 | 76 |
77 | 78 |

Your token is invalid.

79 | 80 |

Your token is valid!

81 |
82 |
83 |
84 |
85 | 86 | -------------------------------------------------------------------------------- /tests/index.cfm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | #testbox.init( directory=rootMapping & url.path ).run()# 27 | 28 |

Invalid incoming directory: #rootMapping & url.path#

29 |
30 | 31 | 32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | TestBox Browser 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
66 |
67 |
68 | 69 |
70 | v#testbox.getVersion()# 71 |
72 | 73 |
74 |
75 |
76 |
77 |
78 | 79 |

TestBox Test Browser:

80 |

81 | Below is a listing of the files and folders starting from your root #rootPath#. You can click on individual tests in order to execute them 82 | or click on the Run All button on your left and it will execute a directory runner from the visible folder. 83 |

84 | 85 |
86 | Contents: #executePath# 87 | 88 |

89 |
90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | ✚ #qResults.name#
98 | 99 | target="_blank"
>#qResults.name#
100 | 101 | target="_blank">#qResults.name#
102 | 103 | #qResults.name#
104 | 105 | 106 |
107 |
108 |
109 |
110 |
111 |
112 | 113 | 114 | 115 |
116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

totp

2 |

3 | Available on ForgeBox 4 | Tested With TestBox 5 |

6 |

7 | Compatible with ColdFusion 2016 8 | Compatible with ColdFusion 2018 9 | Compatible with Lucee 5 10 |

11 | 12 | ## A CFML Implementation of Time-based One-time Passwords 13 | 14 | ### Inspiration 15 | 16 | - [Java TOTP](https://github.com/samdjstevens/java-totp) 17 | - [totp-generator](https://github.com/bellstrand/totp-generator) 18 | - [Base32](https://github.com/bennadel/Base32.cfc) 19 | 20 | ### Installation 21 | 22 | Install via CommandBox and ForgeBox: `box install totp` 23 | 24 | If you install manually, you need to download the [CFzxing library](https://github.com/alanquinlan/cfzxing) to `/modules/CFzxing`. 25 | 26 | ### Usage 27 | 28 | Obtain a new instance of TOTP using WireBox or simplying by creating a new instance (`new TOTP()`). 29 | 30 | > WireBox/ColdBox is **NOT** required to use this module. 31 | 32 | You can view an example of using this library by viewing the `index.cfm` 33 | file in the root of this module. 34 | 35 | #### `generate` 36 | 37 | Generates a secret, authenticator url, and a QR code for a given email and issuer. 38 | The `secret` should be stored securely and associated to the user who created it. 39 | It is also recommended that you have the user verify a code using the secret before saving the secret. 40 | 41 | | Name | Type | Required | Default | Description | 42 | | ------ | ------- | -------- | ------- | ---------------------------------------------------------- | 43 | | email | string | true | | The email address of the user associated with this secret. | 44 | | issuer | string | true | | The name of the issuer of this secret. | 45 | | length | numeric | false | 32 | The length of the secret key. | 46 | | width | numeric | false | 128 | The width of the QR code. | 47 | | height | numeric | false | 128 | The height of the QR code. | 48 | 49 | #### `generateSecret` 50 | 51 | Generates a Base32 string to use as a secret key when generating and verifying TOTPs. 52 | This key should be stored securely and associated to the user who created it. 53 | It is also recommended that you have the user verify a code using the secret before saving the secret. 54 | 55 | | Name | Type | Required | Default | Description | 56 | | ------ | ------- | -------- | ------- | ----------------------------- | 57 | | length | numeric | false | 32 | The length of the secret key. | 58 | 59 | #### `generateUrl` 60 | 61 | Generates a URL to use with authenticator apps containing the email, issuer, and generated secret key. 62 | 63 | | Name | Type | Required | Default | Description | 64 | | ------ | ------- | -------- | ------- | ---------------------------------------------------------- | 65 | | email | string | true | | The email address of the user associated with this secret. | 66 | | issuer | string | true | | The name of the issuer of this secret. | 67 | | length | numeric | false | 32 | The length of the secret key. | 68 | 69 | #### `generateQRCode` 70 | 71 | Generates a QR Code to use with authenticator apps containing the authenticator url. 72 | 73 | | Name | Type | Required | Default | Description | 74 | | ---------------- | ------- | -------- | ------- | --------------------------------------------------------------------------------- | 75 | | authenticatorUrl | string | true | | The authenticator url to encode in a QR code, usually from calling `generateUrl`. | 76 | | width | numeric | false | 128 | The width of the QR code. | 77 | | height | numeric | false | 128 | The height of the QR code. | 78 | 79 | #### `generateRecoveryCodes` 80 | 81 | Generates an array of recovery codes. 82 | 83 | Each code composed of numbers and lower case characters from latin alphabet (36 possible characters). 84 | The code is split in groups separated with dash for better readability. 85 | For example: `4ckn-xspn-et8t-xgr0` 86 | 87 | | Name | Type | Required | Default | Description | 88 | | ------ | ------- | -------- | ------- | -------------------------------- | 89 | | amount | numeric | true | | The amount of codes to generate. | 90 | 91 | #### `generateCode` 92 | 93 | Generates a Time-based One-time Password (TOTP) for a given secret. 94 | 95 | | Name | Type | Required | Default | Description | 96 | | ---------- | ------- | -------- | ------- | ------------------------------------------------------------------------------------------------------- | 97 | | secret | string | true | | The Base32 string to use when generating the code. | 98 | | digits | numeric | false | 6 | The number of digits of the code to return. | 99 | | algorithm | string | false | "SHA1" | The algorithm to use when generating the code. Valid algorithms are: MD5, SHA1, SHA256, SHA384, SHA512. | 100 | | time | numeric | false | now | The current time (expressed as seconds since January 1, 1970). | 101 | | timePeriod | numeric | false | 30 | The time period the code is valid, in seconds. | 102 | 103 | #### `verifyCode` 104 | 105 | Verifies a given TOTP for a given secret. 106 | 107 | | Name | Type | Required | Default | Description | 108 | | ---------------------------- | ------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 109 | | secret | string | true | | The Base32 string to use when verifying the code. (This needs to be the same secret used to generate the code.) | 110 | | code | string | true | | The code to verify. | 111 | | algorithm | string | false | "SHA1" | The algorithm to use when verifying the code. (This needs to be the same algorithm used to generate the code.) Valid algorithms are: MD5, SHA1, SHA256, SHA384, SHA512. | 112 | | time | numeric | false | now | The current time (expressed as seconds since January 1, 1970). | 113 | | timePeriod | numeric | false | 30 | The time period the code is valid, in seconds. | 114 | | allowedTimePeriodDiscrepancy | numeric | false | 1 | The number of periods, before and after, a code is considered valid. By default, a code is valid for 30 seconds before to 30 seconds after its valid period for a total of 90 seconds. | 115 | -------------------------------------------------------------------------------- /tests/specs/unit/TOTPSpec.cfc: -------------------------------------------------------------------------------- 1 | component extends="testbox.system.BaseSpec" { 2 | 3 | function beforeAll() { 4 | variables.totp = new totp.models.TOTP(); 5 | var barcodeService = new CFzxing.models.Barcode(); 6 | barcodeService.setJavaloader( { 7 | "create": function( path ) { 8 | return createObject( "java", path ); 9 | } 10 | } ); 11 | variables.totp.setBarcodeService( barcodeService ); 12 | } 13 | 14 | function run() { 15 | describe( "TOTP", function() { 16 | describe( "generateCode", function() { 17 | getGenerateCodeTestCases().each( function( t ) { 18 | it( 19 | title = "correctly generates a code for secret code [#t.secret#] and time [#t.time#] and algorithm [#t.algorithm#] and digits [#t.digits#]", 20 | data = t, 21 | body = function( data ) { 22 | var code = variables.totp.generateCode( 23 | data.secret, 24 | data.digits, 25 | data.algorithm, 26 | data.time 27 | ); 28 | expect( code ).toBe( data.expectedCode ); 29 | } 30 | ); 31 | } ); 32 | } ); 33 | 34 | describe( "verifyCode", function() { 35 | it( "verifies a code is valid when the code is for the correct time period", function() { 36 | var secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; 37 | var timeToRunAt = 1567975936; 38 | var correctCode = "862707"; 39 | var timePeriod = 30; 40 | 41 | expect( 42 | variables.totp.verifyCode( 43 | secret, 44 | correctCode, 45 | "SHA1", 46 | timeToRunAt, 47 | timePeriod 48 | ) 49 | ).toBeTrue(); 50 | } ); 51 | 52 | it( "verifies a code is valid if it is within the configured discrepancy period", function() { 53 | var secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; 54 | var timeToRunAt = 1567975936; 55 | var correctCode = "862707"; 56 | var timePeriod = 30; 57 | 58 | // allow for a -/+ ~30 second discrepancy 59 | expect( 60 | variables.totp.verifyCode( 61 | secret, 62 | correctCode, 63 | "SHA1", 64 | timeToRunAt - timePeriod, 65 | timePeriod 66 | ) 67 | ).toBeTrue(); 68 | expect( 69 | variables.totp.verifyCode( 70 | secret, 71 | correctCode, 72 | "SHA1", 73 | timeToRunAt + timePeriod, 74 | timePeriod 75 | ) 76 | ).toBeTrue(); 77 | } ); 78 | 79 | it( "fails to verify a code if it is outside the discrepancy window", function() { 80 | var secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; 81 | var timeToRunAt = 1567975936; 82 | var correctCode = "862707"; 83 | var timePeriod = 30; 84 | 85 | expect( 86 | variables.totp.verifyCode( 87 | secret, 88 | correctCode, 89 | "SHA1", 90 | timeToRunAt + timePeriod + 15, 91 | timePeriod 92 | ) 93 | ).toBeFalse(); 94 | } ); 95 | 96 | it( "fails to verify incorrect codes", function() { 97 | var secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; 98 | var timeToRunAt = 1567975936; 99 | var correctCode = "862707"; 100 | var timePeriod = 30; 101 | 102 | expect( 103 | variables.totp.verifyCode( 104 | secret, 105 | "123", 106 | "SHA1", 107 | timeToRunAt, 108 | timePeriod 109 | ) 110 | ).toBeFalse(); 111 | } ); 112 | 113 | it( "fails to verify a code that is not the expected length", function() { 114 | var secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; 115 | var timeToRunAt = 1567975936; 116 | var correctCode = "862707"; 117 | var timePeriod = 30; 118 | 119 | expect( 120 | variables.totp.verifyCode( 121 | secret, 122 | 7, // 7 is one of the correct answers 123 | "SHA1", 124 | timeToRunAt, 125 | timePeriod 126 | ) 127 | ).toBeFalse(); 128 | } ); 129 | } ); 130 | 131 | describe( "generateSecret", function() { 132 | it( "can generate a secret", function() { 133 | expect( variables.totp.generateSecret() ).toHaveLength( 32 ); 134 | } ); 135 | 136 | it( "can generate custom length secrets", function() { 137 | expect( variables.totp.generateSecret( 16 ) ).toHaveLength( 16 ); 138 | expect( variables.totp.generateSecret( 128 ) ).toHaveLength( 128 ); 139 | } ); 140 | 141 | it( "is a valid Base32 string", function() { 142 | expect( variables.totp.generateSecret() ).toMatchWithCase( 143 | "^[A-Z2-7]+=*$", 144 | "Secret must be a valid Base32 string." 145 | ); 146 | } ); 147 | } ); 148 | 149 | describe( "generateUrl", function() { 150 | it( "generates a url for use in authenticator apps", function() { 151 | var email = "john@example.com"; 152 | var issuer = "Example Company"; 153 | var secret = variables.totp.generateSecret(); 154 | var totpUrl = variables.totp.generateUrl( 155 | email = email, 156 | issuer = "Example Company", 157 | secret = secret 158 | ); 159 | expect( totpUrl ).toBe( 160 | "otpauth://totp/Example%20Company:john@example.com?secret=#secret#&issuer=Example%20Company" 161 | ); 162 | } ); 163 | } ); 164 | 165 | describe( "generateQRCode", function() { 166 | it( "generates a qr code for use in authenticator apps", function() { 167 | var email = "john@example.com"; 168 | var issuer = "Example Company"; 169 | var secret = variables.totp.generateSecret(); 170 | var totpUrl = variables.totp.generateUrl( 171 | email = email, 172 | issuer = "Example Company", 173 | secret = secret 174 | ); 175 | var qrCode = variables.totp.generateQrCode( totpUrl ); 176 | expect( isImage( qrCode ) ).toBeTrue( "An image should have been returned" ); 177 | expect( variables.totp.getBarcodeService().decode( qrCode ) ).toBe( totpUrl ); 178 | } ); 179 | } ); 180 | 181 | describe( "generateRecoveryCodes", function() { 182 | it( "generates human-readable recovery codes", function() { 183 | var codes = variables.totp.generateRecoveryCodes( 4 ); 184 | expect( codes ).toBeArray(); 185 | expect( codes ).toHaveLength( 4 ); 186 | 187 | var uniqueCodes = {}; 188 | for ( var code in codes ) { 189 | expect( code ).toMatchWithCase( 190 | "[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}", 191 | "Recovery code does not match the expected format." 192 | ); 193 | uniqueCodes[ code ] = ""; 194 | } 195 | 196 | expect( uniqueCodes ).toHaveLength( 4, "All codes should be unique" ); 197 | } ); 198 | 199 | it( "throws an excpetion if the amount is not positive", function() { 200 | expect( function() { 201 | var codes = variables.totp.generateRecoveryCodes( 0 ); 202 | } ).toThrow( type = "totp.InvalidRecoveryCodeAmount" ); 203 | 204 | expect( function() { 205 | var codes = variables.totp.generateRecoveryCodes( -2 ); 206 | } ).toThrow( type = "totp.InvalidRecoveryCodeAmount" ); 207 | } ); 208 | } ); 209 | 210 | it( "can use a generated secret to generate and verify a code", function() { 211 | var secret = variables.totp.generateSecret(); 212 | var code = variables.totp.generateCode( secret ); 213 | expect( variables.totp.verifyCode( secret, code ) ).toBeTrue(); 214 | } ); 215 | 216 | it( "can generate a secret and a url and a qr code all at once", function() { 217 | var email = "john@example.com"; 218 | var issuer = "Example Company"; 219 | var config = variables.totp.generate( email, issuer ); 220 | expect( config ).toBeStruct(); 221 | expect( config ).toHaveKey( "secret" ); 222 | expect( config.secret ).toHaveLength( 32 ); 223 | expect( config.secret ).toMatchWithCase( "^[A-Z2-7]+=*$", "Secret must be a valid Base32 string." ); 224 | expect( config ).toHaveKey( "url" ); 225 | expect( config.url ).toBe( 226 | "otpauth://totp/Example%20Company:john@example.com?secret=#config.secret#&issuer=Example%20Company" 227 | ); 228 | expect( config ).toHaveKey( "qrCode" ); 229 | expect( isImage( config.qrCode ) ).toBeTrue( "An image should have been returned" ); 230 | expect( variables.totp.getBarcodeService().decode( config.qrCode ) ).toBe( config.url ); 231 | } ); 232 | 233 | it( "generates the same base64 string from the QR code multiple times", function() { 234 | var email = "john@example.com"; 235 | var issuer = "Example Company"; 236 | var config = variables.totp.generate( email, issuer ); 237 | var firstBase64 = toBase64( config.qrCode ); 238 | var secondBase64 = toBase64( config.qrCode ); 239 | expect( firstBase64 ).toBe( 240 | secondBase64, 241 | "toBase64 should return the same value every time it is called." 242 | ); 243 | } ); 244 | } ); 245 | } 246 | 247 | private array function getGenerateCodeTestCases() { 248 | return [ 249 | { 250 | "secret": "W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 251 | "time": 1567631536, 252 | "algorithm": "SHA1", 253 | "digits": 6, 254 | "expectedCode": "082371" 255 | }, 256 | { 257 | "secret": "W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 258 | "time": 1567631536, 259 | "algorithm": "SHA1", 260 | "digits": 8, 261 | "expectedCode": "11082371" 262 | }, 263 | { 264 | "secret": "W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 265 | "time": 1567631536, 266 | "algorithm": "SHA1", 267 | "digits": 4, 268 | "expectedCode": "2371" 269 | }, 270 | { 271 | "secret": "W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 272 | "time": 1567631536, 273 | "algorithm": "SHA256", 274 | "digits": 6, 275 | "expectedCode": "272978" 276 | }, 277 | { 278 | "secret": "W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 279 | "time": 1567631536, 280 | "algorithm": "SHA512", 281 | "digits": 6, 282 | "expectedCode": "325200" 283 | }, 284 | { 285 | "secret": "makrzl2hict4ojeji2iah4kndmq6sgka", 286 | "time": 1582750403, 287 | "algorithm": "SHA1", 288 | "digits": 6, 289 | "expectedCode": "848586" 290 | }, 291 | { 292 | "secret": "makrzl2hict4ojeji2iah4kndmq6sgka", 293 | "time": 1582750403, 294 | "algorithm": "SHA256", 295 | "digits": 6, 296 | "expectedCode": "965726" 297 | }, 298 | { 299 | "secret": "makrzl2hict4ojeji2iah4kndmq6sgka", 300 | "time": 1582750403, 301 | "algorithm": "SHA512", 302 | "digits": 6, 303 | "expectedCode": "741306" 304 | } 305 | ]; 306 | } 307 | 308 | } 309 | -------------------------------------------------------------------------------- /models/Base32.cfc: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Ben Nadel 3 | * @source https://github.com/bennadel/Base32.cfc 4 | * Slight modifications made to `encode` and `decode` to make them a bit more managable 5 | */ 6 | component output=false hint="I provide encoding and decoding methods for Base32 values." singleton { 7 | 8 | /** 9 | * I server no purpose since the methods on this component are "static". 10 | * 11 | * @output false 12 | */ 13 | public any function init() { 14 | return ( this ); 15 | } 16 | 17 | 18 | // --- 19 | // STATIC METHODS. 20 | // --- 21 | 22 | 23 | /** 24 | * I decode the given Base32-encoded string value. 25 | * 26 | * @output false 27 | * @hint The input string is assumed to be utf-8. 28 | */ 29 | public any function decode( required string input, boolean toString = false, string encoding = "utf-8" ) { 30 | var binaryOutput = decodeBinary( charsetDecode( uCase( arguments.input ), arguments.encoding ) ); 31 | return arguments.toString ? charsetEncode( binaryOutput, arguments.encoding ) : binaryOutput; 32 | } 33 | 34 | 35 | /** 36 | * I decode the given Base32-encoded binary value. 37 | * 38 | * @output false 39 | */ 40 | public binary function decodeBinary( required binary input ) { 41 | // I map the encoded bytes onto the original 5-bit partial input bytes. 42 | var decodingMap = getDecodingMap(); 43 | 44 | // I hold the intermediary, decoded bytes. 45 | var buffer = getAllocatedDecodingBuffer( input ); 46 | 47 | // The input maybe be padded with "=" to make sure that the value is evenly 48 | // divisible by 8 (to make the length of data more predictable). Once we hit 49 | // this byte (if it exists), we have consumed all of the encoded data. 50 | var terminatingByte = asc( "=" ); 51 | 52 | // I help zero-out the parts of the byte that were not discarded. 53 | var rightMostBits = [ 54 | inputBaseN( "1", 2 ), 55 | inputBaseN( "11", 2 ), 56 | inputBaseN( "111", 2 ), 57 | inputBaseN( "1111", 2 ) 58 | ]; 59 | 60 | // As we loop over the encoded bytes, we may have to build up each decoded byte 61 | // across multiple input bytes. This will help us keep track of how many more 62 | // bits we need to complete the pending byte. 63 | var decodedByte = 0; 64 | var bitsNeeded = 8; 65 | 66 | // Decode each input byte. 67 | for ( var byte in input ) { 68 | // If we hit the EOF byte, there's nothing more to process. 69 | if ( byte == terminatingByte ) { 70 | break; 71 | } 72 | 73 | // Get the original 5-bit input that was encoded. 74 | var partialByte = decodingMap[ byte ]; 75 | 76 | // If we need more than 5 bits, we can consume the given value in it's 77 | // entirety without actually filling the pending bit. 78 | if ( bitsNeeded > 5 ) { 79 | // Push the incoming 5-bits onto the end of the pending byte. 80 | decodedByte = bitOr( bitShln( decodedByte, 5 ), partialByte ); 81 | 82 | bitsNeeded -= 5; 83 | 84 | // If we need exactly 5 more bits, we can use the given value to complete 85 | // the pending bit. 86 | } else if ( bitsNeeded == 5 ) { 87 | // Push the incoming 5-bits onto the end of the pending byte. 88 | decodedByte = bitOr( bitShln( decodedByte, 5 ), partialByte ); 89 | 90 | // At this point, the pending byte is complete. 91 | buffer.put( toSignedByte( decodedByte ) ); 92 | 93 | decodedByte = 0; 94 | bitsNeeded = 8; 95 | 96 | // If we need between 1 and 4 bits, we have to consume the given value 97 | // across, two different pending bytes since it won't fit entirely into the 98 | // currently-pending byte (the leading bits complete the currently-pending 99 | // byte, then the trailing bits start the next pending byte). 100 | } else { 101 | var discardedCount = ( 5 - bitsNeeded ); 102 | 103 | // Push only the leading bits onto the end of the pending byte. 104 | decodedByte = bitOr( bitShln( decodedByte, bitsNeeded ), bitShrn( partialByte, discardedCount ) ); 105 | 106 | // At this point, the pending byte is complete. 107 | buffer.put( toSignedByte( decodedByte ) ); 108 | 109 | // Start the next pending byte with the trailing bits that we discarded 110 | // in the last operation. 111 | decodedByte = bitAnd( partialByte, rightMostBits[ discardedCount ] ); 112 | 113 | bitsNeeded = ( 8 - discardedCount ); 114 | } 115 | 116 | // NOTE: We will never need an ELSE case that requiers zero bits to complete 117 | // the pending byte. Since each case that can result in a completed byte 118 | // (need 5 bits (1) or less than 5 bits (2)) already push a byte on to the 119 | // result, we will never complete a byte without pushing it onto the output. 120 | } 121 | 122 | // Return the result as a binary value. 123 | return ( buffer.array() ); 124 | } 125 | 126 | 127 | /** 128 | * I encode the given string value using Base32 encoding. 129 | * 130 | * @output false 131 | * @hint The input string is assumed to be utf-8. 132 | */ 133 | public any function encode( required any input, boolean toString = true, string encoding = "utf-8" ) { 134 | if ( !isBinary( arguments.input ) ) { 135 | arguments.input = charsetDecode( uCase( arguments.input ), arguments.encoding ); 136 | } 137 | var binaryOutput = encodeBinary( arguments.input ); 138 | return arguments.toString ? charsetEncode( binaryOutput, arguments.encoding ) : binaryOutput; 139 | } 140 | 141 | 142 | /** 143 | * I encode the given binary value using Base32 encoding. 144 | * 145 | * @output false 146 | */ 147 | public binary function encodeBinary( required binary input ) { 148 | // I map the 5-bit input chunks to the base32-encoding bytes. 149 | var encodingMap = getEncodingMap(); 150 | 151 | // Base32-encoded strings must be divisible by 8 (so that the length of the data 152 | // is more predictable). We'll pad the null characters with "=". 153 | var paddingByte = asc( "=" ); 154 | 155 | // I hold the intermediary, encoded bytes. 156 | var buffer = getAllocatedEncodingBuffer( input ); 157 | 158 | // In order to iterate over the input bits more easily, we'll wrap it in a 159 | // BigInteger instance - this allows us to check individual bits without having 160 | // to calculate the offset across multiple bytes. 161 | var inputWrapper = createObject( "java", "java.math.BigInteger" ).init( input ); 162 | 163 | // Since BigInteger will not take leading zeros into account, we have to 164 | // explicitly calculate the number of input bits based on the number of input 165 | // bytes. 166 | var bitCount = ( arrayLen( input ) * 8 ); 167 | 168 | // Since each encoded chunk uses 5 bits, which may not divide evenly into a 169 | // set of 8-bit bytes, we need to normalize the input. Let's sanitize the input 170 | // wrapper to be evenly divisible by 5 (by pushing zeros onto the end). This 171 | // way, we never have to worry about reading an incomplete chunk of bits from 172 | // the underlying data. 173 | if ( bitCount % 5 ) { 174 | var missingBitCount = ( 5 - ( bitCount % 5 ) ); 175 | 176 | inputWrapper = inputWrapper.shiftLeft( javacast( "int", missingBitCount ) ); 177 | 178 | bitCount += missingBitCount; 179 | } 180 | 181 | // Now that we have know that our input bit count is evenly divisible by 5, 182 | // we can loop over the input in increments of 5 to read one decoded partial 183 | // byte at a time. 184 | // -- 185 | // NOTE: We are starting a bitCount-1 since the bits are zero-indexed. 186 | for ( var chunkOffset = ( bitCount - 1 ); chunkOffset > 0; chunkOffset -= 5 ) { 187 | var partialByte = 0; 188 | 189 | // Read 5 bits into the partial byte, starting at the chunk offset. 190 | for ( var i = chunkOffset; i > ( chunkOffset - 5 ); i-- ) { 191 | // Implicit read of "0" bit into the right-most bit of the partial byte. 192 | partialByte = bitShln( partialByte, 1 ); 193 | 194 | // If the underlying input bit is "1", update the partial byte. 195 | if ( inputWrapper.testBit( javacast( "int", i ) ) ) { 196 | partialByte = bitOr( partialByte, 1 ); 197 | } 198 | } 199 | 200 | // At this point, the partial byte value is a number that refers to the 201 | // index of the encoded value in the base32 character-set. Push the mapped 202 | // characterByte onto the buffer. 203 | // -- 204 | // NOTE: We don't have to worry about converting to a signed byte since we 205 | // know the range of the inputs is always less than 128. 206 | buffer.put( encodingMap[ partialByte ] ); 207 | } 208 | 209 | // If the number of chunks isn't divisible by 8, we need to pad the result. 210 | while ( buffer.remaining() ) { 211 | buffer.put( paddingByte ); 212 | } 213 | 214 | // Return the result as a binary value. 215 | return ( buffer.array() ); 216 | } 217 | 218 | 219 | // --- 220 | // PRIVATE METHODS. 221 | // --- 222 | 223 | 224 | /** 225 | * I provide a pre-allocated ByteBuffer that will store the output string value 226 | * during the decoding process. This takes into account the possible padding of the 227 | * input and will discard padding characters during buffer allocation. 228 | * 229 | * @output false 230 | */ 231 | private any function getAllocatedDecodingBuffer( required binary input ) { 232 | var paddingByte = asc( "=" ); 233 | 234 | var inputLength = arrayLen( input ); 235 | 236 | // When allocating the output buffer, we don't want to take the padding 237 | // characters into account. Decrement the input length until we hit our first 238 | // trailing non-padding character. 239 | while ( input[ inputLength ] == paddingByte ) { 240 | inputLength--; 241 | } 242 | 243 | // Since each encoded byte reprsents only 5-bits of the encoded input, we know 244 | // that we know that the total number of meaningful input bits is *5. But then, 245 | // that has to represent full, 8-bit bytes. 246 | var totalOutputBytes = fix( inputLength * 5 / 8 ); 247 | 248 | return ( createObject( "java", "java.nio.ByteBuffer" ).allocate( javacast( "int", totalOutputBytes ) ) ); 249 | } 250 | 251 | 252 | /** 253 | * I provide a pre-allocated ByteBuffer that will store the base32 value during the 254 | * encoding process. This takes into account the possible need to pad the final output 255 | * and will be allocated to the exact length needed for encoding. 256 | * 257 | * @output false 258 | */ 259 | private any function getAllocatedEncodingBuffer( required binary input ) { 260 | // Each 5-bits of the input bytes will represent a byte in the encoded value. As 261 | // such, we know that the output will required the total number of bits (n * 8) 262 | // divided by 5-bits (for each output byte). 263 | var totalOutputBytes = ceiling( arrayLen( input ) * 8 / 5 ); 264 | 265 | // The length of the output has to be evenly divisible by 8; as such, if it is 266 | // not, we have to account of the trailing padding ("=") characters. 267 | if ( totalOutputBytes % 8 ) { 268 | totalOutputBytes += ( 8 - ( totalOutputBytes % 8 ) ); 269 | } 270 | 271 | return ( createObject( "java", "java.nio.ByteBuffer" ).allocate( javacast( "int", totalOutputBytes ) ) ); 272 | } 273 | 274 | 275 | /** 276 | * I return an array of character-bytes used to represent the set of possible Base32 277 | * encoding values. 278 | * 279 | * @output false 280 | * @hint This returns a Java array, not a ColdFusion array. 281 | */ 282 | private array function getBase32Bytes() { 283 | return ( javacast( "string", "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" ).getBytes() ); 284 | } 285 | 286 | 287 | /** 288 | * I return a struct that maps Base32 input characters to the bytes that are 289 | * used in the encoding process. 290 | * 291 | * Example: map[ encodedByte ] => byte. 292 | * 293 | * @output false 294 | */ 295 | private struct function getDecodingMap() { 296 | var byteMap = {}; 297 | 298 | var bytes = getBase32Bytes(); 299 | var byteLength = arrayLen( bytes ); 300 | 301 | for ( var i = 1; i <= byteLength; i++ ) { 302 | byteMap[ bytes[ i ] ] = ( i - 1 ); 303 | } 304 | 305 | return ( byteMap ); 306 | } 307 | 308 | 309 | /** 310 | * I return a struct that maps input bytes to the characters that are used 311 | * to encode in Base32. 312 | * 313 | * Example: map[ byte ] => encodedByte. 314 | * 315 | * @output false 316 | */ 317 | private struct function getEncodingMap() { 318 | var byteMap = {}; 319 | 320 | var bytes = getBase32Bytes(); 321 | var byteLength = arrayLen( bytes ); 322 | 323 | for ( var i = 1; i <= byteLength; i++ ) { 324 | byteMap[ i - 1 ] = bytes[ i ]; 325 | } 326 | 327 | return ( byteMap ); 328 | } 329 | 330 | 331 | /** 332 | * I convert the given input to a Java Byte (which is signed). 333 | * 334 | * @output false 335 | */ 336 | private any function toSignedByte( required numeric input ) { 337 | // If the 8th bit is turned on, then we have to make sure at the sign bit 338 | // is part of a 32-bit value with the sign carried over from the most 339 | // significant bit. 340 | if ( bitMaskRead( input, 7, 1 ) ) { 341 | return ( javacast( "byte", bitOr( input, -256 ) ) ); 342 | } else { 343 | return ( javacast( "byte", input ) ); 344 | } 345 | } 346 | 347 | } 348 | -------------------------------------------------------------------------------- /models/TOTP.cfc: -------------------------------------------------------------------------------- 1 | component singleton accessors="true" { 2 | 3 | property name="secureRandom"; 4 | property name="instant"; 5 | property name="base32"; 6 | property name="barcodeService" inject="Barcode@CFzxing"; 7 | 8 | public TOTP function init() { 9 | variables.secureRandom = createObject( "java", "java.security.SecureRandom" ).init(); 10 | variables.instant = createObject( "java", "java.time.Instant" ); 11 | variables.base32 = new Base32(); 12 | return this; 13 | } 14 | 15 | /** 16 | * Generates a secret, authenticator url, and a QR code for a given email and issuer. 17 | * 18 | * @email The email address of the user associated with this secret. 19 | * @issuer The name of the issuer of this secret. 20 | * @length The length of the secret key. Default: 32 21 | * @width The width of the QR code. Default: 128. 22 | * @height The height of the QR code. Default: 128. 23 | * 24 | * @returns A struct containing a `secret`, a `url`, and a `qrCode`. 25 | */ 26 | public struct function generate( 27 | required string email, 28 | required string issuer, 29 | numeric length = 32, 30 | numeric width = 128, 31 | numeric height = 128 32 | ) { 33 | var config = {}; 34 | config[ "secret" ] = generateSecret( arguments.length ); 35 | config[ "url" ] = generateUrl( arguments.email, arguments.issuer, config.secret ); 36 | config[ "qrCode" ] = generateQRCode( config.url, arguments.width, arguments.height ); 37 | 38 | // Lucee does not generate the same base64 string on the first call of `toBase64`. 39 | // We call it here once so that user-land code gets the correct value if they call `toBase64`. 40 | // https://luceeserver.atlassian.net/browse/LDEV-3964 41 | toBase64( config[ "qrCode" ] ); 42 | 43 | return config; 44 | } 45 | 46 | /** 47 | * Generates a Base32 string to use as a secret key when generating and verifying TOTPs. 48 | * This key should be stored securely and associated to the user who created it. 49 | * It is also recommended that you have the user verify a code using the secret before saving the secret. 50 | * 51 | * @length The length of the secret key. 52 | * Default: 32 53 | * 54 | * @returns A new secret key. 55 | */ 56 | public string function generateSecret( numeric length = 32 ) { 57 | var initialBytes = []; 58 | for ( var i = 1; i <= ceiling( ( arguments.length * 5 ) / 8 ); i++ ) { 59 | initialBytes.append( 0 ); 60 | } 61 | var bytes = javacast( "byte[]", initialBytes ); 62 | variables.secureRandom.nextBytes( bytes ); 63 | return variables.base32.encode( bytes ); 64 | } 65 | 66 | /** 67 | * Generates a URL to use with authenticator apps containing the email, issuer, and generated secret key. 68 | * It is also recommended that you have the user verify a code using the secret before saving the secret. 69 | * 70 | * @email The email address of the user associated with this secret. 71 | * @issuer The name of the issuer of this secret. 72 | * @secret The Base32 string to use as a secret key when generating and verifying TOTPs. 73 | * 74 | * @returns A URL for use in authenticator apps. 75 | */ 76 | public string function generateUrl( required string email, required string issuer, required string secret ) { 77 | return arrayToList( 78 | [ 79 | "otpauth://totp/", 80 | urlEncodedFormat( arguments.issuer ), 81 | ":", 82 | arguments.email, 83 | "?secret=", 84 | arguments.secret, 85 | "&issuer=", 86 | urlEncodedFormat( arguments.issuer ) 87 | ], 88 | "" 89 | ); 90 | } 91 | 92 | /** 93 | * Generates a QR Code to use with authenticator apps containing the authenticator url. 94 | * 95 | * @authenticatorUrl The authenticator url to encode in a QR code, usually from calling `generateUrl`. 96 | * @width The width of the QR code. Default: 128. 97 | * @height The height of the QR code. Default: 128. 98 | * 99 | * @returns An Image containing the QR code. 100 | */ 101 | function generateQRCode( required string authenticatorUrl, numeric width = 128, numeric height = 128 ) { 102 | if ( isNull( variables.barcodeService ) ) { 103 | throw( 104 | type = "totp.MissingBarcodeService", 105 | message = "No barcodeService configured. Please set one using the `setBarcodeService` method. (We recommend CFzxing.)" 106 | ); 107 | } 108 | 109 | return variables.barcodeService.getBarcodeImage( 110 | contents = arguments.authenticatorUrl, 111 | type = "QR_CODE", 112 | width = arguments.width, 113 | height = arguments.height 114 | ); 115 | } 116 | 117 | /** 118 | * Generates an array of recovery codes. 119 | * 120 | * @amount The amount of codes to generate. 121 | * 122 | * @returns An array of recovery codes. 123 | */ 124 | public array function generateRecoveryCodes( required numeric amount ) { 125 | if ( arguments.amount <= 0 ) { 126 | throw( 127 | type = "totp.InvalidRecoveryCodeAmount", 128 | message = "You must generate a positive amount of recovery codes." 129 | ); 130 | } 131 | 132 | var codes = []; 133 | for ( var i = 1; i <= arguments.amount; i++ ) { 134 | codes.append( generateRecoveryCode() ); 135 | } 136 | return codes; 137 | } 138 | 139 | /** 140 | * Generates a code composed of numbers and lower case characters from latin alphabet (36 possible characters). 141 | * The code is split in groups separated with dash for better readability. 142 | * For example: `4ckn-xspn-et8t-xgr0` 143 | * 144 | * Recovery codes must reach a minimum entropy to be secured 145 | * `code entropy = log( {characters-count} ^ {code-length} ) / log(2)` 146 | * The settings used below allows the code to reach an entropy of 82 bits : 147 | * log(36^16) / log(2) == 82.7... 148 | * 149 | * @returns A recovery code string. 150 | */ 151 | private string function generateRecoveryCode() { 152 | var CODE_LENGTH = 16; 153 | var GROUPS_NUMBER = 4; 154 | var CHARACTERS = listToArray( "abcdefghijklmnopqrstuvwxyz0123456789", "" ); 155 | var CHARACTERS_LENGTH = CHARACTERS.len(); 156 | 157 | var code = ""; 158 | for ( var i = 1; i <= CODE_LENGTH; i++ ) { 159 | // Append random character from authorized ones 160 | code &= CHARACTERS[ variables.secureRandom.nextInt( CHARACTERS_LENGTH ) + 1 ]; 161 | // Split code into groups for increased readability 162 | if ( i % GROUPS_NUMBER == 0 && i != CODE_LENGTH ) { 163 | code &= "-"; 164 | } 165 | } 166 | 167 | return code; 168 | } 169 | 170 | /** 171 | * Generates a TOTP for a given secret. 172 | * 173 | * @secret The Base32 string to use when generating the code. 174 | * @digits The number of digits of the code to return. 175 | * @algorithm The algorithm to use when generating the code. 176 | * Valid algorithms are: 177 | * - MD5 178 | * - SHA1 179 | * - SHA256 180 | * - SHA384 181 | * - SHA512 182 | * Default: SHA1 183 | * @time The current time (expressed as seconds since January 1, 1970). 184 | * Default: now 185 | * @timePeriod The time period the code is valid, in seconds. 186 | * Default: 30. 187 | * 188 | * @returns A TOTP 189 | */ 190 | public string function generateCode( 191 | required string secret, 192 | numeric digits = 6, 193 | string algorithm = "SHA1", 194 | numeric time = variables.instant.now().getEpochSecond(), 195 | numeric timePeriod = 30 196 | ) { 197 | if ( arguments.digits <= 0 ) { 198 | throw( 199 | type = "totp.InvalidDigitAmount", 200 | message = "You must generate a code with a positive amount of digits." 201 | ); 202 | } 203 | var counter = floor( arguments.time / arguments.timePeriod ); 204 | var hash = generateHash( arguments.secret, counter, arguments.algorithm ); 205 | return getDigitsFromHash( hash, arguments.digits ); 206 | } 207 | 208 | /** 209 | * Verifies a given TOTP for a given secret. 210 | * 211 | * @secret The Base32 string to use when verifying the code. 212 | * (This needs to be the same secret used to generate the code.) 213 | * @code The code to verify. 214 | * @algorithm The algorithm to use when verifying the code. 215 | * (This needs to be the same algorithm used to generate the code.) 216 | * Valid algorithms are: 217 | * - MD5 218 | * - SHA1 219 | * - SHA256 220 | * - SHA384 221 | * - SHA512 222 | * Default: SHA1 223 | * @time The current time (expressed as seconds since January 1, 1970). 224 | * Default: now 225 | * @timePeriod The time period the code is valid, in seconds. 226 | * Default: 30. 227 | * @allowedTimePeriodDiscrepancy The number of periods, before and after, a code is considered valid. 228 | * By default, a code is valid for 30 seconds before to 30 seconds after its valid period for a total of 90 seconds. 229 | * Default: 1 230 | * @digits The number of digits that should be passed in. Used to not allow users to supply a one-digit code to try and fool the system. 231 | * Default: 6 232 | * 233 | * @returns True, if the code is valid. False, otherwise. 234 | */ 235 | public boolean function verifyCode( 236 | required string secret, 237 | required string code, 238 | string algorithm = "SHA1", 239 | numeric time = variables.instant.now().getEpochSecond(), 240 | numeric timePeriod = 30, 241 | numeric allowedTimePeriodDiscrepancy = 1, 242 | numeric digits = 6 243 | ) { 244 | var currentBucket = floor( arguments.time / arguments.timePeriod ); 245 | 246 | // Calculate and compare the codes for all the "valid" time periods, even if we get an early match, to avoid timing attacks 247 | var success = false; 248 | for ( var i = -1 * arguments.allowedTimePeriodDiscrepancy; i <= allowedTimePeriodDiscrepancy; i++ ) { 249 | success = checkCode( 250 | arguments.secret, 251 | arguments.code, 252 | currentBucket + i, 253 | arguments.algorithm, 254 | arguments.digits 255 | ) || success; 256 | } 257 | return success; 258 | } 259 | 260 | private boolean function checkCode( 261 | required string secret, 262 | required string code, 263 | required numeric counter, 264 | string algorithm = "SHA1", 265 | numeric digits = 6 266 | ) { 267 | // code should have a minimal length. Empty strings should not generate exceptions but just return false 268 | if ( !len( arguments.code ) ) { 269 | return false; 270 | } 271 | 272 | // code must match the required amount of digits. If not, return false. 273 | if ( len( arguments.code ) != arguments.digits ) { 274 | return false; 275 | } 276 | 277 | var hash = generateHash( arguments.secret, arguments.counter, arguments.algorithm ); 278 | return getDigitsFromHash( hash, arguments.digits ) == arguments.code; 279 | } 280 | 281 | private string function generateHash( required string secret, required numeric counter, string algorithm = "SHA1" ) { 282 | var time = leftPad( decimalToHex( arguments.counter ), 16, "0" ); 283 | return hmac( 284 | binaryDecode( time, "hex" ), 285 | variables.base32.decode( arguments.secret ), 286 | "HMAC#arguments.algorithm#" 287 | ); 288 | } 289 | 290 | private string function getDigitsFromHash( required string hash, numeric digits = 6 ) { 291 | var offset = hexToDecimal( right( arguments.hash, 1 ) ); 292 | var otp = toString( 293 | _bitAnd( hexToDecimal( mid( arguments.hash, ( offset * 2 ) + 1, 8 ) ), hexToDecimal( "7fffffff" ) ) 294 | ); 295 | var truncatedDigits = mid( otp, max( otp.len() - arguments.digits + 1, 1 ), arguments.digits ); 296 | return truncatedDigits; 297 | } 298 | 299 | private numeric function hexToDecimal( required string hex ) { 300 | return inputBaseN( arguments.hex, 16 ); 301 | } 302 | 303 | private numeric function binaryToDecimal( required string bin ) { 304 | return inputBaseN( arguments.bin, 2 ); 305 | } 306 | 307 | private string function decimalToHex( required numeric dec ) { 308 | return formatBaseN( arguments.dec, 16 ); 309 | } 310 | 311 | private string function decimalToBinary( required numeric dec ) { 312 | return formatBaseN( arguments.dec, 2 ); 313 | } 314 | 315 | private string function leftPad( required string str, required numeric len, required string pad ) { 316 | if ( arguments.str.len() >= arguments.len ) { 317 | return arguments.str; 318 | } 319 | return repeatString( arguments.pad, arguments.len - arguments.str.len() ) & arguments.str; 320 | } 321 | 322 | /** 323 | * We have our own bitAnd function because ACF and Lucee only support 32-bit signed integers. 324 | * 325 | * @one The first decimal number. 326 | * @two The second decimal number. 327 | * 328 | * @returns The decimal representation of the two numbers after a bitwise and operation. 329 | */ 330 | private string function _bitAnd( required numeric one, required numeric two ) { 331 | var oneBigInt = createObject( "java", "java.math.BigInteger" ).init( arguments.one ); 332 | var twoBigInt = createObject( "java", "java.math.BigInteger" ).init( arguments.two ); 333 | var andBigInt = oneBigInt.and( twoBigInt ); 334 | return andBigInt.intValue(); 335 | } 336 | 337 | } 338 | --------------------------------------------------------------------------------