├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── config ├── default.json └── production.json ├── package.json ├── public ├── app.js └── index.html ├── src ├── app.hooks.js ├── app.js ├── authentication.js ├── email-templates │ └── sign-in.js ├── hooks │ └── logger.js ├── index.js ├── middleware │ └── index.js ├── models │ └── users.model.js └── services │ ├── auth-management │ ├── auth-management.hooks.js │ ├── auth-management.service.js │ └── notifier.js │ ├── index.js │ ├── mailer │ ├── mailer.hooks.js │ └── mailer.service.js │ └── users │ ├── users.filters.js │ ├── users.hooks.js │ └── users.service.js └── test ├── app.test.js └── services ├── auth-management.test.js ├── mailer.test.js └── users.test.js /.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 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "rules": { 9 | "indent": [ 10 | "error", 11 | 2 12 | ], 13 | "linebreak-style": [ 14 | "error", 15 | "unix" 16 | ], 17 | "quotes": [ 18 | "error", 19 | "single" 20 | ], 21 | "semi": [ 22 | "error", 23 | "always" 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | # IDEs and editors (shamelessly copied from @angular/cli's .gitignore) 31 | /.idea 32 | .project 33 | .classpath 34 | .c9/ 35 | *.launch 36 | .settings/ 37 | *.sublime-workspace 38 | 39 | # IDE - VSCode 40 | .vscode/* 41 | !.vscode/settings.json 42 | !.vscode/tasks.json 43 | !.vscode/launch.json 44 | !.vscode/extensions.json 45 | 46 | ### Linux ### 47 | *~ 48 | 49 | # temporary files which can be created if a process still has a handle open of a deleted file 50 | .fuse_hidden* 51 | 52 | # KDE directory preferences 53 | .directory 54 | 55 | # Linux trash folder which might appear on any partition or disk 56 | .Trash-* 57 | 58 | # .nfs files are created when an open file is removed but is still being accessed 59 | .nfs* 60 | 61 | ### OSX ### 62 | *.DS_Store 63 | .AppleDouble 64 | .LSOverride 65 | 66 | # Icon must end with two \r 67 | Icon 68 | 69 | 70 | # Thumbnails 71 | ._* 72 | 73 | # Files that might appear in the root of a volume 74 | .DocumentRevisions-V100 75 | .fseventsd 76 | .Spotlight-V100 77 | .TemporaryItems 78 | .Trashes 79 | .VolumeIcon.icns 80 | .com.apple.timemachine.donotpresent 81 | 82 | # Directories potentially created on remote AFP share 83 | .AppleDB 84 | .AppleDesktop 85 | Network Trash Folder 86 | Temporary Items 87 | .apdisk 88 | 89 | ### Windows ### 90 | # Windows thumbnail cache files 91 | Thumbs.db 92 | ehthumbs.db 93 | ehthumbs_vista.db 94 | 95 | # Folder config file 96 | Desktop.ini 97 | 98 | # Recycle Bin used on file shares 99 | $RECYCLE.BIN/ 100 | 101 | # Windows Installer files 102 | *.cab 103 | *.msi 104 | *.msm 105 | *.msp 106 | 107 | # Windows shortcuts 108 | *.lnk 109 | 110 | # Others 111 | lib/ 112 | data/ 113 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | data/ 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 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 | # feathers-passwordless-auth-example 2 | 3 | > Example of passwordless authentication in FeathersJS 4 | 5 | ## About 6 | 7 | This project uses [Feathers](http://feathersjs.com). It accomplishes a passwordless authentication 8 | strategy by using local authentication and the reset-password functionality from the 9 | [feathers-authentication-management](https://github.com/feathersjs/feathers-authentication-management) package. 10 | 11 | ## Run the app 12 | - clone the repo 13 | - cd into the directory 14 | - run `npm install` 15 | - run `FROM_EMAIL=your.email@domain.com npm start` 16 | 17 | The client-side isn't functioning yet. I need to add a simple client-side router. 18 | Right now, the client-side code is only for showing code examples. 19 | 20 | ## Recreate the Example 21 | Example repos have package versions that quickly fall out of date. For that reason, 22 | and so that you can integrate the passwordless strategy into an existing Feathers app, 23 | I've include the list of steps to show how the example app was created. 24 | 25 | ### Generate app 26 | Use the feathers-cli to generate the initial app structure. 27 | Use the default options for `feathers generate authentication`, but choose the options 28 | for the database you're using. 29 | 30 | ``` 31 | mkdir example-app 32 | cd example-app 33 | feathers generate app 34 | feathers generate authentication 35 | ``` 36 | 37 | ### Config 38 | In `config/default.json`, set the value of `authentication.local.passwordField` to "email" 39 | We're using the local auth strategy, but users won't actually have a password. 40 | 41 | Also add the keys "protocal" and "src" keys to your config. We'll need these to build 42 | this link that we put in the email. 43 | 44 | ### User hooks file 45 | There's too much code to include in this list of steps, but just copy/paste all of 46 | `src/services/users/users.hooks.js`. 47 | In the code changes, we are: 48 | 49 | 1. Removing all the `hashPassword` hooks, to prevent the email address from getting hashed. 50 | Since we set the email address as the password field, it would get hashed if we don't 51 | remove all the calls to `hashPassword` 52 | 2. Add/Remove verification properties on user objects, and prevent those properties from 53 | getting changed by external providers (rest/socket.io) 54 | 55 | ### User model 56 | If you have a user model in `src/models/users.model.js`, add these properties. 57 | 58 | ``` 59 | isVerified: { type: Boolean }, 60 | verifyToken: { type: String }, 61 | verifyExpires: { type: Date }, 62 | verifyChanges: { type: Object }, 63 | resetToken: { type: String }, 64 | resetExpires: { type: Date } 65 | ``` 66 | 67 | ### Authentication service 68 | In `authentication.js`, before the existing create hook, add 2 hooks. 69 | - The first hook disallows local authentication from external providers. 70 | - The second hook puts the userId onto params.payload so that it gets into the jwt token. 71 | 72 | ``` 73 | const { iff, disallow } = require('feathers-hooks-common'); 74 | 75 | ... 76 | 77 | before: { 78 | create: [ 79 | iff(hook => hook.data.strategy === 'local', disallow('external')), 80 | iff(hook => hook.data.strategy === 'local', hook => { 81 | const query = { email: hook.data.email } 82 | return hook.app.service('users').find({ query }).then(users => { 83 | hook.params.payload = { userId: users.data[0]._id } 84 | return hook 85 | }) 86 | }), 87 | authentication.hooks.authenticate(config.strategies) 88 | ], 89 | ``` 90 | 91 | ### Email templates 92 | Add a folder for your email templates. This repo uses Handlebars for email templates. 93 | The email file exports html and plain text. 94 | `src/email-templates/sign-in.js` 95 | 96 | ### Mailer Service 97 | Add a service for sending emails. How you setup the mailer service will vary according to your preferred way of sending emails. This example uses nodemailer sendmail, which sends emails directly to destination host. 98 | 99 | 100 | ``` 101 | feathers generate service 102 | ``` 103 | 104 | I called the service 'mailer', and set it up as custom service. Choose "No" for authentication. 105 | 106 | The `mailer.service.js` file is setup to use `feathers-mailer` and `nodemailer-sendmail-transport`. 107 | We also use the environment variable `FROM_EMAIL` in `src/services/notifier` to set email address the notifications are sent from. 108 | 109 | In `mailer.hooks.js`, disallow all external providers, in the `before all` hooks array. 110 | In this file, we're precompiling email templates and associating them with a key. Since we'll be 111 | sending a lot of emails, it's best to precompile the handlebar templates so that the emails are 112 | sent faster. When you call create on the mailer service, you'll pass in a `template` key and a data 113 | object. The hook will take care of compiling the email. 114 | 115 | ### Authentication Management Service 116 | As mentioned, we're using the reset password flow from the 117 | [feathers-authentication-management](https://github.com/feathersjs/feathers-authentication-management) 118 | package to achieve a passwordless auth strategy. 119 | 120 | ``` 121 | feathers generate service 122 | ``` 123 | 124 | Name it "authManagement" and change the path to "/authManagement". Choose "No" for authentication. 125 | 126 | ### notifier 127 | The `feathers-authentication-management` package uses a "notifier" function, which listens for 128 | various auth management actions and calls "create" on the mailer service with a payload that 129 | includes all the data needed to compose the email. Copy/paste the `src/services/auth-management/notifier.js` 130 | file from this repo. It currently only listens for "sendResetPwd", but you can listen to any of the 131 | actions supported by the auth management package. 132 | 133 | ### auth-management.service.js 134 | Copy/paste the configuration for the auth-management package. 135 | 136 | We set `skipIsVerifiedCheck` to allow us avoid having to make the user verify their email address. 137 | They are essentially verifying it when they sign in. 138 | 139 | Include a `sanitizeUserForClient` function to prevent sending user info to client prior to authentication. 140 | We need the email address though, so only send that. 141 | 142 | ### auth-management.hooks.js 143 | In this file, we add a `before create` hook and an `after create` hook. 144 | 145 | After the user clicks on the link in the email, the app will create a 'resetPwdLong' action. 146 | This action requires a password. We're not using passwords, so in the `before create` hook, 147 | we provide an empty string to bypass the error caused by not having a password. 148 | 149 | In the `after create` hook, again for the 'resetPwdLong' action, use the authentication service 150 | to get an access token and attach it to the result so it get's sent to the user. 151 | 152 | ### Sign in on client 153 | This repo doesn't use the `feathers-authentication-management` client library. 154 | I got an error when using it. I may have used in incorrectly, but it's not needed anyway. 155 | When you get the email address from the input field, create a 'sendResetPwd' action. 156 | ``` 157 | // const app = feathers() 158 | // const authManagement = app.service('authManagement') 159 | 160 | authManagement.create({ 161 | action: 'sendResetPwd', 162 | value: { email } 163 | }) 164 | 165 | ``` 166 | 167 | This will cause an email to be sent. 168 | 169 | 170 | ### Handle navigation from email 171 | Client code will vary a lot, but make a route that matches the link in the email. 172 | In that way, you can get the resetToken from a route param. 173 | 174 | Note: put the token in a route path param and not a query param. 175 | I orignally put the token in a query param and this caused problems 176 | when the user clicked on the email link, but it worked when the link is copy/pasted into 177 | the address bar. The problem may be related to Nuxt, but i would use a route path param to be safe. 178 | 179 | When you have the reset token, create a "resetPwdLong" action. You'll get back the email 180 | address and access token. You can then use these to authenticate and fetch the user. 181 | 182 | ``` 183 | authManagement.create({ action: 'resetPwdLong', token: resetToken }) 184 | .then(result => { 185 | return app.authenticate({ 186 | strategy: 'jwt', 187 | token: result.accessToken 188 | }) 189 | .then(() => app.service('users').find({ query: { email: result.email } })) 190 | }) 191 | .then(users => { 192 | console.log('user', users.data[0]) 193 | }) 194 | }) 195 | ``` 196 | 197 | ## Conclusion 198 | Thanks to the nice people on the Feathers Slack channel for helping me figure this out. 199 | Thanks to [this tutorial](https://blog.feathersjs.com/how-to-setup-email-verification-in-feathersjs-72ce9882e744) 200 | from where I took some code. 201 | 202 | ## Contribute 203 | Contributions and improvements always welcome. Please start with making an issue. 204 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "protocal": "http", 3 | "host": "localhost", 4 | "port": 3030, 5 | "public": "../public/", 6 | "src": "../src/", 7 | "paginate": { 8 | "default": 10, 9 | "max": 50 10 | }, 11 | "authentication": { 12 | "secret": "05875c70cc2cc4f19059964f296a4aefdd1b6ca99bf0045a99f0dacf9bc2cc1029ef31f8dbf9efac7b957aac583ebaf1295c7086c3c35368fda6c8427d942b59cf7af7c6a2c48a0d2fc974b9c1039a705170356378d2c49ecef246e6fac5c27e192cc1e749d0f348e3cac5c987c8348a6bef074ba1df85d46a882cbda692243a48463679de37b988fcd26ca89ad0c6b74f0b15fe6e51d945491c60bfd8f026d958cbd9ffb58f0c7bec9f99c5df397a377f303471bc3f4d518edd0c99c12d4dbdabc8e730bf734e1087b9e2a0e57436ef679f47fe06ba778318f223b8de27a25b090f5394dbe26cf0546080b7f8021e819d974235991d740063ebcae1da108ed2", 13 | "strategies": [ 14 | "jwt", 15 | "local" 16 | ], 17 | "path": "/authentication", 18 | "service": "users", 19 | "jwt": { 20 | "header": { 21 | "type": "access" 22 | }, 23 | "audience": "https://yourdomain.com", 24 | "subject": "anonymous", 25 | "issuer": "feathers", 26 | "algorithm": "HS256", 27 | "expiresIn": "1d" 28 | }, 29 | "local": { 30 | "entity": "user", 31 | "usernameField": "email", 32 | "passwordField": "email" 33 | } 34 | }, 35 | "nedb": "../data" 36 | } 37 | -------------------------------------------------------------------------------- /config/production.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "feathers-passwordless-auth-example-app.feathersjs.com", 3 | "port": "PORT" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feathers-passwordless-auth-example", 3 | "description": "Example of passwordless authentication in FeathersJS", 4 | "version": "0.0.0", 5 | "homepage": "", 6 | "main": "src", 7 | "keywords": [ 8 | "feathers" 9 | ], 10 | "author": { 11 | "name": "Nicholas Baroni", 12 | "email": "nick@rhythnic.com" 13 | }, 14 | "contributors": [], 15 | "bugs": {}, 16 | "directories": { 17 | "lib": "src", 18 | "test": "test/" 19 | }, 20 | "engines": { 21 | "node": ">= 6.0.0", 22 | "npm": ">= 3.0.0" 23 | }, 24 | "scripts": { 25 | "test": "npm run eslint && npm run mocha", 26 | "eslint": "eslint src/. test/. --config .eslintrc.json", 27 | "start": "node src/", 28 | "mocha": "mocha test/ --recursive" 29 | }, 30 | "dependencies": { 31 | "body-parser": "^1.17.2", 32 | "compression": "^1.7.0", 33 | "cors": "^2.8.4", 34 | "feathers": "^2.1.7", 35 | "feathers-authentication": "^1.2.7", 36 | "feathers-authentication-hooks": "^0.1.4", 37 | "feathers-authentication-jwt": "^0.3.2", 38 | "feathers-authentication-local": "^0.4.4", 39 | "feathers-authentication-management": "^1.0.0", 40 | "feathers-configuration": "^0.4.1", 41 | "feathers-errors": "^2.9.1", 42 | "feathers-hooks": "^2.0.2", 43 | "feathers-hooks-common": "^3.7.1", 44 | "feathers-mailer": "^2.0.0", 45 | "feathers-nedb": "^2.7.0", 46 | "feathers-rest": "^1.8.0", 47 | "feathers-socketio": "^2.0.0", 48 | "handlebars": "^4.0.11", 49 | "helmet": "^3.8.1", 50 | "nedb": "^1.8.0", 51 | "nodemailer-sendmail-transport": "^1.0.2", 52 | "winston": "^2.3.1" 53 | }, 54 | "devDependencies": { 55 | "eslint": "^4.4.1", 56 | "mocha": "^3.5.0", 57 | "request": "^2.81.0", 58 | "request-promise": "^4.2.1" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /public/app.js: -------------------------------------------------------------------------------- 1 | // ************************************************************************* 2 | // Client code isn't working yet 3 | // It's here for showing code examples. 4 | // ************************************************************************* 5 | 6 | // Configure Feathers client 7 | const socket = io() 8 | const client = feathers() 9 | .configure(feathers.hooks()) 10 | .configure(feathers.socketio(socket)) 11 | .configure(feathers.authentication({ 12 | storage: window.localStorage 13 | })) 14 | 15 | const authManagement = client.service('authManagement'); 16 | 17 | 18 | // Get user's email address and initiate email sending 19 | const form = document.getElementById('loginForm') 20 | form.addEventListener('submit', evt => { 21 | evt.preventDefault() 22 | const email = form.elements['email'].value 23 | authManagement.create({ 24 | action: 'sendResetPwd', 25 | value: { email } 26 | }) 27 | .catch(console.error) 28 | }) 29 | 30 | // after user navigates from their email 31 | // get resetToken from route and exchange it for an access token 32 | const emailTokenIndex = window.location.pathname.indexOf('email-token') 33 | if (emailTokenIndex > -1) { 34 | const resetToken = window.location.pathname.slice(emailTokenIndex).split('/')[1] 35 | authManagement.create({ action: 'resetPwdLong', token: resetToken }) 36 | .then(result => { 37 | return client.authenticate({ 38 | strategy: 'jwt', 39 | token: result.accessToken 40 | }) 41 | .then(() => client.service('users').find({ query: { email: result.email } })) 42 | }) 43 | .then(users => { 44 | console.log('user', users.data[0]) 45 | }) 46 | } 47 | 48 | 49 | 50 | //logout 51 | document.getElementById('logoutBtn').addEventListener('click', evt => { 52 | client.logout().then(() => { console.log('logged out') }) 53 | }) 54 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/app.hooks.js: -------------------------------------------------------------------------------- 1 | // Application hooks that run for every service 2 | const logger = require('./hooks/logger'); 3 | 4 | module.exports = { 5 | before: { 6 | all: [], 7 | find: [], 8 | get: [], 9 | create: [], 10 | update: [], 11 | patch: [], 12 | remove: [] 13 | }, 14 | 15 | after: { 16 | all: [ logger() ], 17 | find: [], 18 | get: [], 19 | create: [], 20 | update: [], 21 | patch: [], 22 | remove: [] 23 | }, 24 | 25 | error: { 26 | all: [ logger() ], 27 | find: [], 28 | get: [], 29 | create: [], 30 | update: [], 31 | patch: [], 32 | remove: [] 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const compress = require('compression'); 3 | const cors = require('cors'); 4 | const helmet = require('helmet'); 5 | const bodyParser = require('body-parser'); 6 | 7 | const feathers = require('feathers'); 8 | const configuration = require('feathers-configuration'); 9 | const hooks = require('feathers-hooks'); 10 | const rest = require('feathers-rest'); 11 | const socketio = require('feathers-socketio'); 12 | 13 | const handler = require('feathers-errors/handler'); 14 | const notFound = require('feathers-errors/not-found'); 15 | 16 | const middleware = require('./middleware'); 17 | const services = require('./services'); 18 | const appHooks = require('./app.hooks'); 19 | 20 | const authentication = require('./authentication'); 21 | 22 | const app = feathers(); 23 | 24 | // Load app configuration 25 | app.configure(configuration()); 26 | // Enable CORS, security, compression and body parsing 27 | app.use(cors()); 28 | app.use(helmet()); 29 | app.use(compress()); 30 | app.use(bodyParser.json()); 31 | app.use(bodyParser.urlencoded({ extended: true })); 32 | // Host the public folder 33 | app.use('/', feathers.static(app.get('public'))); 34 | 35 | // Set up Plugins and providers 36 | app.configure(hooks()); 37 | app.configure(rest()); 38 | app.configure(socketio()); 39 | 40 | // Configure other middleware (see `middleware/index.js`) 41 | app.configure(middleware); 42 | app.configure(authentication); 43 | // Set up our services (see `services/index.js`) 44 | app.configure(services); 45 | // Configure a middleware for 404s and the error handler 46 | app.use(notFound()); 47 | app.use(handler()); 48 | 49 | app.hooks(appHooks); 50 | 51 | module.exports = app; 52 | -------------------------------------------------------------------------------- /src/authentication.js: -------------------------------------------------------------------------------- 1 | const authentication = require('feathers-authentication'); 2 | const jwt = require('feathers-authentication-jwt'); 3 | const local = require('feathers-authentication-local'); 4 | const { iff, disallow } = require('feathers-hooks-common'); 5 | 6 | 7 | module.exports = function () { 8 | const app = this; 9 | const config = app.get('authentication'); 10 | 11 | // Set up authentication with the secret 12 | app.configure(authentication(config)); 13 | app.configure(jwt()); 14 | app.configure(local(config.local)); 15 | 16 | // The `authentication` service is used to create a JWT. 17 | // The before `create` hook registers strategies that can be used 18 | // to create a new valid JWT (e.g. local or oauth2) 19 | app.service('authentication').hooks({ 20 | before: { 21 | create: [ 22 | iff(hook => hook.data.strategy === 'local', disallow('external')), 23 | iff(hook => hook.data.strategy === 'local', hook => { 24 | const query = { email: hook.data.email } 25 | return hook.app.service('users').find({ query }).then(users => { 26 | hook.params.payload = { userId: users.data[0]._id } 27 | return hook 28 | }) 29 | }), 30 | authentication.hooks.authenticate(config.strategies) 31 | ], 32 | remove: [ 33 | authentication.hooks.authenticate('jwt') 34 | ] 35 | } 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /src/email-templates/sign-in.js: -------------------------------------------------------------------------------- 1 | module.exports.html = ` 2 | 3 | 4 |
5 |

Hi

6 |
7 |
8 |

Please follow the link below to sign in to my app.

9 | {{hashLink}} 10 |
11 | 12 | 13 | ` 14 | 15 | module.exports.text = ` 16 | Hi! 17 | 18 | Please follow the link below to sign in to my app. 19 | 20 | {{hashLink}} 21 | ` 22 | -------------------------------------------------------------------------------- /src/hooks/logger.js: -------------------------------------------------------------------------------- 1 | // A hook that logs service method before, after and error 2 | const logger = require('winston'); 3 | 4 | module.exports = function () { 5 | return function (hook) { 6 | let message = `${hook.type}: ${hook.path} - Method: ${hook.method}`; 7 | 8 | if (hook.type === 'error') { 9 | message += `: ${hook.error.message}`; 10 | } 11 | 12 | logger.info(message); 13 | logger.debug('hook.data', hook.data); 14 | logger.debug('hook.params', hook.params); 15 | 16 | if (hook.result) { 17 | logger.debug('hook.result', hook.result); 18 | } 19 | 20 | if (hook.error) { 21 | logger.error(hook.error); 22 | } 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const logger = require('winston'); 3 | const app = require('./app'); 4 | const port = app.get('port'); 5 | const server = app.listen(port); 6 | 7 | process.on('unhandledRejection', (reason, p) => 8 | logger.error('Unhandled Rejection at: Promise ', p, reason) 9 | ); 10 | 11 | server.on('listening', () => 12 | logger.info(`Feathers application started on ${app.get('host')}:${port}`) 13 | ); 14 | -------------------------------------------------------------------------------- /src/middleware/index.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | // Add your custom middleware here. Remember, that 3 | // in Express the order matters 4 | const app = this; // eslint-disable-line no-unused-vars 5 | }; 6 | -------------------------------------------------------------------------------- /src/models/users.model.js: -------------------------------------------------------------------------------- 1 | const NeDB = require('nedb'); 2 | const path = require('path'); 3 | 4 | module.exports = function (app) { 5 | const dbPath = app.get('nedb'); 6 | const Model = new NeDB({ 7 | filename: path.join(dbPath, 'users.db'), 8 | autoload: true 9 | }); 10 | 11 | Model.ensureIndex({ fieldName: 'email', unique: true }); 12 | 13 | return Model; 14 | }; 15 | -------------------------------------------------------------------------------- /src/services/auth-management/auth-management.hooks.js: -------------------------------------------------------------------------------- 1 | const { iff, disallow } = require('feathers-hooks-common'); 2 | 3 | module.exports = { 4 | before: { 5 | all: [], 6 | find: [], 7 | get: [], 8 | create: [ 9 | iff(hook => hook.data.action === 'resetPwdLong', hook => { 10 | hook.data.value.password = '' 11 | return Promise.resolve(hook) 12 | }) 13 | ], 14 | update: [], 15 | patch: [], 16 | remove: [] 17 | }, 18 | 19 | after: { 20 | all: [], 21 | find: [], 22 | get: [], 23 | create: [ 24 | iff(hook => hook.data.action === 'resetPwdLong', hook => { 25 | const { app, result } = hook 26 | return app.service('authentication').create({ 27 | strategy: 'local', 28 | email: result.email 29 | }) 30 | .then(({ accessToken }) => { 31 | result.accessToken = accessToken 32 | return hook; 33 | }) 34 | }) 35 | ], 36 | update: [], 37 | patch: [], 38 | remove: [] 39 | }, 40 | 41 | error: { 42 | all: [], 43 | find: [], 44 | get: [], 45 | create: [], 46 | update: [], 47 | patch: [], 48 | remove: [] 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /src/services/auth-management/auth-management.service.js: -------------------------------------------------------------------------------- 1 | const authManagement = require('feathers-authentication-management'); 2 | const hooks = require('./auth-management.hooks'); 3 | const configureNotifier = require('./notifier'); 4 | 5 | module.exports = function () { 6 | const app = this; 7 | 8 | app.configure(authManagement({ 9 | skipIsVerifiedCheck: true, 10 | sanitizeUserForClient (user) { 11 | // avoid returning the user to the client when not authorized 12 | return { email: user.email } 13 | }, 14 | notifier: configureNotifier(app) 15 | })) 16 | 17 | // Get our initialized service so that we can register hooks and filters 18 | const service = app.service('authManagement'); 19 | 20 | service.hooks(hooks); 21 | }; 22 | -------------------------------------------------------------------------------- /src/services/auth-management/notifier.js: -------------------------------------------------------------------------------- 1 | const isProd = process.env.NODE_ENV === 'production' 2 | 3 | module.exports = function configureNotifier(app) { 4 | function getLink (path) { 5 | const port = (app.get('port') === '80' || isProd) ? '' : ':' + app.get('port') 6 | const host = (app.get('host') === 'HOST') ? 'localhost' : app.get('host') 7 | let protocal = (app.get('protocal') === 'PROTOCAL') ? 'http' : app.get('protocal') 8 | return `${protocal}://${host}${port}/${path}` 9 | } 10 | 11 | function sendEmail (email) { 12 | return app.service('mailer') 13 | .create(email) 14 | .catch(err => { console.log('Error sending email', err) }) 15 | } 16 | 17 | return function notifier(type, user, notifierOptions) { 18 | let email 19 | switch (type) { 20 | case 'sendResetPwd': 21 | return sendEmail({ 22 | to: user.email, 23 | from: process.env.FROM_EMAIL, 24 | subject: 'Sign in to my app', 25 | template: 'signIn', 26 | data: { 27 | hashLink: getLink(`auth/email-token/${user.resetToken}`), 28 | name: user.name || user.email 29 | } 30 | }) 31 | default: 32 | return Promise.resolve(user); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/services/index.js: -------------------------------------------------------------------------------- 1 | const users = require('./users/users.service.js'); 2 | const mailer = require('./mailer/mailer.service.js'); 3 | const authManagement = require('./auth-management/auth-management.service.js'); 4 | module.exports = function () { 5 | const app = this; // eslint-disable-line no-unused-vars 6 | app.configure(users); 7 | app.configure(mailer); 8 | app.configure(authManagement); 9 | }; 10 | -------------------------------------------------------------------------------- /src/services/mailer/mailer.hooks.js: -------------------------------------------------------------------------------- 1 | const { disallow } = require('feathers-hooks-common'); 2 | const Handlebars = require('handlebars') 3 | const signIn = require('../../email-templates/sign-in') 4 | 5 | const templates = { 6 | signIn: { 7 | html: Handlebars.compile(signIn.html), 8 | text: Handlebars.compile(signIn.text) 9 | } 10 | } 11 | 12 | module.exports = { 13 | before: { 14 | all: [ 15 | disallow('external') 16 | ], 17 | create: [ 18 | hook => { 19 | const template = templates[hook.data.template] 20 | if (!template) { 21 | return Promise.reject(new Error(`Unknown email template: ${hook.data.template}`)) 22 | } 23 | if (template.html) hook.data.html = template.html(hook.data.data) 24 | if (template.text) hook.data.text = template.text(hook.data.data) 25 | delete hook.data.data 26 | delete hook.data.template 27 | return Promise.resolve(hook) 28 | } 29 | ] 30 | }, 31 | after: { 32 | create: [ 33 | hook => { 34 | const email = hook.data 35 | console.log(`Sent email to ${email.to} with subject: ${email.subject}`) 36 | return Promise.resolve(hook) 37 | } 38 | ] 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/services/mailer/mailer.service.js: -------------------------------------------------------------------------------- 1 | // Initializes the `mailer` service on path `/mailer` 2 | const hooks = require('./mailer.hooks'); 3 | const Mailer = require('feathers-mailer'); 4 | const sendmailTransport = require('nodemailer-sendmail-transport'); 5 | 6 | module.exports = function () { 7 | const app = this; 8 | 9 | app.use('/mailer', Mailer(sendmailTransport({ 10 | sendmail: true 11 | // see https://nodemailer.com/transports/sendmail/ for options 12 | }))); 13 | 14 | // Get our initialized service so that we can register hooks and filters 15 | const service = app.service('mailer'); 16 | 17 | service.hooks(hooks); 18 | }; 19 | -------------------------------------------------------------------------------- /src/services/users/users.filters.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 1 */ 2 | console.warn('You are using the default filter for the users service. For more information about event filters see https://docs.feathersjs.com/api/events.html#event-filtering'); // eslint-disable-line no-console 3 | 4 | module.exports = function (data, connection, hook) { // eslint-disable-line no-unused-vars 5 | return false; 6 | }; 7 | -------------------------------------------------------------------------------- /src/services/users/users.hooks.js: -------------------------------------------------------------------------------- 1 | const { authenticate } = require('feathers-authentication').hooks; 2 | const { iff, isProvider, preventChanges, when, discard } = require('feathers-hooks-common'); 3 | const { restrictToOwner } = require('feathers-authentication-hooks'); 4 | const verifyHooks = require('feathers-authentication-management').hooks; 5 | 6 | function removeVerificationProperties (user) { 7 | delete user.verifyExpires; 8 | delete user.resetExpires; 9 | delete user.verifyChanges; 10 | delete user.verifyToken; 11 | delete user.verifyShortToken; 12 | delete user.resetToken; 13 | delete user.resetShortToken; 14 | } 15 | 16 | const preventVerificationPropertyChanges = 17 | iff(isProvider('external'), preventChanges( 18 | 'isVerified', 19 | 'verifyToken', 20 | 'verifyShortToken', 21 | 'verifyExpires', 22 | 'verifyChanges', 23 | 'resetToken', 24 | 'resetShortToken', 25 | 'resetExpires' 26 | )) 27 | 28 | const restrict = [ 29 | authenticate('jwt'), 30 | restrictToOwner({ 31 | idField: '_id', 32 | ownerField: '_id' 33 | }) 34 | ]; 35 | 36 | module.exports = { 37 | before: { 38 | all: [], 39 | find: [ authenticate('jwt') ], 40 | get: [ ...restrict ], 41 | create: [ 42 | verifyHooks.addVerification() 43 | ], 44 | update: [ 45 | ...restrict, 46 | preventVerificationPropertyChanges 47 | ], 48 | patch: [ 49 | ...restrict, 50 | preventVerificationPropertyChanges 51 | ], 52 | remove: [ ...restrict ] 53 | }, 54 | 55 | after: { 56 | all: [ 57 | when( 58 | hook => hook.params.provider, 59 | discard('password') 60 | ) 61 | ], 62 | find: [ 63 | iff(isProvider('external'), hook => { 64 | if (Array.isArray(hook.result.data)) { 65 | hook.result.data.forEach(removeVerificationProperties) 66 | } 67 | return Promise.resolve(hook) 68 | }) 69 | ], 70 | get: [ 71 | iff(isProvider('external'), verifyHooks.removeVerification()) 72 | ], 73 | create: [ 74 | verifyHooks.removeVerification() 75 | ], 76 | update: [], 77 | patch: [], 78 | remove: [] 79 | }, 80 | 81 | error: { 82 | all: [], 83 | find: [], 84 | get: [], 85 | create: [], 86 | update: [], 87 | patch: [], 88 | remove: [] 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /src/services/users/users.service.js: -------------------------------------------------------------------------------- 1 | // Initializes the `users` service on path `/users` 2 | const createService = require('feathers-nedb'); 3 | const createModel = require('../../models/users.model'); 4 | const hooks = require('./users.hooks'); 5 | const filters = require('./users.filters'); 6 | 7 | module.exports = function () { 8 | const app = this; 9 | const Model = createModel(app); 10 | const paginate = app.get('paginate'); 11 | 12 | const options = { 13 | name: 'users', 14 | Model, 15 | paginate 16 | }; 17 | 18 | // Initialize our service with any options it requires 19 | app.use('/users', createService(options)); 20 | 21 | // Get our initialized service so that we can register hooks and filters 22 | const service = app.service('users'); 23 | 24 | service.hooks(hooks); 25 | 26 | if (service.filter) { 27 | service.filter(filters); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /test/app.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const rp = require('request-promise'); 3 | const app = require('../src/app'); 4 | 5 | describe('Feathers application tests', () => { 6 | before(function(done) { 7 | this.server = app.listen(3030); 8 | this.server.once('listening', () => done()); 9 | }); 10 | 11 | after(function(done) { 12 | this.server.close(done); 13 | }); 14 | 15 | it('starts and shows the index page', () => { 16 | return rp('http://localhost:3030').then(body => 17 | assert.ok(body.indexOf('') !== -1) 18 | ); 19 | }); 20 | 21 | describe('404', function() { 22 | it('shows a 404 HTML page', () => { 23 | return rp({ 24 | url: 'http://localhost:3030/path/to/nowhere', 25 | headers: { 26 | 'Accept': 'text/html' 27 | } 28 | }).catch(res => { 29 | assert.equal(res.statusCode, 404); 30 | assert.ok(res.error.indexOf('') !== -1); 31 | }); 32 | }); 33 | 34 | it('shows a 404 JSON error without stack trace', () => { 35 | return rp({ 36 | url: 'http://localhost:3030/path/to/nowhere', 37 | json: true 38 | }).catch(res => { 39 | assert.equal(res.statusCode, 404); 40 | assert.equal(res.error.code, 404); 41 | assert.equal(res.error.message, 'Page not found'); 42 | assert.equal(res.error.name, 'NotFound'); 43 | }); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/services/auth-management.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const app = require('../../src/app'); 3 | 4 | describe('\'authManagement\' service', () => { 5 | it('registered the service', () => { 6 | const service = app.service('authManagement'); 7 | 8 | assert.ok(service, 'Registered the service'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /test/services/mailer.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const app = require('../../src/app'); 3 | 4 | describe('\'mailer\' service', () => { 5 | it('registered the service', () => { 6 | const service = app.service('mailer'); 7 | 8 | assert.ok(service, 'Registered the service'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /test/services/users.test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const app = require('../../src/app'); 3 | 4 | describe('\'users\' service', () => { 5 | it('registered the service', () => { 6 | const service = app.service('users'); 7 | 8 | assert.ok(service, 'Registered the service'); 9 | }); 10 | }); 11 | --------------------------------------------------------------------------------