├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── app ├── .env └── config │ ├── app.js │ └── database.js ├── appveyor.yml ├── config └── index.js ├── instructions.js ├── instructions.md ├── package.json ├── providers └── PersonaProvider.js ├── src └── Persona.js └── test ├── persona.spec.js └── setup.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = space 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | .DS_Store 4 | npm-debug.log 5 | .idea 6 | out 7 | .nyc_output 8 | app/database.sqlite 9 | yarn.lock 10 | shrinkwrap.yaml 11 | package-lock.json 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | .DS_Store 4 | npm-debug.log 5 | test 6 | .travis.yml 7 | .editorconfig 8 | benchmarks 9 | .idea 10 | bin 11 | out 12 | .nyc_output 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | - 8.0.0 5 | sudo: false 6 | install: 7 | - npm install 8 | after_script: 9 | - npm run coverage 10 | notifications: 11 | slack: 12 | secure: m91zkX2cLVDRDMBAUnR1d+hbZqtSHXLkuPencHadhJ3C3wm53Box8U25co/goAmjnW5HNJ1SMSIg+DojtgDhqTbReSh5gSbU0uU8YaF8smbvmUv3b2Q8PRCA7f6hQiea+a8+jAb7BOvwh66dV4Al/1DJ2b4tCjPuVuxQ96Wll7Pnj1S7yW/Hb8fQlr9wc+INXUZOe8erFin+508r5h1L4Xv0N5ZmNw+Gqvn2kPJD8f/YBPpx0AeZdDssTL0IOcol1+cDtDzMw5PAkGnqwamtxhnsw+i8OW4avFt1GrRNlz3eci5Cb3NQGjHxJf+JIALvBeSqkOEFJIFGqwAXMctJ9q8/7XyXk7jVFUg5+0Z74HIkBwdtLwi/BTyXMZAgsnDjndmR9HsuBP7OSTJF5/V7HCJZAaO9shEgS8DwR78owv9Fr5er5m9IMI+EgSH3qtb8iuuQaPtflbk+cPD3nmYbDqmPwkSCXcXRfq3IxdcV9hkiaAw52AIqqhnAXJWZfL6+Ct32i2mtSaov9FYtp/G0xb4tjrUAsDUd/AGmMJNEBVoHtP7mKjrVQ35cEtFwJr/8SmZxGvOaJXPaLs43dhXKa2tAGl11wF02d+Rz1HhbOoq9pJvJuqkLAVvRdBHUJrB4/hnTta5B0W5pe3mIgLw3AmOpk+s/H4hAP4Hp0gOWlPA= 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [1.0.5](https://github.com/adonisjs/adonis-persona/compare/v1.0.4...v1.0.5) (2018-06-11) 3 | 4 | 5 | ### Bug Fixes 6 | 7 | * **persona:** reference model using getModel method ([8151bdd](https://github.com/adonisjs/adonis-persona/commit/8151bdd)) 8 | 9 | 10 | 11 | 12 | ## [1.0.4](https://github.com/adonisjs/adonis-persona/compare/v1.0.3...v1.0.4) (2018-04-30) 13 | 14 | 15 | ### Bug Fixes 16 | 17 | * **updateProfile:** make sure to validate empty email address on update ([fdbc9b3](https://github.com/adonisjs/adonis-persona/commit/fdbc9b3)) 18 | 19 | 20 | 21 | 22 | ## [1.0.3](https://github.com/adonisjs/adonis-persona/compare/v1.0.2...v1.0.3) (2018-03-31) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * **verifyToken:** remove token once used ([328c22b](https://github.com/adonisjs/adonis-persona/commit/328c22b)), closes [#5](https://github.com/adonisjs/adonis-persona/issues/5) 28 | 29 | 30 | 31 | 32 | ## [1.0.2](https://github.com/adonisjs/adonis-persona/compare/v1.0.1...v1.0.2) (2018-03-29) 33 | 34 | 35 | ### Features 36 | 37 | * **token:** encrypt tokens before sending ([fef3d2f](https://github.com/adonisjs/adonis-persona/commit/fef3d2f)) 38 | 39 | 40 | 41 | 42 | # 1.0.0 (2018-03-29) 43 | 44 | 45 | ### Features 46 | 47 | * Initial commit ([bbb8001](https://github.com/adonisjs/adonis-persona/commit/bbb8001)) 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](http://res.cloudinary.com/adonisjs/image/upload/q_100/v1522328931/adonis-persona_qlb1ix.svg) 2 | 3 | > Opinionated user management service for AdonisJs 4 | 5 | **Make sure @adonisjs/framework version is >= 5.0.6** 6 | 7 | AdonisJs is all about removing redundant code from your code base. This add-on tries to do the same. 8 | 9 | ## What is Persona? 10 | 11 | Persona is a simple, functional service to let you **create**, **verify** and **update** user profiles. 12 | 13 | Persona is not for everyone; if your login system is too complex and relies on many factors, Persona is not for you. **However, persona works great for most use cases**. 14 | 15 | ## What does it do? 16 | 1. Helps you register new users. 17 | 2. Generates email verification tokens. 18 | 3. Validates credentials on login. 19 | 4. On email change, sets the user account to a `pending` state and re-generates the email verification token. 20 | 5. Allows changing passwords. 21 | 6. Allows recovering forgotten passwords. 22 | 23 | ## What does it NOT do? 24 | 1. Does not generate any routes, controllers or views for you. 25 | 2. Does not send emails. However, it emits events that you can use to send emails. 26 | 3. Does not create sessions or generate JWT tokens. 27 | 28 | 29 | ## Setup 30 | Run the following command to grab the add-on from npm: 31 | 32 | ```bash 33 | adonis install @adonisjs/persona 34 | 35 | # for yarn 36 | adonis install @adonisjs/persona --yarn 37 | ``` 38 | 39 | Follow up by registering the provider inside the providers array: 40 | 41 | ```js 42 | const providers = [ 43 | '@adonisjs/persona/providers/PersonaProvider' 44 | ] 45 | ``` 46 | 47 | You may then access it as follows: 48 | 49 | ```js 50 | const Persona = use('Persona') 51 | ``` 52 | 53 | ## Config 54 | 55 | The config file is saved as `config/persona.js`. 56 | 57 | | Key | Value | Description | 58 | |-----|--------|------------| 59 | | uids | ['email'] | An array of database columns that will be used as `uids`. If your system allows `username` and `emails` both, then simply add them to this array. 60 | | email | email | The field to be used as email. Every time a user changes the value of this field, their account will be set to the `pending` state. 61 | | password | password | The field to be used as password. 62 | | model | App/Models/User | The user model to be used. 63 | | newAccountState | pending | The default account state for new users. 64 | | verifiedAccountState | active | The account state for users after verifying their email address. 65 | | dateFormat | YYYY-MM-DD HH:mm:ss | Your database date format, required for determining if the token has been expired or not. 66 | | validationMessages | function | A function that returns an object of messages to be used for validation. The syntax is the same as `Validator` custom messages. 67 | 68 | ## Constraints 69 | 70 | There are some intentional constraints in place. 71 | 72 | 1. Only works with `Lucid` models. 73 | 2. The `App/Models/User` must have a relationship setup with `App/Models/Token` and vice-versa. 74 | 75 | ```js 76 | class User extends Model { 77 | tokens () { 78 | return this.hasMany('App/Models/Token') 79 | } 80 | } 81 | 82 | class Token extends Model { 83 | user () { 84 | return this.belongsTo('App/Models/User') 85 | } 86 | } 87 | ``` 88 | 89 | 3. User table must have a `string` column called `account_status`. 90 | 91 | ## API 92 | 93 | Let's go through the API of persona. 94 | 95 | #### register(payload, [callback]) 96 | 97 | > The optional `callback` is invoked with the original payload just before the user is saved to the database. You can use it if you need to attach any other properties to the payload. 98 | 99 | The register method takes the user input data and performs the following actions on it. 100 | 101 | 1. Validates that all `uids` are unique. 102 | 2. Checks that email is unique and is a valid email address. 103 | 3. Makes sure the password is confirmed. 104 | 4. Creates a new user account with the `account_status = pending`. 105 | 5. Generates and saves an email verification token inside the `tokens` table. 106 | 5. Emits a `user::created` event. You can listen for this event to send an email to the user. 107 | 108 | > Make sure to use `querystring` module to encode the token when sending via Email. 109 | 110 | ```js 111 | const Persona = use('Persona') 112 | 113 | async register ({ request, auth, response }) { 114 | const payload = request.only(['email', 'password', 'password_confirmation']) 115 | 116 | const user = await Persona.register(payload) 117 | 118 | // optional 119 | await auth.login(user) 120 | response.redirect('/dashboard') 121 | } 122 | ``` 123 | 124 | #### verify(payload, [callback]) 125 | 126 | > The optional `callback` is invoked with the user instance just before the password verification. You can use it to check for `userRole` or any other property you want. 127 | 128 | Verifies the user credentials. The value of `uid` will be checked against all the `uids`. 129 | 130 | ```js 131 | async login ({ request, auth, response }) { 132 | const payload = request.only(['uid', 'password']) 133 | const user = await Persona.verify(payload) 134 | 135 | await auth.login(user) 136 | response.redirect('/dashboard') 137 | } 138 | ``` 139 | 140 | #### verifyEmail(token) 141 | 142 | Verifies the user's email using the token. Ideally that should be after someone clicks a URL from their email address. 143 | 144 | 1. Removes the token from the tokens table. 145 | 2. Set user `account_status = active`. 146 | 147 | ```js 148 | async verifyEmail ({ params, session, response }) { 149 | const user = await Persona.verifyEmail(params.token) 150 | 151 | session.flash({ message: 'Email verified' }) 152 | response.redirect('back') 153 | } 154 | ``` 155 | 156 | #### updateProfile(user, payload) 157 | 158 | Updates the user columns inside the database. However, if the email is changed, it performs the following steps: 159 | 160 | > Please note that this method will throw an exception if the user is trying to change the password. 161 | 162 | 1. Sets the user's `account_status = pending`. 163 | 2. Generates an email verification token. 164 | 3. Fires the `email::changed` event. 165 | 166 | ```js 167 | async update ({ request, auth }) { 168 | const payload = request.only(['firstname', 'email']) 169 | const user = auth.user 170 | await Persona.updateProfile(user, payload) 171 | } 172 | ``` 173 | 174 | #### updatePassword(user, payload) 175 | 176 | Updates the user's password by performing the following steps: 177 | 178 | > Make sure to have the `beforeSave` hook in place for hashing the password. Otherwise 179 | > the password will be saved as a plain string. 180 | 181 | 1. Ensures `old_password` matches the user's password. 182 | 2. Makes sure the new password is confirmed. 183 | 3. Updates the user password. 184 | 4. Fires the `password::changed` event. You can listen for this event to send an email about the password change. 185 | 186 | ```js 187 | async updatePassword ({ request, auth }) { 188 | const payload = request.only(['old_password', 'password', 'password_confirmation']) 189 | const user = auth.user 190 | await Persona.updatePassword(user, payload) 191 | } 192 | ``` 193 | 194 | #### forgotPassword(uid) 195 | 196 | Takes a forgot password request from the user by passing their `uid`. Uid will be matched against all the `uids` inside the config file. 197 | 198 | 1. Finds a user with the matching uid. 199 | 2. Generates a password change token. 200 | 3. Emits the `forgot::password` event. You can listen for this event to send an email with the token to reset the password. 201 | 202 | ```js 203 | async forgotPassword ({ request }) { 204 | await Persona.forgotPassword(request.input('uid')) 205 | } 206 | ``` 207 | 208 | #### updatePasswordByToken(token, payload) 209 | 210 | Updates the user password using a token. This method performs the following checks: 211 | 212 | 1. Makes sure the token is valid and not expired. 213 | 2. Ensures the password is confirmed. 214 | 3. Updates the user's password. 215 | 216 | ```js 217 | async updatePasswordByToken ({ request, params }) { 218 | const token = params.token 219 | const payload = request.only(['password', 'password_confirmation']) 220 | 221 | const user = await Persona.updatePasswordByToken(token, payload) 222 | } 223 | ``` 224 | 225 | ## Custom messages 226 | You can define a function inside the `config/persona.js` file, which returns an object of messages to be used as validation messages. The syntax is the same as `Validator` custom messages. 227 | 228 | ```js 229 | { 230 | validationMessages (action) => { 231 | return { 232 | 'email.required': 'Email is required', 233 | 'password.mis_match': 'Invalid password' 234 | } 235 | } 236 | } 237 | ``` 238 | 239 | The `validationMessages` method gets an `action` parameter. You can use it to customize the messages for different actions. Following is the list of actions. 240 | 241 | 1. register 242 | 2. login 243 | 3. emailUpdate 244 | 4. passwordUpdate 245 | 246 | ## Events emitted 247 | 248 | Below is the list of events emitted at different occasions. 249 | 250 | | Event | Payload | Description | 251 | |--------|--------|-------------| 252 | | user::created | `{ user, token }` | Emitted when a new user is created | 253 | | email::changed | `{ user, oldEmail, token }` | Emitted when a user changes their email address | 254 | | password::changed | `{ user }` | Emitted when a user changes their password by providing the old password | 255 | | forgot::password | `{ user, token }` | Emitted when a user asks for a token to change their password | 256 | | password::recovered | `{ user }` | Emitted when a user's password is changed using the token | 257 | 258 | ## Exceptions raised 259 | 260 | The entire API is driven by exceptions, which means you will hardly have to write `if/else` statements. 261 | 262 | This is great, since Adonis allows managing responses by catching exceptions globally. 263 | 264 | #### ValidationException 265 | Raised when validation fails. If you are already handling `Validator` exceptions, you won't have to do anything special. 266 | 267 | #### InvalidTokenException 268 | Raised when a supplied token, to verify an email or reset password with, is invalid. 269 | 270 | ## Custom rules 271 | At times, you may want to have custom set of rules when registering or login new users. You can override following methods for same. 272 | 273 | The code can be added inside the hooks file or even in the registeration controller. 274 | 275 | #### registerationRules 276 | 277 | ```js 278 | Persona.registerationRules = function () { 279 | return { 280 | email: 'required|email|unique:users,email', 281 | password: 'required|confirmed' 282 | } 283 | } 284 | ``` 285 | 286 | #### updateEmailRules 287 | ```js 288 | Persona.updateEmailRules = function (userId) { 289 | return { 290 | email: `required|email|unique:users,email,id,${userId}` 291 | } 292 | } 293 | ``` 294 | 295 | #### updatePasswordRules 296 | ```js 297 | Persona.updatePasswordRules = function (enforceOldPassword = true) { 298 | if (!enforceOldPassword) { 299 | return { 300 | password: 'required|confirmed' 301 | } 302 | } 303 | 304 | return { 305 | old_password: 'required', 306 | password: 'required|confirmed' 307 | } 308 | } 309 | ``` 310 | 311 | #### loginRules 312 | ```js 313 | Persona.loginRules = function () { 314 | return { 315 | uid: 'required', 316 | password: 'required' 317 | } 318 | } 319 | ``` 320 | -------------------------------------------------------------------------------- /app/.env: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/config/app.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | appKey: '16charslongtoken', 3 | logger: { 4 | transport: 'console', 5 | console: { 6 | driver: 'console' 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /app/config/database.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | connection: 'sqlite', 5 | 6 | sqlite: { 7 | client: 'sqlite3', 8 | connection: { 9 | filename: path.join(__dirname, '../database.sqlite') 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - nodejs_version: 'Stable' 4 | - nodejs_version: '8' 5 | 6 | init: 7 | git config --global core.autocrlf true 8 | 9 | install: 10 | - ps: Install-Product node $env:nodejs_version 11 | - npm install 12 | 13 | test_script: 14 | - node --version 15 | - npm --version 16 | - npm run test:win 17 | 18 | build: off 19 | clone_depth: 1 20 | 21 | matrix: 22 | fast_finish: true 23 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* 4 | |-------------------------------------------------------------------------- 5 | | Persona 6 | |-------------------------------------------------------------------------- 7 | | 8 | | The persona is a simple and opinionated service to register, login and 9 | | manage user account 10 | | 11 | */ 12 | 13 | module.exports = { 14 | /* 15 | |-------------------------------------------------------------------------- 16 | | Uids 17 | |-------------------------------------------------------------------------- 18 | | 19 | | An array of fields, that can be used to indetify a user uniquely. During 20 | | login and reset password, these fields be checked against the user 21 | | input 22 | | 23 | */ 24 | uids: ['email'], 25 | 26 | /* 27 | |-------------------------------------------------------------------------- 28 | | Email field 29 | |-------------------------------------------------------------------------- 30 | | 31 | | The name of the email field inside the database and the user payload. 32 | | 33 | */ 34 | email: 'email', 35 | 36 | /* 37 | |-------------------------------------------------------------------------- 38 | | Password 39 | |-------------------------------------------------------------------------- 40 | | 41 | | The password field to be used for verifying and storing user password 42 | | 43 | */ 44 | password: 'password', 45 | 46 | /* 47 | |-------------------------------------------------------------------------- 48 | | New account state 49 | |-------------------------------------------------------------------------- 50 | | 51 | | State of user when a new account is created 52 | | 53 | */ 54 | newAccountState: 'pending', 55 | 56 | /* 57 | |-------------------------------------------------------------------------- 58 | | Verified account state 59 | |-------------------------------------------------------------------------- 60 | | 61 | | State of user after they verify their email address 62 | | 63 | */ 64 | verifiedAccountState: 'active', 65 | 66 | /* 67 | |-------------------------------------------------------------------------- 68 | | Model 69 | |-------------------------------------------------------------------------- 70 | | 71 | | The model to be used for verifying and creating users 72 | | 73 | */ 74 | model: 'App/Models/User', 75 | 76 | /* 77 | |-------------------------------------------------------------------------- 78 | | Date Format 79 | |-------------------------------------------------------------------------- 80 | | 81 | | The date format for the tokens table. It is required to calculate the 82 | | expiry of a token. 83 | | 84 | */ 85 | dateFormat: 'YYYY-MM-DD HH:mm:ss', 86 | 87 | /* 88 | |-------------------------------------------------------------------------- 89 | | Validation messages 90 | |-------------------------------------------------------------------------- 91 | | 92 | | An object of validation messages to be used when validation fails. 93 | | 94 | */ 95 | validationMessages: () => { 96 | return {} 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /instructions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-persona 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const path = require('path') 13 | 14 | module.exports = async (cli) => { 15 | try { 16 | const inFile = path.join(__dirname, './config', 'index.js') 17 | const outFile = path.join(cli.helpers.configPath(), 'persona.js') 18 | await cli.copy(inFile, outFile) 19 | cli.command.completed('create', 'config/persona.js') 20 | } catch (error) { 21 | // ignore error 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /instructions.md: -------------------------------------------------------------------------------- 1 | ## Register provider 2 | Register provider inside `start/app.js` file. 3 | 4 | ```js 5 | const providers = [ 6 | '@adonisjs/persona/providers/PersonaProvider' 7 | ] 8 | ``` 9 | 10 | And then you can access it as follows 11 | 12 | ```js 13 | const Persona = use('Persona') 14 | ``` 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adonisjs/persona", 3 | "version": "1.0.5", 4 | "description": "Opinionated user management service for AdonisJs", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "standard", 8 | "pretest": "npm run lint", 9 | "test": "nyc japa", 10 | "test:win": "node ./node_modules/japa-cli/index.js", 11 | "coverage": "nyc report --reporter=text-lcov | coveralls" 12 | }, 13 | "files": [ 14 | "README.md", 15 | "instructions.md", 16 | "instructions.js", 17 | "src", 18 | "providers", 19 | "config" 20 | ], 21 | "keywords": [ 22 | "adonisjs", 23 | "adonis-framework" 24 | ], 25 | "author": "virk", 26 | "license": "MIT", 27 | "devDependencies": { 28 | "@adonisjs/ace": "^5.0.8", 29 | "@adonisjs/fold": "^4.0.9", 30 | "@adonisjs/framework": "^5.0.12", 31 | "@adonisjs/lucid": "^5.0.4", 32 | "@adonisjs/sink": "^1.0.17", 33 | "@adonisjs/validator": "^5.0.6", 34 | "coveralls": "^3.0.2", 35 | "cz-conventional-changelog": "^2.1.0", 36 | "japa": "^1.0.6", 37 | "japa-cli": "^1.0.1", 38 | "nyc": "^11.9.0", 39 | "sqlite3": "^4.0.6", 40 | "standard": "^11.0.1" 41 | }, 42 | "dependencies": { 43 | "@adonisjs/generic-exceptions": "^2.0.1", 44 | "moment": "^2.24.0", 45 | "rand-token": "^0.4.0" 46 | }, 47 | "standard": { 48 | "globals": [ 49 | "use" 50 | ] 51 | }, 52 | "directories": { 53 | "test": "test" 54 | }, 55 | "repository": { 56 | "type": "git", 57 | "url": "git+https://github.com/adonisjs/adonis-persona.git" 58 | }, 59 | "bugs": { 60 | "url": "https://github.com/adonisjs/adonis-persona/issues" 61 | }, 62 | "homepage": "https://github.com/adonisjs/adonis-persona#readme", 63 | "config": { 64 | "commitizen": { 65 | "path": "./node_modules/cz-conventional-changelog" 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /providers/PersonaProvider.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-persona 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const { ServiceProvider } = require('@adonisjs/fold') 13 | 14 | class PersonaProvider extends ServiceProvider { 15 | register () { 16 | this.app.singleton('Adonis/Addons/Persona', (app) => { 17 | const Config = app.use('Adonis/Src/Config') 18 | const Event = app.use('Adonis/Src/Event') 19 | const Hash = app.use('Adonis/Src/Hash') 20 | const Encryption = app.use('Adonis/Src/Encryption') 21 | const Validator = app.use('Adonis/Addons/Validator') 22 | const Persona = require('../src/Persona') 23 | 24 | return new Persona(Config, Validator, Event, Encryption, Hash) 25 | }) 26 | 27 | this.app.alias('Adonis/Addons/Persona', 'Persona') 28 | } 29 | } 30 | 31 | module.exports = PersonaProvider 32 | -------------------------------------------------------------------------------- /src/Persona.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-persona 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | const moment = require('moment') 13 | const randtoken = require('rand-token') 14 | const GE = require('@adonisjs/generic-exceptions') 15 | 16 | /** 17 | * Raised when token is invalid or expired 18 | * 19 | * @class InvalidTokenException 20 | */ 21 | class InvalidTokenException extends GE.LogicalException { 22 | static invalidToken () { 23 | return new this('The token is invalid or expired', 400) 24 | } 25 | } 26 | 27 | /** 28 | * The personna class is used to manage the user profile 29 | * creation, verification and updation with ease. 30 | * 31 | * @class Persona 32 | * 33 | * @param {Object} Config 34 | * @param {Object} Validator 35 | * @param {Object} Event 36 | * @param {Object} Hash 37 | */ 38 | class Persona { 39 | constructor (Config, Validator, Event, Encryption, Hash) { 40 | this.config = Config.merge('persona', { 41 | uids: ['email'], 42 | email: 'email', 43 | password: 'password', 44 | model: 'App/Models/User', 45 | newAccountState: 'pending', 46 | verifiedAccountState: 'active', 47 | dateFormat: 'YYYY-MM-DD HH:mm:ss' 48 | }) 49 | 50 | /** 51 | * Varients of password fields 52 | */ 53 | this._oldPasswordField = `old_${this.config.password}` 54 | this._passwordConfirmationField = `${this.config.password}_confirmation` 55 | 56 | this.Hash = Hash 57 | this.Event = Event 58 | this.Validator = Validator 59 | 60 | this._encrypter = Encryption.getInstance({ hmac: false }) 61 | this._model = null 62 | } 63 | 64 | /** 65 | * Returns the email value from an object 66 | * 67 | * @method _getEmail 68 | * 69 | * @param {Object} payload 70 | * 71 | * @return {String} 72 | * 73 | * @private 74 | */ 75 | _getEmail (payload) { 76 | return payload[this.config.email] 77 | } 78 | 79 | /** 80 | * Returns the password value from an object 81 | * 82 | * @method _getPassword 83 | * 84 | * @param {Object} payload 85 | * 86 | * @return {String} 87 | * 88 | * @private 89 | */ 90 | _getPassword (payload) { 91 | return payload[this.config.password] 92 | } 93 | 94 | /** 95 | * Updates email field value on an object 96 | * 97 | * @method _setEmail 98 | * 99 | * @param {Object} payload 100 | * @param {String} email 101 | * 102 | * @private 103 | */ 104 | _setEmail (payload, email) { 105 | payload[this.config.email] = email 106 | } 107 | 108 | /** 109 | * Sets password field value on an object 110 | * 111 | * @method _setPassword 112 | * 113 | * @param {Object} payload 114 | * @param {String} password 115 | * 116 | * @private 117 | */ 118 | _setPassword (payload, password) { 119 | payload[this.config.password] = password 120 | } 121 | 122 | /** 123 | * Makes the custom message for a given key 124 | * 125 | * @method _makeCustomMessage 126 | * 127 | * @param {String} key 128 | * @param {Object} data 129 | * @param {String} defaultValue 130 | * 131 | * @return {String} 132 | * 133 | * @private 134 | */ 135 | _makeCustomMessage (key, data, defaultValue) { 136 | const customMessage = this.getMessages()[key] 137 | if (!customMessage) { 138 | return defaultValue 139 | } 140 | 141 | return customMessage.replace(/{{\s?(\w+)\s?}}/g, (match, group) => { 142 | return data[group] || '' 143 | }) 144 | } 145 | 146 | /** 147 | * Adds query constraints to pull the right token 148 | * 149 | * @method _addTokenConstraints 150 | * 151 | * @param {Object} query 152 | * @param {String} type 153 | * 154 | * @private 155 | */ 156 | _addTokenConstraints (query, type) { 157 | query 158 | .where('type', type) 159 | .where('is_revoked', false) 160 | .where('updated_at', '>=', moment().subtract(24, 'hours').format(this.config.dateFormat)) 161 | } 162 | 163 | /** 164 | * Generates a new token for a user and given type. Ideally 165 | * tokens will be for verifying email and forgot password 166 | * 167 | * @method generateToken 168 | * 169 | * @param {Object} user 170 | * @param {String} type 171 | * 172 | * @return {String} 173 | * 174 | * @example 175 | * ``` 176 | * const user = await User.find(1) 177 | * const token = await Persona.generateToken(user, 'email') 178 | * ``` 179 | */ 180 | async generateToken (user, type) { 181 | const query = user.tokens() 182 | this._addTokenConstraints(query, type) 183 | 184 | const row = await query.first() 185 | if (row) { 186 | return row.token 187 | } 188 | 189 | const token = this._encrypter.encrypt(randtoken.generate(16)) 190 | await user.tokens().create({ type, token }) 191 | return token 192 | } 193 | 194 | /** 195 | * Returns the token instance along with releated 196 | * users 197 | * 198 | * @method getToken 199 | * 200 | * @param {String} token 201 | * @param {String} type 202 | * 203 | * @return {Object|Null} 204 | * 205 | * @example 206 | * ``` 207 | * const token = request.input('token') 208 | * const tokenRow = await Persona.getToken(token, 'email') 209 | * 210 | * if (!tokenRow) { 211 | * // token is invalid or expired 212 | * } 213 | * 214 | * const user = tokenRow.getRelated('user') 215 | * ``` 216 | */ 217 | async getToken (token, type) { 218 | const query = this.getModel().prototype.tokens().RelatedModel.query() 219 | this._addTokenConstraints(query, type) 220 | 221 | const row = await query.where('token', token).with('user').first() 222 | return row && row.getRelated('user') ? row : null 223 | } 224 | 225 | /** 226 | * Remvoes the token from the tokens table 227 | * 228 | * @method removeToken 229 | * 230 | * @param {String} token 231 | * @param {String} type 232 | * 233 | * @return {void} 234 | */ 235 | async removeToken (token, type) { 236 | const query = this.getModel().prototype.tokens().RelatedModel.query() 237 | await query.where('token', token).where('type', type).delete() 238 | } 239 | 240 | /** 241 | * Returns the model class 242 | * 243 | * @method getModel 244 | * 245 | * @return {Model} 246 | */ 247 | getModel () { 248 | if (!this._model) { 249 | this._model = use(this.config.model) 250 | } 251 | return this._model 252 | } 253 | 254 | /** 255 | * Returns an object of messages to be used for validation 256 | * failures 257 | * 258 | * @method getMessages 259 | * 260 | * @param {String} action 261 | * 262 | * @return {Object} 263 | */ 264 | getMessages (action) { 265 | return typeof (this.config.validationMessages) === 'function' ? this.config.validationMessages(action) : {} 266 | } 267 | 268 | /** 269 | * Returns the table in user 270 | * 271 | * @method getTable 272 | * 273 | * @return {String} 274 | */ 275 | getTable () { 276 | return this.getModel().table 277 | } 278 | 279 | /** 280 | * Returns an object of registeration rules 281 | * 282 | * @method registerationRules 283 | * 284 | * @return {Object} 285 | */ 286 | registerationRules () { 287 | return this.config.uids.reduce((result, uid) => { 288 | const rules = ['required'] 289 | if (uid === this.config.email) { 290 | rules.push('email') 291 | } 292 | 293 | rules.push(`unique:${this.getTable()},${uid}`) 294 | 295 | result[uid] = rules.join('|') 296 | return result 297 | }, { 298 | [this.config.password]: 'required|confirmed' 299 | }) 300 | } 301 | 302 | /** 303 | * Returns the validation rules for updating email address 304 | * 305 | * @method updateEmailRules 306 | * 307 | * @param {String} userId 308 | * 309 | * @return {Object} 310 | */ 311 | updateEmailRules (userId) { 312 | if (!userId) { 313 | throw new Error('updateEmailRules needs the current user id to generate the validation rules') 314 | } 315 | 316 | return { 317 | [this.config.email]: `required|email|unique:${this.getTable()},${this.config.email},${this.getModel().primaryKey},${userId}` 318 | } 319 | } 320 | 321 | /** 322 | * Returns the validation rules for updating the passowrd 323 | * 324 | * @method updatePasswordRules 325 | * 326 | * @param {Boolean} enforceOldPassword 327 | * 328 | * @return {Object} 329 | */ 330 | updatePasswordRules (enforceOldPassword = true) { 331 | const rules = { 332 | [this.config.password]: 'required|confirmed' 333 | } 334 | 335 | /** 336 | * Enforcing to define old password 337 | */ 338 | if (enforceOldPassword) { 339 | rules[this._oldPasswordField] = 'required' 340 | } 341 | 342 | return rules 343 | } 344 | 345 | /** 346 | * Returns an object of loginRules 347 | * 348 | * @method loginRules 349 | * 350 | * @return {String} 351 | */ 352 | loginRules () { 353 | return { 354 | 'uid': 'required', 355 | [this.config.password]: 'required' 356 | } 357 | } 358 | 359 | /** 360 | * Mutates the registeration payload in the shape that 361 | * can be inserted to the database 362 | * 363 | * @method massageRegisterationData 364 | * 365 | * @param {Object} payload 366 | * 367 | * @return {void} 368 | */ 369 | massageRegisterationData (payload) { 370 | delete payload[this._passwordConfirmationField] 371 | payload.account_status = this.config.newAccountState 372 | } 373 | 374 | /** 375 | * Runs validations using the validator and throws error 376 | * if validation fails 377 | * 378 | * @method runValidation 379 | * 380 | * @param {Object} payload 381 | * @param {Object} rules 382 | * @param {String} action 383 | * 384 | * @return {void} 385 | * 386 | * @throws {ValidationException} If validation fails 387 | */ 388 | async runValidation (payload, rules, action) { 389 | const validation = await this.Validator.validateAll(payload, rules, this.getMessages(action)) 390 | 391 | if (validation.fails()) { 392 | throw this.Validator.ValidationException.validationFailed(validation.messages()) 393 | } 394 | } 395 | 396 | /** 397 | * Verifies two password and throws exception when they are not 398 | * valid 399 | * 400 | * @method verifyPassword 401 | * 402 | * @param {String} newPassword 403 | * @param {String} oldPassword 404 | * @param {String} [field = this.config.password] 405 | * 406 | * @return {void} 407 | */ 408 | async verifyPassword (newPassword, oldPassword, field = this.config.password) { 409 | const verified = await this.Hash.verify(newPassword, oldPassword) 410 | if (!verified) { 411 | const data = { field, validation: 'mis_match', value: newPassword } 412 | throw this.Validator.ValidationException.validationFailed([ 413 | { 414 | message: this._makeCustomMessage(`${field}.mis_match`, data, 'Invalid password'), 415 | field: field, 416 | validation: 'mis_match' 417 | } 418 | ]) 419 | } 420 | } 421 | 422 | /** 423 | * Finds the user by looking for any of the given uids 424 | * 425 | * @method getUserByUids 426 | * 427 | * @param {String} value 428 | * 429 | * @return {Object} 430 | */ 431 | async getUserByUids (value) { 432 | const userQuery = this.getModel().query() 433 | 434 | /** 435 | * Search for all uids to allow login with 436 | * any identifier 437 | */ 438 | this.config.uids.forEach((uid) => userQuery.orWhere(uid, value)) 439 | 440 | /** 441 | * Search for user 442 | */ 443 | const user = await userQuery.first() 444 | if (!user) { 445 | const data = { field: 'uid', validation: 'exists', value } 446 | 447 | throw this.Validator.ValidationException.validationFailed([ 448 | { 449 | message: this._makeCustomMessage('uid.exists', data, 'Unable to locate user'), 450 | field: 'uid', 451 | validation: 'exists' 452 | } 453 | ]) 454 | } 455 | 456 | return user 457 | } 458 | 459 | /** 460 | * Creates a new user account and email verification token 461 | * for them. 462 | * 463 | * This method will fire `user::created` event. 464 | * 465 | * @method register 466 | * 467 | * @param {Object} payload 468 | * @param {Function} callback 469 | * 470 | * @return {User} 471 | * 472 | * @example 473 | * ```js 474 | * const payload = request.only(['email', 'password', 'password_confirmation']) 475 | * await Persona.register(payload) 476 | * ``` 477 | */ 478 | async register (payload, callback) { 479 | await this.runValidation(payload, this.registerationRules(), 'register') 480 | this.massageRegisterationData(payload) 481 | 482 | if (typeof (callback) === 'function') { 483 | await callback(payload) 484 | } 485 | 486 | const user = await this.getModel().create(payload) 487 | 488 | /** 489 | * Get email verification token for the user 490 | */ 491 | const token = await this.generateToken(user, 'email') 492 | 493 | /** 494 | * Fire new::user event to app to wire up events 495 | */ 496 | this.Event.fire('user::created', { user, token }) 497 | 498 | return user 499 | } 500 | 501 | /** 502 | * Verifies user credentials 503 | * 504 | * @method verify 505 | * 506 | * @param {Object} payload 507 | * @param {Function} callback 508 | * 509 | * @return {User} 510 | * 511 | * @example 512 | * ```js 513 | * const payload = request.only(['uid', 'password']) 514 | * await Persona.verify(payload) 515 | * ``` 516 | */ 517 | async verify (payload, callback) { 518 | await this.runValidation(payload, this.loginRules(), 'verify') 519 | const user = await this.getUserByUids(payload.uid) 520 | 521 | const enteredPassword = this._getPassword(payload) 522 | const userPassword = this._getPassword(user) 523 | 524 | if (typeof (callback) === 'function') { 525 | await callback(user, enteredPassword) 526 | } 527 | 528 | await this.verifyPassword(enteredPassword, userPassword) 529 | 530 | return user 531 | } 532 | 533 | /** 534 | * Verifies the user email address using a unique 535 | * token associated to their account 536 | * 537 | * @method verifyEmail 538 | * 539 | * @param {String} token 540 | * 541 | * @return {User} 542 | * 543 | * @example 544 | * ```js 545 | * const token = request.input('token') 546 | * await Persona.verifyEmail(token) 547 | * ``` 548 | */ 549 | async verifyEmail (token) { 550 | const tokenRow = await this.getToken(token, 'email') 551 | if (!tokenRow) { 552 | throw InvalidTokenException.invalidToken() 553 | } 554 | 555 | const user = tokenRow.getRelated('user') 556 | 557 | /** 558 | * Update user account only when in the newAccountState 559 | */ 560 | if (user.account_status === this.config.newAccountState) { 561 | user.account_status = this.config.verifiedAccountState 562 | await user.save() 563 | await this.removeToken(token, 'email') 564 | } 565 | 566 | return user 567 | } 568 | 569 | /** 570 | * Updates the user email address and fires an event for same. This 571 | * method will fire `email::changed` event. 572 | * 573 | * @method updateEmail 574 | * 575 | * @param {Object} user 576 | * @param {String} newEmail 577 | * 578 | * @return {User} 579 | * 580 | * @example 581 | * ```js 582 | * const user = auth.user 583 | * const newEmail = request.input('email') 584 | * 585 | * if (user.email !== newEmail) { 586 | * await Persona.updateEmail(user, newEmail) 587 | * } 588 | * ``` 589 | */ 590 | async updateEmail (user, newEmail) { 591 | await this.runValidation({ [this.config.email]: newEmail }, this.updateEmailRules(user.primaryKeyValue), 'emailUpdate') 592 | 593 | const oldEmail = this._getEmail(user) 594 | 595 | /** 596 | * Updating user details 597 | */ 598 | user.account_status = this.config.newAccountState 599 | this._setEmail(user, newEmail) 600 | await user.save() 601 | 602 | /** 603 | * Getting a new token for verifying the email and firing 604 | * the event 605 | */ 606 | const token = await this.generateToken(user, 'email') 607 | this.Event.fire('email::changed', { user, oldEmail, token }) 608 | 609 | return user 610 | } 611 | 612 | /** 613 | * Update user profile. Updating passwords is not allowed here. Also 614 | * if email is provided, then this method will internally call 615 | * `updateEmail`. 616 | * 617 | * @method updateProfile 618 | * 619 | * @param {Object} user 620 | * @param {Object} payload 621 | * 622 | * @return {User} 623 | * 624 | * @example 625 | * ```js 626 | * const user = auth.user 627 | * const payload = request.only(['firstname', 'lastname', 'email']) 628 | * 629 | * await Persona.updateProfile(user, payload) 630 | * ``` 631 | */ 632 | async updateProfile (user, payload) { 633 | /** 634 | * Do not allow changing passwords here. Password flow needs 635 | * old password to be verified 636 | */ 637 | if (this._getPassword(payload)) { 638 | throw new Error('Changing password is not allowed via updateProfile method. Instead use updatePassword') 639 | } 640 | 641 | const newEmail = this._getEmail(payload) 642 | const oldEmail = this._getEmail(user) 643 | 644 | /** 645 | * Update new props with the user attributes 646 | */ 647 | user.merge(payload) 648 | 649 | if (newEmail !== undefined && oldEmail !== newEmail) { 650 | /** 651 | * We need to reset the user email, since we are calling 652 | * updateEmail and it needs user old email address 653 | */ 654 | this._setEmail(user, oldEmail) 655 | await this.updateEmail(user, newEmail) 656 | } else { 657 | await user.save() 658 | } 659 | 660 | return user 661 | } 662 | 663 | /** 664 | * Updates the user password. This method will emit `password::changed` event. 665 | * 666 | * @method updatePassword 667 | * 668 | * @param {Object} user 669 | * @param {Object} payload 670 | * 671 | * @return {User} 672 | * 673 | * @example 674 | * ```js 675 | * const user = auth.user 676 | * const payload = request.only(['old_password', 'password', 'password_confirmation']) 677 | * 678 | * await Persona.updatePassword(user, payload) 679 | * ``` 680 | */ 681 | async updatePassword (user, payload) { 682 | await this.runValidation(payload, this.updatePasswordRules(), 'passwordUpdate') 683 | 684 | const oldPassword = payload[this._oldPasswordField] 685 | const newPassword = this._getPassword(payload) 686 | const existingOldPassword = this._getPassword(user) 687 | 688 | await this.verifyPassword(oldPassword, existingOldPassword, this._oldPasswordField) 689 | 690 | this._setPassword(user, newPassword) 691 | await user.save() 692 | 693 | this.Event.fire('password::changed', { user }) 694 | 695 | return user 696 | } 697 | 698 | /** 699 | * Finds the user using one of their uids and then fires 700 | * `forgot::password` event with a temporary token 701 | * to update the password. 702 | * 703 | * @method forgotPassword 704 | * 705 | * @param {String} email 706 | * 707 | * @return {void} 708 | * 709 | * @example 710 | * ```js 711 | * const email = request.input('email') 712 | * await Persona.forgotPassword(email) 713 | * ``` 714 | */ 715 | async forgotPassword (uid) { 716 | const user = await this.getUserByUids(uid) 717 | const token = await this.generateToken(user, 'password') 718 | 719 | this.Event.fire('forgot::password', { user, token }) 720 | } 721 | 722 | /** 723 | * Updates the password for user using a pre generated token. This method 724 | * will fire `password::recovered` event. 725 | * 726 | * @method updatePasswordByToken 727 | * 728 | * @param {String} token 729 | * @param {Object} payload 730 | * 731 | * @return {User} 732 | * 733 | * @example 734 | * ```js 735 | * const token = request.input('token') 736 | * const payload = request.only(['password', 'password_confirmation']) 737 | * 738 | * await Persona.updatePasswordByToken(token, payload) 739 | * ``` 740 | */ 741 | async updatePasswordByToken (token, payload) { 742 | await this.runValidation(payload, this.updatePasswordRules(false), 'passwordUpdate') 743 | 744 | const tokenRow = await this.getToken(token, 'password') 745 | if (!tokenRow) { 746 | throw InvalidTokenException.invalidToken() 747 | } 748 | 749 | const user = tokenRow.getRelated('user') 750 | this._setPassword(user, this._getPassword(payload)) 751 | 752 | await user.save() 753 | await this.removeToken(token, 'password') 754 | 755 | this.Event.fire('password::recovered', { user }) 756 | return user 757 | } 758 | } 759 | 760 | module.exports = Persona 761 | -------------------------------------------------------------------------------- /test/persona.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const test = require('japa') 4 | const moment = require('moment') 5 | 6 | const setup = require('./setup') 7 | const Persona = require('../src/Persona') 8 | 9 | function getUser () { 10 | return use('App/Models/User') 11 | } 12 | 13 | test.group('Persona', (group) => { 14 | group.before(async () => { 15 | await setup.wire() 16 | await setup.migrateUp() 17 | }) 18 | 19 | group.beforeEach(async () => { 20 | this.persona = new Persona(use('Config'), use('Validator'), use('Event'), use('Encryption'), use('Hash')) 21 | await use('Database').beginGlobalTransaction() 22 | }) 23 | 24 | group.afterEach(async () => { 25 | await use('Database').rollbackGlobalTransaction() 26 | }) 27 | 28 | group.after(async () => { 29 | await setup.migrateDown() 30 | }) 31 | 32 | test('get registeration rules', async (assert) => { 33 | assert.deepEqual(this.persona.registerationRules(), { 34 | email: 'required|email|unique:users,email', 35 | password: 'required|confirmed' 36 | }) 37 | }) 38 | 39 | test('get registeration rules when uids are multiple', async (assert) => { 40 | this.persona.config.uids = ['username', 'email'] 41 | 42 | assert.deepEqual(this.persona.registerationRules(), { 43 | email: 'required|email|unique:users,email', 44 | username: 'required|unique:users,username', 45 | password: 'required|confirmed' 46 | }) 47 | }) 48 | 49 | test('get login rules', async (assert) => { 50 | assert.deepEqual(this.persona.loginRules(), { 51 | uid: 'required', 52 | password: 'required' 53 | }) 54 | }) 55 | 56 | test('throw validation error when user email is missing', async (assert) => { 57 | assert.plan(1) 58 | 59 | try { 60 | await this.persona.register({}) 61 | } catch (error) { 62 | assert.deepEqual(error.messages, [ 63 | { 64 | message: 'required validation failed on password', 65 | field: 'password', 66 | validation: 'required' 67 | }, 68 | { 69 | message: 'required validation failed on email', 70 | field: 'email', 71 | validation: 'required' 72 | } 73 | ]) 74 | } 75 | }) 76 | 77 | test('throw validation error when email is already taken', async (assert) => { 78 | await getUser().create({ email: 'virk@adonisjs.com' }) 79 | assert.plan(1) 80 | 81 | try { 82 | await this.persona.register({ 83 | email: 'virk@adonisjs.com', 84 | password: 'secret', 85 | password_confirmation: 'secret' 86 | }) 87 | } catch (error) { 88 | assert.deepEqual(error.messages, [ 89 | { 90 | message: 'unique validation failed on email', 91 | field: 'email', 92 | validation: 'unique' 93 | } 94 | ]) 95 | } 96 | }) 97 | 98 | test('create user account, token and emit event', async (assert) => { 99 | const Event = use('Event') 100 | Event.fake() 101 | 102 | const user = await this.persona.register({ 103 | email: 'virk@adonisjs.com', 104 | password: 'secret', 105 | password_confirmation: 'secret' 106 | }) 107 | 108 | const recentEvent = Event.pullRecent() 109 | assert.equal(recentEvent.event, 'user::created') 110 | assert.deepEqual(recentEvent.data[0].user, user) 111 | assert.exists(recentEvent.data[0].token) 112 | 113 | Event.restore() 114 | assert.equal(user.account_status, 'pending') 115 | }) 116 | 117 | test('return error when during login uid or password is missing', async (assert) => { 118 | assert.plan(1) 119 | 120 | try { 121 | await this.persona.verify({ 122 | }) 123 | } catch (error) { 124 | assert.deepEqual(error.messages, [ 125 | { 126 | message: 'required validation failed on uid', 127 | field: 'uid', 128 | validation: 'required' 129 | }, 130 | { 131 | message: 'required validation failed on password', 132 | field: 'password', 133 | validation: 'required' 134 | } 135 | ]) 136 | } 137 | }) 138 | 139 | test('return error unable to locate user with given uids', async (assert) => { 140 | assert.plan(1) 141 | 142 | try { 143 | await this.persona.verify({ 144 | uid: 'foo@bar.com', 145 | password: 'hello' 146 | }) 147 | } catch (error) { 148 | assert.deepEqual(error.messages, [ 149 | { 150 | message: 'Unable to locate user', 151 | field: 'uid', 152 | validation: 'exists' 153 | } 154 | ]) 155 | } 156 | }) 157 | 158 | test('return error when user password is incorrect', async (assert) => { 159 | await getUser().create({ email: 'foo@bar.com', password: 'secret' }) 160 | assert.plan(1) 161 | 162 | try { 163 | await this.persona.verify({ 164 | uid: 'foo@bar.com', 165 | password: 'hello' 166 | }) 167 | } catch (error) { 168 | assert.deepEqual(error.messages, [ 169 | { 170 | message: 'Invalid password', 171 | field: 'password', 172 | validation: 'mis_match' 173 | } 174 | ]) 175 | } 176 | }) 177 | 178 | test('return user when everything matches', async (assert) => { 179 | await getUser().create({ email: 'foo@bar.com', password: 'secret' }) 180 | 181 | const verifiedUser = await this.persona.verify({ 182 | uid: 'foo@bar.com', 183 | password: 'secret' 184 | }) 185 | 186 | assert.equal(verifiedUser.id, 1) 187 | }) 188 | 189 | test('return error when unable to find email token inside db', async (assert) => { 190 | assert.plan(2) 191 | 192 | try { 193 | await this.persona.verifyEmail('hello') 194 | } catch ({ message, name }) { 195 | assert.equal(message, 'The token is invalid or expired') 196 | assert.equal(name, 'InvalidTokenException') 197 | } 198 | }) 199 | 200 | test('return error token is found but is expired', async (assert) => { 201 | const user = await getUser().create({ email: 'foo@bar.com' }) 202 | 203 | await use('Database').table('tokens').insert({ 204 | token: 'hello', 205 | type: 'email', 206 | user_id: user.id, 207 | is_revoked: false, 208 | created_at: moment().subtract(2, 'days').format('YYYY-MM-DD HH:mm:ss'), 209 | updated_at: moment().subtract(2, 'days').format('YYYY-MM-DD HH:mm:ss') 210 | }) 211 | 212 | assert.plan(2) 213 | 214 | try { 215 | await this.persona.verifyEmail('hello') 216 | } catch ({ message, name }) { 217 | assert.equal(message, 'The token is invalid or expired') 218 | assert.equal(name, 'InvalidTokenException') 219 | } 220 | }) 221 | 222 | test('return error token is found but of wrong type', async (assert) => { 223 | const user = await getUser().create({ email: 'foo@bar.com' }) 224 | 225 | await use('Database').table('tokens').insert({ 226 | token: 'hello', 227 | type: 'password', 228 | user_id: user.id, 229 | is_revoked: false, 230 | created_at: moment().format('YYYY-MM-DD HH:mm:ss'), 231 | updated_at: moment().format('YYYY-MM-DD HH:mm:ss') 232 | }) 233 | 234 | assert.plan(2) 235 | 236 | try { 237 | await this.persona.verifyEmail('hello') 238 | } catch ({ message, name }) { 239 | assert.equal(message, 'The token is invalid or expired') 240 | assert.equal(name, 'InvalidTokenException') 241 | } 242 | }) 243 | 244 | test('set user account to active when token is valid', async (assert) => { 245 | const user = await getUser().create({ email: 'foo@bar.com' }) 246 | 247 | await use('Database').table('tokens').insert({ 248 | token: 'hello', 249 | type: 'email', 250 | is_revoked: false, 251 | user_id: user.id, 252 | created_at: moment().format('YYYY-MM-DD HH:mm:ss'), 253 | updated_at: moment().format('YYYY-MM-DD HH:mm:ss') 254 | }) 255 | 256 | await this.persona.verifyEmail('hello') 257 | 258 | await user.reload() 259 | assert.equal(user.account_status, 'active') 260 | 261 | const tokens = await user.tokens().fetch() 262 | assert.equal(tokens.size(), 0) 263 | }) 264 | 265 | test('do not set to active when initial state is not pending', async (assert) => { 266 | const user = await getUser().create({ email: 'foo@bar.com', account_status: 'inactive' }) 267 | 268 | await use('Database').table('tokens').insert({ 269 | token: 'hello', 270 | type: 'email', 271 | is_revoked: false, 272 | user_id: user.id, 273 | created_at: moment().format('YYYY-MM-DD HH:mm:ss'), 274 | updated_at: moment().format('YYYY-MM-DD HH:mm:ss') 275 | }) 276 | 277 | await this.persona.verifyEmail('hello') 278 | 279 | await user.reload() 280 | assert.equal(user.account_status, 'inactive') 281 | }) 282 | 283 | test('throw error when trying to update password using updateProfile', async (assert) => { 284 | assert.plan(1) 285 | 286 | const user = await getUser().create({ 287 | email: 'foo@bar.com', 288 | account_status: 'inactive', 289 | password: 'secret' 290 | }) 291 | 292 | try { 293 | await this.persona.updateProfile(user, { password: 'hello' }) 294 | } catch ({ message }) { 295 | assert.equal(message, 'Changing password is not allowed via updateProfile method. Instead use updatePassword') 296 | } 297 | }) 298 | 299 | test('update user profile', async (assert) => { 300 | const user = await getUser().create({ 301 | email: 'foo@bar.com', 302 | account_status: 'inactive', 303 | password: 'secret' 304 | }) 305 | 306 | await this.persona.updateProfile(user, { firstname: 'virk' }) 307 | 308 | await user.reload() 309 | assert.equal(user.firstname, 'virk') 310 | }) 311 | 312 | test('get updateEmail validation rules', async (assert) => { 313 | assert.deepEqual(this.persona.updateEmailRules(1), { 314 | email: 'required|email|unique:users,email,id,1' 315 | }) 316 | }) 317 | 318 | test('when updating email make sure its required', async (assert) => { 319 | assert.plan(1) 320 | 321 | const user = await getUser().create({ 322 | email: 'foo@bar.com', 323 | account_status: 'inactive', 324 | password: 'secret' 325 | }) 326 | 327 | try { 328 | await this.persona.updateProfile(user, { email: '' }) 329 | } catch (error) { 330 | assert.deepEqual(error.messages, [{ 331 | message: 'required validation failed on email', 332 | field: 'email', 333 | validation: 'required' 334 | }]) 335 | } 336 | }) 337 | 338 | test('when updating email make sure its valid', async (assert) => { 339 | assert.plan(1) 340 | 341 | const user = await getUser().create({ 342 | email: 'foo@bar.com', 343 | account_status: 'inactive', 344 | password: 'secret' 345 | }) 346 | 347 | try { 348 | await this.persona.updateProfile(user, { email: 'haha' }) 349 | } catch (error) { 350 | assert.deepEqual(error.messages, [{ 351 | message: 'email validation failed on email', 352 | field: 'email', 353 | validation: 'email' 354 | }]) 355 | } 356 | }) 357 | 358 | test('when updating email make sure its not taken by anyone else', async (assert) => { 359 | assert.plan(1) 360 | 361 | const user = await getUser().create({ 362 | email: 'foo@bar.com', 363 | account_status: 'active', 364 | password: 'secret' 365 | }) 366 | 367 | await getUser().create({ 368 | email: 'baz@bar.com', 369 | account_status: 'active', 370 | password: 'secret' 371 | }) 372 | 373 | try { 374 | await this.persona.updateProfile(user, { email: 'baz@bar.com' }) 375 | } catch (error) { 376 | assert.deepEqual(error.messages, [{ 377 | message: 'unique validation failed on email', 378 | field: 'email', 379 | validation: 'unique' 380 | }]) 381 | } 382 | }) 383 | 384 | test('set user account status to pending when email is changed', async (assert) => { 385 | const Event = use('Event') 386 | Event.fake() 387 | 388 | const user = await getUser().create({ 389 | email: 'foo@bar.com', 390 | account_status: 'active', 391 | password: 'secret' 392 | }) 393 | 394 | await this.persona.updateProfile(user, { firstname: 'virk', email: 'baz@bar.com' }) 395 | 396 | await user.reload() 397 | assert.equal(user.firstname, 'virk') 398 | assert.equal(user.account_status, 'pending') 399 | 400 | const recentEvent = Event.pullRecent() 401 | assert.equal(recentEvent.event, 'email::changed') 402 | assert.deepEqual(recentEvent.data[0].user, user) 403 | assert.deepEqual(recentEvent.data[0].oldEmail, 'foo@bar.com') 404 | assert.exists(recentEvent.data[0].token) 405 | Event.restore() 406 | 407 | const tokens = await user.tokens().fetch() 408 | assert.equal(tokens.size(), 1) 409 | }) 410 | 411 | test('do not set account to pending when same email is set', async (assert) => { 412 | const user = await getUser().create({ 413 | email: 'foo@bar.com', 414 | account_status: 'active', 415 | password: 'secret' 416 | }) 417 | 418 | await this.persona.updateProfile(user, { firstname: 'virk', email: 'foo@bar.com' }) 419 | 420 | await user.reload() 421 | assert.equal(user.firstname, 'virk') 422 | assert.equal(user.account_status, 'active') 423 | 424 | const tokens = await user.tokens().fetch() 425 | assert.equal(tokens.size(), 0) 426 | }) 427 | 428 | test('get update password rules', async (assert) => { 429 | const rules = this.persona.updatePasswordRules() 430 | 431 | assert.deepEqual(rules, { 432 | old_password: 'required', 433 | password: 'required|confirmed' 434 | }) 435 | }) 436 | 437 | test('make sure old password is set', async (assert) => { 438 | assert.plan(1) 439 | 440 | const user = await getUser().create({ 441 | email: 'foo@bar.com', 442 | account_status: 'active', 443 | password: 'secret' 444 | }) 445 | 446 | try { 447 | await this.persona.updatePassword(user, {}) 448 | } catch (error) { 449 | assert.deepEqual(error.messages, [ 450 | { 451 | field: 'password', 452 | validation: 'required', 453 | message: 'required validation failed on password' 454 | }, 455 | { 456 | field: 'old_password', 457 | validation: 'required', 458 | message: 'required validation failed on old_password' 459 | } 460 | ]) 461 | } 462 | }) 463 | 464 | test('make sure old password is correct', async (assert) => { 465 | assert.plan(1) 466 | 467 | const user = await getUser().create({ 468 | email: 'foo@bar.com', 469 | account_status: 'active', 470 | password: 'secret' 471 | }) 472 | 473 | try { 474 | await this.persona.updatePassword(user, { old_password: 'foo', password: 'newsecret', password_confirmation: 'newsecret' }) 475 | } catch (error) { 476 | assert.deepEqual(error.messages, [ 477 | { 478 | field: 'old_password', 479 | validation: 'mis_match', 480 | message: 'Invalid password' 481 | } 482 | ]) 483 | } 484 | }) 485 | 486 | test('update user password and fire password::changed event', async (assert) => { 487 | const Event = use('Event') 488 | Event.fake() 489 | 490 | const user = await getUser().create({ 491 | email: 'foo@bar.com', 492 | account_status: 'active', 493 | password: 'secret' 494 | }) 495 | 496 | await this.persona.updatePassword(user, { old_password: 'secret', password: 'newsecret', password_confirmation: 'newsecret' }) 497 | 498 | const recentEvent = Event.pullRecent() 499 | assert.equal(recentEvent.event, 'password::changed') 500 | assert.deepEqual(recentEvent.data[0].user, user) 501 | Event.restore() 502 | 503 | await user.reload() 504 | const verified = await use('Hash').verify('newsecret', user.password) 505 | assert.isTrue(verified) 506 | }) 507 | 508 | test('return error when unable to locate user with the uid', async (assert) => { 509 | assert.plan(1) 510 | 511 | try { 512 | await this.persona.forgotPassword('foo@bar.com') 513 | } catch (error) { 514 | assert.deepEqual(error.messages, [ 515 | { 516 | field: 'uid', 517 | message: 'Unable to locate user', 518 | validation: 'exists' 519 | } 520 | ]) 521 | } 522 | }) 523 | 524 | test('generate forget password token when able to locate user', async (assert) => { 525 | const Event = use('Event') 526 | Event.fake() 527 | 528 | const user = await getUser().create({ 529 | email: 'foo@bar.com', 530 | account_status: 'active', 531 | password: 'secret' 532 | }) 533 | 534 | await user.reload() 535 | 536 | await this.persona.forgotPassword('foo@bar.com') 537 | 538 | const recentEvent = Event.pullRecent() 539 | assert.equal(recentEvent.event, 'forgot::password') 540 | assert.deepEqual(recentEvent.data[0].user.toJSON(), user.toJSON()) 541 | assert.exists(recentEvent.data[0].token) 542 | 543 | Event.restore() 544 | 545 | const tokens = await user.tokens().fetch() 546 | assert.equal(tokens.size(), 1) 547 | assert.equal(tokens.first().token, recentEvent.data[0].token) 548 | }) 549 | 550 | test('updatePasswordByToken make sure new password exists', async (assert) => { 551 | assert.plan(1) 552 | 553 | try { 554 | await this.persona.updatePasswordByToken('hello', {}) 555 | } catch (error) { 556 | assert.deepEqual(error.messages, [ 557 | { 558 | field: 'password', 559 | message: 'required validation failed on password', 560 | validation: 'required' 561 | } 562 | ]) 563 | } 564 | }) 565 | 566 | test('updatePasswordByToken make sure new password is confirmed', async (assert) => { 567 | assert.plan(1) 568 | 569 | try { 570 | await this.persona.updatePasswordByToken('hello', { password: 'foobar' }) 571 | } catch (error) { 572 | assert.deepEqual(error.messages, [ 573 | { 574 | field: 'password', 575 | message: 'confirmed validation failed on password', 576 | validation: 'confirmed' 577 | } 578 | ]) 579 | } 580 | }) 581 | 582 | test('updatePasswordByToken make sure new token is valid', async (assert) => { 583 | assert.plan(1) 584 | 585 | try { 586 | await this.persona.updatePasswordByToken('hello', { password: 'foobar', password_confirmation: 'foobar' }) 587 | } catch ({ message }) { 588 | assert.equal(message, 'The token is invalid or expired') 589 | } 590 | }) 591 | 592 | test('updatePasswordByToken make sure new token type is password', async (assert) => { 593 | assert.plan(1) 594 | 595 | const user = await getUser().create({ 596 | email: 'foo@bar.com', 597 | account_status: 'active', 598 | password: 'secret' 599 | }) 600 | 601 | await use('Database').table('tokens').insert({ 602 | token: 'hello', 603 | type: 'email', 604 | user_id: user.id, 605 | is_revoked: false, 606 | created_at: moment().format('YYYY-MM-DD HH:mm:ss'), 607 | updated_at: moment().format('YYYY-MM-DD HH:mm:ss') 608 | }) 609 | 610 | try { 611 | await this.persona.updatePasswordByToken('hello', { password: 'foobar', password_confirmation: 'foobar' }) 612 | } catch ({ message }) { 613 | assert.equal(message, 'The token is invalid or expired') 614 | } 615 | }) 616 | 617 | test('updatePasswordByToken make sure new token is not expired', async (assert) => { 618 | assert.plan(1) 619 | 620 | const user = await getUser().create({ 621 | email: 'foo@bar.com', 622 | account_status: 'active', 623 | password: 'secret' 624 | }) 625 | 626 | await use('Database').table('tokens').insert({ 627 | token: 'hello', 628 | type: 'password', 629 | user_id: user.id, 630 | is_revoked: false, 631 | created_at: moment().subtract(2, 'days').format('YYYY-MM-DD HH:mm:ss'), 632 | updated_at: moment().subtract(2, 'days').format('YYYY-MM-DD HH:mm:ss') 633 | }) 634 | 635 | try { 636 | await this.persona.updatePasswordByToken('hello', { password: 'foobar', password_confirmation: 'foobar' }) 637 | } catch ({ message }) { 638 | assert.equal(message, 'The token is invalid or expired') 639 | } 640 | }) 641 | 642 | test('update user password when token is valid', async (assert) => { 643 | const Event = use('Event') 644 | Event.fake() 645 | 646 | const user = await getUser().create({ 647 | email: 'foo@bar.com', 648 | account_status: 'active', 649 | password: 'secret' 650 | }) 651 | 652 | await use('Database').table('tokens').insert({ 653 | token: 'hello', 654 | type: 'password', 655 | user_id: user.id, 656 | is_revoked: false, 657 | created_at: moment().format('YYYY-MM-DD HH:mm:ss'), 658 | updated_at: moment().format('YYYY-MM-DD HH:mm:ss') 659 | }) 660 | 661 | await this.persona.updatePasswordByToken('hello', { password: 'newsecret', password_confirmation: 'newsecret' }) 662 | await user.reload() 663 | 664 | const recentEvent = Event.pullRecent() 665 | assert.equal(recentEvent.event, 'password::recovered') 666 | assert.deepEqual(recentEvent.data[0].user.toJSON(), user.toJSON()) 667 | Event.restore() 668 | 669 | await user.reload() 670 | const tokens = await user.tokens().fetch() 671 | assert.equal(tokens.size(), 0) 672 | 673 | const verified = await use('Hash').verify('newsecret', user.password) 674 | assert.isTrue(verified) 675 | }) 676 | 677 | test('get user when any of the uid matches', async (assert) => { 678 | await getUser().create({ 679 | username: 'virk', 680 | email: 'foo@bar.com', 681 | account_status: 'active', 682 | password: 'secret' 683 | }) 684 | 685 | this.persona.config.uids = ['username', 'email'] 686 | 687 | const user = await this.persona.getUserByUids('virk') 688 | const user1 = await this.persona.getUserByUids('foo@bar.com') 689 | 690 | assert.deepEqual(user.toJSON(), user1.toJSON()) 691 | }) 692 | 693 | test('generate token do not regenerate token when one already exists', async (assert) => { 694 | const user = await getUser().create({ 695 | username: 'virk', 696 | email: 'foo@bar.com', 697 | account_status: 'active', 698 | password: 'secret' 699 | }) 700 | 701 | const token = await this.persona.generateToken(user, 'email') 702 | const token1 = await this.persona.generateToken(user, 'email') 703 | assert.equal(token, token1) 704 | }) 705 | 706 | test('generate token do regenerate token when one of different types', async (assert) => { 707 | const user = await getUser().create({ 708 | username: 'virk', 709 | email: 'foo@bar.com', 710 | account_status: 'active', 711 | password: 'secret' 712 | }) 713 | 714 | const token = await this.persona.generateToken(user, 'email') 715 | const token1 = await this.persona.generateToken(user, 'password') 716 | assert.notEqual(token, token1) 717 | }) 718 | 719 | test('generate token do regenerate token when for different users', async (assert) => { 720 | const user = await getUser().create({ 721 | username: 'virk', 722 | email: 'foo@bar.com', 723 | account_status: 'active', 724 | password: 'secret' 725 | }) 726 | 727 | const user1 = await getUser().create({ 728 | username: 'nikk', 729 | email: 'nikk@bar.com', 730 | account_status: 'active', 731 | password: 'secret' 732 | }) 733 | 734 | const token = await this.persona.generateToken(user, 'email') 735 | const token1 = await this.persona.generateToken(user1, 'email') 736 | assert.notEqual(token, token1) 737 | }) 738 | 739 | test('use custom registration rules', async (assert) => { 740 | this.persona.registerationRules = function () { 741 | return { 742 | username: 'required' 743 | } 744 | } 745 | 746 | await this.persona.register({ username: 'virk', email: 'foo@bar.com' }) 747 | const users = await getUser().all() 748 | 749 | assert.equal(users.size(), 1) 750 | assert.equal(users.first().email, 'foo@bar.com') 751 | }) 752 | }) 753 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * adonis-persona 5 | * 6 | * (c) Harminder Virk 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | process.env.SILENT_ENV = true 13 | 14 | const path = require('path') 15 | const { registrar, ioc } = require('@adonisjs/fold') 16 | const { setupResolver, Helpers } = require('@adonisjs/sink') 17 | 18 | module.exports = { 19 | wire: async function () { 20 | setupResolver() 21 | ioc.bind('Adonis/Src/Helpers', () => new Helpers(path.join(__dirname, '..', 'app'))) 22 | 23 | await registrar.providers([ 24 | '@adonisjs/framework/providers/AppProvider', 25 | '@adonisjs/lucid/providers/LucidProvider', 26 | '@adonisjs/validator/providers/ValidatorProvider' 27 | ]).registerAndBoot() 28 | 29 | ioc.singleton('App/Models/Token', (app) => { 30 | const Model = app.use('Model') 31 | class Token extends Model { 32 | user () { 33 | return this.belongsTo('App/Models/User') 34 | } 35 | } 36 | Token._bootIfNotBooted() 37 | return Token 38 | }) 39 | 40 | ioc.singleton('App/Models/User', (app) => { 41 | const Model = app.use('Model') 42 | class User extends Model { 43 | tokens () { 44 | return this.hasMany('App/Models/Token') 45 | } 46 | 47 | static boot () { 48 | super.boot() 49 | this.addHook('beforeSave', async (userinstance) => { 50 | if (userinstance.dirty.password) { 51 | userinstance.password = await use('Hash').make(userinstance.dirty.password) 52 | } 53 | }) 54 | } 55 | } 56 | User._bootIfNotBooted() 57 | return User 58 | }) 59 | }, 60 | 61 | async migrateUp () { 62 | await use('Database').schema.createTable('users', (table) => { 63 | table.increments() 64 | table.string('username').unique() 65 | table.string('email').unique().notNull() 66 | table.string('firstname').nullable() 67 | table.string('lastname').nullable() 68 | table.string('password').unique() 69 | table.enum('account_status', ['pending', 'active', 'inactive']).defaultsTo('pending') 70 | table.timestamps() 71 | }) 72 | 73 | await use('Database').schema.createTable('tokens', (table) => { 74 | table.increments() 75 | table.integer('user_id') 76 | table.string('token').notNull() 77 | table.string('type').notNull() 78 | table.boolean('is_revoked').defaultsTo(false) 79 | table.timestamps() 80 | }) 81 | }, 82 | 83 | async migrateDown () { 84 | await use('Database').schema.dropTableIfExists('users') 85 | await use('Database').schema.dropTableIfExists('tokens') 86 | } 87 | } 88 | --------------------------------------------------------------------------------