├── .babelrc ├── .editorconfig ├── .github ├── contributing.md ├── issue_template.md └── pull_request_template.md ├── .gitignore ├── .istanbul.yml ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── crypto-test-test.js ├── docs.md ├── misc ├── hash-timing-tests.js ├── invite-user.png └── upgrading.md ├── mocha.opts ├── package-lock.json ├── package.json ├── src ├── change-protected-fields.js ├── check-unique.js ├── delete-expired-users.js ├── helpers │ ├── decode-reset-password-token.js │ ├── encode-reset-password-token.js │ ├── ensure-field-has-changed.js │ ├── ensure-obj-props-valid.js │ ├── ensure-values-are-strings.js │ ├── get-validated-user.js │ └── index.js ├── hooks │ ├── add-verification.js │ ├── index.js │ ├── is-verified.js │ ├── local-management-hook.js │ ├── protect-user-alm-fields.js │ ├── remove-verification.js │ ├── send-verify-signup-notification.js │ └── sequelize-convert-alm.js ├── index.js ├── password-change.js ├── plugins-default.js ├── plugins-extensions.js ├── resend-verify-signup.js ├── reset-password.js ├── send-mfa.js ├── send-reset-pwd.js ├── service.js ├── verify-mfa.js ├── verify-signup.js └── xx.js ├── test-data ├── README.md └── users.sqlite └── test ├── add-verification.test.js ├── change-protected-fields.test.js ├── check-unique.test.js ├── client-server.test.js ├── delete-expired-users.test.js ├── encode-reset-password-token.test.js ├── errors-async-await.test.js ├── helpers └── config.js ├── is-verified.test.js ├── password-change-history.test.js ├── password-change.test.js ├── protect-user-alm-fields.test.js ├── remove-verification.test.js ├── resend-verify-signup.test.js ├── reset-pwd-long.test.js ├── reset-pwd-short.test.js ├── scaffolding.test.js ├── send-mfa.test.js ├── send-reset-pwd.test.js ├── send-verify-signup-notifications.test.js ├── sequelize-convert-alm.test.js ├── sequelize.test.js ├── verify-mfa.test.js ├── verify-signup-long.test.js └── verify-signup-short.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "add-module-exports" 4 | ], 5 | "presets": [ "es2015" ] 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to Feathers 2 | 3 | Thank you for contributing to Feathers! :heart: :tada: 4 | 5 | This repo is the main core and where most issues are reported. Feathers embraces modularity and is broken up across many repos. To make this easier to manage we currently use [Zenhub](https://www.zenhub.com/) for issue triage and visibility. They have a free browser plugin you can install so that you can see what is in flight at any time, but of course you also always see current issues in Github. 6 | 7 | ## Report a bug 8 | 9 | Before creating an issue please make sure you have checked out the docs, specifically the [FAQ](https://docs.feathersjs.com/help/faq.html) section. You might want to also try searching Github. It's pretty likely someone has already asked a similar question. 10 | 11 | If you haven't found your answer please feel free to join our [slack channel](http://slack.feathersjs.com), create an issue on Github, or post on [Stackoverflow](http://stackoverflow.com) using the `feathers` or `feathersjs` tag. We try our best to monitor Stackoverflow but you're likely to get more immediate responses in Slack and Github. 12 | 13 | Issues can be reported in the [issue tracker](https://github.com/feathersjs/feathers/issues). Since feathers combines many modules it can be hard for us to assess the root cause without knowing which modules are being used and what your configuration looks like, so **it helps us immensely if you can link to a simple example that reproduces your issue**. 14 | 15 | ## Report a Security Concern 16 | 17 | We take security very seriously at Feathers. We welcome any peer review of our 100% open source code to ensure nobody's Feathers app is ever compromised or hacked. As a web application developer you are responsible for any security breaches. We do our very best to make sure Feathers is as secure as possible by default. 18 | 19 | In order to give the community time to respond and upgrade we strongly urge you report all security issues to us. Send one of the core team members a PM in [Slack](http://slack.feathersjs.com) or email us at hello@feathersjs.com with details and we will respond ASAP. 20 | 21 | For full details refer to our [Security docs](https://docs.feathersjs.com/SECURITY.html). 22 | 23 | ## Pull Requests 24 | 25 | We :heart: pull requests and we're continually working to make it as easy as possible for people to contribute, including a [Plugin Generator](https://github.com/feathersjs/generator-feathers-plugin) and a [common test suite](https://github.com/feathersjs/feathers-service-tests) for database adapters. 26 | 27 | We prefer small pull requests with minimal code changes. The smaller they are the easier they are to review and merge. A core team member will pick up your PR and review it as soon as they can. They may ask for changes or reject your pull request. This is not a reflection of you as an engineer or a person. Please accept feedback graciously as we will also try to be sensitive when providing it. 28 | 29 | Although we generally accept many PRs they can be rejected for many reasons. We will be as transparent as possible but it may simply be that you do not have the same context or information regarding the roadmap that the core team members have. We value the time you take to put together any contributions so we pledge to always be respectful of that time and will try to be as open as possible so that you don't waste it. :smile: 30 | 31 | **All PRs (except documentation) should be accompanied with tests and pass the linting rules.** 32 | 33 | ### Code style 34 | 35 | Before running the tests from the `test/` folder `npm test` will run ESlint. You can check your code changes individually by running `npm run lint`. 36 | 37 | ### ES6 compilation 38 | 39 | Feathers uses [Babel](https://babeljs.io/) to leverage the latest developments of the JavaScript language. All code and samples are currently written in ES2015. To transpile the code in this repository run 40 | 41 | > npm run compile 42 | 43 | __Note:__ `npm test` will run the compilation automatically before the tests. 44 | 45 | ### Tests 46 | 47 | [Mocha](http://mochajs.org/) tests are located in the `test/` folder and can be run using the `npm run mocha` or `npm test` (with ESLint and code coverage) command. 48 | 49 | ### Documentation 50 | 51 | Feathers documentation is contained in Markdown files in the [feathers-docs](https://github.com/feathersjs/feathers-docs) repository. To change the documentation submit a pull request to that repo, referencing any other PR if applicable, and the docs will be updated with the next release. 52 | 53 | ## External Modules 54 | 55 | If you're written something awesome for Feathers, the Feathers ecosystem, or using Feathers please add it to the [showcase](https://docs.feathersjs.com/why/showcase.html). You also might want to check out the [Plugin Generator](https://github.com/feathersjs/generator-feathers-plugin) that can be used to scaffold plugins to be Feathers compliant from the start. 56 | 57 | If you think it would be a good core module then please contact one of the Feathers core team members in [Slack](http://slack.feathersjs.com) and we can discuss whether it belongs in core and how to get it there. :beers: 58 | 59 | ## Contributor Code of Conduct 60 | 61 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 62 | 63 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 64 | 65 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 66 | 67 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 68 | 69 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 72 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ### Steps to reproduce 2 | 3 | - [ ] Tell us what broke. The more detailed the better. 4 | - [ ] Include the `feathers-gen-specs.json` file from your app. 5 | - [ ] Include the `src/services/[serviceName]/[serviceName].schema.?s` files if the issue involves the fields in one or more services. 6 | 7 | These last 2 items usually allow us to regen enough of your app to recreate the issue. 8 | We may otherwise ask you to provide a minimal GitHub repo or gist isolating the issue. 9 | 10 | ### Expected behavior 11 | Tell us what should happen 12 | 13 | ### Actual behavior 14 | Tell us what happens instead 15 | 16 | ### System configuration 17 | 18 | Tell us about the applicable parts of your setup. 19 | 20 | **Module versions** (especially the part that's not working): 21 | 22 | **NodeJS version**: 23 | 24 | **Operating System**: 25 | 26 | **Browser Version**: 27 | 28 | **React Native Version**: 29 | 30 | **Module Loader**: -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | 3 | (If you have not already please refer to the contributing guideline as [described 4 | here](https://github.com/feathersjs/feathers/blob/master/.github/contributing.md#pull-requests)) 5 | 6 | - [ ] Tell us about the problem your pull request is solving. 7 | - [ ] Are there any open issues that are related to this? 8 | - [ ] Is this PR dependent on PRs in other repos? 9 | 10 | If so, please mention them to keep the conversations linked together. 11 | 12 | ### Other Information 13 | 14 | If there's anything else that's important and relevant to your pull 15 | request, mention that information here. This could include 16 | benchmarks, or other information. 17 | 18 | Your PR will be reviewed by a core team member and they will work with you to get your changes merged in a timely manner. If merged your PR will automatically be added to the changelog in the next release. 19 | 20 | If your changes involve documentation updates please mention that and link the appropriate PR in [feathers-docs](https://github.com/feathersjs/feathers-docs). 21 | 22 | Thanks for contributing to Feathers! :heart: -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # Commenting this out is preferred by some people, see 27 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 28 | node_modules 29 | 30 | # Users Environment Variables 31 | .lock-wscript 32 | 33 | # The compiled/babelified modules 34 | lib/ 35 | 36 | ## editor 37 | .idea/ -------------------------------------------------------------------------------- /.istanbul.yml: -------------------------------------------------------------------------------- 1 | verbose: false 2 | instrumentation: 3 | root: ./src/ 4 | excludes: 5 | - lib/ 6 | include-all-sources: true 7 | reporting: 8 | print: summary 9 | reports: 10 | - html 11 | - text 12 | - lcov 13 | watermarks: 14 | statements: [50, 80] 15 | lines: [50, 80] 16 | functions: [50, 80] 17 | branches: [50, 80] -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .travis.yml 3 | .istanbul.yml 4 | .idea/ 5 | src/ 6 | test/ 7 | !lib/ 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | - '8' 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Feathers 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## authentication-local-management 2 | 3 | > Adds sign up verification, forgotten password reset, and other capabilities to local 4 | [`feathersjs/authentication`](https://docs.feathersjs.com/api/authentication/local-management.html). 5 | 6 | **In development** 7 | -------------------------------------------------------------------------------- /crypto-test-test.js: -------------------------------------------------------------------------------- 1 | 2 | const crypto = require('crypto'); 3 | 4 | const x = get(); 5 | 6 | async function get() { 7 | console.log('1'); 8 | const x1 = await randomBytes1(); 9 | console.log('1.1'); 10 | console.log('x1=', x1); 11 | 12 | console.log('2'); 13 | const x2 = await randomBytes2(10); 14 | console.log('2.1'); 15 | console.log('x2=', x2); 16 | 17 | return [x1, x2]; 18 | } 19 | 20 | 21 | function randomBytes1 (len) { 22 | return new Promise((resolve, reject) => { 23 | console.log('..randomBytes before'); 24 | resolve('aaaaaaaaaaaaaaa'); 25 | }); 26 | } 27 | 28 | function randomBytes2 (len) { 29 | return new Promise((resolve, reject) => { 30 | console.log('..........randomBytes before'); 31 | crypto.randomBytes(len, (err, buf) => { 32 | console.log('..........randomBytes in', typeof err, typeof buf); 33 | return err ? reject(err) : resolve(buf.toString('hex')) 34 | }); 35 | }) 36 | .then(result => { 37 | console.log('..........randomBytes after', result); 38 | return result; 39 | }) 40 | .catch(err => { 41 | console.log('..........randomBytes err', err.message); 42 | throw err; 43 | }) 44 | } -------------------------------------------------------------------------------- /misc/hash-timing-tests.js: -------------------------------------------------------------------------------- 1 | 2 | const bcrypt = require('bcrypt'); 3 | const bcryptjs = require('bcryptjs'); 4 | const hash = require('@feathersjs/authentication-local/lib/utils/hash'); 5 | const { hashPassword } = require('@feathersjs/authentication-local').hooks; 6 | 7 | const NS_PER_SEC = 1e9; 8 | const times = 4; 9 | 10 | testSuite(); 11 | 12 | async function testSuite() { 13 | console.log('\n0. Warm up the code in case that makes a difference.'); 14 | await runAsyncTest('hashPassword, sequential', async () => await hashPassword()(context()), 1); 15 | await runAsyncTest('util/hash, sequential', async () => await hash('aa'), 1); 16 | await runAsyncTest('util/hash2, sequential', async () => await hash2('aa'), 1); 17 | await runAsyncTest('bcrypt, sequential', async () => await hashBcrypt('aa'), 1); 18 | await runParallelTest('hashPassword, parallel ', () => hashPassword()(context()), 1); 19 | 20 | console.log('\n1. hashPassword sequential - check consistency of timings.'); 21 | await runAsyncTest('', async () => await hashPassword()(context()), 1); 22 | await runAsyncTest('', async () => await hashPassword()(context()), 2); 23 | await runAsyncTest('', async () => await hashPassword()(context()), 4); 24 | 25 | console.log('\n2. utils/hash sequential - check consistency of timings.'); 26 | await runAsyncTest('', async () => await hash('aa'), 1); 27 | await runAsyncTest('', async () => await hash('aa'), 2); 28 | await runAsyncTest('', async () => await hash('aa'), 4); 29 | 30 | console.log('\n3. utils/hash2 sequential - check consistency of timings.'); 31 | await runAsyncTest('', async () => await hash2('aa'), 1); 32 | await runAsyncTest('', async () => await hash2('aa'), 2); 33 | await runAsyncTest('', async () => await hash2('aa'), 4); 34 | 35 | console.log('\n4. bcrypt sequential - check consistency of timings.'); 36 | await runAsyncTest('', async () => await hashBcrypt('aa'), 1); 37 | await runAsyncTest('', async () => await hashBcrypt('aa'), 2); 38 | await runAsyncTest('', async () => await hashBcrypt('aa'), 4); 39 | 40 | console.log('\n5. hashPassword parallel - check consistency of timings.'); 41 | await runParallelTest('', () => hashPassword()(context()), 1); 42 | await runParallelTest('', () => hashPassword()(context()), 2); 43 | await runParallelTest('', () => hashPassword()(context()), 4); 44 | 45 | console.log('\n6. utils/hash parallel - check consistency of timings.'); 46 | await runParallelTest('', () => hash('aa'), 1); 47 | await runParallelTest('', () => hash('aa'), 2); 48 | await runParallelTest('', () => hash('aa'), 4); 49 | 50 | console.log('\n7. utils/hash2 parallel - check consistency of timings.'); 51 | await runParallelTest('', () => hash2('aa'), 1); 52 | await runParallelTest('', () => hash2('aa'), 2); 53 | await runParallelTest('', () => hash2('aa'), 4); 54 | 55 | console.log('\n8. hashBycrypt parallel - check consistency of timings.'); 56 | await runParallelTest('', () => hashBcrypt('aa'), 1); 57 | await runParallelTest('', () => hashBcrypt('aa'), 2); 58 | await runParallelTest('', () => hashBcrypt('aa'), 4); 59 | } 60 | 61 | async function runAsyncTest(desc, func, times) { 62 | const timers = []; 63 | 64 | for (let i = 0; i < times; i++) { 65 | await runAsyncFunc(func, timers); 66 | } 67 | 68 | const avgMs = Math.round((timers.reduce((a, b) => a + b)) / times); 69 | console.log(` ${desc} x${times}. Avg/hash=`, [avgMs], 70 | ' ms. Individual=', timers.map(a => Math.round(a))); 71 | } 72 | 73 | async function runAsyncFunc(func, timesMs) { 74 | const startTime = process.hrtime(); 75 | 76 | await func(); 77 | 78 | const diff = process.hrtime(startTime); 79 | const timeMs = (diff[0] * NS_PER_SEC + diff[1])/1000000; 80 | timesMs.push(timeMs); 81 | } 82 | 83 | function runParallelTest(desc, func, times) { 84 | const promises = []; 85 | const timers = []; 86 | 87 | for (let i = 0; i < times; i++) { 88 | promises.push(runParallelFunc(func, timers)); 89 | } 90 | 91 | // return Promise.all(new Array(times).map(() => runParallelFunc(func))) 92 | return Promise.all(promises) 93 | .then(() => { 94 | const avgMs = Math.round((timers.reduce((a, b) => a + b)) / times); 95 | console.log(` ${desc} x${times}. Avg/hash=`, [avgMs], 96 | ' ms. Individual=', timers.map(a => Math.round(a))); 97 | }); 98 | } 99 | 100 | function runParallelFunc(func, timesMs) { 101 | const startTime = process.hrtime(); 102 | 103 | return func() 104 | .then(() => { 105 | const diff = process.hrtime(startTime); 106 | const timeMs = (diff[0] * NS_PER_SEC + diff[1])/1000000; 107 | timesMs.push(timeMs); 108 | }) 109 | } 110 | 111 | function hash2 (password) { 112 | const BCRYPT_WORK_FACTOR_BASE = 12; 113 | const BCRYPT_DATE_BASE = 1483228800000; 114 | const BCRYPT_WORK_INCREASE_INTERVAL = 47300000000; 115 | 116 | return new Promise((resolve, reject) => { 117 | /* ********************************************************************************************* 118 | let BCRYPT_CURRENT_DATE = new Date().getTime(); 119 | let BCRYPT_WORK_INCREASE = Math.max(0, Math.floor((BCRYPT_CURRENT_DATE - BCRYPT_DATE_BASE) / BCRYPT_WORK_INCREASE_INTERVAL)); 120 | let BCRYPT_WORK_FACTOR = Math.min(19, BCRYPT_WORK_FACTOR_BASE + BCRYPT_WORK_INCREASE); 121 | ********************************************************************************************* */ 122 | let BCRYPT_WORK_FACTOR = BCRYPT_WORK_FACTOR_BASE; 123 | 124 | bcryptjs.genSalt(BCRYPT_WORK_FACTOR, function (error, salt) { 125 | if (error) { 126 | return reject(error); 127 | } 128 | 129 | bcryptjs.hash(password, salt, function (error, hashedPassword) { 130 | if (error) { 131 | return reject(error); 132 | } 133 | 134 | resolve(hashedPassword); 135 | }); 136 | }); 137 | }); 138 | } 139 | 140 | function hashBcrypt (password) { 141 | const BCRYPT_WORK_FACTOR_BASE = 12; 142 | const BCRYPT_DATE_BASE = 1483228800000; 143 | const BCRYPT_WORK_INCREASE_INTERVAL = 47300000000; 144 | 145 | return new Promise((resolve, reject) => { 146 | let BCRYPT_CURRENT_DATE = new Date().getTime(); 147 | let BCRYPT_WORK_INCREASE = Math.max(0, Math.floor((BCRYPT_CURRENT_DATE - BCRYPT_DATE_BASE) / BCRYPT_WORK_INCREASE_INTERVAL)); 148 | let BCRYPT_WORK_FACTOR = Math.min(19, BCRYPT_WORK_FACTOR_BASE + BCRYPT_WORK_INCREASE); 149 | 150 | 151 | bcrypt.genSalt(BCRYPT_WORK_FACTOR, function (error, salt) { 152 | if (error) { 153 | return reject(error); 154 | } 155 | 156 | bcrypt.hash(password, salt, function (error, hashedPassword) { 157 | if (error) { 158 | return reject(error); 159 | } 160 | 161 | resolve(hashedPassword); 162 | }); 163 | }); 164 | }); 165 | } 166 | 167 | function context() { 168 | return { 169 | app: { get: () => {} }, 170 | type: 'before', 171 | method: 'create', 172 | data: { 173 | password: 'aa', 174 | }, 175 | }; 176 | } 177 | -------------------------------------------------------------------------------- /misc/invite-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-plus/authentication-local-management/b3ec1be56ffc95cb1986bbe448a12e20c30693be/misc/invite-user.png -------------------------------------------------------------------------------- /mocha.opts: -------------------------------------------------------------------------------- 1 | --timeout 40000 2 | --recursive test/ 3 | --exit -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@feathers-plus/authentication-local-management", 3 | "description": "Adds sign up verification, forgotten password reset, and other capabilities to local feathers-authentication ", 4 | "version": "3.0.0", 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/feathersjs/authentication-local-management.git" 8 | }, 9 | "license": "MIT", 10 | "bugs": { 11 | "url": "https://github.com/feathersjs/authentication-feathers-management/issues" 12 | }, 13 | "homepage": "https://github.com/feathers-plus/authentication-local-management", 14 | "keywords": [ 15 | "feathers", 16 | "feathers-plus", 17 | "feathers-plugin", 18 | "hook", 19 | "hooks", 20 | "services", 21 | "authentication", 22 | "verification" 23 | ], 24 | "author": { 25 | "name": "John Szwaronek", 26 | "email": "johnsz9999@gmail.com" 27 | }, 28 | "engines": { 29 | "node": ">= 8.12.0" 30 | }, 31 | "main": "src/", 32 | "directories": { 33 | "lib": "src" 34 | }, 35 | "scripts": { 36 | "release:patch": "npm version patch && npm publish", 37 | "release:minor": "npm version minor && npm publish", 38 | "release:major": "npm version major && npm publish", 39 | "public": "npm publish --access public", 40 | "publish": "git push origin --tags && npm run changelog && git push origin", 41 | "changelog": "github_changelog_generator && git add CHANGELOG.md && git commit -am \"Updating changelog\"", 42 | "test": "npm run lint && npm run coverage", 43 | "lint": "semistandard src/**/*.js test/**/*.js --fix", 44 | "coverage": "istanbul cover _mocha -- --opts mocha.opts", 45 | "mocha": "mocha --opts mocha.opts", 46 | "start": "node example/app" 47 | }, 48 | "semistandard": { 49 | "env": [ 50 | "mocha" 51 | ] 52 | }, 53 | "dependencies": { 54 | "@feathersjs/errors": "^3.3.4", 55 | "bcryptjs": "^2.3.0", 56 | "debug": "^3.2.6", 57 | "feathers-hooks-common": "^4.20.0", 58 | "lodash.merge": "^4.6.1" 59 | }, 60 | "devDependencies": { 61 | "@feathers-plus/commons": "^0.3.1", 62 | "@feathers-plus/test-utils": "^0.3.5", 63 | "@feathersjs/authentication": "^2.1.13", 64 | "@feathersjs/authentication-jwt": "^2.0.7", 65 | "@feathersjs/authentication-local": "^1.2.7", 66 | "@feathersjs/client": "^3.7.5", 67 | "@feathersjs/configuration": "^2.0.4", 68 | "@feathersjs/express": "^1.2.7", 69 | "@feathersjs/feathers": "^3.2.3", 70 | "@feathersjs/socketio": "^3.2.7", 71 | "bcrypt": "^3.0.2", 72 | "chai": "^4.2.0", 73 | "feathers-memory": "^2.2.0", 74 | "feathers-sequelize": "^3.1.3", 75 | "istanbul": "^1.1.0-alpha.1", 76 | "mocha": "^3.3.0", 77 | "semistandard": "^11.0.0", 78 | "sequelize": "^4.41.1", 79 | "socket.io-client": "^2.1.1", 80 | "sqlite3": "^4.0.4" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/change-protected-fields.js: -------------------------------------------------------------------------------- 1 | 2 | const errors = require('@feathersjs/errors'); 3 | const makeDebug = require('debug'); 4 | const ensureObjPropsValid = require('./helpers/ensure-obj-props-valid'); 5 | const getValidatedUser = require('./helpers/get-validated-user'); 6 | const { comparePasswords, getId, getLongToken, getShortToken } = require('@feathers-plus/commons'); 7 | 8 | const debug = makeDebug('authLocalMgnt:changeProtectedFields'); 9 | 10 | module.exports = changeProtectedFields; 11 | 12 | async function changeProtectedFields ( 13 | { options, plugins }, identifyUser, password, changesIdentifyUser, notifierOptions, 14 | authUser, provider 15 | ) { 16 | // note this call does not update the authenticated user info in hooks.params.user. 17 | debug('changeProtectedFields', password, changesIdentifyUser); 18 | const usersService = options.app.service(options.usersServicePath); 19 | const usersServiceIdName = usersService.id; 20 | 21 | ensureObjPropsValid(identifyUser, options.userIdentityFields); 22 | ensureObjPropsValid(changesIdentifyUser, options.userIdentityFields); 23 | 24 | const users = await plugins.run('changeProtectedFields.find', { 25 | usersService, 26 | params: { query: identifyUser }, 27 | }); 28 | 29 | const user1 = getValidatedUser(users); 30 | 31 | if (options.ownAcctOnly && authUser && (getId(authUser) !== getId(user1))) { 32 | throw new errors.BadRequest('Can only affect your own account.', 33 | { errors: { $className: 'not-own-acct' } } 34 | ); 35 | } 36 | 37 | try { 38 | await comparePasswords(password, user1[options.passwordField], () => {}, options.bcryptCompare); 39 | } catch (err) { 40 | throw new errors.BadRequest('Password is incorrect.', 41 | { errors: { password: 'Password is incorrect.', $className: 'badParams' } } 42 | ); 43 | } 44 | 45 | const user2 = await plugins.run('changeProtectedFields.patch', { 46 | usersService, 47 | id: user1[usersServiceIdName], 48 | data: { 49 | verifyExpires: Date.now() + options.verifyDelay, 50 | verifyToken: await getLongToken(options.longTokenLen), 51 | verifyShortToken: await getShortToken(options.shortTokenLen, options.shortTokenDigits), 52 | verifyChanges: changesIdentifyUser, 53 | }, 54 | }); 55 | 56 | const user3 = await plugins.run('sanitizeUserForNotifier', user2); 57 | 58 | const user4 = await plugins.run('notifier', { 59 | type: 'changeProtectedFields', 60 | sanitizedUser: user3, 61 | notifierOptions, 62 | }); 63 | 64 | return await plugins.run('sanitizeUserForClient', user4); 65 | } 66 | -------------------------------------------------------------------------------- /src/check-unique.js: -------------------------------------------------------------------------------- 1 | 2 | const errors = require('@feathersjs/errors'); 3 | const makeDebug = require('debug'); 4 | const { isNullsy } = require('@feathers-plus/commons'); 5 | 6 | const debug = makeDebug('authLocalMgnt:checkUnique'); 7 | 8 | module.exports = checkUnique; 9 | 10 | // This module is usually called from the UI to check username, email, etc. are unique. 11 | async function checkUnique ({ options, plugins }, identifyUser, ownId, meta, authUser, provider) { 12 | debug('checkUnique', identifyUser, ownId, meta); 13 | const usersService = options.app.service(options.usersServicePath); 14 | const usersServiceIdName = usersService.id; 15 | const allProps = []; 16 | 17 | const keys = Object.keys(identifyUser).filter( 18 | key => !isNullsy(identifyUser[key]) 19 | ); 20 | 21 | try { 22 | for (let i = 0, ilen = keys.length; i < ilen; i++) { 23 | const prop = keys[i]; 24 | 25 | const users = await plugins.run('checkUnique.find', { 26 | usersService, 27 | params: { query: { [prop]: identifyUser[prop].trim() } }, 28 | }); 29 | 30 | const items = Array.isArray(users) ? users : users.data; 31 | const isNotUnique = items.length > 1 || 32 | (items.length === 1 && items[0][usersServiceIdName] !== ownId); 33 | allProps.push(isNotUnique ? prop : null); 34 | } 35 | } catch (err) { 36 | console.log(err); 37 | throw new errors.BadRequest(meta.noErrMsg ? null : 'checkUnique unexpected error.', 38 | { errors: { msg: err.message, $className: 'unexpected' } } 39 | ); 40 | } 41 | 42 | const errProps = allProps.filter(prop => prop); 43 | 44 | if (errProps.length) { 45 | const errs = {}; 46 | errProps.forEach(prop => { errs[prop] = 'Already taken.'; }); 47 | 48 | throw new errors.BadRequest(meta.noErrMsg ? null : 'Values already taken.', 49 | { errors: errs } 50 | ); 51 | } 52 | 53 | return null; 54 | } 55 | -------------------------------------------------------------------------------- /src/delete-expired-users.js: -------------------------------------------------------------------------------- 1 | 2 | const makeDebug = require('debug'); 3 | const debug = makeDebug('authLocalMgnt:deleteExpiredUsers'); 4 | 5 | module.exports = deleteExpiredUsers; 6 | 7 | async function deleteExpiredUsers ( 8 | { options, plugins }, data, notifierOptions, authUser, provider 9 | ) { 10 | debug('deleteExpiredUsers'); 11 | if (provider || authUser) return; // Only call by server is allowed. 12 | 13 | const usersService = options.app.service(options.usersServicePath); 14 | const usersServiceIdName = usersService.id; 15 | debug('id', usersService.id); 16 | 17 | const result = await usersService.find({ query: { isVerified: false }, paginate: false }); 18 | const users = result.data || result; 19 | 20 | for (let i = 0, leni = users.length; i < leni; i++) { 21 | const user = users[i]; 22 | 23 | const isExpired = await plugins.run('deleteExpiredUsers.filter', { 24 | callData: data, 25 | id: user[usersServiceIdName], 26 | user, 27 | }); 28 | 29 | if (isExpired) { 30 | await plugins.run('deleteExpiredUsers.remove', { 31 | usersService, 32 | id: user[usersServiceIdName], 33 | }); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/helpers/decode-reset-password-token.js: -------------------------------------------------------------------------------- 1 | 2 | const errors = require('@feathersjs/errors'); 3 | 4 | module.exports = decodeResetPasswordToken; 5 | 6 | function decodeResetPasswordToken (token) { 7 | if (!token.includes('___')) { 8 | throw new errors.BadRequest('Token is not in the correct format.', 9 | { errors: { $className: 'badParams' } } 10 | ); 11 | } 12 | 13 | return token.slice(0, token.indexOf('___')); 14 | } 15 | -------------------------------------------------------------------------------- /src/helpers/encode-reset-password-token.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = encodeResetPasswordToken; 3 | 4 | function encodeResetPasswordToken (id, token) { 5 | return `${id}___${token}`; 6 | } 7 | -------------------------------------------------------------------------------- /src/helpers/ensure-field-has-changed.js: -------------------------------------------------------------------------------- 1 | 2 | const { isNullsy } = require('@feathers-plus/commons'); 3 | 4 | module.exports = ensureFieldHasChanged; 5 | 6 | // Verify that obj1 and obj2 have different 'field' field 7 | // Returns false if either object is null/undefined 8 | function ensureFieldHasChanged (obj1, obj2) { 9 | return isNullsy(obj1) || isNullsy(obj2) 10 | ? () => false 11 | : field => obj1[field] !== obj2[field]; 12 | } 13 | -------------------------------------------------------------------------------- /src/helpers/ensure-obj-props-valid.js: -------------------------------------------------------------------------------- 1 | 2 | const errors = require('@feathersjs/errors'); 3 | 4 | module.exports = ensureObjPropsValid; 5 | 6 | function ensureObjPropsValid (obj, props, allowNone) { 7 | const keys = Object.keys(obj); 8 | const valid = keys.every(key => props.includes(key) && typeof obj[key] === 'string'); 9 | 10 | if (!valid || (keys.length === 0 && !allowNone)) { 11 | throw new errors.BadRequest('User info is not valid. (authLocalMgnt)', 12 | { errors: { $className: 'badParams' } } 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/helpers/ensure-values-are-strings.js: -------------------------------------------------------------------------------- 1 | 2 | const errors = require('@feathersjs/errors'); 3 | 4 | module.exports = ensureValuesAreStrings; 5 | 6 | function ensureValuesAreStrings (...rest) { 7 | if (!rest.every(str => typeof str === 'string')) { 8 | throw new errors.BadRequest('Expected string value. (authLocalMgnt)', 9 | { errors: { $className: 'badParams' } } 10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/helpers/get-validated-user.js: -------------------------------------------------------------------------------- 1 | 2 | const errors = require('@feathersjs/errors'); 3 | 4 | module.exports = getValidatedUser; 5 | 6 | function getValidatedUser (data, checks = []) { 7 | if (Array.isArray(data) ? data.length === 0 : data.total === 0) { 8 | throw new errors.BadRequest('User not found.', 9 | { errors: { $className: 'badParams' } }); 10 | } 11 | 12 | const users = Array.isArray(data) ? data : data.data || [ data ]; 13 | const user = users[0]; 14 | 15 | if (users.length !== 1) { 16 | throw new errors.BadRequest('More than 1 user selected.', 17 | { errors: { $className: 'badParams' } }); 18 | } 19 | 20 | if (checks.includes('isNotVerified') && user.isVerified) { 21 | throw new errors.BadRequest('User is already verified.', 22 | { errors: { $className: 'isNotVerified' } }); 23 | } 24 | 25 | if (checks.includes('isNotVerifiedOrHasVerifyChanges') && 26 | user.isVerified && !Object.keys(user.verifyChanges || {}).length 27 | ) { 28 | throw new errors.BadRequest('User is already verified & not awaiting changes.', 29 | { errors: { $className: 'nothingToVerify' } }); 30 | } 31 | 32 | if (checks.includes('isVerified') && !user.isVerified) { 33 | throw new errors.BadRequest('User is not verified.', 34 | { errors: { $className: 'isVerified' } }); 35 | } 36 | 37 | if (checks.includes('verifyNotExpired') && user.verifyExpires < Date.now()) { 38 | throw new errors.BadRequest('Verification token has expired.', 39 | { errors: { $className: 'verifyExpired' } }); 40 | } 41 | 42 | if (checks.includes('resetNotExpired') && user.resetExpires < Date.now()) { 43 | throw new errors.BadRequest('Password reset token has expired.', 44 | { errors: { $className: 'resetExpired' } }); 45 | } 46 | 47 | if (checks.includes('mfaNotExpired') && user.mfaExpires < Date.now()) { 48 | throw new errors.BadRequest('Multi factor token has expired.', 49 | { errors: { $className: 'mfaExpired' } }); 50 | } 51 | 52 | return user; 53 | } 54 | -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | 2 | // const callNotifier = require('./call-notifier'); 3 | // const cloneObject = require('./clone-object'); 4 | const encodeResetPasswordToken = require('./encode-reset-password-token'); 5 | const decodeResetPasswordToken = require('./decode-reset-password-token'); 6 | const ensureFieldHasChanged = require('./ensure-field-has-changed'); 7 | const ensureObjPropsValid = require('./ensure-obj-props-valid'); 8 | const ensureValuesAreStrings = require('./ensure-values-are-strings'); 9 | const getValidatedUser = require('./get-validated-user'); 10 | // const sanitizeUserForClient = require('./sanitize-user-for-client'); 11 | // const sanitizeUserForNotifier = require('./sanitize-user-for-notifier'); 12 | 13 | module.exports = { 14 | // callNotifier, 15 | // cloneObject, 16 | encodeResetPasswordToken, 17 | decodeResetPasswordToken, 18 | ensureFieldHasChanged, 19 | ensureObjPropsValid, 20 | ensureValuesAreStrings, 21 | getValidatedUser 22 | // sanitizeUserForClient, 23 | // sanitizeUserForNotifier 24 | }; 25 | -------------------------------------------------------------------------------- /src/hooks/add-verification.js: -------------------------------------------------------------------------------- 1 | 2 | const { checkContext, getItems, replaceItems } = require('feathers-hooks-common'); 3 | const { getLongToken, getShortToken } = require('@feathers-plus/commons'); 4 | const { ensureFieldHasChanged } = require('../helpers'); 5 | 6 | module.exports = addVerification; 7 | 8 | function addVerification () { 9 | return async context => { 10 | checkContext(context, 'before', ['create', 'patch', 'update']); 11 | 12 | const items = getItems(context); 13 | const recs = Array.isArray(items) ? items : [items]; 14 | const options = context.app.get('localManagement'); 15 | 16 | for (let i = 0, ilen = recs.length; i < ilen; i++) { 17 | const rec = recs[i]; 18 | 19 | // We do NOT add verification fields if the 3 following conditions are fulfilled: 20 | // - context is PATCH or PUT 21 | // - user is authenticated 22 | // - user's userIdentityFields fields did not change 23 | if ( 24 | !(context.method === 'patch' || context.method === 'update') || 25 | !context.params.user || 26 | options.userIdentityFields.some(ensureFieldHasChanged(rec, context.params.user)) 27 | ) { 28 | // An invited user, upon creation, must have set rec.isInvitation === true. 29 | // Full users, upon creation, need not have rec.isInvitation set. 30 | rec.isInvitation = 'isInvitation' in rec ? !!rec.isInvitation : false; 31 | rec.isVerified = false; 32 | rec.verifyExpires = Date.now() + options.verifyDelay; 33 | rec.verifyToken = await getLongToken(options.longTokenLen); 34 | rec.verifyShortToken = await getShortToken(options.shortTokenLen, options.shortTokenDigits); 35 | rec.verifyChanges = {}; 36 | } 37 | } 38 | 39 | replaceItems(context, items); 40 | return context; 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/hooks/index.js: -------------------------------------------------------------------------------- 1 | 2 | const addVerification = require('./add-verification'); 3 | const sequelizeConvertAlm = require('./sequelize-convert-alm'); 4 | const isVerified = require('./is-verified'); 5 | const localManagementHook = require('./local-management-hook'); 6 | const protectUserAlmFields = require('./protect-user-alm-fields'); 7 | const removeVerification = require('./remove-verification'); 8 | const sendVerifySignupNotification = require('./send-verify-signup-notification'); 9 | 10 | module.exports = { 11 | addVerification, 12 | sequelizeConvertAlm, 13 | isVerified, 14 | localManagementHook, 15 | protectUserAlmFields, 16 | removeVerification, 17 | sendVerifySignupNotification 18 | }; 19 | -------------------------------------------------------------------------------- /src/hooks/is-verified.js: -------------------------------------------------------------------------------- 1 | 2 | const errors = require('@feathersjs/errors'); 3 | const { checkContext } = require('feathers-hooks-common'); 4 | 5 | module.exports = isVerified; 6 | 7 | function isVerified () { 8 | return context => { 9 | checkContext(context, 'before'); 10 | 11 | if (context.params.provider && (!context.params.user || !context.params.user.isVerified)) { 12 | throw new errors.BadRequest('User\'s email is not yet verified.'); 13 | } 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/local-management-hook.js: -------------------------------------------------------------------------------- 1 | 2 | const { authenticate } = require('@feathersjs/authentication').hooks; 3 | 4 | module.exports = localManagementHook; 5 | 6 | function localManagementHook (commandsNoAuth = []) { 7 | commandsNoAuth = commandsNoAuth || [ 8 | 'resendVerifySignup', 'verifySignupLong', 'verifySignupShort', 9 | 'sendResetPwd', 'resetPwdLong', 'resetPwdShort' 10 | ]; 11 | 12 | return async context => { 13 | if (!context.data || !commandsNoAuth.includes(context.data.action)) { 14 | context = await authenticate('jwt')(context); 15 | } 16 | 17 | context.data.authUser = context.params.user; 18 | context.data.provider = context.params.provider; 19 | 20 | return context; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/protect-user-alm-fields.js: -------------------------------------------------------------------------------- 1 | 2 | const errors = require('@feathersjs/errors'); 3 | const { checkContext } = require('feathers-hooks-common'); 4 | 5 | module.exports = protectUserAlmFields; 6 | 7 | function protectUserAlmFields (preventWhen, verificationFields) { 8 | preventWhen = preventWhen || (context => !!context.params.provider); // no-op on server calls 9 | 10 | verificationFields = verificationFields || [ 11 | 'isInvitation', 'isVerified', 'preferredComm', 12 | 'verifyExpires', 'verifyToken', 'verifyShortToken', 'verifyChanges', 13 | 'resetExpires', 'resetToken', 'resetShortToken', 14 | 'mfaExpires', 'mfaShortToken', 'mfaType', 15 | ]; 16 | 17 | return context => { 18 | checkContext(context, 'before', ['patch'], 'protectUserAlmFields'); 19 | 20 | // Clients cannot directly modify identity fields like email & phone 21 | // nor verification fields. 22 | if (preventWhen(context)) { 23 | const data = context.data; 24 | const options = context.app.get('localManagement'); 25 | const fields = [].concat( 26 | options.userIdentityFields, options.userExtraPasswordFields, options.userProtectedFields, 27 | verificationFields, 28 | ); 29 | 30 | fields.forEach(name => { 31 | if (name in data && data[name] !== undefined) { 32 | throw new errors.BadRequest( 33 | `Field ${name} may not be patched. (protectUserAlmFields)` 34 | ); 35 | } 36 | }); 37 | } 38 | 39 | return context; 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/hooks/remove-verification.js: -------------------------------------------------------------------------------- 1 | 2 | const { checkContext, getItems, replaceItems } = require('feathers-hooks-common'); 3 | 4 | module.exports = removeVerification; 5 | 6 | function removeVerification (ifReturnTokens) { 7 | return context => { 8 | checkContext(context, 'after'); 9 | 10 | // Retrieve the items from the context 11 | let users = getItems(context); 12 | if (!users) return; 13 | const isArray = Array.isArray(users); 14 | users = (isArray ? users : [users]); 15 | 16 | users.forEach(user => { 17 | if (!('isVerified' in user) && context.method === 'create') { 18 | /* eslint-disable no-console */ 19 | console.warn('Property isVerified not found in user properties.'); 20 | console.warn('Have you added localManagement\'s properties to your model? (Refer to README.md)'); 21 | console.warn('Have you added the addVerification hook on users::create? (removeVerification)'); 22 | /* eslint-enable */ 23 | } 24 | 25 | if (context.params.provider && user) { // noop if initiated by server 26 | delete user.verifyExpires; 27 | delete user.verifyChanges; 28 | delete user.resetExpires; 29 | delete user.mfaExpires; 30 | if (!ifReturnTokens) { 31 | delete user.verifyToken; 32 | delete user.verifyShortToken; 33 | delete user.resetToken; 34 | delete user.resetShortToken; 35 | delete user.mfaShortToken; 36 | delete user.mfaType; 37 | } 38 | } 39 | }); 40 | // Replace the items within the hook 41 | replaceItems(context, isArray ? users : users[0]); 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/hooks/send-verify-signup-notification.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = sendVerifySignupNotification; 3 | 4 | function sendVerifySignupNotification (notifierOptions1, notifyWhen) { 5 | notifyWhen = notifyWhen || (context => !!context.params.provider); 6 | 7 | const notifierOptions = typeof notifierOptions1 === 'function' 8 | ? notifierOptions1 : () => notifierOptions1; 9 | 10 | return async context => { 11 | if (notifyWhen(context)) { 12 | const options = context.app.get('localManagement'); 13 | 14 | const sanitizedUser = await options.plugins.run('sanitizeUserForNotifier', context.result); 15 | 16 | await options.plugins.run('notifier', { 17 | type: sanitizedUser.isInvitation ? 'sendInvitationSignup' : 'sendVerifySignup', 18 | sanitizedUser, 19 | notifierOptions 20 | }); 21 | } 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/hooks/sequelize-convert-alm.js: -------------------------------------------------------------------------------- 1 | 2 | const { sequelizeConvert, getItems, replaceItems } = require('feathers-hooks-common'); 3 | 4 | module.exports = sequelizeConvertAlm; 5 | 6 | function sequelizeConvertAlm (converts, ignores, conversions) { 7 | converts = converts || { 8 | isInvitation: 'boolean', 9 | isVerified: 'boolean', 10 | verifyExpires: 'date', 11 | verifyChanges: 'json', 12 | resetExpires: 'date', 13 | mfaExpires: 'date', 14 | passwordHistory: 'json', 15 | }; 16 | 17 | return sequelizeConvert (converts, ignores, conversions); 18 | } 19 | 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 2 | const helpers = require('./helpers'); 3 | const hooks = require('./hooks'); 4 | const service = require('./service'); 5 | 6 | service.hooks = hooks; 7 | service.helpers = helpers; 8 | 9 | module.exports = service; 10 | -------------------------------------------------------------------------------- /src/password-change.js: -------------------------------------------------------------------------------- 1 | 2 | const errors = require('@feathersjs/errors'); 3 | const makeDebug = require('debug'); 4 | const ensureObjPropsValid = require('./helpers/ensure-obj-props-valid'); 5 | const ensureValuesAreStrings = require('./helpers/ensure-values-are-strings'); 6 | const getValidatedUser = require('./helpers/get-validated-user'); 7 | const { getId } = require('@feathers-plus/commons'); 8 | 9 | const debug = makeDebug('authLocalMgnt:passwordChange'); 10 | 11 | module.exports = passwordChange; 12 | 13 | async function passwordChange ( 14 | { options, plugins }, identifyUser, oldPassword, password, notifierOptions, authUser, provider 15 | ) { 16 | debug('passwordChange', oldPassword, password); 17 | const usersService = options.app.service(options.usersServicePath); 18 | const usersServiceIdName = usersService.id; 19 | const passwordField = options.passwordField; 20 | let user4; 21 | 22 | ensureValuesAreStrings(oldPassword, password); 23 | ensureObjPropsValid(identifyUser, options.userIdentityFields); 24 | 25 | const users = await plugins.run('passwordChange.find', { 26 | usersService, 27 | params: { query: identifyUser }, 28 | }); 29 | 30 | const user1 = getValidatedUser(users); 31 | 32 | if (options.ownAcctOnly && authUser && (getId(authUser) !== getId(user1))) { 33 | throw new errors.BadRequest('May only affect your own account.', 34 | { errors: { $className: 'not-own-acct' } } 35 | ); 36 | } 37 | 38 | await new Promise((resolve, reject) => { 39 | options.bcryptCompare(oldPassword, user1[options.passwordField], (err, data1) => { 40 | return (err || !data1) ? 41 | reject(new errors.BadRequest('Current password is incorrect.', 42 | { errors: { $className: 'badPassword' } } 43 | )) : 44 | resolve(); 45 | }); 46 | }); 47 | 48 | if (plugins.has('passwordHistoryExists')) { 49 | const exists = await plugins.run('passwordHistoryExists', { 50 | passwordHistory: user1.passwordHistory, 51 | passwordLikeField: options.passwordField, 52 | clearPassword: password, 53 | }); 54 | 55 | if (exists) { 56 | throw new errors.BadRequest('This password has been previously used. Try a new one.', 57 | {errors: {$className: 'repeatedPassword'}} 58 | ); 59 | } 60 | } 61 | 62 | const user2 = await plugins.run('passwordChange.patch', { 63 | usersService, 64 | id: user1[usersServiceIdName], 65 | data: { 66 | [passwordField]: password, 67 | }, 68 | }); 69 | 70 | if (plugins.has('passwordHistoryAdd')) { 71 | // Reread the record as we don't know what was used to encode the password. 72 | const user3 = await plugins.run('passwordChange.get', { 73 | usersService, 74 | id: user2[usersServiceIdName], 75 | }); 76 | 77 | 78 | // Add the new password to password history 79 | const passwordHistory = await plugins.run('passwordHistoryAdd', { 80 | passwordHistory: user3.passwordHistory, 81 | passwordLikeField: passwordField, 82 | hashedPassword: user3[passwordField], 83 | }); 84 | 85 | user4 = await plugins.run('passwordChange.patch', { 86 | usersService, 87 | id: user3[usersServiceIdName], 88 | data: { passwordHistory }, 89 | }); 90 | } else { 91 | user4 = user2; 92 | } 93 | 94 | const user5 = await plugins.run('sanitizeUserForNotifier', user4); 95 | 96 | const user6 = await plugins.run('notifier', { 97 | type: 'passwordChange', 98 | sanitizedUser: user5, 99 | notifierOptions, 100 | }); 101 | 102 | return await plugins.run('sanitizeUserForClient', user6); 103 | } 104 | -------------------------------------------------------------------------------- /src/plugins-default.js: -------------------------------------------------------------------------------- 1 | 2 | const makeDebug = require('debug'); 3 | const checkUnique = require('./check-unique'); 4 | const changeProtectedFields = require('./change-protected-fields'); 5 | const passwordChange = require('./password-change'); 6 | const resendVerifySignup = require('./resend-verify-signup'); 7 | const sendResetPwd = require('./send-reset-pwd'); 8 | const { resetPwdWithLongToken, resetPwdWithShortToken } = require('./reset-password'); 9 | const { verifySignupWithLongToken, verifySignupWithShortToken } = require('./verify-signup'); 10 | 11 | const debug = makeDebug('authLocalMgnt:plugins-default'); 12 | 13 | module.exports = [ 14 | // main service handlers 15 | { 16 | name: 'checkUnique', 17 | desc: 'checkUnique - default plugin, authentication-local-management', 18 | version: '1.0.0', 19 | trigger: 'checkUnique', 20 | run: async (accumulator, data, pluginsContext, pluginContext) => { 21 | return await checkUnique(pluginsContext, 22 | data.value, data.ownId || null, data.meta || {}, 23 | data.authUser, data.provider 24 | ); 25 | }, 26 | }, 27 | { 28 | name: 'changeProtectedFields', 29 | desc: 'changeProtectedFields - default plugin, authentication-local-management', 30 | version: '1.0.0', 31 | trigger: 'changeProtectedFields', 32 | run: async (accumulator, data, pluginsContext, pluginContext) => { 33 | return await changeProtectedFields(pluginsContext, 34 | data.value.user, data.value.password, data.value.changes, data.notifierOptions, 35 | data.authUser, data.provider 36 | ); 37 | }, 38 | }, 39 | { 40 | name: 'passwordChange', 41 | desc: 'passwordChange - default plugin, authentication-local-management', 42 | version: '1.0.0', 43 | trigger: 'passwordChange', 44 | run: async (accumulator, data, pluginsContext, pluginContext) => { 45 | return await passwordChange(pluginsContext, 46 | data.value.user, data.value.oldPassword, data.value.password, data.notifierOptions, 47 | data.authUser, data.provider 48 | ); 49 | }, 50 | }, 51 | { 52 | name: 'resendVerifySignup', 53 | desc: 'resendVerifySignup - default plugin, authentication-local-management', 54 | version: '1.0.0', 55 | trigger: 'resendVerifySignup', 56 | run: async (accumulator, data, pluginsContext, pluginContext) => { 57 | return await resendVerifySignup(pluginsContext, 58 | data.value, data.notifierOptions, 59 | data.authUser, data.provider 60 | ); 61 | }, 62 | }, 63 | { 64 | name: 'resetPwdLong', 65 | desc: 'resetPwdLong - default plugin, authentication-local-management', 66 | version: '1.0.0', 67 | trigger: 'resetPwdLong', 68 | run: async (accumulator, data, pluginsContext, pluginContext) => { 69 | return await resetPwdWithLongToken(pluginsContext, 70 | data.value.token, data.value.password, data.notifierOptions, 71 | data.authUser, data.provider 72 | ); 73 | }, 74 | }, 75 | { 76 | name: 'resetPwdShort', 77 | desc: 'resetPwdShort - default plugin, authentication-local-management', 78 | version: '1.0.0', 79 | trigger: 'resetPwdShort', 80 | run: async (accumulator, data, pluginsContext, pluginContext) => { 81 | return await resetPwdWithShortToken(pluginsContext, 82 | data.value.token, data.value.user, data.value.password, data.notifierOptions, 83 | data.authUser, data.provider 84 | ); 85 | }, 86 | }, 87 | { 88 | name: 'sendResetPwd', 89 | desc: 'sendResetPwd - default plugin, authentication-local-management', 90 | version: '1.0.0', 91 | trigger: 'sendResetPwd', 92 | run: async (accumulator, data, pluginsContext, pluginContext) => { 93 | return await sendResetPwd(pluginsContext, 94 | data.value, data.notifierOptions, 95 | data.authUser, data.provider 96 | ); 97 | }, 98 | }, 99 | { 100 | name: 'verifySignupLong', 101 | desc: 'verifySignupLong - default plugin, authentication-local-management', 102 | version: '1.0.0', 103 | trigger: 'verifySignupLong', 104 | run: async (accumulator, data, pluginsContext, pluginContext) => { 105 | return await verifySignupWithLongToken(pluginsContext, 106 | data.value, data.newPassword, data.notifierOptions, 107 | data.authUser, data.provider 108 | ); 109 | }, 110 | }, 111 | { 112 | name: 'verifySignupShort', 113 | desc: 'verifySignupShort - default plugin, authentication-local-management', 114 | version: '1.0.0', 115 | trigger: 'verifySignupShort', 116 | run: async (accumulator, data, pluginsContext, pluginContext) => { 117 | return await verifySignupWithShortToken(pluginsContext, 118 | data.value.token, data.value.user, data.notifierOptions, 119 | data.authUser, data.provider 120 | ); 121 | }, 122 | }, 123 | 124 | // checkUnique service calls 125 | pluginFactory('checkUnique.find', 'find'), 126 | 127 | // changeProtectedFields service calls 128 | pluginFactory('changeProtectedFields.find', 'find'), 129 | pluginFactory('changeProtectedFields.patch', 'patch'), 130 | 131 | // passwordChange service calls 132 | pluginFactory('passwordChange.find', 'find'), 133 | pluginFactory('passwordChange.get', 'get'), 134 | pluginFactory('passwordChange.patch', 'patch'), 135 | 136 | // resendVerifySignup service calls 137 | pluginFactory('resendVerifySignup.find', 'find'), 138 | pluginFactory('resendVerifySignup.patch', 'patch'), 139 | 140 | // resetPassword service calls 141 | pluginFactory('resetPassword.tokenGet', 'get'), 142 | pluginFactory('resetPassword.shortTokenFind', 'find'), 143 | pluginFactory('resetPassword.badTokenPatch', 'patch'), 144 | pluginFactory('resetPassword.patch', 'patch'), 145 | 146 | // sendResetPwd service calls 147 | pluginFactory('sendResetPwd.find', 'find'), 148 | pluginFactory('sendResetPwd.patch', 'patch'), 149 | 150 | // verifySignup service calls 151 | pluginFactory('verifySignup.find', 'find'), 152 | pluginFactory('verifySignup.patch', 'patch'), 153 | 154 | // notifier 155 | { 156 | trigger: 'notifier', 157 | run: async (accumulator, { type, sanitizedUser, notifierOptions }, { options }, pluginContext) => { 158 | debug('notifier', type); 159 | // console.log('a-l-m default notifier called', type, sanitizedUser, notifierOptions); 160 | return sanitizedUser; 161 | }, 162 | }, 163 | 164 | // buildUrlLink 165 | { 166 | trigger: 'buildUrlLink', 167 | run: async (accumulator, { type, token, actionToVerb }, pluginsContext, pluginContext) => { 168 | const app = pluginsContext.options.app; 169 | const isProd = process.env.NODE_ENV === 'production'; 170 | const port = (app.get('port') === '80' || isProd) ? '' : `:${app.get('port')}`; 171 | const host = (app.get('host') === 'HOST') ? 'localhost' : app.get('host'); 172 | const protocol = (app.get('protocol') === 'PROTOCOL') ? 'http' : app.get('protocol') || 'http'; 173 | const url = `${protocol}://${host}${port}/`; 174 | 175 | actionToVerb = actionToVerb || { 176 | sendInviteSignup: 'invite', 177 | resendInviteSignup: 'invite', 178 | sendVerifySignup: 'verify', 179 | resendVerifySignup: 'verify', 180 | sendResetPwd: 'reset', 181 | }; 182 | 183 | return `${url}${actionToVerb[type] || type}/${token}`; 184 | }, 185 | }, 186 | 187 | // utilities 188 | { 189 | trigger: 'sanitizeUserForNotifier', 190 | run: async (accumulator, user, { options } , pluginContext) => { 191 | const sanitizedUser = shallowCloneObject(user); 192 | 193 | delete sanitizedUser[options.passwordField]; 194 | 195 | return sanitizedUser; 196 | }, 197 | }, 198 | { 199 | trigger: 'sanitizeUserForClient', 200 | run: async (accumulator, user, { options } , pluginContext) => { 201 | const sanitizedUser = shallowCloneObject(user); 202 | 203 | delete sanitizedUser[options.passwordField]; 204 | delete sanitizedUser.verifyExpires; 205 | delete sanitizedUser.verifyToken; 206 | delete sanitizedUser.verifyShortToken; 207 | delete sanitizedUser.verifyChanges; 208 | delete sanitizedUser.resetExpires; 209 | delete sanitizedUser.resetToken; 210 | delete sanitizedUser.resetShortToken; 211 | delete sanitizedUser.mfaExpires; 212 | delete sanitizedUser.mfaShortToken; 213 | delete sanitizedUser.mfaType; 214 | 215 | return sanitizedUser; 216 | }, 217 | }, 218 | 219 | // catch error during processing 220 | { 221 | trigger: 'catchError', 222 | run: async (accumulator, err, pluginsContext, pluginContext) => 223 | Promise.reject(err) // support both async and Promise interfaces 224 | }, 225 | ]; 226 | 227 | function shallowCloneObject(obj) { 228 | return Object.assign({}, obj); 229 | } 230 | 231 | function pluginFactory(trigger, type) { 232 | let run; 233 | 234 | switch (type) { 235 | case 'find': 236 | run = async (accumulator, { usersService, params}, pluginsContext, pluginContext) => 237 | await usersService.find(params); 238 | break; 239 | case 'get': 240 | run = async (accumulator, { usersService, id, params}, pluginsContext, pluginContext) => 241 | await usersService.get(id, params); 242 | break; 243 | case 'patch': 244 | run = async (accumulator, { usersService, id, data, params }, pluginsContext, pluginContext) => 245 | await usersService.patch(id, data, params); 246 | break; 247 | case 'remove': 248 | run = async (accumulator, { usersService, id, params }, pluginsContext, pluginContext) => 249 | await usersService.remove(id, params); 250 | break; 251 | case 'no-op': 252 | run = async (accumulator, data, pluginsContext, pluginContext) => 253 | accumulator || data; 254 | break; 255 | default: 256 | throw new Error(`Invalid type ${type}. (plugins-default`); 257 | } 258 | 259 | return { 260 | name: trigger, 261 | desc: `${trigger} - default plugin, authentication-local-management`, 262 | version: '1.0.0', 263 | trigger: trigger, 264 | run, 265 | }; 266 | } 267 | -------------------------------------------------------------------------------- /src/plugins-extensions.js: -------------------------------------------------------------------------------- 1 | 2 | const makeDebug = require('debug'); 3 | const errors = require('@feathersjs/errors'); 4 | 5 | const deleteExpiredUsers = require('./delete-expired-users'); 6 | const sendMfa = require('./send-mfa'); 7 | const verifyMfa = require('./verify-mfa'); 8 | 9 | const debug = makeDebug('authLocalMgnt:plugins-extensions'); 10 | 11 | module.exports = [ 12 | // main service handlers 13 | { 14 | name: 'deleteExpiredUsers', 15 | desc: 'deleteExpiredUsers - default plugin, authentication-local-management', 16 | version: '1.0.0', 17 | trigger: 'deleteExpiredUsers', 18 | run: async (accumulator, data, pluginsContext, pluginContext) => { 19 | return await deleteExpiredUsers(pluginsContext, 20 | data, 21 | data.authUser, data.provider 22 | ); 23 | }, 24 | }, 25 | { 26 | name: 'sendMfa', 27 | desc: 'sendMfa - default plugin, authentication-local-management', 28 | version: '1.0.0', 29 | trigger: 'sendMfa', 30 | run: async (accumulator, data, pluginsContext, pluginContext) => { 31 | return await sendMfa(pluginsContext, 32 | data.value.user, data.value.type, data.notifierOptions, 33 | data.authUser, data.provider 34 | ); 35 | }, 36 | }, 37 | { 38 | name: 'verifyMfa', 39 | desc: 'verifyMfa - default plugin, authentication-local-management', 40 | version: '1.0.0', 41 | trigger: 'verifyMfa', 42 | run: async (accumulator, data, pluginsContext, pluginContext) => { 43 | return await verifyMfa(pluginsContext, 44 | data.value.user, data.value.token, data.value.type, 45 | data.authUser, data.provider 46 | ); 47 | }, 48 | }, 49 | 50 | // deleteExpiredUsers service calls 51 | { 52 | name: 'deleteExpiredUsers.filter', 53 | desc: 'deleteExpiredUsers.filter - default plugin, authentication-local-management', 54 | version: '1.0.0', 55 | trigger: 'deleteExpiredUsers.filter', 56 | run: async (accumulator, { callData, id, user }, pluginsContext, pluginContext) => { 57 | const isInvitationExpires = callData.isInvitationExpires || Date.now(); 58 | const isVerifiedExpires = callData.isVerifiedExpires || Date.now(); 59 | 60 | // todo should this be user.inviteExpires ????????????????????????????????????????????????????????? 61 | return (user.isInvitation && isInvitationExpires && user.verifyExpires <= isInvitationExpires) || 62 | (!user.isVerified && isVerifiedExpires && user.verifyExpires <= isVerifiedExpires); 63 | }, 64 | }, 65 | pluginFactory('deleteExpiredUsers.remove', 'remove'), 66 | 67 | // sendMfa service calls 68 | pluginFactory('sendMfa.find', 'find'), 69 | pluginFactory('sendMfa.patch', 'patch'), 70 | 71 | // verifyMfa service calls 72 | pluginFactory('verifyMfa.find', 'find'), 73 | pluginFactory('verifyMfa.patch', 'patch'), 74 | 75 | // passwordHistory utilities 76 | { 77 | trigger: 'passwordHistoryExists', 78 | setup: async (pluginsContext, pluginContext) => { 79 | pluginsContext.options.maxPasswordsEachField = 3; 80 | }, 81 | run: async (accumulator, { passwordHistory, passwordLikeField, clearPassword }, { options } , pluginContext) => { 82 | passwordHistory = passwordHistory || []; // [ [passwordField, datetime, hash], ... ] 83 | 84 | for (let i = 0, leni = passwordHistory.length; i < leni; i++) { 85 | const [entryPasswordField, _, hashedPassword] = passwordHistory[i]; 86 | 87 | if (entryPasswordField === passwordLikeField) { 88 | const hasPasswordBeenUsed = new Promise(resolve => { 89 | options.bcryptCompare(clearPassword, hashedPassword, (err, data1) => resolve(err || !data1)); 90 | }); 91 | 92 | if (hasPasswordBeenUsed) return true; 93 | } 94 | } 95 | 96 | return false; 97 | }, 98 | }, 99 | { 100 | trigger: 'passwordHistoryAdd', 101 | run: async (accumulator, { passwordHistory, passwordLikeField, hashedPassword }, { options } , pluginContext) => { 102 | passwordHistory = passwordHistory || []; // [ [passwordField, datetime, hashedPassword], ... ] 103 | 104 | let count = 0; 105 | let lastEntry; 106 | 107 | passwordHistory.forEach(([entryPasswordField, _, entryHash], i) => { 108 | if (entryPasswordField === passwordLikeField) { 109 | lastEntry = i; 110 | count =+ 1; 111 | } 112 | }); 113 | 114 | if (count > options.maxPasswordsEachField) { // remove oldest entry for field 115 | passwordHistory.splice(lastEntry, 1); 116 | } 117 | 118 | passwordHistory.unshift([passwordLikeField, Date.now(), hashedPassword]); 119 | 120 | return passwordHistory; 121 | }, 122 | }, 123 | ]; 124 | 125 | function shallowCloneObject(obj) { 126 | return Object.assign({}, obj); 127 | } 128 | 129 | function pluginFactory(trigger, type) { 130 | let run; 131 | 132 | switch (type) { 133 | case 'find': 134 | run = async (accumulator, { usersService, params}, pluginsContext, pluginContext) => 135 | await usersService.find(params); 136 | break; 137 | case 'get': 138 | run = async (accumulator, { usersService, id, params}, pluginsContext, pluginContext) => 139 | await usersService.get(id, params); 140 | break; 141 | case 'patch': 142 | run = async (accumulator, { usersService, id, data, params }, pluginsContext, pluginContext) => 143 | await usersService.patch(id, data, params); 144 | break; 145 | case 'remove': 146 | run = async (accumulator, { usersService, id, params }, pluginsContext, pluginContext) => 147 | await usersService.remove(id, params); 148 | break; 149 | case 'no-op': 150 | run = async (accumulator, data, pluginsContext, pluginContext) => 151 | accumulator || data; 152 | break; 153 | default: 154 | throw new Error(`Invalid type ${type}. (plugins-default`); 155 | } 156 | 157 | return { 158 | name: trigger, 159 | desc: `${trigger} - default plugin, authentication-local-management`, 160 | version: '1.0.0', 161 | trigger: trigger, 162 | run, 163 | }; 164 | } 165 | -------------------------------------------------------------------------------- /src/resend-verify-signup.js: -------------------------------------------------------------------------------- 1 | 2 | const makeDebug = require('debug'); 3 | const ensureObjPropsValid = require('./helpers/ensure-obj-props-valid'); 4 | const getValidatedUser = require('./helpers/get-validated-user'); 5 | const { getLongToken, getShortToken } = require('@feathers-plus/commons'); 6 | 7 | const debug = makeDebug('authLocalMgnt:resendVerifySignup'); 8 | 9 | module.exports = resendVerifySignup; 10 | 11 | // {email}, {cellphone}, {verifyToken}, {verifyShortToken}, 12 | // {email, cellphone, verifyToken, verifyShortToken} 13 | async function resendVerifySignup ( 14 | { options, plugins }, identifyUser, notifierOptions, authUser, provider 15 | ) { 16 | debug('identifyUser=', identifyUser); 17 | const usersService = options.app.service(options.usersServicePath); 18 | const usersServiceIdName = usersService.id; 19 | 20 | ensureObjPropsValid(identifyUser, 21 | options.userIdentityFields.concat('verifyToken', 'verifyShortToken') 22 | ); 23 | 24 | const users = await plugins.run('resendVerifySignup.find', { 25 | usersService, 26 | params: { query: identifyUser }, 27 | }); 28 | 29 | const user1 = getValidatedUser(users, ['isNotVerified']); 30 | 31 | const user2 = await plugins.run('resendVerifySignup.patch', { 32 | usersService, 33 | id: user1[usersServiceIdName], 34 | data: { 35 | // isInvitation is left as is. 36 | isVerified: false, 37 | verifyExpires: Date.now() + options.verifyDelay, 38 | verifyToken: await getLongToken(options.longTokenLen), 39 | verifyShortToken: await getShortToken(options.shortTokenLen, options.shortTokenDigits), 40 | }, 41 | }); 42 | 43 | const user3 = await plugins.run('sanitizeUserForNotifier', user2); 44 | 45 | const user4 = await plugins.run('notifier', { 46 | type: user3.isInvitation ? 'resendInvitationSignup' : 'resendVerifySignup', 47 | sanitizedUser: user3, 48 | notifierOptions, 49 | }); 50 | 51 | return await plugins.run('sanitizeUserForClient', user4); 52 | } 53 | -------------------------------------------------------------------------------- /src/reset-password.js: -------------------------------------------------------------------------------- 1 | 2 | const errors = require('@feathersjs/errors'); 3 | const makeDebug = require('debug'); 4 | const decodeResetPasswordToken = require('./helpers/decode-reset-password-token'); 5 | const ensureObjPropsValid = require('./helpers/ensure-obj-props-valid'); 6 | const ensureValuesAreStrings = require('./helpers/ensure-values-are-strings'); 7 | const getValidatedUser = require('./helpers/get-validated-user'); 8 | const { comparePasswords } = require('@feathers-plus/commons'); 9 | 10 | const debug = makeDebug('authLocalMgnt:resetPassword'); 11 | 12 | module.exports = { 13 | resetPwdWithLongToken, 14 | resetPwdWithShortToken, 15 | }; 16 | 17 | async function resetPwdWithLongToken( 18 | pluginsContext, resetToken, password, notifierOptions, authUser, provider 19 | ) { 20 | ensureValuesAreStrings(resetToken, password); 21 | 22 | return await resetPassword( 23 | pluginsContext, { resetToken }, { resetToken }, password, notifierOptions, authUser, provider 24 | ); 25 | } 26 | 27 | async function resetPwdWithShortToken( 28 | pluginsContext, resetShortToken, identifyUser, password, notifierOptions, authUser, provider 29 | ) { 30 | ensureValuesAreStrings(resetShortToken, password); 31 | ensureObjPropsValid(identifyUser, pluginsContext.options.userIdentityFields); 32 | 33 | return await resetPassword( 34 | pluginsContext, identifyUser, { resetShortToken }, password, notifierOptions, authUser, provider 35 | ); 36 | } 37 | 38 | async function resetPassword ( 39 | { options, plugins }, query, tokens, password, notifierOptions, authUser, provider 40 | ) { 41 | debug('resetPassword', query, tokens, password); 42 | const usersService = options.app.service(options.usersServicePath); 43 | const usersServiceIdName = usersService.id; 44 | const promises = []; 45 | let users; 46 | 47 | if (tokens.resetToken) { 48 | let id = decodeResetPasswordToken(tokens.resetToken); 49 | 50 | users = await plugins.run('resetPassword.tokenGet', { 51 | usersService, 52 | id, 53 | }); 54 | } else if (tokens.resetShortToken) { 55 | users = await plugins.run('resetPassword.shortTokenFind', { 56 | usersService, 57 | params: { query }, 58 | }); 59 | } else { 60 | throw new errors.BadRequest('resetToken and resetShortToken are missing. (authLocalMgnt)', 61 | { errors: { $className: 'incorrectToken' } } 62 | ); 63 | } 64 | 65 | const user1 = getValidatedUser(users, ['resetNotExpired']); 66 | 67 | Object.keys(tokens).forEach((key) => { 68 | promises.push(comparePasswords(tokens[key], user1[key], () => 69 | new errors.BadRequest('Reset Token is incorrect. (authLocalMgnt)', 70 | { errors: { $className: 'incorrectToken' } }) 71 | ), options.bcryptCompare); 72 | }); 73 | 74 | try { 75 | await Promise.all(promises); 76 | } catch (err) { 77 | await plugins.run('resetPassword.badTokenPatch', { 78 | usersService, 79 | id: user1[usersServiceIdName], 80 | data: { 81 | resetToken: null, 82 | resetShortToken: null, 83 | resetExpires: null, 84 | }, 85 | }); 86 | 87 | new errors.BadRequest('Invalid token. Get for a new one. (authLocalMgnt)', 88 | { errors: { $className: 'incorrectToken' } } 89 | ); 90 | } 91 | 92 | const user2 = await plugins.run('resetPassword.patch', { 93 | usersService, 94 | id: user1[usersServiceIdName], 95 | data: { 96 | [options.passwordField]: password, 97 | resetToken: null, 98 | resetShortToken: null, 99 | resetExpires: null, 100 | }, 101 | }); 102 | 103 | const user3 = await plugins.run('sanitizeUserForNotifier', user2); 104 | 105 | const user4 = await plugins.run('notifier', { 106 | type: 'resetPwd', 107 | sanitizedUser: user3, 108 | notifierOptions, 109 | }); 110 | 111 | return await plugins.run('sanitizeUserForClient', user4); 112 | } 113 | -------------------------------------------------------------------------------- /src/send-mfa.js: -------------------------------------------------------------------------------- 1 | 2 | const makeDebug = require('debug'); 3 | const ensureObjPropsValid = require('./helpers/ensure-obj-props-valid'); 4 | const getValidatedUser = require('./helpers/get-validated-user'); 5 | const { getShortToken } = require('@feathers-plus/commons'); 6 | 7 | const debug = makeDebug('authLocalMgnt:sendResetPwd'); 8 | 9 | 10 | module.exports = sendMfa; 11 | 12 | async function sendMfa ( 13 | { options, plugins }, identifyUser, type, notifierOptions, authUser, provider 14 | ) { 15 | debug('sendMfa'); 16 | const usersService = options.app.service(options.usersServicePath); 17 | const usersServiceIdName = usersService.id; 18 | debug('id', usersService.id); 19 | 20 | ensureObjPropsValid(identifyUser, options.userIdentityFields); 21 | 22 | const users = await plugins.run('sendMfa.find', { 23 | usersService, 24 | params: { query: identifyUser }, 25 | }); 26 | 27 | const user1 = getValidatedUser(users, options.skipIsVerifiedCheck ? [] : ['isVerified']); 28 | 29 | const user2 = Object.assign(user1, { 30 | mfaExpires: Date.now() + options.resetDelay, 31 | mfaShortToken: await getShortToken(options.shortTokenLen, options.shortTokenDigits), 32 | mfaType: type, 33 | }); 34 | 35 | const user3 = await plugins.run('sanitizeUserForNotifier', user2); 36 | 37 | await plugins.run('notifier', { 38 | type: 'sendMfa', 39 | sanitizedUser: user3, 40 | notifierOptions, 41 | }); 42 | 43 | const user4 = await plugins.run('sendMfa.patch', { 44 | usersService, 45 | id: user1[usersServiceIdName], 46 | data: { 47 | mfaExpires: user2.mfaExpires, 48 | mfaShortToken: user2.mfaShortToken, 49 | mfaType: user2.mfaType, 50 | }, 51 | }); 52 | 53 | return await plugins.run('sanitizeUserForClient', user4); 54 | } 55 | -------------------------------------------------------------------------------- /src/send-reset-pwd.js: -------------------------------------------------------------------------------- 1 | 2 | const makeDebug = require('debug'); 3 | const encodeResetPasswordToken = require('./helpers/encode-reset-password-token'); 4 | const ensureObjPropsValid = require('./helpers/ensure-obj-props-valid'); 5 | const getValidatedUser = require('./helpers/get-validated-user'); 6 | const { getLongToken, getShortToken } = require('@feathers-plus/commons'); 7 | 8 | const debug = makeDebug('authLocalMgnt:sendResetPwd'); 9 | 10 | 11 | module.exports = sendResetPwd; 12 | 13 | async function sendResetPwd ( 14 | { options, plugins }, identifyUser, notifierOptions, authUser, provider 15 | ) { 16 | debug('sendResetPwd'); 17 | const usersService = options.app.service(options.usersServicePath); 18 | const usersServiceIdName = usersService.id; 19 | debug('id', usersService.id); 20 | 21 | ensureObjPropsValid(identifyUser, options.userIdentityFields); 22 | 23 | const users = await plugins.run('sendResetPwd.find', { 24 | usersService, 25 | params: { query: identifyUser }, 26 | }); 27 | 28 | const user1 = getValidatedUser(users, options.commandsNoAuth.includes('sendResetPwd') ? [] : ['isVerified']); 29 | 30 | const user2 = Object.assign(user1, { 31 | resetExpires: Date.now() + options.resetDelay, 32 | resetToken: encodeResetPasswordToken( 33 | user1[usersServiceIdName], 34 | await getLongToken(options.longTokenLen) 35 | ), 36 | resetShortToken: await getShortToken(options.shortTokenLen, options.shortTokenDigits), 37 | }); 38 | 39 | const user3 = await plugins.run('sanitizeUserForNotifier', user2); 40 | 41 | await plugins.run('notifier', { 42 | type: 'sendResetPwd', 43 | sanitizedUser: user3, 44 | notifierOptions, 45 | }); 46 | 47 | const user4 = await plugins.run('sendResetPwd.patch', { 48 | usersService, 49 | id: user1[usersServiceIdName], 50 | data: { 51 | resetExpires: user2.resetExpires, 52 | resetToken: user2.resetToken, 53 | resetShortToken: user2.resetShortToken, 54 | }, 55 | }); 56 | 57 | return await plugins.run('sanitizeUserForClient', user4); 58 | } 59 | -------------------------------------------------------------------------------- /src/service.js: -------------------------------------------------------------------------------- 1 | 2 | const bcrypt = require('bcryptjs'); 3 | const errors = require('@feathersjs/errors'); 4 | const makeDebug = require('debug'); 5 | const merge = require('lodash.merge'); 6 | const Plugins = require('../../plugin-scaffolding/src'); 7 | const { authenticate } = require('@feathersjs/authentication').hooks; 8 | const checkUnique = require('./check-unique'); 9 | const changeProtectedFields = require('./change-protected-fields'); 10 | const passwordChange = require('./password-change'); 11 | const resendVerifySignup = require('./resend-verify-signup'); 12 | const pluginsDefault = require('./plugins-default'); 13 | const pluginsExtensions = require('./plugins-extensions'); 14 | const sendResetPwd = require('./send-reset-pwd'); 15 | const { resetPwdWithLongToken, resetPwdWithShortToken } = require('./reset-password'); 16 | const { verifySignupWithLongToken, verifySignupWithShortToken } = require('./verify-signup'); 17 | 18 | const debug = makeDebug('authLocalMgnt:service'); 19 | let plugins; 20 | 21 | const optionsDefault = { 22 | almServicePath: 'localManagement', 23 | /* These fields are overridden by config/default.json */ 24 | usersServicePath: '/users', // authentication.serviceNeed. Need default '/users' for test suite. 25 | passwordField: 'password', // authentication.local.passwordField. Change using passwordChange. 26 | /* Token lengths */ 27 | longTokenLen: 15, // Len * 0.5. sendResetPwd len is 2 * longTokenLen + users.id.length + 3. 28 | shortTokenLen: 6, 29 | shortTokenDigits: true, // Should short tokens be all digits. 30 | /* Token durations */ 31 | verifyDelay: 1000 * 60 * 60 * 24 * 5, // 5 days for re/sendVerifySignup 32 | resetDelay: 1000 * 60 * 60 * 2, // 2 hours for sendResetPwd 33 | mfaDelay: 1000 * 60 * 60, // 1 hour for sendMfa 34 | /* These fields in users may be changed only with the changeUserFields command. */ 35 | userIdentityFields: [ // Fields uniquely identifying the user. 36 | 'email', 'dialablePhone' 37 | ], 38 | userExtraPasswordFields: [ // Additional password-like fields to hash, excluding passwordField. 39 | // e.g. 'pin', 'badge' 40 | ], 41 | userProtectedFields: [ // Other fields to protect from change. 42 | 'preferredComm' // e.g. 'userIssuingInvitation' 43 | ], 44 | /* Unauthenticated users may run these commands */ 45 | commandsNoAuth: [ 46 | 'resendVerifySignup', 'verifySignupLong', 'verifySignupShort', 47 | 'sendResetPwd', 'resetPwdLong', 'resetPwdShort', 48 | ], 49 | /* Fields used by the notifier. ?????????????????????????????????????????????????????????????????????????????? */ 50 | notifierEmailField: 'email', 51 | notifierDialablePhoneField: 'dialablePhone', // also needs to be coded in dialablePhoneNumber hook. 52 | /* users may only change their own info when using changeProtectedFields, passwordChange */ 53 | ownAcctOnly: true, 54 | /* Allows a replacement hashPassword() hook to be used */ 55 | bcryptCompare: bcrypt.compare, // bcryptCompare(password, hash, (err, data) => {}). 56 | /* Values set during configuration */ 57 | app: null, // Replaced by Feathers app. 58 | plugins: null, // Replaced by instantiated Plugins class during configuration. 59 | 60 | // number of old passwords to retain for each passwordField 61 | // maxPasswordsEachField 62 | // passwordHistory: array of arrays 63 | // [nameField, passwordHash, timestamp] 64 | // 65 | }; 66 | 67 | module.exports = authenticationLocalManagement; 68 | 69 | function authenticationLocalManagement(options1 = {}) { 70 | debug('service being configured.'); 71 | 72 | return function (app) { 73 | // Get default options 74 | const authConfig = app.get('authentication') || {}; 75 | 76 | let options = Object.assign({}, optionsDefault, { 77 | app, 78 | usersServicePath: authConfig.service || optionsDefault.usersServicePath, 79 | passwordField: (authConfig.local || {}).passwordField || optionsDefault.passwordField, 80 | }); 81 | 82 | // Load plugins. They may add additional default options. 83 | const pluginsContext = { options }; 84 | plugins = new Plugins(pluginsContext); 85 | plugins.register(pluginsDefault); 86 | plugins.register(pluginsExtensions); 87 | 88 | if (options1.plugins) { 89 | plugins.register(options1.plugins); 90 | } 91 | 92 | (async function() { 93 | await plugins.setup(); 94 | }()); 95 | 96 | // Get final options 97 | pluginsContext.options = options = Object.assign(options, options1, { plugins }); 98 | 99 | // Store optiona 100 | app.set('localManagement', options); 101 | 102 | // Configure custom service 103 | options.app.use(options.almServicePath, authLocalMgntMethods(options, plugins)); 104 | }; 105 | } 106 | 107 | function authLocalMgntMethods(options, plugins) { 108 | return { 109 | async create (data) { 110 | const trigger = data.action; 111 | debug(`create called. trigger=${trigger}`); 112 | 113 | if (!plugins.has(trigger)) { 114 | return Promise.reject( 115 | new errors.BadRequest(`Action '${trigger}' is invalid.`, 116 | { errors: { $className: 'badParams' } } 117 | ) 118 | ); 119 | } 120 | 121 | try { 122 | return await plugins.run(trigger, data); 123 | } catch (err) { 124 | return await plugins.run('catchError', err); 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/verify-mfa.js: -------------------------------------------------------------------------------- 1 | 2 | const makeDebug = require('debug'); 3 | const ensureObjPropsValid = require('./helpers/ensure-obj-props-valid'); 4 | const getValidatedUser = require('./helpers/get-validated-user'); 5 | const { getShortToken } = require('@feathers-plus/commons'); 6 | 7 | const debug = makeDebug('authLocalMgnt:sendResetPwd'); 8 | 9 | 10 | module.exports = verifyMfa; 11 | 12 | async function verifyMfa ( 13 | { options, plugins }, identifyUser, token, type, authUser, provider 14 | ) { 15 | debug('verifyMfa'); 16 | const usersService = options.app.service(options.usersServicePath); 17 | const usersServiceIdName = usersService.id; 18 | debug('id', usersService.id); 19 | 20 | ensureObjPropsValid(identifyUser, options.userIdentityFields); 21 | 22 | const users = await plugins.run('verifyMfa.find', { 23 | usersService, 24 | params: { query: identifyUser }, 25 | }); 26 | 27 | const user1 = getValidatedUser(users, options.skipIsVerifiedCheck ? [] : ['isVerified']); 28 | 29 | const ifValid = user1.mfaShortToken === token && user1.mfaType === type && 30 | typeof user1.mfaExpires === 'number' && user1.mfaExpires >= Date.now(); 31 | 32 | const user2 = await plugins.run('verifyMfa.patch', { 33 | usersService, 34 | id: user1[usersServiceIdName], 35 | data: { 36 | mfaExpires: null, 37 | mfaShortToken: null, 38 | mfaType: null, 39 | }, 40 | }); 41 | 42 | if (!ifValid) { 43 | throw new errors.BadRequest('Multi factor token bad.', 44 | { errors: { $className: 'mfaBad' } }); 45 | } 46 | 47 | return await plugins.run('sanitizeUserForClient', user2); 48 | } 49 | -------------------------------------------------------------------------------- /src/verify-signup.js: -------------------------------------------------------------------------------- 1 | 2 | const errors = require('@feathersjs/errors'); 3 | const makeDebug = require('debug'); 4 | const ensureObjPropsValid = require('./helpers/ensure-obj-props-valid'); 5 | const ensureValuesAreStrings = require('./helpers/ensure-values-are-strings'); 6 | const getValidatedUser = require('./helpers/get-validated-user'); 7 | 8 | const debug = makeDebug('authLocalMgnt:verifySignup'); 9 | 10 | module.exports = { 11 | verifySignupWithLongToken, 12 | verifySignupWithShortToken, 13 | }; 14 | 15 | async function verifySignupWithLongToken( 16 | pluginsContext, verifyToken, newPassword, notifierOptions, authUser, provider 17 | ) { 18 | ensureValuesAreStrings(verifyToken); 19 | 20 | return await verifySignup( 21 | pluginsContext, { verifyToken }, { verifyToken }, newPassword, notifierOptions, authUser, provider 22 | ); 23 | } 24 | 25 | async function verifySignupWithShortToken( 26 | pluginsContext, verifyShortToken, identifyUser, notifierOptions, authUser, provider 27 | ) { 28 | ensureValuesAreStrings(verifyShortToken); 29 | ensureObjPropsValid(identifyUser, pluginsContext.options.userIdentityFields); 30 | 31 | return await verifySignup(pluginsContext, identifyUser, { verifyShortToken }, 32 | null, notifierOptions, authUser, provider); 33 | } 34 | 35 | async function verifySignup ( 36 | { options, plugins }, query, tokens, newPassword, notifierOptions, authUser, provider 37 | ) { 38 | debug('verifySignup', query, tokens); 39 | const usersService = options.app.service(options.usersServicePath); 40 | const usersServiceIdName = usersService.id; 41 | 42 | const users = await plugins.run('verifySignup.find', { 43 | usersService, 44 | params: { query }, 45 | }); 46 | 47 | const user1 = getValidatedUser(users, ['isNotVerifiedOrHasVerifyChanges', 'verifyNotExpired']); 48 | 49 | if (!Object.keys(tokens).every(key => tokens[key] === user1[key])) { 50 | await eraseVerifyProps(user1, user1.isVerified, user1.isInvitation); 51 | 52 | throw new errors.BadRequest('Invalid token. Get for a new one. (authLocalMgnt)', 53 | { errors: { $className: 'badParam' } } 54 | ); 55 | } 56 | 57 | const user2 = await eraseVerifyProps( 58 | user1, user1.verifyExpires > Date.now(), user1.isInvitation, user1.verifyChanges 59 | ); 60 | 61 | const user3 = await plugins.run('sanitizeUserForNotifier', user2); 62 | 63 | const user4 = await plugins.run('notifier', { 64 | type: 'verifySignup', 65 | sanitizedUser: user3, 66 | notifierOptions, 67 | }); 68 | 69 | return await plugins.run('sanitizeUserForClient', user4); 70 | 71 | async function eraseVerifyProps (user, isVerified, isInvitation, verifyChanges = {}) { 72 | const patchToUser = Object.assign({}, verifyChanges, { 73 | isInvitation: isVerified ? false : isInvitation, 74 | isVerified, 75 | verifyToken: null, 76 | verifyShortToken: null, 77 | verifyExpires: null, 78 | verifyChanges: {} 79 | }); 80 | 81 | // Change password if processing an invited user 82 | if (isInvitation && newPassword) { 83 | patchToUser[options.passwordField] = newPassword; 84 | } 85 | 86 | return await plugins.run('verifySignup.patch', { 87 | usersService, 88 | id: user1[usersServiceIdName], 89 | data: patchToUser, 90 | }); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/xx.js: -------------------------------------------------------------------------------- 1 | 2 | const errors = require('@feathersjs/errors'); 3 | 4 | // history - a hook? 5 | // [ [passwordFieldName, timestamp, hashedPassword] ] ordered newest change to oldest 6 | function addPasswordToHistory(history = [], passwordField, timeStamp, hashedPassword, maxCount) { 7 | let count = 0; 8 | let lastEntry; 9 | 10 | history.forEach((entry, i) => { 11 | if (entry[0] === passwordField) { 12 | lastEntry = i; 13 | count =+ 1; 14 | 15 | if (entry[2] === hashedPassword) { 16 | throw new errors.BadRequest('Security code has been previously used. Use a new one.'); 17 | } 18 | } 19 | }); 20 | 21 | if (count > maxCount) { 22 | history.splice(lastEntry, 1); 23 | } 24 | 25 | history.unshift([passwordField, timeStamp, hashedPassword]); 26 | return history; 27 | } -------------------------------------------------------------------------------- /test-data/README.md: -------------------------------------------------------------------------------- 1 | ### Coerce fields for Sequelize and Knex 2 | 3 | The second most common issue raised with f-a-m was how to use it with Sequelize/Knex. 4 | f-a-m expected the user-entity model to be in a JS-friendly format, 5 | and the dev was expected to use hooks to reformat that to the Sequelize/Knex model. 6 | 7 | The sequelizeConvertAlm hook has been introduced as a convenience. 8 | It converts the isVerified, verifiedExpires, verifyChanges, resetExpires fields created by this repo. 9 | Its used on the user-entity as follows: 10 | ```js 11 | const { sequelizeConvertAlm } = require('authentication-local-management').hooks; 12 | 13 | module.exports = { 14 | before: { 15 | all: sequelizeConvertAlm(), 16 | }, 17 | after: { 18 | all: sequelizeConvertAlm(), 19 | }, 20 | }; 21 | ``` 22 | 23 | By default it converts 24 | ```txt 25 | Field name Internal Sequelize & Knex 26 | ----------- -------- ---------------- 27 | isInvitation Boolean INTEGER 28 | isVerified Boolean INTEGER 29 | verifyExpires Date.now() DATE (*) 30 | verifyChanges Object STRING, JSON.stringify 31 | resetExpires Date.now() DATE (*) 32 | mfaExpires Date.now() DATE (*) 33 | passwordHistory Array STRING, JSON.stringify 34 | ``` 35 | 36 | (*) The hook passes the 2 datetimes to the adapter as Date.now() when used as a before hook. 37 | The adapter converts them to the DB format. 38 | The hook itself converts the datetimes back to Date.now() when run as an after hook. 39 | 40 | There are options to 41 | - Customize the datetime conversion, 42 | - Customize the convertNonSqlType conversion, 43 | - Skip converting any of these fields. 44 | 45 | The test/sequelize.test.js module uses the feathers-sequelize adapter with an sqlite3 table created with 46 | ```txt 47 | authentication-local-management$ touch ./test-data/users.sqlite 48 | authentication-local-management$ sqlite3 ./test-data/users.sqlite 49 | SQLite version 3.19.3 2017-06-08 14:26:16 50 | Enter ".help" for usage hints. 51 | sqlite> .schema 52 | sqlite> CREATE TABLE 'Users' ('id' INTEGER PRIMARY KEY AUTOINCREMENT, 53 | 'email' VARCHAR( 60), 54 | 'password' VARCHAR( 60), 55 | 'phone' VARCHAR( 30), 56 | 'dialablePhone' VARCHAR( 15), 57 | 'preferredComm' VARCHAR( 5), 58 | 'isInvitation' INTEGER, 59 | 'isVerified' INTEGER, 60 | 'verifyExpires' DATETIME, 61 | 'verifyToken' VARCHAR( 60), 62 | 'verifyShortToken' VARCHAR( 8), 63 | 'verifyChanges' VARCHAR(255), 64 | 'resetExpires' INTEGER, 65 | 'resetToken' VARCHAR( 60), 66 | 'resetShortToken' VARCHAR( 8), 67 | 'mfaExpires' INTEGER, 68 | 'mfaShortToken' VARCHAR( 8), 69 | 'mfaType' VARCHAR( 5), 70 | 'passwordHistory' VARCHAR(512), 71 | 'createdAt' DATETIME, 72 | 'updatedAt' DATETIME 73 | ); 74 | sqlite> .quit 75 | ``` 76 | 77 | Module users.sequelize.js much be customized to reflect the changes sequelizeConvertAlm makes: 78 | 79 | ```js 80 | sequelizeClient.define('users', 81 | { 82 | email: { 83 | type: DataTypes.STRING, 84 | allowNull: false 85 | }, 86 | password: { 87 | type: DataTypes.STRING, 88 | allowNull: false 89 | }, 90 | phone: { 91 | type: DataTypes.STRING, 92 | allowNull: false 93 | }, 94 | dialablePhone: { 95 | type: DataTypes.STRING, 96 | allowNull: false 97 | }, 98 | preferredComm: { 99 | type: DataTypes.STRING, 100 | allowNull: false 101 | }, 102 | isInvitation: { 103 | type: DataTypes.INTEGER, 104 | allowNull: false 105 | }, 106 | isVerified: { 107 | type: DataTypes.INTEGER, 108 | allowNull: false 109 | }, 110 | verifyExpires: { 111 | type: DataTypes.DATE 112 | }, 113 | verifyToken: { 114 | type: DataTypes.STRING, 115 | allowNull: false 116 | }, 117 | verifyShortToken: { 118 | type: DataTypes.STRING, 119 | allowNull: false 120 | }, 121 | verifyChanges: { 122 | type: DataTypes.STRING 123 | }, 124 | resetExpires: { 125 | type: DataTypes.DATE 126 | }, 127 | resetToken: { 128 | type: DataTypes.STRING, 129 | allowNull: false 130 | }, 131 | resetShortToken: { 132 | type: DataTypes.STRING, 133 | allowNull: false 134 | }, 135 | mfaExpires: { 136 | type: DataTypes.DATE 137 | }, 138 | mfaShortToken: { 139 | type: DataTypes.STRING, 140 | allowNull: false 141 | }, 142 | mfaType: { 143 | type: DataTypes.STRING, 144 | allowNull: false 145 | }, 146 | passwordHistory: { 147 | type: DataTypes.STRING 148 | }, 149 | }, 150 | { 151 | hooks: { 152 | beforeCount(options) { 153 | options.raw = true; 154 | }, 155 | }, 156 | }, 157 | ); 158 | ``` -------------------------------------------------------------------------------- /test-data/users.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feathers-plus/authentication-local-management/b3ec1be56ffc95cb1986bbe448a12e20c30693be/test-data/users.sqlite -------------------------------------------------------------------------------- /test/add-verification.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('@feathersjs/feathers'); 4 | const authLocalMgnt = require('../src/index'); 5 | const { addVerification } = require('../src/index').hooks; 6 | const { defaultVerifyDelay, timeoutEachTest, maxTimeAllTests } = require('./helpers/config'); 7 | 8 | describe('add-verification.test.js', function () { 9 | this.timeout(timeoutEachTest); 10 | 11 | let app; 12 | let context; 13 | 14 | beforeEach(() => { 15 | app = feathers(); 16 | 17 | context = { 18 | type: 'before', 19 | method: 'create', 20 | data: { email: 'a@a.com', password: '0000000000' }, 21 | app, 22 | params: { 23 | user: { email: 'b@b.com' } 24 | } 25 | }; 26 | 27 | contextArray = { 28 | type: 'before', 29 | method: 'create', 30 | data: [{ 31 | email: 'a@a.com', password: '0000000000' 32 | }, { 33 | email: 'b@b.com', password: '1111111111' 34 | }], 35 | app, 36 | params: { 37 | user: { email: 'b@b.com' } 38 | } 39 | }; 40 | }); 41 | 42 | describe('basics', () => { 43 | it('works with no options for full user', async () => { 44 | app.configure(authLocalMgnt()); 45 | app.setup(); 46 | 47 | try { 48 | const ctx = await addVerification()(context); 49 | const user = ctx.data; 50 | 51 | assert.strictEqual(user.isInvitation, false, 'isInvitation not false'); 52 | assert.strictEqual(user.isVerified, false, 'isVerified not false'); 53 | assert.isString(user.verifyToken, 'verifyToken not String'); 54 | assert.equal(user.verifyToken.length, 30, 'verify token wrong length'); 55 | assert.equal(user.verifyShortToken.length, 6, 'verify short token wrong length'); 56 | assert.match(user.verifyShortToken, /^[0-9]+$/); 57 | aboutEqualDateTime(user.verifyExpires, makeDateTime()); 58 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 59 | } catch (err) { 60 | console.log(err); 61 | assert(false, 'unexpected error'); 62 | } 63 | }); 64 | 65 | it('works with no options for invitation', async () => { 66 | app.configure(authLocalMgnt()); 67 | app.setup(); 68 | 69 | try { 70 | context.data.isInvitation = true; 71 | 72 | const ctx = await addVerification()(context); 73 | const user = ctx.data; 74 | 75 | assert.strictEqual(user.isInvitation, true, 'isInvitation not true'); 76 | assert.strictEqual(user.isVerified, false, 'isVerified not false'); 77 | assert.isString(user.verifyToken, 'verifyToken not String'); 78 | assert.equal(user.verifyToken.length, 30, 'verify token wrong length'); 79 | assert.equal(user.verifyShortToken.length, 6, 'verify short token wrong length'); 80 | assert.match(user.verifyShortToken, /^[0-9]+$/); 81 | aboutEqualDateTime(user.verifyExpires, makeDateTime()); 82 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 83 | } catch (err) { 84 | console.log(err); 85 | assert(false, 'unexpected error'); 86 | } 87 | }); 88 | 89 | it('works with an array', async () => { 90 | app.configure(authLocalMgnt()); 91 | app.setup(); 92 | 93 | try { 94 | const ctx = await addVerification()(contextArray); 95 | 96 | ctx.data.forEach(user => { 97 | assert.strictEqual(user.isVerified, false, 'isVerified not false'); 98 | assert.isString(user.verifyToken, 'verifyToken not String'); 99 | assert.equal(user.verifyToken.length, 30, 'verify token wrong length'); 100 | assert.equal(user.verifyShortToken.length, 6, 'verify short token wrong length'); 101 | assert.match(user.verifyShortToken, /^[0-9]+$/); 102 | aboutEqualDateTime(user.verifyExpires, makeDateTime()); 103 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 104 | }); 105 | } catch (err) { 106 | console.log(err); 107 | assert(false, 'unexpected error'); 108 | } 109 | }); 110 | 111 | it('verifyDelay option works', async () => { 112 | const options = { verifyDelay: 1000 * 60 * 60 * 24 * 5 }; // 5 days 113 | app.configure(authLocalMgnt(options)); 114 | app.setup(); 115 | 116 | context = { 117 | type: 'before', 118 | method: 'create', 119 | data: { email: 'a@a.com', password: '0000000000' }, 120 | app, 121 | }; 122 | 123 | try { 124 | const ctx = await addVerification()(context); 125 | const user = ctx.data; 126 | 127 | assert.strictEqual(user.isVerified, false, 'isVerified not false'); 128 | assert.isString(user.verifyToken, 'verifyToken not String'); 129 | assert.equal(user.verifyToken.length, 30, 'verify token wrong length'); 130 | assert.equal(user.verifyShortToken.length, 6, 'verify short token wrong length'); 131 | assert.match(user.verifyShortToken, /^[0-9]+$/); 132 | aboutEqualDateTime(user.verifyExpires, makeDateTime(options)); 133 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 134 | } catch (err) { 135 | console.log(err); 136 | assert(false, 'unexpected error'); 137 | } 138 | }); 139 | }); 140 | 141 | describe('long token', () => { 142 | it('length option works', async () => { 143 | const options = { longTokenLen: 10 }; 144 | app.configure(authLocalMgnt(options)); 145 | app.setup(); 146 | 147 | try { 148 | const ctx = await addVerification()(context); 149 | const user = ctx.data; 150 | 151 | assert.strictEqual(user.isVerified, false, 'isVerified not false'); 152 | assert.isString(user.verifyToken, 'verifyToken not String'); 153 | assert.equal(user.verifyToken.length, (options.len || options.longTokenLen) * 2, 'verify token wrong length'); 154 | assert.equal(user.verifyShortToken.length, 6, 'verify short token wrong length'); 155 | assert.match(user.verifyShortToken, /^[0-9]+$/); // small chance of false negative 156 | aboutEqualDateTime(user.verifyExpires, makeDateTime(options)); 157 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 158 | } catch (err) { 159 | console.log(err); 160 | assert(false, 'unexpected error'); 161 | } 162 | }); 163 | }); 164 | 165 | describe('shortToken', () => { 166 | it('produces digit short token', async () => { 167 | const options = { shortTokenDigits: true }; 168 | app.configure(authLocalMgnt(options)); 169 | app.setup(); 170 | 171 | try { 172 | const ctx = await addVerification()(context); 173 | const user = ctx.data; 174 | 175 | assert.strictEqual(user.isVerified, false, 'isVerified not false'); 176 | assert.equal(user.verifyShortToken.length, 6, 'verify short token wrong length'); 177 | assert.match(user.verifyShortToken, /^[0-9]+$/); 178 | aboutEqualDateTime(user.verifyExpires, makeDateTime(options)); 179 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 180 | } catch (err) { 181 | console.log(err); 182 | assert(false, 'unexpected error'); 183 | } 184 | }); 185 | 186 | it('produces alpha short token', async () => { 187 | const options = { shortTokenDigits: false }; 188 | app.configure(authLocalMgnt(options)); 189 | app.setup(); 190 | 191 | try { 192 | const ctx = await addVerification()(context); 193 | const user = ctx.data; 194 | 195 | assert.strictEqual(user.isVerified, false, 'isVerified not false'); 196 | assert.equal(user.verifyShortToken.length, 6, 'verify short token wrong length'); 197 | assert.notMatch(user.verifyShortToken, /^[0-9]+$/); 198 | aboutEqualDateTime(user.verifyExpires, makeDateTime(options)); 199 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 200 | } catch (err) { 201 | console.log(err); 202 | assert(false, 'unexpected error'); 203 | } 204 | }); 205 | 206 | it('length option works with digits', async () => { 207 | const options = { shortTokenLen: 7 }; 208 | app.configure(authLocalMgnt(options)); 209 | app.setup(); 210 | 211 | try { 212 | const ctx = await addVerification()(context); 213 | const user = ctx.data; 214 | 215 | assert.strictEqual(user.isVerified, false, 'isVerified not false'); 216 | assert.equal(user.verifyShortToken.length, 7, 'verify short token wrong length'); 217 | assert.match(user.verifyShortToken, /^[0-9]+$/); 218 | aboutEqualDateTime(user.verifyExpires, makeDateTime(options)); 219 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 220 | } catch (err) { 221 | console.log(err); 222 | assert(false, 'unexpected error'); 223 | } 224 | }); 225 | 226 | it('length option works with alpha', async () => { 227 | const options = { shortTokenLen: 9, shortTokenDigits: false }; 228 | app.configure(authLocalMgnt(options)); 229 | app.setup(); 230 | 231 | try { 232 | const ctx = await addVerification()(context); 233 | const user = ctx.data; 234 | 235 | assert.strictEqual(user.isVerified, false, 'isVerified not false'); 236 | assert.equal(user.verifyShortToken.length, 9, 'verify short token wrong length'); 237 | assert.notMatch(user.verifyShortToken, /^[0-9]+$/); 238 | aboutEqualDateTime(user.verifyExpires, makeDateTime(options)); 239 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 240 | } catch (err) { 241 | console.log(err); 242 | assert(false, 'unexpected error'); 243 | } 244 | }); 245 | }); 246 | 247 | describe('patch & update', () => { 248 | it('works with patch', async () => { 249 | app.configure(authLocalMgnt()); 250 | app.setup(); 251 | context.method = 'patch'; 252 | 253 | try { 254 | const ctx = await addVerification()(context); 255 | const user = ctx.data; 256 | 257 | assert.strictEqual(user.isVerified, false, 'isVerified not false'); 258 | assert.isString(user.verifyToken, 'verifyToken not String'); 259 | assert.equal(user.verifyToken.length, 30, 'verify token wrong length'); 260 | assert.equal(user.verifyShortToken.length, 6, 'verify short token wrong length'); 261 | assert.match(user.verifyShortToken, /^[0-9]+$/); 262 | aboutEqualDateTime(user.verifyExpires, makeDateTime()); 263 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 264 | } catch (err) { 265 | console.log(err); 266 | assert(false, 'unexpected error'); 267 | } 268 | }); 269 | 270 | it('works with update', async () => { 271 | app.configure(authLocalMgnt()); 272 | app.setup(); 273 | context.method = 'update'; 274 | 275 | try { 276 | const ctx = await addVerification()(context); 277 | const user = ctx.data; 278 | 279 | assert.strictEqual(user.isVerified, false, 'isVerified not false'); 280 | assert.isString(user.verifyToken, 'verifyToken not String'); 281 | assert.equal(user.verifyToken.length, 30, 'verify token wrong length'); 282 | assert.equal(user.verifyShortToken.length, 6, 'verify short token wrong length'); 283 | assert.match(user.verifyShortToken, /^[0-9]+$/); 284 | aboutEqualDateTime(user.verifyExpires, makeDateTime()); 285 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 286 | } catch (err) { 287 | console.log(err); 288 | assert(false, 'unexpected error'); 289 | } 290 | }); 291 | 292 | it('does not modify context if email not updated', async () => { 293 | app.configure(authLocalMgnt()); 294 | app.setup(); 295 | context.method = 'update'; 296 | context.params.user.email = 'a@a.com'; 297 | 298 | try { 299 | const ctx = await addVerification()(context); 300 | const user = ctx.data; 301 | 302 | assert.deepEqual(user, { email: 'a@a.com', password: '0000000000' }, 'ctx.data modified'); 303 | } catch (err) { 304 | console.log(err); 305 | assert(false, 'unexpected error'); 306 | } 307 | }); 308 | }); 309 | }); 310 | 311 | function makeDateTime(options1 = {}) { 312 | return Date.now() + (options1.verifyDelay || defaultVerifyDelay); 313 | } 314 | 315 | function aboutEqualDateTime(time1, time2, msg, delta = 500) { 316 | const diff = Math.abs(time1 - time2); 317 | assert.isAtMost(diff, delta, msg || `times differ by ${diff}ms`); 318 | } 319 | -------------------------------------------------------------------------------- /test/change-protected-fields.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('@feathersjs/feathers'); 4 | const feathersMemory = require('feathers-memory'); 5 | const authLocalMgnt = require('../src/index'); 6 | const { hashPasswordFake: { hashPassword, bcryptCompare } } = require('@feathers-plus/commons'); 7 | const { timeoutEachTest } = require('./helpers/config'); 8 | 9 | let stack; 10 | 11 | const makeUsersService = (options) => function (app) { 12 | app.use('/users', feathersMemory(options)); 13 | 14 | app.service('users').hooks({ 15 | before: { 16 | create: hashPassword(), 17 | patch: hashPassword(), 18 | } 19 | }); 20 | }; 21 | 22 | // users DB 23 | const users_Id = [ 24 | { _id: 'a', email: 'a', plainPassword: 'aa', password: 'aa', isVerified: false }, 25 | { _id: 'b', email: 'b', plainPassword: 'bb', password: 'bb', isVerified: true }, 26 | ]; 27 | 28 | const usersId = [ 29 | { id: 'a', email: 'a', plainPassword: 'aa', password: 'aa', isVerified: false }, 30 | { id: 'b', email: 'b', plainPassword: 'bb', password: 'bb', isVerified: true }, 31 | ]; 32 | 33 | // Tests 34 | ['_id', 'id'].forEach(idType => { 35 | ['paginated', 'non-paginated'].forEach(pagination => { 36 | describe(`change-protected-fields.test.js ${pagination} ${idType}`, function () { 37 | this.timeout(timeoutEachTest); 38 | 39 | describe('standard', () => { 40 | let app; 41 | let usersService; 42 | let authLocalMgntService; 43 | let db; 44 | let result; 45 | 46 | beforeEach(async () => { 47 | app = feathers(); 48 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 49 | app.configure(authLocalMgnt({ 50 | bcryptCompare, 51 | })); 52 | app.setup(); 53 | authLocalMgntService = app.service('localManagement'); 54 | 55 | usersService = app.service('users'); 56 | await usersService.remove(null); 57 | db = clone(idType === '_id' ? users_Id : usersId); 58 | await usersService.create(db); 59 | }); 60 | 61 | it('updates verified user', async () => { 62 | try { 63 | const userRec = clone(users_Id[1]); 64 | 65 | result = await authLocalMgntService.create({ 66 | action: 'changeProtectedFields', 67 | value: { 68 | user: { email: userRec.email }, 69 | password: userRec.plainPassword, 70 | changes: { email: 'b@b' } 71 | }, 72 | }); 73 | const user = await usersService.get(result.id || result._id); 74 | 75 | assert.strictEqual(result.isVerified, true, 'isVerified not true'); 76 | assert.equal(user.email, userRec.email); 77 | } catch (err) { 78 | console.log(err); 79 | assert.strictEqual(err, null, 'err code set'); 80 | } 81 | }); 82 | 83 | it('updates unverified user', async () => { 84 | try { 85 | const userRec = clone(users_Id[0]); 86 | 87 | result = await authLocalMgntService.create({ 88 | action: 'changeProtectedFields', 89 | value: { 90 | user: { email: userRec.email }, 91 | password: userRec.plainPassword, 92 | changes: { email: 'a@a' } 93 | }, 94 | }); 95 | const user = await usersService.get(result.id || result._id); 96 | 97 | assert.strictEqual(result.isVerified, false, 'isVerified not false'); 98 | assert.equal(user.email, userRec.email); 99 | } catch (err) { 100 | console.log(err); 101 | assert.strictEqual(err, null, 'err code set'); 102 | } 103 | }); 104 | 105 | it('error on wrong password', async () => { 106 | try { 107 | const userRec = clone(users_Id[0]); 108 | 109 | result = await authLocalMgntService.create({ 110 | action: 'changeProtectedFields', 111 | value: { 112 | user: { email: userRec.email }, 113 | password: 'ghghghg', 114 | changes: { email: 'a@a' } 115 | }, 116 | }); 117 | 118 | assert(false, 'unexpected succeeded.'); 119 | } catch (err) { 120 | assert.isString(err.message); 121 | assert.isNotFalse(err.message); 122 | } 123 | }); 124 | }); 125 | 126 | describe('with notification', () => { 127 | let app; 128 | let usersService; 129 | let authLocalMgntService; 130 | let db; 131 | let result; 132 | 133 | beforeEach(async () => { 134 | stack = []; 135 | 136 | app = feathers(); 137 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 138 | app.configure(authLocalMgnt({ 139 | bcryptCompare, 140 | plugins: [{ 141 | trigger: 'notifier', 142 | position: 'before', 143 | run: async (accumulator, { type, sanitizedUser, notifierOptions }, { options }, pluginContext) => { 144 | stack.push({ args: clone([type, sanitizedUser, notifierOptions]), result: sanitizedUser }); 145 | }, 146 | }], 147 | })); 148 | 149 | app.setup(); 150 | authLocalMgntService = app.service('localManagement'); 151 | 152 | usersService = app.service('users'); 153 | await usersService.remove(null); 154 | db = clone(idType === '_id' ? users_Id : usersId); 155 | await usersService.create(db); 156 | }); 157 | 158 | it('updates verified user', async () => { 159 | try { 160 | const userRec = clone(users_Id[1]); 161 | 162 | result = await authLocalMgntService.create({ 163 | action: 'changeProtectedFields', 164 | value: { 165 | user: { email: userRec.email }, 166 | password: userRec.plainPassword, 167 | changes: { email: 'b@b' } 168 | }, 169 | }); 170 | const user = await usersService.get(result.id || result._id); 171 | 172 | assert.strictEqual(result.isVerified, true, 'isVerified not true'); 173 | 174 | assert.equal(user.email, user.email); 175 | assert.deepEqual(user.verifyChanges, { email: 'b@b' }); 176 | 177 | assert.deepEqual( 178 | stack[0].args, 179 | [ 180 | 'changeProtectedFields', 181 | Object.assign({}, 182 | sanitizeUserForEmail(result), 183 | extractProps( 184 | user, 'verifyExpires', 'verifyToken', 'verifyShortToken', 'verifyChanges' 185 | ) 186 | ), 187 | null 188 | ], 189 | ); 190 | 191 | assert.strictEqual(user.isVerified, true, 'isVerified not false'); 192 | assert.isString(user.verifyToken, 'verifyToken not String'); 193 | assert.isAtLeast(user.verifyToken.length, 30, 'verify token wrong length'); 194 | assert.equal(user.verifyShortToken.length, 6, 'verify short token wrong length'); 195 | assert.match(user.verifyShortToken, /^[0-9]+$/); 196 | } catch (err) { 197 | console.log(err); 198 | assert.strictEqual(err, null, 'err code set'); 199 | } 200 | }); 201 | }); 202 | }); 203 | }); 204 | }); 205 | 206 | // Helpers 207 | 208 | function sanitizeUserForEmail(user) { 209 | const user1 = clone(user); 210 | delete user1.password; 211 | return user1; 212 | } 213 | 214 | function extractProps(obj, ...rest) { 215 | const res = {}; 216 | rest.forEach(key => { 217 | res[key] = obj[key]; 218 | }); 219 | return res; 220 | } 221 | 222 | function clone(obj) { 223 | return JSON.parse(JSON.stringify(obj)); 224 | } 225 | -------------------------------------------------------------------------------- /test/check-unique.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('@feathersjs/feathers'); 4 | const feathersMemory = require('feathers-memory'); 5 | const authLocalMgnt = require('../src/index'); 6 | const { timeoutEachTest } = require('./helpers/config'); 7 | 8 | const makeUsersService = (options) => function (app) { 9 | app.use('/users', feathersMemory(options)); 10 | }; 11 | 12 | const usersId = [ 13 | { id: 'a', email: 'a', username: 'john a' }, 14 | { id: 'b', email: 'b', username: 'john b' }, 15 | { id: 'c', email: 'c', username: 'john b' }, 16 | ]; 17 | 18 | const users_Id = [ 19 | { _id: 'a', email: 'a', username: 'john a' }, 20 | { _id: 'b', email: 'b', username: 'john b' }, 21 | { _id: 'c', email: 'c', username: 'john b' }, 22 | ]; 23 | 24 | ['_id', 'id'].forEach(idType => { 25 | ['paginated', 'non-paginated'].forEach(pagination => { 26 | describe(`check-unique.test.js ${pagination} ${idType}`, function () { 27 | this.timeout(timeoutEachTest); 28 | 29 | describe('standard', () => { 30 | let app; 31 | let usersService; 32 | let authLocalMgntService; 33 | 34 | beforeEach(async () => { 35 | app = feathers(); 36 | app.configure(authLocalMgnt()); 37 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 38 | app.setup(); 39 | authLocalMgntService = app.service('localManagement'); 40 | 41 | usersService = app.service('users'); 42 | await usersService.remove(null); 43 | await usersService.create(clone(idType === '_id' ? users_Id : usersId)); 44 | }); 45 | 46 | it('returns a promise', async () => { 47 | const res = authLocalMgntService.create({ 48 | action: 'checkUnique', 49 | value: { username: 'john a' }, 50 | }) 51 | .then(() => {}) 52 | .catch(() => {}); 53 | 54 | assert.isOk(res, 'no promise returned'); 55 | assert.isFunction(res.then, 'not a function'); 56 | }); 57 | 58 | it('handles empty query', async () => { 59 | try { 60 | await authLocalMgntService.create({ 61 | action: 'checkUnique', 62 | value: {}, 63 | }); 64 | } catch (err) { 65 | console.log(err); 66 | assert(false, `unexpectedly failed: ${err.message}`); 67 | } 68 | }); 69 | 70 | it('handles empty query returning nothing', async () => { 71 | try { 72 | await authLocalMgntService.create({ 73 | action: 'checkUnique', 74 | value: { username: 'hjhjhj' }, 75 | }); 76 | } catch (err) { 77 | console.log(err); 78 | assert(false, `unexpectedly failed: ${err.message}`); 79 | } 80 | }); 81 | 82 | it('finds single query on single item', async () => { 83 | try { 84 | await authLocalMgntService.create({ 85 | action: 'checkUnique', 86 | value: { username: 'john a' }, 87 | }); 88 | 89 | assert.fail(true, false, 'test unexpectedly succeeded'); 90 | } catch (err) { 91 | assert.equal(err.message, 'Values already taken.'); 92 | assert.equal(err.errors.username, 'Already taken.'); 93 | } 94 | }); 95 | 96 | it('handles noErrMsg option', async () => { 97 | try { 98 | await authLocalMgntService.create({ 99 | action: 'checkUnique', 100 | value: { username: 'john a' }, 101 | meta: { noErrMsg: true }, 102 | }); 103 | 104 | assert.fail(true, false, 'test unexpectedly succeeded'); 105 | } catch (err) { 106 | assert.equal(err.message, 'Error'); // feathers default for no error message 107 | assert.equal(err.errors.username, 'Already taken.'); 108 | } 109 | }); 110 | 111 | it('finds single query on multiple items', async () => { 112 | try { 113 | await authLocalMgntService.create({ 114 | action: 'checkUnique', 115 | value: { username: 'john b' }, 116 | }); 117 | 118 | assert.fail(true, false, 'test unexpectedly succeeded'); 119 | } catch (err) { 120 | assert.equal(err.message, 'Values already taken.'); 121 | assert.equal(err.errors.username, 'Already taken.'); 122 | } 123 | }); 124 | 125 | it('finds multiple queries on same item', async () => { 126 | try { 127 | await authLocalMgntService.create({ 128 | action: 'checkUnique', 129 | value: { username: 'john a', email: 'a' }, 130 | }); 131 | 132 | assert.fail(true, false, 'test unexpectedly succeeded'); 133 | } catch (err) { 134 | assert.equal(err.message, 'Values already taken.'); 135 | assert.equal(err.errors.username, 'Already taken.'); 136 | } 137 | }); 138 | 139 | it('finds multiple queries on different item', async () => { 140 | try { 141 | await authLocalMgntService.create({ 142 | action: 'checkUnique', 143 | value: { username: 'john a', email: 'b' }, 144 | }); 145 | 146 | assert.fail(true, false, 'test unexpectedly succeeded'); 147 | } catch (err) { 148 | assert.equal(err.message, 'Values already taken.'); 149 | assert.equal(err.errors.username, 'Already taken.'); 150 | } 151 | }); 152 | 153 | it('ignores null & undefined queries', async () => { 154 | try { 155 | await authLocalMgntService.create({ 156 | action: 'checkUnique', 157 | value: { username: undefined, email: null }, 158 | }); 159 | } catch (err) { 160 | console.log(err); 161 | assert.fail(true, false, 'test unexpectedly failed'); 162 | } 163 | }); 164 | 165 | it('ignores current user on single item', async () => { 166 | try { 167 | await authLocalMgntService.create({ 168 | action: 'checkUnique', 169 | value: { username: 'john a' }, 170 | ownId: 'a', 171 | }); 172 | } catch (err) { 173 | console.log(err); 174 | assert.fail(true, false, 'test unexpectedly failed'); 175 | } 176 | }); 177 | 178 | it('cannot ignore current user on multiple items', async () => { 179 | try { 180 | await authLocalMgntService.create({ 181 | action: 'checkUnique', 182 | value: { username: 'john b' }, 183 | ownId: 'b', 184 | }); 185 | 186 | assert.fail(true, false, 'test unexpectedly succeeded'); 187 | } catch (err) { 188 | assert.equal(err.message, 'Values already taken.'); 189 | assert.equal(err.errors.username, 'Already taken.'); 190 | } 191 | }); 192 | }); 193 | }); 194 | }); 195 | }); 196 | 197 | // Helpers 198 | 199 | function clone(obj) { 200 | return JSON.parse(JSON.stringify(obj)); 201 | } 202 | -------------------------------------------------------------------------------- /test/delete-expired-users.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('@feathersjs/feathers'); 4 | const feathersMemory = require('feathers-memory'); 5 | const authLocalMgnt = require('../src/index'); 6 | const { timeoutEachTest, maxTimeAllTests } = require('./helpers/config'); 7 | 8 | const now = Date.now(); 9 | 10 | const makeUsersService = (options) => function (app) { 11 | app.use('/users', feathersMemory(options)); 12 | }; 13 | 14 | const usersId = [ 15 | { id: 'a', email: 'a', isInvitation: false, isVerified: false, verifyExpires: now - 100 }, 16 | { id: 'b', email: 'b', isInvitation: false, isVerified: false, verifyExpires: now }, 17 | { id: 'c', email: 'b', isInvitation: false, isVerified: false, verifyExpires: now + maxTimeAllTests }, 18 | { id: 'd', email: 'c', isInvitation: false, isVerified: true, verifyExpires: null }, 19 | { id: 'e', email: 'd', isInvitation: true, isVerified: false, verifyExpires: now - 100 }, 20 | { id: 'f', email: 'e', isInvitation: true, isVerified: false, verifyExpires: now }, 21 | { id: 'g', email: 'e', isInvitation: true, isVerified: false, verifyExpires: now + maxTimeAllTests }, 22 | { id: 'h', email: 'f', isInvitation: true, isVerified: true, verifyExpires: null }, 23 | ]; 24 | 25 | const users_Id = [ 26 | { _id: 'a', email: 'a', isInvitation: false, isVerified: false, verifyExpires: now - 100 }, 27 | { _id: 'b', email: 'b', isInvitation: false, isVerified: false, verifyExpires: now }, 28 | { _id: 'c', email: 'b', isInvitation: false, isVerified: false, verifyExpires: now + maxTimeAllTests }, 29 | { _id: 'd', email: 'c', isInvitation: false, isVerified: true, verifyExpires: null }, 30 | { _id: 'e', email: 'd', isInvitation: true, isVerified: false, verifyExpires: now - 100 }, 31 | { _id: 'f', email: 'e', isInvitation: true, isVerified: false, verifyExpires: now }, 32 | { _id: 'g', email: 'e', isInvitation: true, isVerified: false, verifyExpires: now + maxTimeAllTests }, 33 | { _id: 'h', email: 'f', isInvitation: true, isVerified: true, verifyExpires: null }, 34 | ]; 35 | 36 | ['_id', 'id'].forEach(idType => { 37 | ['paginated', 'non-paginated'].forEach(pagination => { 38 | describe(`delete-expired-users.test.js ${pagination} ${idType}`, function () { 39 | this.timeout(timeoutEachTest); 40 | 41 | describe('basic', () => { 42 | let app; 43 | let usersService; 44 | let authLocalMgntService; 45 | let db; 46 | 47 | beforeEach(async () => { 48 | app = feathers(); 49 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 50 | app.configure(authLocalMgnt({ 51 | 52 | })); 53 | app.setup(); 54 | authLocalMgntService = app.service('localManagement'); 55 | 56 | usersService = app.service('users'); 57 | await usersService.remove(null); 58 | db = clone(idType === '_id' ? users_Id : usersId); 59 | await usersService.create(db); 60 | }); 61 | 62 | it('removes users with default datetime', async () => { 63 | //try { 64 | await authLocalMgntService.create({ 65 | action: 'deleteExpiredUsers', 66 | }); 67 | 68 | const result = await usersService.find({ paginate: false }); 69 | const users = result.data || result; 70 | const keys = users.map(users => users.id || users._id).sort(); 71 | 72 | assert.deepEqual(keys, ['c', 'd', 'g', 'h']); 73 | //} catch (err) { 74 | // console.log(err); 75 | // assert(false, 'err code set' + err.message); 76 | //} 77 | }); 78 | 79 | it('removes users with explicit datetime', async () => { 80 | try { 81 | await authLocalMgntService.create({ 82 | action: 'deleteExpiredUsers', 83 | isInvitationExpires: now - 50, 84 | isVerifiedExpires: now - 50, 85 | }); 86 | 87 | const result = await usersService.find({ paginate: false }); 88 | const users = result.data || result; 89 | const keys = users.map(users => users.id || users._id).sort(); 90 | 91 | assert.deepEqual(keys, ['b', 'c', 'd', 'f', 'g', 'h']); 92 | } catch (err) { 93 | console.log(err); 94 | assert(false, 'err code set' + err.message); 95 | } 96 | }); 97 | }); 98 | }); 99 | }); 100 | }); 101 | 102 | // Helpers 103 | 104 | function clone(obj) { 105 | return JSON.parse(JSON.stringify(obj)); 106 | } 107 | -------------------------------------------------------------------------------- /test/encode-reset-password-token.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const { encodeResetPasswordToken } = require('../src/index').helpers; 4 | 5 | describe('encode-reset-password-token.test.js', () => { 6 | it('runs', async () => { 7 | const result = encodeResetPasswordToken('foo', 'bar'); 8 | assert.strictEqual(result, 'foo___bar'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /test/errors-async-await.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const errors = require('@feathersjs/errors'); 4 | 5 | describe('errors-async-await.test.js', () => { 6 | describe('1 deep', () => { 7 | describe('call as async function', () => { 8 | it('successful', async () => { 9 | try { 10 | const result = await service('ok'); 11 | assert.equal(result, 'service ok'); 12 | } catch (err) { 13 | assert(false, `unexpected error: ${err.message}`); 14 | } 15 | }); 16 | 17 | it('throw', async () => { 18 | try { 19 | const result = await service('throw'); 20 | assert.equal(result, 'service ok'); 21 | } catch (err) { 22 | assert.equal(err.message, 'service throw'); 23 | } 24 | }); 25 | }); 26 | 27 | describe('call expecting Promise', () => { 28 | it('successful', () => { 29 | return service('ok') 30 | .then(result => { 31 | assert.equal(result, 'service ok'); 32 | }) 33 | .catch(err => { 34 | assert(false, `unexpected error: ${err.message}`); 35 | }); 36 | }); 37 | 38 | it('throw', () => { 39 | return service('throw') 40 | .then(result => { 41 | assert(false, `unexpectedly succeeded`); 42 | }) 43 | .catch(err => { 44 | assert.equal(err.message, 'service throw'); 45 | }); 46 | }); 47 | }); 48 | }); 49 | 50 | describe('2 deep', () => { 51 | describe('call as async function', () => { 52 | it('successful', async () => { 53 | try { 54 | const result = await service('passwordChange', 'ok'); 55 | assert.equal(result, 'passwordChange ok'); 56 | } catch (err) { 57 | assert(false, `unexpected error: ${err.message}`); 58 | } 59 | }); 60 | 61 | it('throw', async () => { 62 | try { 63 | const result = await service('passwordChange', 'throw'); 64 | assert.equal(result, 'service ok'); 65 | } catch (err) { 66 | assert.equal(err.message, 'passwordChange throw'); 67 | } 68 | }); 69 | }); 70 | 71 | describe('call expecting Promise', () => { 72 | it('successful', () => { 73 | return service('passwordChange', 'ok') 74 | .then(result => { 75 | assert.equal(result, 'passwordChange ok'); 76 | }) 77 | .catch(err => { 78 | assert(false, `unexpected error: ${err.message}`); 79 | }); 80 | }); 81 | 82 | it('throw', () => { 83 | return service('passwordChange', 'throw') 84 | .then(result => { 85 | assert(false, `unexpectedly succeeded`); 86 | }) 87 | .catch(err => { 88 | assert.equal(err.message, 'passwordChange throw'); 89 | }); 90 | }); 91 | }); 92 | }); 93 | 94 | describe('3 deep', () => { 95 | describe('call as async function', () => { 96 | it('successful', async () => { 97 | try { 98 | const result = await service('passwordChange', 'ensureValuesAreStrings', 'ok'); 99 | assert.equal(result, 'ensureValuesAreStrings ok'); 100 | } catch (err) { 101 | assert(false, `unexpected error: ${err.message}`); 102 | } 103 | }); 104 | 105 | it('throw', async () => { 106 | try { 107 | const result = await service('passwordChange', 'ensureValuesAreStrings', 'throw'); 108 | assert.equal(result, 'service ok'); 109 | } catch (err) { 110 | assert.equal(err.message, 'ensureValuesAreStrings throw'); 111 | } 112 | }); 113 | }); 114 | 115 | describe('call expecting Promise', () => { 116 | it('successful', () => { 117 | return service('passwordChange', 'ensureValuesAreStrings', 'ok') 118 | .then(result => { 119 | assert.equal(result, 'ensureValuesAreStrings ok'); 120 | }) 121 | .catch(err => { 122 | assert(false, `unexpected error: ${err.message}`); 123 | }); 124 | }); 125 | 126 | it('throw', () => { 127 | return service('passwordChange', 'ensureValuesAreStrings', 'throw') 128 | .then(result => { 129 | assert(false, `unexpectedly succeeded`); 130 | }) 131 | .catch(err => { 132 | assert.equal(err.message, 'ensureValuesAreStrings throw'); 133 | }); 134 | }); 135 | }); 136 | }); 137 | }); 138 | 139 | async function service(action, param1, param2) { 140 | switch (action) { 141 | case 'ok': 142 | return 'service ok'; 143 | case 'passwordChange': 144 | try { 145 | return await passwordChange(param1, param2); 146 | } catch (err) { 147 | return Promise.reject(err) 148 | } 149 | case 'throw': 150 | throw new errors.BadRequest('service throw'); 151 | default: 152 | throw new errors.BadRequest('service throw default'); 153 | } 154 | } 155 | 156 | async function passwordChange(param1, param2) { 157 | switch (param1) { 158 | case 'ok': 159 | return 'passwordChange ok'; 160 | case 'throw': 161 | throw new errors.BadRequest('passwordChange throw'); 162 | case 'ensureValuesAreStrings': 163 | return await ensureValuesAreStrings(param2); 164 | default: 165 | throw new errors.BadRequest('passwordChange throw default'); 166 | } 167 | } 168 | 169 | async function ensureValuesAreStrings(param2) { 170 | switch (param2) { 171 | case 'ok': 172 | return 'ensureValuesAreStrings ok'; 173 | case 'throw': 174 | throw new errors.BadRequest('ensureValuesAreStrings throw'); 175 | default: 176 | throw new errors.BadRequest('ensureValuesAreStrings throw default'); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /test/helpers/config.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | timeoutEachTest: 60000, 4 | maxTimeAllTests: 1000 * 60 * 60 * 2, // 2 hours 5 | defaultVerifyDelay: 1000 * 60 * 60 * 24 * 5 // 5 days 6 | }; 7 | -------------------------------------------------------------------------------- /test/is-verified.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const { isVerified } = require('../src/index').hooks; 4 | const { timeoutEachTest } = require('./helpers/config'); 5 | 6 | 7 | describe('is-verified.test.js', function () { 8 | this.timeout(timeoutEachTest); 9 | let context; 10 | 11 | beforeEach(() => { 12 | context = { 13 | type: 'before', 14 | method: 'create', 15 | params: { 16 | provider: 'socketio', 17 | user: { email: 'a@a.com', password: '0000000000' }, 18 | }, 19 | }; 20 | }); 21 | 22 | it('throws if not before', () => { 23 | context.type = 'after'; 24 | assert.throws(() => { isVerified()(context) }, undefined, undefined, 'after'); 25 | }); 26 | 27 | it('works with verified used', () => { 28 | context.params.user.isVerified = true; 29 | assert.doesNotThrow(() => { isVerified()(context); }); 30 | }); 31 | 32 | it('throws with unverified user', () => { 33 | context.params.user.isVerified = false; 34 | assert.throws(() => { isVerified()(context); }); 35 | }); 36 | 37 | it('throws if addVerification not run', () => { 38 | assert.throws(() => { isVerified()(context); }); 39 | }); 40 | 41 | it('throws if populate not run', () => { 42 | delete context.params.user; 43 | assert.throws(() => { isVerified()(context); }); 44 | }); 45 | 46 | it('throws with damaged hook', () => { 47 | delete context.params; 48 | assert.throws(() => { isVerified()(context); }); 49 | }); 50 | 51 | it('throws if not before', () => { 52 | context.type = 'after'; 53 | assert.throws(() => { isVerified()(context); }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/password-change-history.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const bcrypt = require('bcryptjs'); 4 | const feathers = require('@feathersjs/feathers'); 5 | const feathersMemory = require('feathers-memory'); 6 | const authLocalMgnt = require('../src/index'); 7 | const { hashPasswordFake: { hashPassword, bcryptCompare, bcryptCompareSync } } = 8 | require('@feathers-plus/commons'); 9 | const { timeoutEachTest } = require('./helpers/config'); 10 | 11 | let stack; 12 | 13 | const makeUsersService = (options) => function (app) { 14 | app.use('/users', feathersMemory(options)); 15 | 16 | app.service('users').hooks({ 17 | before: { 18 | create: hashPassword(), 19 | patch: hashPassword(), 20 | } 21 | }); 22 | }; 23 | 24 | // users DB 25 | const users_Id = [ 26 | { _id: 'a', email: 'a', plainPassword: 'aa', password: 'aa', plainNewPassword: 'xx', isVerified: false, 27 | passwordHistory: [ 28 | [ 'foo', 0, '__foo1' ], 29 | [ 'password', 1546960247540, '__qq' ], 30 | [ 'foo', 0, '__foo2' ], 31 | [ 'password', 1546960247540, '__aa' ], 32 | [ 'foo', 0, '__foo3' ], 33 | ] 34 | }, 35 | { _id: 'b', email: 'b', plainPassword: 'bb', password: 'bb', plainNewPassword: 'yy', isVerified: true, 36 | passwordHistory: [] 37 | }, 38 | ]; 39 | 40 | const usersId = [ 41 | { id: 'a', email: 'a', plainPassword: 'aa', password: 'aa', plainNewPassword: 'xx', isVerified: false, 42 | passwordHistory: [ 43 | [ 'foo', 0, '__foo1' ], 44 | [ 'password', 1546960247540, '__qq' ], 45 | [ 'foo', 0, '__foo2' ], 46 | [ 'password', 1546960247540, '__aa' ], 47 | [ 'foo', 0, '__foo3' ], 48 | ] 49 | }, 50 | { id: 'b', email: 'b', plainPassword: 'bb', password: 'bb', plainNewPassword: 'yy', isVerified: true, 51 | passwordHistory: [] 52 | }, 53 | ]; 54 | 55 | // Tests 56 | describe('password-change-history.test.js', function () { 57 | this.timeout(timeoutEachTest); 58 | 59 | ['_id'/*, 'id'*/].forEach(idType => { 60 | ['paginated'/*, 'non-paginated'*/].forEach(pagination => { 61 | describe(`passwordChange ${pagination} ${idType}`, () => { 62 | describe('standard', () => { 63 | let app; 64 | let usersService; 65 | let authLocalMgntService; 66 | let db; 67 | let result; 68 | 69 | beforeEach(async () => { 70 | app = feathers(); 71 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 72 | app.configure(authLocalMgnt({ 73 | bcryptCompare, 74 | })); 75 | app.setup(); 76 | authLocalMgntService = app.service('localManagement'); 77 | 78 | usersService = app.service('users'); 79 | await usersService.remove(null); 80 | db = clone(idType === '_id' ? users_Id : usersId); 81 | await usersService.create(db); 82 | }); 83 | 84 | it('add unique password', async () => { 85 | try { 86 | const userRec = clone(users_Id[1]); 87 | 88 | result = await authLocalMgntService.create({ 89 | action: 'passwordChange', 90 | value: { 91 | user: { 92 | email: userRec.email 93 | }, 94 | oldPassword: userRec.plainPassword, 95 | password: userRec.plainNewPassword 96 | }, 97 | }); 98 | const user = await usersService.get(result.id || result._id); 99 | 100 | assert.strictEqual(result.isVerified, true, 'isVerified not true'); 101 | assert.isOk(bcryptCompareSync(user.plainNewPassword, user.password), `wrong password [1]`); 102 | 103 | assert.lengthOf(user.passwordHistory, 1, 'wrong passwordHistory length'); 104 | 105 | const [historyField, _, historyPassword] = user.passwordHistory[0]; 106 | assert.strictEqual(historyField, 'password', 'wrong field name'); 107 | assert.isOk(bcryptCompareSync(user.plainNewPassword, historyPassword), `wrong password in history`); 108 | } catch (err) { 109 | console.log(err); 110 | assert.strictEqual(err, null, 'err code set'); 111 | } 112 | }); 113 | 114 | it('add password already used', async () => { 115 | try { 116 | const userRec = clone(users_Id[0]); 117 | 118 | result = await authLocalMgntService.create({ 119 | action: 'passwordChange', 120 | value: { 121 | user: { 122 | email: userRec.email 123 | }, 124 | oldPassword: userRec.plainPassword, 125 | password: userRec.plainNewPassword 126 | }, 127 | }); 128 | 129 | assert(false, 'unexpected succeeded.'); 130 | } catch (err) { 131 | assert.strictEqual(err.errors.$className, 'repeatedPassword', 'unexpected error'); 132 | } 133 | }); 134 | /* 135 | it('error on wrong password', async () => { 136 | try { 137 | const userRec = clone(users_Id[0]); 138 | 139 | result = await authLocalMgntService.create({ 140 | action: 'passwordChange', 141 | value: { 142 | user: { 143 | email: userRec.email 144 | }, 145 | oldPassword: 'fdfgfghghj', 146 | password: userRec.plainNewPassword 147 | }, 148 | }); 149 | const user = await usersService.get(result.id || result._id); 150 | 151 | assert(false, 'unexpected succeeded.'); 152 | } catch (err) { 153 | assert.isString(err.message); 154 | assert.isNotFalse(err.message); 155 | } 156 | }); 157 | */ 158 | }); 159 | }); 160 | }); 161 | }); 162 | }); 163 | 164 | // Helpers 165 | 166 | function clone(obj) { 167 | return JSON.parse(JSON.stringify(obj)); 168 | } 169 | -------------------------------------------------------------------------------- /test/password-change.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const bcrypt = require('bcryptjs'); 4 | const feathers = require('@feathersjs/feathers'); 5 | const feathersMemory = require('feathers-memory'); 6 | const authLocalMgnt = require('../src/index'); 7 | const { hashPasswordFake: { hashPassword, bcryptCompare, bcryptCompareSync } } = 8 | require('@feathers-plus/commons'); 9 | const { timeoutEachTest } = require('./helpers/config'); 10 | 11 | let stack; 12 | 13 | const makeUsersService = (options) => function (app) { 14 | app.use('/users', feathersMemory(options)); 15 | 16 | app.service('users').hooks({ 17 | before: { 18 | create: hashPassword(), 19 | patch: hashPassword(), 20 | } 21 | }); 22 | }; 23 | 24 | // users DB 25 | const users_Id = [ 26 | { _id: 'a', email: 'a', plainPassword: 'aa', password: 'aa', plainNewPassword: 'xx', isVerified: false }, 27 | { _id: 'b', email: 'b', plainPassword: 'bb', password: 'bb', plainNewPassword: 'yy', isVerified: true }, 28 | ]; 29 | 30 | const usersId = [ 31 | { id: 'a', email: 'a', plainPassword: 'aa', password: 'aa', plainNewPassword: 'xx', isVerified: false }, 32 | { id: 'b', email: 'b', plainPassword: 'bb', password: 'bb', plainNewPassword: 'yy', isVerified: true }, 33 | ]; 34 | 35 | // Tests 36 | describe('password-change.test.js', function () { 37 | this.timeout(timeoutEachTest); 38 | 39 | ['_id', 'id'].forEach(idType => { 40 | ['paginated', 'non-paginated'].forEach(pagination => { 41 | describe(`passwordChange ${pagination} ${idType}`, () => { 42 | describe('standard', () => { 43 | let app; 44 | let usersService; 45 | let authLocalMgntService; 46 | let db; 47 | let result; 48 | 49 | beforeEach(async () => { 50 | app = feathers(); 51 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 52 | app.configure(authLocalMgnt({ 53 | bcryptCompare, 54 | })); 55 | app.setup(); 56 | authLocalMgntService = app.service('localManagement'); 57 | 58 | usersService = app.service('users'); 59 | await usersService.remove(null); 60 | db = clone(idType === '_id' ? users_Id : usersId); 61 | await usersService.create(db); 62 | }); 63 | 64 | it('updates verified user', async () => { 65 | try { 66 | const userRec = clone(users_Id[1]); 67 | 68 | result = await authLocalMgntService.create({ 69 | action: 'passwordChange', 70 | value: { 71 | user: { 72 | email: userRec.email 73 | }, 74 | oldPassword: userRec.plainPassword, 75 | password: userRec.plainNewPassword 76 | }, 77 | }); 78 | const user = await usersService.get(result.id || result._id); 79 | 80 | assert.strictEqual(result.isVerified, true, 'isVerified not true'); 81 | assert.isOk(bcryptCompareSync(user.plainNewPassword, user.password), `wrong password [1]`); 82 | } catch (err) { 83 | console.log(err); 84 | assert.strictEqual(err, null, 'err code set'); 85 | } 86 | }); 87 | 88 | it('updates unverified user', async () => { 89 | try { 90 | const userRec = clone(users_Id[0]); 91 | 92 | result = await authLocalMgntService.create({ 93 | action: 'passwordChange', 94 | value: { 95 | user: { 96 | email: userRec.email 97 | }, 98 | oldPassword: userRec.plainPassword, 99 | password: userRec.plainNewPassword 100 | }, 101 | }); 102 | const user = await usersService.get(result.id || result._id); 103 | 104 | assert.strictEqual(result.isVerified, false, 'isVerified not false'); 105 | assert.isOk(bcryptCompareSync(user.plainNewPassword, user.password), `[0]`); 106 | } catch (err) { 107 | console.log(err); 108 | assert.strictEqual(err, null, 'err code set'); 109 | } 110 | }); 111 | 112 | it('error on wrong password', async () => { 113 | try { 114 | const userRec = clone(users_Id[0]); 115 | 116 | result = await authLocalMgntService.create({ 117 | action: 'passwordChange', 118 | value: { 119 | user: { 120 | email: userRec.email 121 | }, 122 | oldPassword: 'fdfgfghghj', 123 | password: userRec.plainNewPassword 124 | }, 125 | }); 126 | const user = await usersService.get(result.id || result._id); 127 | 128 | assert(false, 'unexpected succeeded.'); 129 | } catch (err) { 130 | assert.isString(err.message); 131 | assert.isNotFalse(err.message); 132 | } 133 | }); 134 | }); 135 | 136 | describe('with notification', () => { 137 | let app; 138 | let usersService; 139 | let authLocalMgntService; 140 | let db; 141 | let result; 142 | 143 | beforeEach(async () => { 144 | stack = []; 145 | 146 | app = feathers(); 147 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 148 | app.configure(authLocalMgnt({ 149 | bcryptCompare, 150 | plugins: [{ 151 | trigger: 'notifier', 152 | position: 'before', 153 | run: async (accumulator, { type, sanitizedUser, notifierOptions }, { options }, pluginContext) => { 154 | stack.push({ args: clone([type, sanitizedUser, notifierOptions]), result: sanitizedUser }); 155 | }, 156 | }], 157 | })); 158 | app.setup(); 159 | authLocalMgntService = app.service('localManagement'); 160 | 161 | usersService = app.service('users'); 162 | await usersService.remove(null); 163 | db = clone(idType === '_id' ? users_Id : usersId); 164 | await usersService.create(db); 165 | }); 166 | 167 | it('updates verified user', async () => { 168 | try { 169 | const userRec = clone(users_Id[1]); 170 | 171 | result = await authLocalMgntService.create({ 172 | action: 'passwordChange', 173 | value: { 174 | user: { 175 | email: userRec.email 176 | }, 177 | oldPassword: userRec.plainPassword, 178 | password: userRec.plainNewPassword 179 | }, 180 | }); 181 | const user = await usersService.get(result.id || result._id); 182 | 183 | assert.strictEqual(result.isVerified, true, 'isVerified not true'); 184 | assert.isOk(bcryptCompareSync(user.plainNewPassword, user.password), `[1`); 185 | assert.deepEqual( 186 | stack[0].args, 187 | [ 188 | 'passwordChange', 189 | sanitizeUserForEmail(user), 190 | null 191 | ]); 192 | } catch (err) { 193 | console.log(err); 194 | assert.strictEqual(err, null, 'err code set'); 195 | } 196 | }); 197 | }); 198 | }); 199 | }); 200 | }); 201 | }); 202 | 203 | // Helpers 204 | 205 | function sanitizeUserForEmail(user) { 206 | const user1 = clone(user); 207 | delete user1.password; 208 | return user1; 209 | } 210 | 211 | function clone(obj) { 212 | return JSON.parse(JSON.stringify(obj)); 213 | } 214 | -------------------------------------------------------------------------------- /test/protect-user-alm-fields.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const { protectUserAlmFields } = require('../src/index').hooks; 4 | 5 | function makeApp(userIdentityFields) { 6 | return { 7 | get() { 8 | return { userIdentityFields }; 9 | } 10 | } 11 | } 12 | 13 | 14 | describe('prevent-changes-verification.test.js', () => { 15 | let contextPatch; 16 | 17 | beforeEach(() => { 18 | contextPatch = { 19 | app: makeApp(['email', 'dialablePhone']), 20 | method: 'patch', 21 | type: 'before', 22 | params: { provider: 'rest' }, 23 | id: 1, 24 | }; 25 | 26 | }); 27 | 28 | it('default verificationFields fields changes', async () => { 29 | contextPatch.data = { verifyToken: 'aaa' }; 30 | 31 | assert.throws(() => protectUserAlmFields()(contextPatch)) 32 | }); 33 | 34 | it('default verificationFields field isInvitation changes', async () => { 35 | contextPatch.data = { isInvitation: false }; 36 | 37 | assert.throws(() => protectUserAlmFields()(contextPatch)) 38 | }); 39 | 40 | it('explicit verificationFields fields changes', async () => { 41 | contextPatch.data = { myVerifyToken: 'aaa' }; 42 | 43 | assert.throws(() => protectUserAlmFields( 44 | null, ['myVerifyToken'] 45 | )(contextPatch)) 46 | }); 47 | 48 | it('preventWhen works', async () => { 49 | contextPatch.data = { email: 'email1', dialablePhone: 'dialablePhone1' }; 50 | const result = await protectUserAlmFields( 51 | () => false 52 | )(contextPatch); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /test/remove-verification.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const { removeVerification } = require('../src/index').hooks; 4 | 5 | let context; 6 | 7 | describe('remove-verification.test.js', () => { 8 | beforeEach(() => { 9 | context = { 10 | type: 'after', 11 | method: 'create', 12 | params: { provider: 'socketio' }, 13 | result: { 14 | email: 'a@a.com', 15 | password: '0000000000', 16 | isVerified: true, 17 | verifyExpires: Date.now(), 18 | verifyToken: '000', 19 | verifyShortToken: '999', 20 | verifyChanges: {}, 21 | resetExpires: Date.now(), 22 | resetToken: '000', 23 | resetShortToken: '999', 24 | mfaExpires: Date.now(), 25 | mfaShortToken: '999', 26 | mfaType: '2fa', 27 | }, 28 | }; 29 | }); 30 | 31 | it('works with verified user', () => { 32 | assert.doesNotThrow(() => { removeVerification()(context); }); 33 | 34 | const user = context.result; 35 | assert.property(user, 'isVerified'); 36 | assert.equal(user.isVerified, true); 37 | assert.notProperty(user, 'verifyExpires'); 38 | assert.notProperty(user, 'verifyToken'); 39 | assert.notProperty(user, 'verifyShortToken'); 40 | assert.notProperty(user, 'verifyChanges'); 41 | assert.notProperty(user, 'resetExpires'); 42 | assert.notProperty(user, 'resetToken'); 43 | assert.notProperty(user, 'resetShortToken'); 44 | assert.notProperty(user, 'mfaExpires'); 45 | assert.notProperty(user, 'mfaShortToken'); 46 | assert.notProperty(user, 'mfaType'); 47 | }); 48 | 49 | it('works with unverified user', () => { 50 | context.result.isVerified = false; 51 | 52 | assert.doesNotThrow(() => { removeVerification()(context); }); 53 | 54 | const user = context.result; 55 | assert.property(user, 'isVerified'); 56 | assert.equal(user.isVerified, false); 57 | assert.notProperty(user, 'verifyExpires'); 58 | assert.notProperty(user, 'verifyToken'); 59 | assert.notProperty(user, 'verifyShortToken'); 60 | assert.notProperty(user, 'verifyChanges'); 61 | assert.notProperty(user, 'resetExpires'); 62 | assert.notProperty(user, 'resetToken'); 63 | assert.notProperty(user, 'resetShortToken'); 64 | assert.notProperty(user, 'mfaExpires'); 65 | assert.notProperty(user, 'mfaShortToken'); 66 | assert.notProperty(user, 'mfaType'); 67 | }); 68 | 69 | it('works if addVerification not run', () => { 70 | context.result = {}; 71 | 72 | assert.doesNotThrow(() => { removeVerification()(context); }); 73 | }); 74 | 75 | it('noop if server initiated', () => { 76 | context.params.provider = undefined; 77 | assert.doesNotThrow(() => { removeVerification()(context); }); 78 | 79 | const user = context.result; 80 | assert.property(user, 'isVerified'); 81 | assert.equal(user.isVerified, true); 82 | assert.property(user, 'verifyExpires'); 83 | assert.property(user, 'verifyToken'); 84 | assert.property(user, 'verifyShortToken'); 85 | assert.property(user, 'verifyChanges'); 86 | assert.property(user, 'resetExpires'); 87 | assert.property(user, 'resetToken'); 88 | assert.property(user, 'resetShortToken'); 89 | assert.property(user, 'mfaExpires'); 90 | assert.property(user, 'mfaShortToken'); 91 | assert.property(user, 'mfaType'); 92 | }); 93 | 94 | it('works with multiple verified user', () => { 95 | context.result = [context.result, context.result] 96 | assert.doesNotThrow(() => { removeVerification()(context); }); 97 | 98 | context.result.forEach(user => { 99 | assert.property(user, 'isVerified'); 100 | assert.equal(user.isVerified, true); 101 | assert.notProperty(user, 'verifyExpires'); 102 | assert.notProperty(user, 'verifyToken'); 103 | assert.notProperty(user, 'verifyShortToken'); 104 | assert.notProperty(user, 'verifyChanges'); 105 | assert.notProperty(user, 'resetExpires'); 106 | assert.notProperty(user, 'resetToken'); 107 | assert.notProperty(user, 'resetShortToken'); 108 | assert.notProperty(user, 'mfaExpires'); 109 | assert.notProperty(user, 'mfaShortToken'); 110 | assert.notProperty(user, 'mfaType'); 111 | }) 112 | }); 113 | 114 | it('does not throw with damaged hook', () => { 115 | delete context.result; 116 | 117 | assert.doesNotThrow(() => { removeVerification()(context); }); 118 | }); 119 | 120 | it('throws if not after', () => { 121 | context.type = 'before'; 122 | 123 | assert.throws(() => { removeVerification()(context); }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /test/reset-pwd-long.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('@feathersjs/feathers'); 4 | const feathersMemory = require('feathers-memory'); 5 | const authLocalMgnt = require('../src/index'); 6 | const { hashPasswordFake: { hashPassword } } = require('@feathers-plus/commons'); 7 | const { timeoutEachTest, maxTimeAllTests } = require('./helpers/config'); 8 | 9 | const now = Date.now(); 10 | let stack; 11 | 12 | const makeUsersService = (options) => function (app) { 13 | app.use('/users', feathersMemory(options)); 14 | 15 | app.service('users').hooks({ 16 | before: { 17 | create: hashPassword(), 18 | patch: hashPassword(), 19 | } 20 | }); 21 | }; 22 | 23 | const fieldToHash = 'resetToken'; 24 | const users_Id = [ 25 | // The added time interval must be longer than it takes to run ALL the tests 26 | { _id: 'a', email: 'a', isVerified: true, resetToken: 'a___000', resetExpires: now + maxTimeAllTests }, 27 | { _id: 'b', email: 'b', isVerified: true, resetToken: null, resetExpires: null }, 28 | { _id: 'c', email: 'c', isVerified: true, resetToken: 'c___111', resetExpires: now - maxTimeAllTests }, 29 | { _id: 'd', email: 'd', isVerified: false, resetToken: 'd___222', resetExpires: now - maxTimeAllTests }, 30 | ]; 31 | 32 | const usersId = [ 33 | // The added time interval must be longer than it takes to run ALL the tests 34 | { id: 'a', email: 'a', isVerified: true, resetToken: 'a___000', resetExpires: now + maxTimeAllTests }, 35 | { id: 'b', email: 'b', isVerified: true, resetToken: null, resetExpires: null }, 36 | { id: 'c', email: 'c', isVerified: true, resetToken: 'c___111', resetExpires: now - maxTimeAllTests }, 37 | { id: 'd', email: 'd', isVerified: false, resetToken: 'd___222', resetExpires: now - maxTimeAllTests }, 38 | ]; 39 | 40 | // Tests 41 | ['_id', 'id'].forEach(idType => { 42 | ['paginated', 'non-paginated'].forEach(pagination => { 43 | describe(`reset-pwd-long.test.js ${pagination} ${idType}`, function () { 44 | this.timeout(timeoutEachTest); 45 | 46 | describe('basic', () => { 47 | let app; 48 | let usersService; 49 | let authLocalMgntService; 50 | let db; 51 | let result; 52 | 53 | beforeEach(async () => { 54 | app = feathers(); 55 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 56 | app.configure(authLocalMgnt({ 57 | 58 | })); 59 | app.setup(); 60 | authLocalMgntService = app.service('localManagement'); 61 | 62 | usersService = app.service('users'); 63 | await usersService.remove(null); 64 | db = clone(idType === '_id' ? users_Id : usersId); 65 | await usersService.create(db); 66 | }); 67 | 68 | it('verifies valid token', async () => { 69 | try { 70 | result = await authLocalMgntService.create({ 71 | action: 'resetPwdLong', 72 | value: { token: 'a___000', password: '123456' } 73 | }); 74 | const user = await usersService.get(result.id || result._id); 75 | 76 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 77 | 78 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 79 | assert.strictEqual(user.resetToken, null, 'resetToken not null'); 80 | assert.strictEqual(user.resetShortToken, null, 'resetShortToken not null'); 81 | assert.strictEqual(user.resetExpires, null, 'resetExpires not null'); 82 | assert.isString(user.password, 'password not a string'); 83 | assert.isAbove(user.password.length, 6, 'password wrong length'); 84 | } catch (err) { 85 | console.log(err); 86 | assert.strictEqual(err, null, 'err code set'); 87 | } 88 | }); 89 | 90 | it('user is sanitized', async () => { 91 | try { 92 | result = await authLocalMgntService.create({ 93 | action: 'resetPwdLong', 94 | value: { token: 'a___000', password: '123456' } 95 | }); 96 | const user = await usersService.get(result.id || result._id); 97 | 98 | assert.strictEqual(result.isVerified, true, 'isVerified not true'); 99 | assert.strictEqual(result.resetToken, undefined, 'resetToken not undefined'); 100 | assert.strictEqual(result.resetShortToken, undefined, 'resetShortToken not undefined'); 101 | assert.strictEqual(result.resetExpires, undefined, 'resetExpires not undefined'); 102 | assert.isString(user.password, 'password not a string'); 103 | assert.isAbove(user.password.length, 6, 'password wrong length'); 104 | } catch (err) { 105 | console.log(err); 106 | assert.strictEqual(err, null, 'err code set'); 107 | } 108 | }); 109 | 110 | it('error on unverified user', async () => { 111 | try { 112 | result = await authLocalMgntService.create({ 113 | action: 'resetPwdLong', 114 | value: { token: 'd___222', password: '123456' } 115 | }); 116 | 117 | assert(false, 'unexpected succeeded.'); 118 | } catch (err) { 119 | assert.isString(err.message); 120 | assert.isNotFalse(err.message); 121 | } 122 | }); 123 | 124 | it('error on expired token', async () => { 125 | try { 126 | result = await authLocalMgntService.create({ 127 | action: 'resetPwdLong', 128 | value: { token: 'c___111', password: '123456' } 129 | }); 130 | 131 | assert(false, 'unexpected succeeded.'); 132 | } catch (err) { 133 | assert.isString(err.message); 134 | assert.isNotFalse(err.message); 135 | } 136 | }); 137 | 138 | it('error on token not found', async () => { 139 | try { 140 | result = await authLocalMgntService.create({ 141 | action: 'resetPwdLong', 142 | value: { token: 'a___999', password: '123456' } 143 | }); 144 | 145 | assert(false, 'unexpected succeeded.'); 146 | } catch (err) { 147 | assert.isString(err.message); 148 | assert.isNotFalse(err.message); 149 | } 150 | }); 151 | }); 152 | 153 | describe('with notification', () => { 154 | let app; 155 | let usersService; 156 | let authLocalMgntService; 157 | let db; 158 | let result; 159 | 160 | beforeEach(async () => { 161 | stack = []; 162 | 163 | app = feathers(); 164 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 165 | app.configure(authLocalMgnt({ 166 | testMode: true, 167 | plugins: [{ 168 | trigger: 'notifier', 169 | position: 'before', 170 | run: async (accumulator, { type, sanitizedUser, notifierOptions }, { options }, pluginContext) => { 171 | stack.push({ args: clone([type, sanitizedUser, notifierOptions]), result: sanitizedUser }); 172 | }, 173 | }], 174 | })); 175 | app.setup(); 176 | authLocalMgntService = app.service('localManagement'); 177 | 178 | usersService = app.service('users'); 179 | await usersService.remove(null); 180 | db = clone(idType === '_id' ? users_Id : usersId); 181 | await usersService.create(db); 182 | }); 183 | 184 | it('verifies valid token', async () => { 185 | try { 186 | result = await authLocalMgntService.create({ 187 | action: 'resetPwdLong', 188 | value: { token: 'a___000', password: '123456' } 189 | }); 190 | const user = await usersService.get(result.id || result._id); 191 | 192 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 193 | 194 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 195 | assert.strictEqual(user.resetToken, null, 'resetToken not null'); 196 | assert.strictEqual(user.resetExpires, null, 'resetExpires not null'); 197 | assert.isString(user.password, 'password not a string'); 198 | assert.isAbove(user.password.length, 6, 'password wrong length'); 199 | 200 | assert.deepEqual( 201 | stack[0].args, 202 | [ 203 | 'resetPwd', 204 | Object.assign({}, sanitizeUserForEmail(user)), 205 | null 206 | ]); 207 | } catch (err) { 208 | console.log(err); 209 | assert.strictEqual(err, null, 'err code set'); 210 | } 211 | }); 212 | }); 213 | }); 214 | }); 215 | }); 216 | 217 | // Helpers 218 | 219 | function sanitizeUserForEmail(user) { 220 | const user1 = Object.assign({}, user); 221 | delete user1.password; 222 | return user1; 223 | } 224 | 225 | function clone(obj) { 226 | return JSON.parse(JSON.stringify(obj)); 227 | } 228 | -------------------------------------------------------------------------------- /test/scaffolding.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('@feathersjs/feathers'); 4 | const localManagement = require('../src/index'); 5 | 6 | const optionsDefault = { 7 | app: null, // assigned during initialization 8 | usersServicePath: '/users', // need exactly this for test suite 9 | almServicePath: 'localManagement', 10 | // token's length will be twice this. 11 | // resetPassword token will be twice this + id/_id length + 3 12 | longTokenLen: 15, 13 | ownAcctOnly: true, 14 | passwordField: 'password', 15 | shortTokenLen: 6, 16 | shortTokenDigits: true, 17 | verifyDelay: 1000 * 60 * 60 * 24 * 5, // 5 days for re/sendVerifySignup 18 | resetDelay: 1000 * 60 * 60 * 2, // 2 hours for sendResetPwd 19 | mfaDelay: 1000 * 60 * 60, // 1 hour for sendMfa 20 | commandsNoAuth: [ // Unauthenticated users may run these commands 21 | 'resendVerifySignup', 'verifySignupLong', 'verifySignupShort', 22 | 'sendResetPwd', 'resetPwdLong', 'resetPwdShort', 23 | ], 24 | notifierEmailField: 'email', 25 | notifierDialablePhoneField: 'dialablePhone', 26 | userIdentityFields: ['email', 'dialablePhone'], 27 | userExtraPasswordFields: [], 28 | userProtectedFields: ['preferredComm'], 29 | maxPasswordsEachField: 3, 30 | plugins: null, // changes top default plugins 31 | }; 32 | 33 | const userMgntOptions = { 34 | service: '/users', 35 | notifier: () => Promise.resolve(), 36 | shortTokenLen: 8, 37 | }; 38 | 39 | const orgMgntOptions = { 40 | service: '/organizations', 41 | almServicePath: 'localManagement/org', // *** specify path for this instance of service 42 | notifier: () => Promise.resolve(), 43 | shortTokenLen: 10, 44 | }; 45 | 46 | function services() { 47 | const app = this; 48 | app.configure(user); 49 | app.configure(organization); 50 | } 51 | 52 | function user() { 53 | const app = this; 54 | 55 | app.use('/users', { 56 | async create(data) { return data; } 57 | }); 58 | 59 | const service = app.service('/users'); 60 | 61 | service.hooks({ 62 | before: { create: localManagement.hooks.addVerification() } 63 | }); 64 | } 65 | 66 | function organization() { 67 | const app = this; 68 | 69 | app.use('/organizations', { 70 | async create(data) { return data; } 71 | }); 72 | 73 | const service = app.service('/organizations'); 74 | 75 | service.hooks({ 76 | before: { create: localManagement.hooks.addVerification('localManagement/org') }, // *** which one 77 | }); 78 | } 79 | 80 | describe('scaffolding.test.js', () => { 81 | describe('can configure 1 service', () => { 82 | let app; 83 | 84 | beforeEach(() => { 85 | app = feathers(); 86 | app.configure(localManagement(userMgntOptions)); 87 | app.configure(services); 88 | app.setup(); 89 | }); 90 | 91 | it('configures', () => { 92 | const options = app.get('localManagement'); 93 | 94 | delete options.app; 95 | delete options.bcryptCompare; 96 | delete options.authManagementHooks; 97 | delete options.plugins; 98 | 99 | const expected = Object.assign({}, optionsDefault, userMgntOptions); 100 | delete expected.app; 101 | delete expected.bcryptCompare; 102 | delete expected.authManagementHooks; 103 | delete expected.plugins; 104 | 105 | assert.deepEqual(options, expected); 106 | }); 107 | 108 | it('can create an item', async () => { 109 | const user = app.service('/users'); 110 | 111 | const result = await user.create({ username: 'John Doe' }); 112 | assert.equal(result.username, 'John Doe'); 113 | assert.equal(result.verifyShortToken.length, 8); 114 | }); 115 | 116 | it('can call service', async () => { 117 | const authLocalMgntService = app.service('localManagement'); 118 | 119 | const result = await authLocalMgntService.create({ 120 | action: 'checkUnique', 121 | value: {} 122 | }); 123 | 124 | assert.strictEqual(result, null); 125 | }); 126 | }); 127 | 128 | describe('can configure 2 services', () => { 129 | let app; 130 | 131 | beforeEach(() => { 132 | app = feathers(); 133 | app.configure(localManagement(userMgntOptions)); 134 | app.configure(localManagement(orgMgntOptions)); 135 | app.configure(services); 136 | app.setup(); 137 | }); 138 | 139 | it('can create items', async () => { 140 | const user = app.service('/users'); 141 | const organization = app.service('/organizations'); 142 | 143 | // create a user item 144 | const result = await user.create({ username: 'John Doe' }) 145 | 146 | assert.equal(result.username, 'John Doe'); 147 | assert.equal(result.verifyShortToken.length, 10); 148 | 149 | // create an organization item 150 | const result1 = await organization.create({ organization: 'Black Ice' }); 151 | 152 | assert.equal(result1.organization, 'Black Ice'); 153 | assert.equal(result1.verifyShortToken.length, 10); 154 | }); 155 | 156 | it('can call services', async () => { 157 | const authLocalMgntService = app.service('localManagement'); // *** the default 158 | const authMgntOrgService = app.service('localManagement/org'); // *** which one 159 | 160 | // call the user instance 161 | const result = await authLocalMgntService.create({ 162 | action: 'checkUnique', 163 | value: {} 164 | }); 165 | 166 | assert.strictEqual(result, null); 167 | 168 | // call the organization instance 169 | const result1 = await authMgntOrgService.create({ 170 | action: 'checkUnique', 171 | value: {} 172 | }); 173 | 174 | assert.strictEqual(result1, null); 175 | }); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /test/send-mfa.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('@feathersjs/feathers'); 4 | const feathersMemory = require('feathers-memory'); 5 | const authLocalMgnt = require('../src/index'); 6 | const { hashPassword } = require('@feathersjs/authentication-local').hooks; 7 | const { timeoutEachTest, maxTimeAllTests } = require('./helpers/config'); 8 | 9 | const now = Date.now(); 10 | const timeout = timeoutEachTest; 11 | let stack; 12 | 13 | const makeUsersService = (options) => function (app) { 14 | app.use('/users', feathersMemory(options)); 15 | 16 | app.service('users').hooks({ 17 | before: { 18 | create: hashPassword(), 19 | patch: hashPassword(), 20 | } 21 | }); 22 | }; 23 | 24 | const usersId = [ 25 | { id: 'a', email: 'a', isVerified: false, verifyToken: '000', verifyExpires: now + maxTimeAllTests }, 26 | { id: 'b', email: 'b', isVerified: true, verifyToken: null, verifyExpires: null }, 27 | ]; 28 | 29 | const users_Id = [ 30 | { _id: 'a', email: 'a', isVerified: false, verifyToken: '000', verifyExpires: now + maxTimeAllTests }, 31 | { _id: 'b', email: 'b', isVerified: true, verifyToken: null, verifyExpires: null }, 32 | ]; 33 | 34 | ['_id', 'id'].forEach(idType => { 35 | ['paginated', 'non-paginated'].forEach(pagination => { 36 | describe(`send-mfa.test.js ${pagination} ${idType}`, function () { 37 | this.timeout(timeoutEachTest); 38 | 39 | describe('basic', () => { 40 | let app; 41 | let usersService; 42 | let authLocalMgntService; 43 | let db; 44 | 45 | beforeEach(async () => { 46 | app = feathers(); 47 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 48 | app.configure(authLocalMgnt({ 49 | 50 | })); 51 | app.setup(); 52 | authLocalMgntService = app.service('localManagement'); 53 | 54 | usersService = app.service('users'); 55 | await usersService.remove(null); 56 | db = clone(idType === '_id' ? users_Id : usersId); 57 | await usersService.create(db); 58 | }); 59 | 60 | it('updates verified user', async function () { 61 | const result = await authLocalMgntService.create({ // This returns without everything being resolved ????? 62 | action: 'sendMfa', 63 | value: { 64 | user: { email: 'b' }, 65 | type: 'xyz', 66 | }, 67 | }); 68 | 69 | const user = await usersService.get(result.id || result._id); // Causing this to fail ?????????????? 70 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 71 | 72 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 73 | assert.equal(user.mfaShortToken.length, 6, 'mfa short token wrong length'); 74 | assert.strictEqual(user.mfaType, 'xyz', 'mfaType is wrong'); 75 | aboutEqualDateTime(user.mfaExpires, makeDateTime()); 76 | }); 77 | 78 | it('error on unverified user', async function () { 79 | try { 80 | const result = await authLocalMgntService.create({ 81 | action: 'sendMfa', 82 | value: { 83 | user: { email: 'a' }, 84 | type: 'xyz', 85 | }, 86 | }); 87 | 88 | assert(false, 'unexpected succeeded.'); 89 | } catch (err) { 90 | assert.isString(err.message); 91 | assert.isNotFalse(err.message); 92 | } 93 | }); 94 | 95 | it('error on email not found', async function () { 96 | try { 97 | const result = await authLocalMgntService.create({ 98 | action: 'sendMfa', 99 | value: { 100 | user: { email: 'x' }, 101 | type: 'xyz', 102 | }, 103 | }); 104 | 105 | assert(false, 'unexpected succeeded.'); 106 | } catch (err) { 107 | assert.isString(err.message); 108 | assert.isNotFalse(err.message); 109 | } 110 | }); 111 | 112 | it('user is sanitized', async function () { 113 | try { 114 | const result = await authLocalMgntService.create({ 115 | action: 'sendMfa', 116 | value: { 117 | user: { email: 'b' }, 118 | type: 'abc', 119 | }, 120 | }); 121 | 122 | assert.strictEqual(result.isVerified, true, 'isVerified not true'); 123 | assert.strictEqual(result.mfaShortToken, undefined, 'mfaToken not undefined'); 124 | assert.strictEqual(result.mfaType, undefined, 'mfaToken not undefined'); 125 | assert.strictEqual(result.mfaExpires, undefined, 'mfaExpires not undefined'); 126 | } catch (err) { 127 | console.log(err); 128 | assert(false, 'err code set'); 129 | } 130 | }); 131 | }); 132 | 133 | describe('length can change (digits)', () => { 134 | let app; 135 | let usersService; 136 | let authLocalMgntService; 137 | let db; 138 | 139 | beforeEach(async () => { 140 | app = feathers(); 141 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 142 | app.configure(authLocalMgnt({ 143 | longTokenLen: 10, 144 | shortTokenLen: 9, 145 | shortTokenDigits: true, 146 | })); 147 | app.setup(); 148 | authLocalMgntService = app.service('localManagement'); 149 | 150 | usersService = app.service('users'); 151 | await usersService.remove(null); 152 | db = clone(idType === '_id' ? users_Id : usersId); 153 | await usersService.create(db); 154 | }); 155 | 156 | it('updates verified user', async function () { 157 | try { 158 | const result = await authLocalMgntService.create({ 159 | action: 'sendMfa', 160 | value: { 161 | user: { email: 'b' }, 162 | type: 'abc', 163 | }, 164 | }); 165 | const user = await usersService.get(result.id || result._id); 166 | 167 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 168 | 169 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 170 | assert.equal(user.mfaShortToken.length, 9, 'mfa short token wrong length'); 171 | assert.strictEqual(user.mfaType, 'abc', 'mfsType is wrong'); 172 | aboutEqualDateTime(user.mfaExpires, makeDateTime()); 173 | } catch (err) { 174 | console.log(err); 175 | assert(false, 'err code set'); 176 | } 177 | }); 178 | }); 179 | 180 | describe('length can change (alpha)', () => { 181 | let app; 182 | let usersService; 183 | let authLocalMgntService; 184 | let db; 185 | let result; 186 | 187 | beforeEach(async () => { 188 | app = feathers(); 189 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 190 | app.configure(authLocalMgnt({ 191 | longTokenLen: 10, 192 | shortTokenLen: 9, 193 | shortTokenDigits: false, 194 | })); 195 | app.setup(); 196 | authLocalMgntService = app.service('localManagement'); 197 | 198 | usersService = app.service('users'); 199 | await usersService.remove(null); 200 | db = clone(idType === '_id' ? users_Id : usersId); 201 | await usersService.create(db); 202 | }); 203 | 204 | it('updates verified user', async function () { 205 | try { 206 | result = await authLocalMgntService.create({ 207 | action: 'sendMfa', 208 | value: { 209 | user: { email: 'b' }, 210 | type: 'abc', 211 | }, 212 | }); 213 | const user = await usersService.get(result.id || result._id); 214 | 215 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 216 | 217 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 218 | assert.equal(user.mfaShortToken.length, 9, 'mfa short token wrong length'); 219 | assert.strictEqual(user.mfaType, 'abc', 'mfaType wrong'); 220 | aboutEqualDateTime(user.mfaExpires, makeDateTime()); 221 | } catch (err) { 222 | console.log(err); 223 | assert(false, 'err code set'); 224 | } 225 | }); 226 | }); 227 | 228 | describe('with notification', () => { 229 | let app; 230 | let usersService; 231 | let authLocalMgntService; 232 | let db; 233 | let result; 234 | 235 | beforeEach(async () => { 236 | stack = []; 237 | 238 | app = feathers(); 239 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 240 | app.configure(authLocalMgnt({ 241 | longTokenLen: 15, 242 | shortTokenLen: 6, 243 | shortTokenDigits: true, 244 | plugins: [{ 245 | trigger: 'notifier', 246 | position: 'clear', 247 | run: async (accumulator, { type, sanitizedUser, notifierOptions }, { options }, pluginContext) => { 248 | stack.push({ args: clone([type, sanitizedUser, notifierOptions]), result: sanitizedUser }); 249 | }, 250 | }], 251 | })); 252 | app.setup(); 253 | authLocalMgntService = app.service('localManagement'); 254 | 255 | usersService = app.service('users'); 256 | await usersService.remove(null); 257 | db = clone(idType === '_id' ? users_Id : usersId); 258 | await usersService.create(db); 259 | }); 260 | 261 | it('is called', async function () { 262 | try { 263 | result = await authLocalMgntService.create({ 264 | action: 'sendMfa', 265 | value: { 266 | user: { email: 'b' }, 267 | type: 'abc', 268 | }, 269 | notifierOptions: { transport: 'sms' } 270 | }); 271 | const user = await usersService.get(result.id || result._id); 272 | 273 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 274 | 275 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 276 | assert.equal(user.mfaShortToken.length, 6, 'mfa token wrong length'); 277 | aboutEqualDateTime(user.mfaExpires, makeDateTime()); 278 | 279 | const expected = stack[0].args 280 | expected[1] = Object.assign({}, expected[1], { 281 | mfaShortToken: user.mfaShortToken, 282 | mfaType: user.mfaType, 283 | }); 284 | 285 | assert.deepEqual(expected, [ 286 | 'sendMfa', 287 | sanitizeUserForEmail(user), 288 | { transport: 'sms' } 289 | ]); 290 | } catch (err) { 291 | console.log(err); 292 | assert(false, 'err code set'); 293 | } 294 | }); 295 | }); 296 | }); 297 | }); 298 | }); 299 | 300 | 301 | // Helpers 302 | 303 | function makeDateTime(options1) { 304 | options1 = options1 || {}; 305 | return Date.now() + (options1.verifyDelay || maxTimeAllTests); 306 | } 307 | 308 | function aboutEqualDateTime(time1, time2, msg, delta) { 309 | delta = delta || maxTimeAllTests; 310 | const diff = Math.abs(time1 - time2); 311 | assert.isAtMost(diff, delta, msg || `times differ by ${diff}ms`); 312 | } 313 | 314 | function sanitizeUserForEmail(user) { 315 | const user1 = clone(user); 316 | delete user1.password; 317 | return user1; 318 | } 319 | 320 | function clone(obj) { 321 | return JSON.parse(JSON.stringify(obj)); 322 | } 323 | -------------------------------------------------------------------------------- /test/send-reset-pwd.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('@feathersjs/feathers'); 4 | const feathersMemory = require('feathers-memory'); 5 | const authLocalMgnt = require('../src/index'); 6 | const { hashPassword } = require('@feathersjs/authentication-local').hooks; 7 | const { timeoutEachTest, maxTimeAllTests } = require('./helpers/config'); 8 | 9 | const now = Date.now(); 10 | const timeout = timeoutEachTest; 11 | let stack; 12 | 13 | const makeUsersService = (options) => function (app) { 14 | app.use('/users', feathersMemory(options)); 15 | 16 | app.service('users').hooks({ 17 | before: { 18 | create: hashPassword(), 19 | patch: hashPassword(), 20 | } 21 | }); 22 | }; 23 | 24 | const usersId = [ 25 | { id: 'a', email: 'a', isVerified: false, verifyToken: '000', verifyExpires: now + maxTimeAllTests }, 26 | { id: 'b', email: 'b', isVerified: true, verifyToken: null, verifyExpires: null }, 27 | ]; 28 | 29 | const users_Id = [ 30 | { _id: 'a', email: 'a', isVerified: false, verifyToken: '000', verifyExpires: now + maxTimeAllTests }, 31 | { _id: 'b', email: 'b', isVerified: true, verifyToken: null, verifyExpires: null }, 32 | ]; 33 | 34 | ['_id', 'id'].forEach(idType => { 35 | ['paginated', 'non-paginated'].forEach(pagination => { 36 | describe(`send-reset-pwd.test.js ${pagination} ${idType}`, function () { 37 | this.timeout(timeoutEachTest); 38 | 39 | describe('basic', () => { 40 | let app; 41 | let usersService; 42 | let authLocalMgntService; 43 | let db; 44 | 45 | beforeEach(async () => { 46 | app = feathers(); 47 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 48 | app.configure(authLocalMgnt({ 49 | 50 | })); 51 | app.setup(); 52 | authLocalMgntService = app.service('localManagement'); 53 | 54 | usersService = app.service('users'); 55 | await usersService.remove(null); 56 | db = clone(idType === '_id' ? users_Id : usersId); 57 | await usersService.create(db); 58 | }); 59 | 60 | it('updates verified user', async function () { 61 | const result = await authLocalMgntService.create({ // This returns without everything being resolved ????? 62 | action: 'sendResetPwd', 63 | value: { email: 'b' } 64 | }); 65 | 66 | const user = await usersService.get(result.id || result._id); // Causing this to fail ?????????????? 67 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 68 | 69 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 70 | assert.isString(user.resetToken, 'resetToken not String'); 71 | assert.equal(user.resetToken.length, 34, 'reset token wrong length'); 72 | assert.equal(user.resetShortToken.length, 6, 'reset short token wrong length'); 73 | aboutEqualDateTime(user.resetExpires, makeDateTime()); 74 | }); 75 | 76 | it('error on unverified user', async function () { 77 | try { 78 | result = await authLocalMgntService.create({ 79 | action: 'sendResetPwd', 80 | value: { email: 'a' } 81 | }); 82 | 83 | assert(false, 'unexpected succeeded.'); 84 | } catch (err) { 85 | assert.isString(err.message); 86 | assert.isNotFalse(err.message); 87 | } 88 | }); 89 | 90 | it('error on email not found', async function () { 91 | try { 92 | result = await authLocalMgntService.create({ 93 | action: 'sendResetPwd', 94 | value: { email: 'x' } 95 | }); 96 | 97 | assert(false, 'unexpected succeeded.'); 98 | } catch (err) { 99 | assert.isString(err.message); 100 | assert.isNotFalse(err.message); 101 | } 102 | }); 103 | 104 | it('user is sanitized', async function () { 105 | try { 106 | result = await authLocalMgntService.create({ 107 | action: 'sendResetPwd', 108 | value: { email: 'b' } 109 | }); 110 | 111 | assert.strictEqual(result.isVerified, true, 'isVerified not true'); 112 | assert.strictEqual(result.resetToken, undefined, 'resetToken not undefined'); 113 | assert.strictEqual(result.resetShortToken, undefined, 'resetToken not undefined'); 114 | assert.strictEqual(result.resetExpires, undefined, 'resetExpires not undefined'); 115 | } catch (err) { 116 | console.log(err); 117 | assert(false, 'err code set'); 118 | } 119 | }); 120 | }); 121 | 122 | describe('length can change (digits)', () => { 123 | let app; 124 | let usersService; 125 | let authLocalMgntService; 126 | let db; 127 | let result; 128 | 129 | beforeEach(async () => { 130 | app = feathers(); 131 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 132 | app.configure(authLocalMgnt({ 133 | longTokenLen: 10, 134 | shortTokenLen: 9, 135 | shortTokenDigits: true, 136 | })); 137 | app.setup(); 138 | authLocalMgntService = app.service('localManagement'); 139 | 140 | usersService = app.service('users'); 141 | await usersService.remove(null); 142 | db = clone(idType === '_id' ? users_Id : usersId); 143 | await usersService.create(db); 144 | }); 145 | 146 | it('updates verified user', async function () { 147 | try { 148 | result = await authLocalMgntService.create({ 149 | action: 'sendResetPwd', 150 | value: { email: 'b' } 151 | }); 152 | const user = await usersService.get(result.id || result._id); 153 | 154 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 155 | 156 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 157 | assert.isString(user.resetToken, 'resetToken not String'); 158 | assert.equal(user.resetToken.length, 24, 'reset token wrong length'); 159 | assert.equal(user.resetShortToken.length, 9, 'reset short token wrong length'); 160 | aboutEqualDateTime(user.resetExpires, makeDateTime()); 161 | } catch (err) { 162 | console.log(err); 163 | assert(false, 'err code set'); 164 | } 165 | }); 166 | }); 167 | 168 | describe('length can change (alpha)', () => { 169 | let app; 170 | let usersService; 171 | let authLocalMgntService; 172 | let db; 173 | let result; 174 | 175 | beforeEach(async () => { 176 | app = feathers(); 177 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 178 | app.configure(authLocalMgnt({ 179 | longTokenLen: 10, 180 | shortTokenLen: 9, 181 | shortTokenDigits: false, 182 | })); 183 | app.setup(); 184 | authLocalMgntService = app.service('localManagement'); 185 | 186 | usersService = app.service('users'); 187 | await usersService.remove(null); 188 | db = clone(idType === '_id' ? users_Id : usersId); 189 | await usersService.create(db); 190 | }); 191 | 192 | it('updates verified user', async function () { 193 | try { 194 | result = await authLocalMgntService.create({ 195 | action: 'sendResetPwd', 196 | value: { email: 'b' } 197 | }); 198 | const user = await usersService.get(result.id || result._id); 199 | 200 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 201 | 202 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 203 | assert.isString(user.resetToken, 'resetToken not String'); 204 | assert.equal(user.resetToken.length, 24, 'reset token wrong length'); 205 | assert.equal(user.resetShortToken.length, 9, 'reset short token wrong length'); 206 | aboutEqualDateTime(user.resetExpires, makeDateTime()); 207 | } catch (err) { 208 | console.log(err); 209 | assert(false, 'err code set'); 210 | } 211 | }); 212 | }); 213 | 214 | describe('with notification', () => { 215 | let app; 216 | let usersService; 217 | let authLocalMgntService; 218 | let db; 219 | let result; 220 | 221 | beforeEach(async () => { 222 | stack = []; 223 | 224 | app = feathers(); 225 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 226 | app.configure(authLocalMgnt({ 227 | longTokenLen: 15, 228 | shortTokenLen: 6, 229 | shortTokenDigits: true, 230 | plugins: [{ 231 | trigger: 'notifier', 232 | position: 'clear', 233 | run: async (accumulator, { type, sanitizedUser, notifierOptions }, { options }, pluginContext) => { 234 | stack.push({ args: clone([type, sanitizedUser, notifierOptions]), result: sanitizedUser }); 235 | }, 236 | }], 237 | })); 238 | app.setup(); 239 | authLocalMgntService = app.service('localManagement'); 240 | 241 | usersService = app.service('users'); 242 | await usersService.remove(null); 243 | db = clone(idType === '_id' ? users_Id : usersId); 244 | await usersService.create(db); 245 | }); 246 | 247 | it('is called', async function () { 248 | try { 249 | result = await authLocalMgntService.create({ 250 | action: 'sendResetPwd', 251 | value: { email: 'b' }, 252 | notifierOptions: { transport: 'sms' } 253 | }); 254 | const user = await usersService.get(result.id || result._id); 255 | 256 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 257 | 258 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 259 | assert.isString(user.resetToken, 'resetToken not String'); 260 | assert.equal(user.resetToken.length, 34, 'reset token wrong length'); 261 | aboutEqualDateTime(user.resetExpires, makeDateTime()); 262 | 263 | const expected = stack[0].args 264 | expected[1] = Object.assign({}, expected[1], { 265 | resetToken: user.resetToken, 266 | resetShortToken: user.resetShortToken 267 | }); 268 | 269 | assert.deepEqual(expected, [ 270 | 'sendResetPwd', 271 | sanitizeUserForEmail(user), 272 | { transport: 'sms' } 273 | ]); 274 | } catch (err) { 275 | console.log(err); 276 | assert(false, 'err code set'); 277 | } 278 | }); 279 | }); 280 | }); 281 | }); 282 | }); 283 | 284 | 285 | // Helpers 286 | 287 | function notifier(app, options) { 288 | return async (...args) => { 289 | const [ type, sanitizedUser, notifierOptions ] = args; 290 | 291 | stack.push({ args: clone(args), result: sanitizedUser }); 292 | 293 | return sanitizedUser 294 | } 295 | } 296 | 297 | function makeDateTime(options1) { 298 | options1 = options1 || {}; 299 | return Date.now() + (options1.verifyDelay || maxTimeAllTests); 300 | } 301 | 302 | function aboutEqualDateTime(time1, time2, msg, delta) { 303 | delta = delta || maxTimeAllTests; 304 | const diff = Math.abs(time1 - time2); 305 | assert.isAtMost(diff, delta, msg || `times differ by ${diff}ms`); 306 | } 307 | 308 | function sanitizeUserForEmail(user) { 309 | const user1 = clone(user); 310 | delete user1.password; 311 | return user1; 312 | } 313 | 314 | function clone(obj) { 315 | return JSON.parse(JSON.stringify(obj)); 316 | } 317 | -------------------------------------------------------------------------------- /test/send-verify-signup-notifications.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const { sendVerifySignupNotification } = require('../src/index').hooks; 4 | 5 | function notifierOptions() { 6 | return {}; 7 | } 8 | 9 | let pluginsRun; 10 | 11 | function makeApp(userIdentityFields) { 12 | return { 13 | get() { 14 | return { 15 | userIdentityFields, 16 | plugins: { 17 | run(trigger, data) { 18 | pluginsRun.push({ trigger, data }); 19 | return data; 20 | }, 21 | }, 22 | }; 23 | } 24 | } 25 | } 26 | 27 | describe('send-verify-signup-notification.test.js', () => { 28 | let context; 29 | 30 | beforeEach(() => { 31 | pluginsRun = []; 32 | 33 | context = { 34 | app: makeApp(['email', 'dialablePhone']), 35 | method: 'create', 36 | type: 'after', 37 | params: { provider: 'rest' }, 38 | }; 39 | 40 | }); 41 | 42 | it('for full user', async () => { 43 | const result = { isInvitation: false, email1: 'email1', dialablePhone1: 'dialablePhone1' }; 44 | context.result = result; 45 | 46 | const ctx = await sendVerifySignupNotification(notifierOptions)(context); 47 | 48 | assert.deepEqual(pluginsRun, [ 49 | { trigger: 'sanitizeUserForNotifier', data: result}, 50 | { trigger: 'notifier', data: { 51 | type: 'sendVerifySignup', 52 | sanitizedUser: result, 53 | notifierOptions 54 | }} 55 | ]); 56 | }); 57 | 58 | it('for invited user', async () => { 59 | const result = { isInvitation: true, email1: 'email1', dialablePhone1: 'dialablePhone1' }; 60 | context.result = result; 61 | 62 | const ctx = await sendVerifySignupNotification(notifierOptions)(context); 63 | 64 | assert.deepEqual(pluginsRun, [ 65 | { trigger: 'sanitizeUserForNotifier', data: result}, 66 | { trigger: 'notifier', data: { 67 | type: 'sendInvitationSignup', 68 | sanitizedUser: result, 69 | notifierOptions 70 | }} 71 | ]); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/sequelize.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const createService = require('feathers-sequelize'); 4 | const feathers = require('@feathersjs/feathers'); 5 | const Sequelize = require('sequelize') 6 | const { sequelizeConvertAlm } = require('../src/hooks'); 7 | const { timeoutEachTest } = require('./helpers/config'); 8 | 9 | const DataTypes = Sequelize.DataTypes; 10 | let sequelizeClient; 11 | let trace; 12 | 13 | const time1 = 1541878856535; 14 | const time1Str = '2018-11-10T19:40:56.535Z'; 15 | const time2 = 1541878856545; 16 | const time2Str = '2018-11-10T19:40:56.545Z'; 17 | 18 | const internalRecs = [ 19 | { email: 'a', password: 'aa', phone: '+123', dialablePhone: '123', preferredComm: 'sms', 20 | verifyToken: 'abc', verifyShortToken: '123', resetToken: 'abc', resetShortToken: '123', 21 | mfaShortToken: '123', mfaType: 'email', 22 | isInvitation: true, isVerified: false, verifyExpires: time1, verifyChanges: { foo: 'bar' }, 23 | resetExpires: time2, mfaExpires: time1, passwordHistory: [] }, 24 | { email: 'b', password: 'bb', phone: '+123', dialablePhone: '123', preferredComm: 'sms', 25 | verifyToken: 'abc', verifyShortToken: '123', resetToken: 'abc', resetShortToken: '123', 26 | mfaShortToken: '123', mfaType: 'email', 27 | isInvitation: false, isVerified: true, verifyExpires: time1, verifyChanges: { foo: 'bar' }, 28 | resetExpires: time2, mfaExpires: null, passwordHistory: [] }, 29 | ]; 30 | 31 | const sequelizeRecsIn = [ 32 | { email: 'a', password: 'aa', phone: '+123', dialablePhone: '123', preferredComm: 'sms', 33 | verifyToken: 'abc', verifyShortToken: '123', resetToken: 'abc', resetShortToken: '123', 34 | mfaShortToken: '123', mfaType: 'email', 35 | isInvitation: 1, isVerified: 0, verifyExpires: time1, verifyChanges: '{"foo":"bar"}', 36 | resetExpires: time2, mfaExpires: time1, passwordHistory: '[]' }, 37 | { email: 'b', password: 'bb', phone: '+123', dialablePhone: '123', preferredComm: 'sms', 38 | verifyToken: 'abc', verifyShortToken: '123', resetToken: 'abc', resetShortToken: '123', 39 | mfaShortToken: '123', mfaType: 'email', 40 | isInvitation: 0, isVerified: 1, verifyExpires: time1, verifyChanges: '{"foo":"bar"}', 41 | resetExpires: time2, mfaExpires: null, passwordHistory: '[]' }, 42 | ]; 43 | 44 | const sequelizeRecsOut = [ 45 | { email: 'a', password: 'aa', phone: '+123', dialablePhone: '123', preferredComm: 'sms', 46 | verifyToken: 'abc', verifyShortToken: '123', resetToken: 'abc', resetShortToken: '123', 47 | mfaShortToken: '123', mfaType: 'email', 48 | isInvitation: 1, isVerified: 0, verifyExpires: time1Str, verifyChanges: '{"foo":"bar"}', 49 | resetExpires: time2Str, mfaExpires: time1Str, passwordHistory: '[]' }, 50 | { email: 'b', password: 'bb', phone: '+123', dialablePhone: '123', preferredComm: 'sms', 51 | verifyToken: 'abc', verifyShortToken: '123', resetToken: 'abc', resetShortToken: '123', 52 | mfaShortToken: '123', mfaType: 'email', 53 | isInvitation: 0, isVerified: 1, verifyExpires: time1Str, verifyChanges: '{"foo":"bar"}', 54 | resetExpires: time2Str, mfaExpires: null, passwordHistory: '[]' }, 55 | ]; 56 | 57 | function tracer(name) { 58 | return context => { 59 | if (context.type === 'before') { 60 | trace[name] = context.data ? clone(context.data) : context.data; 61 | } else { 62 | trace[name] = context.data ? clone(context.result) : context.data; 63 | } 64 | }; 65 | } 66 | 67 | // Tests 68 | describe('sequelize.test.js', function () { 69 | this.timeout(timeoutEachTest); 70 | 71 | describe('converts', () => { 72 | let app; 73 | let usersService; 74 | 75 | beforeEach(async () => { 76 | app = feathers(); 77 | app.configure(makeUsersService()); 78 | app.setup(); 79 | 80 | trace = {}; 81 | 82 | usersService = app.service('users'); 83 | await usersService.remove(null); 84 | await usersService.create(clone(internalRecs)); 85 | }); 86 | 87 | it('can create records', async () => { 88 | try { 89 | const userRecs = cleanup(await usersService.create(clone(internalRecs))); 90 | 91 | assert.deepEqual(cleanup(trace.beforeIn), internalRecs, 'beforeIn'); 92 | assert.deepEqual(cleanup(trace.beforeOut), sequelizeRecsIn, 'beforeOut'); 93 | assert.deepEqual(cleanup(trace.afterIn), sequelizeRecsOut, 'afterIn'); 94 | assert.deepEqual(cleanup(trace.afterOut), internalRecs, 'afterOut'); 95 | 96 | assert.deepEqual(userRecs, internalRecs, 'service call'); 97 | } catch (err) { 98 | console.log(err); 99 | assert.strictEqual(err, null, 'err code set'); 100 | } 101 | }); 102 | }); 103 | }); 104 | 105 | // Helpers 106 | 107 | const makeUsersService = () => function (app) { 108 | let Model = createUsersModel(app); 109 | 110 | let options = { 111 | name: 'users', 112 | Model, 113 | paginate:false, 114 | }; 115 | 116 | app.use('/users', createService(options)); 117 | 118 | app.service('users').hooks({ 119 | before: { 120 | all: [tracer('beforeIn'), sequelizeConvertAlm(), tracer('beforeOut')], 121 | }, 122 | after: { 123 | all: [tracer('afterIn'), sequelizeConvertAlm(), tracer('afterOut')], 124 | } 125 | }); 126 | }; 127 | 128 | function createUsersModel(app) { 129 | sequelize(app); 130 | 131 | return sequelizeClient.define('users', 132 | { 133 | email: { 134 | type: DataTypes.STRING, 135 | allowNull: false 136 | }, 137 | password: { 138 | type: DataTypes.STRING, 139 | allowNull: false 140 | }, 141 | phone: { 142 | type: DataTypes.STRING, 143 | allowNull: false 144 | }, 145 | dialablePhone: { 146 | type: DataTypes.STRING, 147 | allowNull: false 148 | }, 149 | preferredComm: { 150 | type: DataTypes.STRING, 151 | allowNull: false 152 | }, 153 | isInvitation: { 154 | type: DataTypes.INTEGER, 155 | allowNull: false 156 | }, 157 | isVerified: { 158 | type: DataTypes.INTEGER, 159 | allowNull: false 160 | }, 161 | verifyExpires: { 162 | type: DataTypes.DATE 163 | }, 164 | verifyToken: { 165 | type: DataTypes.STRING, 166 | allowNull: false 167 | }, 168 | verifyShortToken: { 169 | type: DataTypes.STRING, 170 | allowNull: false 171 | }, 172 | verifyChanges: { 173 | type: DataTypes.STRING 174 | }, 175 | resetExpires: { 176 | type: DataTypes.DATE 177 | }, 178 | resetToken: { 179 | type: DataTypes.STRING, 180 | allowNull: false 181 | }, 182 | resetShortToken: { 183 | type: DataTypes.STRING, 184 | allowNull: false 185 | }, 186 | mfaExpires: { 187 | type: DataTypes.DATE 188 | }, 189 | mfaShortToken: { 190 | type: DataTypes.STRING, 191 | allowNull: false 192 | }, 193 | mfaType: { 194 | type: DataTypes.STRING, 195 | allowNull: false 196 | }, 197 | passwordHistory: { 198 | type: DataTypes.STRING 199 | }, 200 | }, 201 | { 202 | hooks: { 203 | beforeCount(options) { 204 | options.raw = true; 205 | }, 206 | }, 207 | }, 208 | ); 209 | } 210 | 211 | function sequelize(app) { 212 | let connectionString = 'sqlite://test-data/users.sqlite'; 213 | let sequelize = new Sequelize(connectionString, { 214 | dialect: 'sqlite', 215 | logging: false, 216 | define: { 217 | freezeTableName: true 218 | } 219 | }); 220 | 221 | let oldSetup = app.setup; 222 | sequelizeClient = sequelize; 223 | 224 | app.setup = async function (...args) { 225 | let result = oldSetup.apply(this, args); 226 | 227 | // Set up data relationships 228 | const models = sequelize.models; 229 | Object.keys(models).forEach(name => { 230 | if ('associate' in models[name]) { 231 | models[name].associate(models); 232 | } 233 | }); 234 | 235 | // Sync to the database 236 | await sequelize.sync(/* { alter: true } */); 237 | 238 | return result; 239 | }; 240 | } 241 | 242 | function cleanup(recs) { 243 | return recs.map(rec => { 244 | delete rec.id; 245 | delete rec.createdAt; 246 | delete rec.updatedAt; 247 | 248 | return rec; 249 | }); 250 | } 251 | 252 | function clone(obj) { 253 | return JSON.parse(JSON.stringify(obj)); 254 | } 255 | -------------------------------------------------------------------------------- /test/verify-mfa.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('@feathersjs/feathers'); 4 | const feathersMemory = require('feathers-memory'); 5 | const authLocalMgnt = require('../src/index'); 6 | const { hashPassword } = require('@feathersjs/authentication-local').hooks; 7 | const { timeoutEachTest, maxTimeAllTests } = require('./helpers/config'); 8 | 9 | const now = Date.now(); 10 | const timeout = timeoutEachTest; 11 | let stack; 12 | 13 | const makeUsersService = (options) => function (app) { 14 | app.use('/users', feathersMemory(options)); 15 | 16 | app.service('users').hooks({ 17 | before: { 18 | create: hashPassword(), 19 | patch: hashPassword(), 20 | } 21 | }); 22 | }; 23 | 24 | const usersId = [ 25 | { id: 'a', email: 'a', isVerified: false, verifyToken: '000', verifyExpires: now + maxTimeAllTests }, 26 | { id: 'b', email: 'b', isVerified: true, verifyToken: null, verifyExpires: null }, 27 | ]; 28 | 29 | const users_Id = [ 30 | { _id: 'a', email: 'a', isVerified: false, verifyToken: '000', verifyExpires: now + maxTimeAllTests }, 31 | { _id: 'b', email: 'b', isVerified: true, verifyToken: null, verifyExpires: null }, 32 | ]; 33 | 34 | ['_id', 'id'].forEach(idType => { 35 | ['paginated', 'non-paginated'].forEach(pagination => { 36 | describe(`verify-mfa.test.js ${pagination} ${idType}`, function () { 37 | this.timeout(timeoutEachTest); 38 | 39 | describe('basic', () => { 40 | let app; 41 | let usersService; 42 | let authLocalMgntService; 43 | let db; 44 | 45 | beforeEach(async () => { 46 | app = feathers(); 47 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 48 | app.configure(authLocalMgnt({ 49 | 50 | })); 51 | app.setup(); 52 | authLocalMgntService = app.service('localManagement'); 53 | 54 | usersService = app.service('users'); 55 | await usersService.remove(null); 56 | db = clone(idType === '_id' ? users_Id : usersId); 57 | await usersService.create(db); 58 | }); 59 | 60 | it('verifies token', async function () { 61 | const result = await authLocalMgntService.create({ 62 | action: 'sendMfa', 63 | value: { 64 | user: { email: 'b' }, 65 | type: 'xyz', 66 | }, 67 | }); 68 | 69 | const user = await usersService.get(result.id || result._id); 70 | 71 | await authLocalMgntService.create({ 72 | action: 'verifyMfa', 73 | value: { 74 | user: { email: 'b' }, 75 | type: 'xyz', 76 | token: user.mfaShortToken, 77 | }, 78 | }); 79 | 80 | const user1 = await usersService.get(result.id || result._id); 81 | 82 | assert.strictEqual(user1.mfaExpires, null); 83 | assert.strictEqual(user1.mfaShortToken, null); 84 | assert.strictEqual(user1.mfaType, null); 85 | }); 86 | 87 | it('error on bad token', async function () { 88 | try { 89 | const result = await authLocalMgntService.create({ 90 | action: 'sendMfa', 91 | value: { 92 | user: { email: 'a' }, 93 | type: 'xyz', 94 | }, 95 | }); 96 | 97 | await authLocalMgntService.create({ 98 | action: 'verifyMfa', 99 | value: { 100 | user: { email: 'b' }, 101 | type: 'xyz', 102 | token: '3456789', 103 | }, 104 | }); 105 | 106 | assert(false, 'unexpected succeeded.'); 107 | } catch (err) { 108 | assert.isString(err.message); 109 | assert.isNotFalse(err.message); 110 | } 111 | }); 112 | 113 | it('error on bad type', async function () { 114 | try { 115 | const result = await authLocalMgntService.create({ 116 | action: 'sendMfa', 117 | value: { 118 | user: { email: 'a' }, 119 | type: 'xyz', 120 | }, 121 | }); 122 | 123 | await authLocalMgntService.create({ 124 | action: 'verifyMfa', 125 | value: { 126 | user: { email: 'b' }, 127 | type: '$%^&*()', 128 | token: user.mfaShortToken, 129 | }, 130 | }); 131 | 132 | assert(false, 'unexpected succeeded.'); 133 | } catch (err) { 134 | assert.isString(err.message); 135 | assert.isNotFalse(err.message); 136 | } 137 | }); 138 | 139 | 140 | it('error on expired token', async function () { 141 | try { 142 | const result = await authLocalMgntService.create({ 143 | action: 'sendMfa', 144 | value: { 145 | user: { email: 'a' }, 146 | type: 'xyz', 147 | }, 148 | }); 149 | 150 | const user = await usersService.get(result.id || result._id); 151 | await usersService.get(result.id || result._id, { mfaExpired: user.mfaExpired - 1000 }); 152 | 153 | await authLocalMgntService.create({ 154 | action: 'verifyMfa', 155 | value: { 156 | user: { email: 'b' }, 157 | type: 'xyz', 158 | token: user.mfaShortToken, 159 | }, 160 | }); 161 | 162 | assert(false, 'unexpected succeeded.'); 163 | } catch (err) { 164 | assert.isString(err.message); 165 | assert.isNotFalse(err.message); 166 | } 167 | }); 168 | }); 169 | }); 170 | }); 171 | }); 172 | 173 | 174 | // Helpers 175 | 176 | function makeDateTime(options1) { 177 | options1 = options1 || {}; 178 | return Date.now() + (options1.verifyDelay || maxTimeAllTests); 179 | } 180 | 181 | function aboutEqualDateTime(time1, time2, msg, delta) { 182 | delta = delta || maxTimeAllTests; 183 | const diff = Math.abs(time1 - time2); 184 | assert.isAtMost(diff, delta, msg || `times differ by ${diff}ms`); 185 | } 186 | 187 | function clone(obj) { 188 | return JSON.parse(JSON.stringify(obj)); 189 | } 190 | -------------------------------------------------------------------------------- /test/verify-signup-long.test.js: -------------------------------------------------------------------------------- 1 | 2 | const assert = require('chai').assert; 3 | const feathers = require('@feathersjs/feathers'); 4 | const feathersMemory = require('feathers-memory'); 5 | const authLocalMgnt = require('../src/index'); 6 | const { timeoutEachTest, maxTimeAllTests } = require('./helpers/config'); 7 | 8 | const now = Date.now(); 9 | let stack; 10 | 11 | const makeUsersService = (options) => function (app) { 12 | app.use('/users', feathersMemory(options)); 13 | }; 14 | 15 | const usersId = [ 16 | { id: 'a', email: 'a', isVerified: false, verifyToken: '000', verifyExpires: now + maxTimeAllTests }, 17 | { id: 'b', email: 'b', isVerified: false, verifyToken: null, verifyExpires: null }, 18 | { id: 'c', email: 'c', isVerified: false, verifyToken: '111', verifyExpires: now - maxTimeAllTests }, 19 | { id: 'd', email: 'd', isVerified: true, verifyToken: '222', verifyExpires: now - maxTimeAllTests }, 20 | { id: 'e', email: 'e', isVerified: true, verifyToken: '800', verifyExpires: now + maxTimeAllTests, 21 | verifyChanges: { cellphone: '800' } }, 22 | { id: 'f', email: 'f', isVerified: false, verifyToken: '999', verifyExpires: now + maxTimeAllTests, 23 | isInvitation: true }, 24 | ]; 25 | 26 | const users_Id = [ 27 | { _id: 'a', email: 'a', isVerified: false, verifyToken: '000', verifyExpires: now + maxTimeAllTests }, 28 | { _id: 'b', email: 'b', isVerified: false, verifyToken: null, verifyExpires: null }, 29 | { _id: 'c', email: 'c', isVerified: false, verifyToken: '111', verifyExpires: now - maxTimeAllTests }, 30 | { _id: 'd', email: 'd', isVerified: true, verifyToken: '222', verifyExpires: now - maxTimeAllTests }, 31 | { _id: 'e', email: 'e', isVerified: true, verifyToken: '800', verifyExpires: now + maxTimeAllTests, 32 | verifyChanges: { cellphone: '800' } }, 33 | { _id: 'f', email: 'f', isVerified: false, verifyToken: '999', verifyExpires: now + maxTimeAllTests, 34 | isInvitation: true }, 35 | ]; 36 | 37 | ['_id', 'id'].forEach(idType => { 38 | ['paginated', 'non-paginated'].forEach(pagination => { 39 | describe(`verify-signup-long.test.js ${pagination} ${idType}`, function () { 40 | this.timeout(timeoutEachTest); 41 | 42 | describe('basic', () => { 43 | let app; 44 | let usersService; 45 | let authLocalMgntService; 46 | let db; 47 | let result; 48 | 49 | beforeEach(async () => { 50 | app = feathers(); 51 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 52 | app.configure(authLocalMgnt({ 53 | 54 | })); 55 | app.setup(); 56 | authLocalMgntService = app.service('localManagement'); 57 | 58 | usersService = app.service('users'); 59 | await usersService.remove(null); 60 | db = clone(idType === '_id' ? users_Id : usersId); 61 | await usersService.create(db); 62 | }); 63 | 64 | it('verifies valid token if not verified full user', async () => { 65 | try { 66 | result = await authLocalMgntService.create({ 67 | action: 'verifySignupLong', 68 | value: '000', 69 | }); 70 | const user = await usersService.get(result.id || result._id); 71 | 72 | assert.strictEqual(result.isInvitation, false, 'user.isInvitation not false'); 73 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 74 | 75 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 76 | assert.strictEqual(user.verifyToken, null, 'verifyToken not null'); 77 | assert.strictEqual(user.verifyShortToken, null, 'verifyShortToken not null'); 78 | assert.strictEqual(user.verifyExpires, null, 'verifyExpires not null'); 79 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 80 | } catch (err) { 81 | console.log(err); 82 | assert(false, 'err code set' + err.message); 83 | } 84 | }); 85 | 86 | it('verifies valid token if not verified invited user', async () => { 87 | try { 88 | result = await authLocalMgntService.create({ 89 | action: 'verifySignupLong', 90 | value: '999', 91 | newPassword: 'abcd', 92 | }); 93 | const user = await usersService.get(result.id || result._id); 94 | 95 | assert.strictEqual(result.isInvitation, false, 'user.isInvitation not false'); 96 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 97 | 98 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 99 | assert.strictEqual(user.verifyToken, null, 'verifyToken not null'); 100 | assert.strictEqual(user.verifyShortToken, null, 'verifyShortToken not null'); 101 | assert.strictEqual(user.verifyExpires, null, 'verifyExpires not null'); 102 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 103 | 104 | assert.strictEqual(user.password, 'abcd', 'password incorrect'); 105 | } catch (err) { 106 | console.log(err); 107 | assert(false, 'err code set' + err.message); 108 | } 109 | }); 110 | 111 | it('verifies valid token if verifyChanges', async () => { 112 | try { 113 | result = await authLocalMgntService.create({ 114 | action: 'verifySignupLong', 115 | value: '800', 116 | }); 117 | const user = await usersService.get(result.id || result._id); 118 | 119 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 120 | 121 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 122 | assert.strictEqual(user.verifyToken, null, 'verifyToken not null'); 123 | assert.strictEqual(user.verifyShortToken, null, 'verifyShortToken not null'); 124 | assert.strictEqual(user.verifyExpires, null, 'verifyExpires not null'); 125 | assert.deepEqual(user.verifyChanges, {}, 'verifyChanges not empty object'); 126 | 127 | assert.strictEqual(user.cellphone, '800', 'cellphone wrong'); 128 | } catch (err) { 129 | console.log(err); 130 | assert(false, 'err code set'); 131 | } 132 | }); 133 | 134 | it('user is sanitized', async () => { 135 | try { 136 | result = await authLocalMgntService.create({ 137 | action: 'verifySignupLong', 138 | value: '000', 139 | }); 140 | const user = await usersService.get(result.id || result._id); 141 | 142 | assert.strictEqual(result.isVerified, true, 'isVerified not true'); 143 | assert.strictEqual(result.verifyToken, undefined, 'verifyToken not undefined'); 144 | assert.strictEqual(result.verifyShortToken, undefined, 'verifyShortToken not undefined'); 145 | assert.strictEqual(result.verifyExpires, undefined, 'verifyExpires not undefined'); 146 | assert.strictEqual(result.verifyChanges, undefined, 'verifyChanges not undefined'); 147 | } catch (err) { 148 | console.log(err); 149 | assert(false, 'err code set'); 150 | } 151 | }); 152 | 153 | it('error on verified user without verifyChange', async () => { 154 | try { 155 | result = await authLocalMgntService.create({ 156 | action: 'verifySignupLong', 157 | value: '222', 158 | }, 159 | {}, 160 | (err, user) => {} 161 | ); 162 | 163 | assert(fail, 'unexpectedly succeeded'); 164 | } catch (err) { 165 | assert.isString(err.message); 166 | assert.isNotFalse(err.message); 167 | } 168 | }); 169 | 170 | it('error on expired token', async () => { 171 | try { 172 | result = await authLocalMgntService.create({ 173 | action: 'verifySignupLong', 174 | value: '111', 175 | }); 176 | 177 | assert(fail, 'unexpectedly succeeded'); 178 | } catch (err) { 179 | assert.isString(err.message); 180 | assert.isNotFalse(err.message); 181 | } 182 | }); 183 | 184 | it('error on token not found', async () => { 185 | try { 186 | result = await authLocalMgntService.create({ 187 | action: 'verifySignupLong', 188 | value: 'xxx' 189 | }); 190 | 191 | assert(false, 'unexpectedly succeeded'); 192 | } catch (err) { 193 | assert.isString(err.message); 194 | assert.equal(err.message, 'User not found.'); 195 | assert.isNotFalse(err.message); 196 | } 197 | }); 198 | }); 199 | 200 | describe('with notification', () => { 201 | let app; 202 | let usersService; 203 | let authLocalMgntService; 204 | let db; 205 | let result; 206 | 207 | beforeEach(async () => { 208 | stack = []; 209 | 210 | app = feathers(); 211 | app.configure(makeUsersService({ id: idType, paginate: pagination === 'paginated' })); 212 | app.configure(authLocalMgnt({ 213 | testMode: true, 214 | plugins: [{ 215 | trigger: 'notifier', 216 | position: 'before', 217 | run: async (accumulator, { type, sanitizedUser, notifierOptions }, { options }, pluginContext) => { 218 | stack.push({ args: clone([type, sanitizedUser, notifierOptions]), result: sanitizedUser }); 219 | }, 220 | }], 221 | })); 222 | app.setup(); 223 | authLocalMgntService = app.service('localManagement'); 224 | 225 | usersService = app.service('users'); 226 | await usersService.remove(null); 227 | db = clone(idType === '_id' ? users_Id : usersId); 228 | await usersService.create(db); 229 | }); 230 | 231 | it('verifies valid token', async () => { 232 | try { 233 | result = await authLocalMgntService.create({ 234 | action: 'verifySignupLong', 235 | value: '000', 236 | }); 237 | const user = await usersService.get(result.id || result._id); 238 | 239 | assert.strictEqual(result.isVerified, true, 'user.isVerified not true'); 240 | 241 | assert.strictEqual(user.isVerified, true, 'isVerified not true'); 242 | assert.strictEqual(user.verifyToken, null, 'verifyToken not null'); 243 | assert.strictEqual(user.verifyExpires, null, 'verifyExpires not null'); 244 | 245 | assert.deepEqual(stack[0].args, [ 246 | 'verifySignup', 247 | Object.assign({}, sanitizeUserForEmail(user)), 248 | null 249 | ]); 250 | } catch (err) { 251 | console.log(err); 252 | assert(false, 'err code set'); 253 | } 254 | }); 255 | }); 256 | }); 257 | }); 258 | }); 259 | 260 | // Helpers 261 | 262 | function notifier(app, options) { 263 | return async (...args) => { 264 | const [ type, sanitizedUser, notifierOptions ] = args; 265 | 266 | stack.push({ args: clone(args), result: sanitizedUser }); 267 | 268 | return sanitizedUser 269 | } 270 | } 271 | 272 | function sanitizeUserForEmail(user) { 273 | const user1 = Object.assign({}, user); 274 | delete user1.password; 275 | return user1; 276 | } 277 | 278 | function clone(obj) { 279 | return JSON.parse(JSON.stringify(obj)); 280 | } 281 | --------------------------------------------------------------------------------