diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9142239 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# editorconfig.org +root = true + +[*] +indent_size = 2 +indent_style = space +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dcfccd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +coverage +node_modules +.DS_Store +npm-debug.log +.idea +out +.nyc_output +package-lock.json +app/database.sqlite diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..225554a --- /dev/null +++ b/.npmignore @@ -0,0 +1,12 @@ +coverage +node_modules +.DS_Store +npm-debug.log +test +.travis.yml +.editorconfig +benchmarks +.idea +bin +out +.nyc_output diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..536ead8 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: node_js +node_js: +- node +- 8.0.0 +sudo: false +install: +- npm install +notifications: + slack: + 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= diff --git a/README.md b/README.md new file mode 100644 index 0000000..10398f3 --- /dev/null +++ b/README.md @@ -0,0 +1,263 @@ +![](http://res.cloudinary.com/adonisjs/image/upload/q_100/v1522328931/adonis-persona_qlb1ix.svg) + +> Opinionated user management service for AdonisJs + +Since AdonisJs is all about removing redundant code from your code base. This add-on is another attempt for same. + + +## What is Persona? + +Persona is a simple functional service to let you **create**, **verify** and **update** user profiles. + +Persona is not for everyone, if your login system is too complex and rely on many factors, then Persona is not for you. **However, persona works great for majority of use cases**. + +## What is does? +1. It helps you in registering new users. +2. Generate email verification token. +3. Validate credentials on login. +4. On email change, set the user account to `pending` state and re-generate the email verification token. +5. Allow password change. +6. Allow forget password. + +## What is doesn't? + +1. Do not generate any routes, controllers or views for you. +2. Do not send emails. However emit events that you can catch and send emails. +3. Doesn't set any sessions or generate JWT tokens + + +## Setup +Run the following command to grab the add-on from npm. + +```bash +adonis install @adonisjs/persona + +# for yarn +adonis install @adonisjs/persona --yarn +``` + +And then register the provider inside the providers array. + +```js +const providers = [ + '@adonisjs/persona/providers/PersonaProvider' +] +``` + +And then you can access it as follows + +```js +const Persona = use('Persona') +``` + +## Config + +The config file is saved as `config/persona.js`. + +| Key | Value | Description | +|-----|--------|------------| +| 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. +| email | email | The field to be used as email. Everytime user changes the value of this field, their account will be set to `pending` state. +| password | password | The field to be used as password. +| model | App/Models/User | The user model to be used. +| newAccountState | pending | What is the account state of the user, when they first signup. +| verifiedAccountState | active | The account state of the user when they verify their email address +| dateFormat | YYYY-MM-DD HH:mm:ss | Your database date format, required for finding if the token has been expired or not. +| validationMessages | function | A function that returns an object of messages to be used for validation. It is same the validator custom messages. + +## Constraints + +There are some intentional constraints in place. + +1. Only works with `Lucid` models. +2. The `App/Models/User` must have a relationship setup with `App/Models/Token` and vice-versa. + + ```js + class User extends Model { + tokens () { + return this.hasMany('App/Models/Token') + } + } + + class Token extends Model { + user () { + return this.belongsTo('App/Models/User') + } + } + ``` + + 3. User table must have a column called `account_status`. + +## API + +Let's go through the API of persona. + +#### register(payload, [callback]) + +> The optional `callback` is invoked with the original payload, just before the user is saved to the database. So this is your chance to attach any other properties to the payload. + +The register method takes the user input data and perform following actions on it. + +1. Validate that all `uids` are unique. +2. Email is unique and is a valid email address. +3. Password is confirmed. +4. Creates user account with the `account_status = pending`. +5. Generate and save email verification token inside the `tokens` table. +5. Emits `user::created` event. You can listen this event to send an email to the user. + +```js +const Persona = use('Persona') + +async register ({ request, auth, response }) { + const payload = request.only(['email', 'password', 'password_confirmation']) + + const user = await Persona.register(payload) + + // optional + await auth.login(user) + response.redirect('/dashboard') +} +``` + +#### verify(payload, [callback]) + +> The optional `callback` is invoked with the user instance, just before the password verification. So this is your chance to check for `userRole` or any other property you want. + +Verify the user credentials. The value of `uid` will be checked against all the `uids`. + +```js +async login ({ request, auth, response }) { + const payload = request.only(['uid', 'password']) + const user = await Persona.verify(payload) + + await auth.login(user) + response.redirect('/dashboard') +}) +``` + +#### verifyEmail(token) + +Verify user email using the token. Ideally it will be after someone clicks a URL from their email address. + +1. It will remove the token from the tokens table. +2. Set user `account_status = active`. + +```js +async verifyEmail ({ params, session, response }) { + const user = await Persona.verifyEmail(params.token) + + session.flash({ message: 'Email verified' }) + response.redirect('back') +}) +``` + +#### updateProfile(user, payload) + +Updates the user columns inside the database. However, if email is changed, then it will perform following steps. + +> Note this method will throw exception if user is trying to change the password. + +1. Set user `account_status = pending`. +2. Generate email verification token. +3. Fire `email::changed` event. + +```js +async update ({ request, auth }) { + const payload = request.only(['firstname', 'email']) + const user = auth.user + await Persona.updateProfile(user, payload) +}) +``` + +#### updatePassword(user, payload) + +Updates the user password by performing following steps. + +1. Ensure `old_password` matches the user password. +2. New password is confirmed. +3. Updates the user password +4. Fires `password::changed` event. You can use this event to send an email about password change. + +```js +async updatePassword ({ request, auth }) { + const payload = request.only(['old_password', 'password', 'password_confirmation']) + const user = auth.user + await Persona.updatePassword(user, payload) +}) +``` + +#### forgotPassword(uid) + +Take a forgot password request from the user by passing their `uid`. Uid will be matched for all the `uids` inside the config file. + +1. Find a user with the matching uid. +2. Generate password change token. +3. Emit `forgot::password` event. You can use this event to send the email with the token to reset the password. + +```js +forgotPassword ({ request }) { + await Persona.forgotPassword(request.input('uid')) +} +``` + +#### updatePasswordByToken(token, payload) + +Update the user password by using a token. This method will perform following checks. + +1. Make sure token is valid and not expired. +2. Ensure password is confirmed. +3. Update user password. + +```js +updatePasswordByToken ({ request, params }) { + const token = params.token + const payload = request.only(['password', 'password_confirmation']) + + const user = await Persona.updatePasswordByToken(payload) +} +``` + +## Custom messages +You can define a function inside `config/persona.js` file, which returns an object of messages to be used as validation messages. The syntax is same as the `Validator` custom messages. + +```js +{ + validationMessages (action) => { + return { + 'email.required': 'Email is required', + 'password.mis_match': 'Invalid password' + } + } +} +``` + +The `validationMessages` method gets an `action` parameter. You can use it to customize the messages for different actions. Following is the list of actions. + +1. register +2. login +3. emailUpdate +4. passwordUpdate + +## Events emitted + +Below is the list of events emitted at different occasion. + +| Event | Payload | Description | +|--------|--------|-------------| +| user::created | `{ user, token }` | Emitted when a new user is created | +| email::changed | `{ user, oldEmail, token }` | Emitted when user changes their email address +| password::changed | `{ user }` | When user change their password by providing the old password | +| forgot::password | `{ user, token }` | Emitted when user asks for a token to change their password. +| password::recovered | `{ user }` | Emitted when user password is changed using the token | + +## Exceptions raised + +The entire API is driven by exceptions, which means you will hardly have to write `if/else` statements. + +This is great, since Adonis allows managing response by catching exceptions globally. + +#### ValidationException +The validation exception is raised when validation fails. If you are already handling `Validator` exceptions, then you won't have to do anything special. + +#### InvalidTokenException +Raised when the token user is using to verify their email, or reset password is invalid. diff --git a/app/.env b/app/.env new file mode 100644 index 0000000..e69de29 diff --git a/app/config/app.js b/app/config/app.js new file mode 100644 index 0000000..32c8474 --- /dev/null +++ b/app/config/app.js @@ -0,0 +1,8 @@ +module.exports = { + logger: { + transport: 'console', + console: { + driver: 'console' + } + } +} diff --git a/app/config/database.js b/app/config/database.js new file mode 100644 index 0000000..3ff2577 --- /dev/null +++ b/app/config/database.js @@ -0,0 +1,12 @@ +const path = require('path') + +module.exports = { + connection: 'sqlite', + + sqlite: { + client: 'sqlite3', + connection: { + filename: path.join(__dirname, '../database.sqlite') + } + } +} diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..0a586b0 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,22 @@ +environment: + matrix: + - nodejs_version: 'Stable' + - nodejs_version: '8' + +init: + git config --global core.autocrlf true + +install: + - ps: Install-Product node $env:nodejs_version + - npm install + +test_script: + - node --version + - npm --version + - npm run test:win + +build: off +clone_depth: 1 + +matrix: + fast_finish: true diff --git a/config/index.js b/config/index.js new file mode 100644 index 0000000..71fbc3f --- /dev/null +++ b/config/index.js @@ -0,0 +1,98 @@ +'use strict' + +/* +|-------------------------------------------------------------------------- +| Persona +|-------------------------------------------------------------------------- +| +| The persona is a simple and opinionated service to register, login and +| manage user account +| +*/ + +module.exports = { + /* + |-------------------------------------------------------------------------- + | Uids + |-------------------------------------------------------------------------- + | + | An array of fields, that can be used to indetify a user uniquely. During + | login and reset password, these fields be checked against the user + | input + | + */ + uids: ['email'], + + /* + |-------------------------------------------------------------------------- + | Email field + |-------------------------------------------------------------------------- + | + | The name of the email field inside the database and the user payload. + | + */ + email: 'email', + + /* + |-------------------------------------------------------------------------- + | Password + |-------------------------------------------------------------------------- + | + | The password field to be used for verifying and storing user password + | + */ + password: 'password', + + /* + |-------------------------------------------------------------------------- + | New account state + |-------------------------------------------------------------------------- + | + | State of user when a new account is created + | + */ + newAccountState: 'pending', + + /* + |-------------------------------------------------------------------------- + | Verified account state + |-------------------------------------------------------------------------- + | + | State of user after they verify their email address + | + */ + verifiedAccountState: 'active', + + /* + |-------------------------------------------------------------------------- + | Model + |-------------------------------------------------------------------------- + | + | The model to be used for verifying and creating users + | + */ + model: 'App/Models/User', + + /* + |-------------------------------------------------------------------------- + | Date Format + |-------------------------------------------------------------------------- + | + | The date format for the tokens table. It is required to calculate the + | expiry of a token. + | + */ + dateFormat: 'YYYY-MM-DD HH:mm:ss', + + /* + |-------------------------------------------------------------------------- + | Validation messages + |-------------------------------------------------------------------------- + | + | An object of validation messages to be used when validation fails. + | + */ + validationMessages: () => { + return {} + } +} diff --git a/instructions.js b/instructions.js new file mode 100644 index 0000000..9378977 --- /dev/null +++ b/instructions.js @@ -0,0 +1,23 @@ +'use strict' + +/** + * adonis-persona + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +const path = require('path') + +module.exports = async (cli) => { + try { + const inFile = path.join(__dirname, './config', 'index.js') + const outFile = path.join(cli.helpers.configPath(), 'persona.js') + await cli.copy(inFile, outFile) + cli.command.completed('create', 'config/persona.js') + } catch (error) { + // ignore error + } +} diff --git a/instructions.md b/instructions.md new file mode 100644 index 0000000..604e119 --- /dev/null +++ b/instructions.md @@ -0,0 +1,14 @@ +## Register provider +Register provider inside `start/app.js` file. + +```js +const providers = [ + '@adonisjs/persona/providers/PersonaProvider' +] +``` + +And then you can access it as follows + +```js +const Persona = use('Persona') +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..aee7a8c --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "@adonisjs/persona", + "version": "1.0.0", + "description": "Opinionated user management service for AdonisJs", + "main": "index.js", + "scripts": { + "lint": "standard", + "pretest": "npm run lint", + "posttest": "npm run coverage", + "test": "nyc japa", + "test:win": "node ./node_modules/japa-cli/index.js", + "coverage": "nyc report --reporter=text-lcov | coveralls" + }, + "files": [ + "README.md", + "instructions.md", + "instructions.js", + "src", + "providers", + "config" + ], + "private": true, + "keywords": [ + "adonisjs", + "adonis-framework" + ], + "author": "", + "license": "", + "devDependencies": { + "@adonisjs/ace": "^5.0.1", + "@adonisjs/fold": "^4.0.7", + "@adonisjs/framework": "^5.0.5", + "@adonisjs/lucid": "^5.0.3", + "@adonisjs/sink": "^1.0.16", + "@adonisjs/validator": "^5.0.3", + "coveralls": "^3.0.0", + "japa": "^1.0.5", + "japa-cli": "^1.0.1", + "nyc": "^11.6.0", + "sqlite3": "^4.0.0", + "standard": "^10.0.3" + }, + "dependencies": { + "@adonisjs/generic-exceptions": "^2.0.0", + "moment": "^2.21.0", + "rand-token": "^0.4.0" + }, + "standard": { + "globals": [ + "use" + ] + } +} diff --git a/providers/README.md b/providers/README.md new file mode 100644 index 0000000..1ecc367 --- /dev/null +++ b/providers/README.md @@ -0,0 +1,3 @@ +# /providers + +Keep your providers inside this folder, ideally a single provider file is required, but feel free to create more. diff --git a/src/Persona.js b/src/Persona.js new file mode 100644 index 0000000..526e7ab --- /dev/null +++ b/src/Persona.js @@ -0,0 +1,741 @@ +'use strict' + +/** + * adonis-persona + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +const moment = require('moment') +const randtoken = require('rand-token') +const GE = require('@adonisjs/generic-exceptions') + +/** + * Raised when token is invalid or expired + * + * @class InvalidTokenException + */ +class InvalidTokenException extends GE.LogicalException { + static invalidToken () { + return new this('The token is invalid or expired', 400) + } +} + +/** + * The personna class is used to manage the user profile + * creation, verification and updation with ease. + * + * @class Persona + * + * @param {Object} Config + * @param {Object} Validator + * @param {Object} Event + * @param {Object} Hash + */ +class Persona { + constructor (Config, Validator, Event, Hash) { + this.config = Config.merge('persona', { + uids: ['email'], + email: 'email', + password: 'password', + model: 'App/Models/User', + newAccountState: 'pending', + verifiedAccountState: 'active', + dateFormat: 'YYYY-MM-DD HH:mm:ss' + }) + + /** + * Varients of password fields + */ + this._oldPasswordField = `old_${this.config.password}` + this._passwordConfirmationField = `${this.config.password}_confirmation` + + this.Hash = Hash + this.Event = Event + this.Validator = Validator + this._model = null + } + + /** + * Returns the email value from an object + * + * @method _getEmail + * + * @param {Object} payload + * + * @return {String} + * + * @private + */ + _getEmail (payload) { + return payload[this.config.email] + } + + /** + * Returns the password value from an object + * + * @method _getPassword + * + * @param {Object} payload + * + * @return {String} + * + * @private + */ + _getPassword (payload) { + return payload[this.config.password] + } + + /** + * Updates email field value on an object + * + * @method _setEmail + * + * @param {Object} payload + * @param {String} email + * + * @private + */ + _setEmail (payload, email) { + payload[this.config.email] = email + } + + /** + * Sets password field value on an object + * + * @method _setPassword + * + * @param {Object} payload + * @param {String} password + * + * @private + */ + _setPassword (payload, password) { + payload[this.config.password] = password + } + + /** + * Makes the custom message for a given key + * + * @method _makeCustomMessage + * + * @param {String} key + * @param {Object} data + * @param {String} defaultValue + * + * @return {String} + * + * @private + */ + _makeCustomMessage (key, data, defaultValue) { + const customMessage = this.getMessages()[key] + if (!customMessage) { + return defaultValue + } + + return customMessage.replace(/{{\s?(\w+)\s?}}/g, (match, group) => { + return data[group] || '' + }) + } + + /** + * Adds query constraints to pull the right token + * + * @method _addTokenConstraints + * + * @param {Object} query + * @param {String} type + * + * @private + */ + _addTokenConstraints (query, type) { + query + .where('type', type) + .where('is_revoked', false) + .where('updated_at', '>=', moment().subtract(24, 'hours').format(this.config.dateFormat)) + } + + /** + * Generates a new token for a user and given type. Ideally + * tokens will be for verifying email and forgot password + * + * @method generateToken + * + * @param {Object} user + * @param {String} type + * + * @return {String} + * + * @example + * ``` + * const user = await User.find(1) + * const token = await Persona.generateToken(user, 'email') + * ``` + */ + async generateToken (user, type) { + const query = user.tokens() + this._addTokenConstraints(query, type) + + const row = await query.first() + if (row) { + return row.token + } + + const token = randtoken.generate(16) + await user.tokens().create({ type, token }) + return token + } + + /** + * Returns the token instance along with releated + * users + * + * @method getToken + * + * @param {String} token + * @param {String} type + * + * @return {Object|Null} + * + * @example + * ``` + * const token = request.input('token') + * const tokenRow = await Persona.getToken(token, 'email') + * + * if (!tokenRow) { + * // token is invalid or expired + * } + * + * const user = tokenRow.getRelated('user') + * ``` + */ + async getToken (token, type) { + const query = this.getModel().prototype.tokens().RelatedModel.query() + this._addTokenConstraints(query, type) + + const row = await query.where('token', token).with('user').first() + return row && row.getRelated('user') ? row : null + } + + /** + * Returns the model class + * + * @method getModel + * + * @return {Model} + */ + getModel () { + if (!this._model) { + this._model = use(this.config.model) + } + return this._model + } + + /** + * Returns an object of messages to be used for validation + * failures + * + * @method getMessages + * + * @param {String} action + * + * @return {Object} + */ + getMessages (action) { + return typeof (this.config.validationMessages) === 'function' ? this.config.validationMessages(action) : {} + } + + /** + * Returns the table in user + * + * @method getTable + * + * @return {String} + */ + getTable () { + return this.getModel().table + } + + /** + * Returns an object of registeration rules + * + * @method registerationRules + * + * @return {Object} + */ + registerationRules () { + return this.config.uids.reduce((result, uid) => { + const rules = ['required'] + if (uid === this.config.email) { + rules.push('email') + } + + rules.push(`unique:${this.getTable()},${uid}`) + + result[uid] = rules.join('|') + return result + }, { + [this.config.password]: 'required|confirmed' + }) + } + + /** + * Returns the validation rules for updating email address + * + * @method updateEmailRules + * + * @param {String} userId + * + * @return {Object} + */ + updateEmailRules (userId) { + if (!userId) { + throw new Error('updateEmailRules needs the current user id to generate the validation rules') + } + + return { + [this.config.email]: `email|unique:${this.getTable()},${this.config.email},${this.getModel().primaryKey},${userId}` + } + } + + /** + * Returns the validation rules for updating the passowrd + * + * @method updatePasswordRules + * + * @param {Boolean} enforceOldPassword + * + * @return {Object} + */ + updatePasswordRules (enforceOldPassword = true) { + const rules = { + [this.config.password]: 'required|confirmed' + } + + /** + * Enforcing to define old password + */ + if (enforceOldPassword) { + rules[this._oldPasswordField] = 'required' + } + + return rules + } + + /** + * Returns an object of loginRules + * + * @method loginRules + * + * @return {String} + */ + loginRules () { + return { + 'uid': 'required', + [this.config.password]: 'required' + } + } + + /** + * Mutates the registeration payload in the shape that + * can be inserted to the database + * + * @method massageRegisterationData + * + * @param {Object} payload + * + * @return {void} + */ + massageRegisterationData (payload) { + delete payload[this._passwordConfirmationField] + payload.account_status = this.config.newAccountState + } + + /** + * Runs validations using the validator and throws error + * if validation fails + * + * @method runValidation + * + * @param {Object} payload + * @param {Object} rules + * @param {String} action + * + * @return {void} + * + * @throws {ValidationException} If validation fails + */ + async runValidation (payload, rules, action) { + const validation = await this.Validator.validateAll(payload, rules, this.getMessages(action)) + + if (validation.fails()) { + throw this.Validator.ValidationException.validationFailed(validation.messages()) + } + } + + /** + * Verifies two password and throws exception when they are not + * valid + * + * @method verifyPassword + * + * @param {String} newPassword + * @param {String} oldPassword + * @param {String} [field = this.config.password] + * + * @return {void} + */ + async verifyPassword (newPassword, oldPassword, field = this.config.password) { + const verified = await this.Hash.verify(newPassword, oldPassword) + if (!verified) { + const data = { field, validation: 'mis_match', value: newPassword } + throw this.Validator.ValidationException.validationFailed([ + { + message: this._makeCustomMessage(`${field}.mis_match`, data, 'Invalid password'), + field: field, + validation: 'mis_match' + } + ]) + } + } + + /** + * Finds the user by looking for any of the given uids + * + * @method getUserByUids + * + * @param {String} value + * + * @return {Object} + */ + async getUserByUids (value) { + const userQuery = this.getModel().query() + + /** + * Search for all uids to allow login with + * any identifier + */ + this.config.uids.forEach((uid) => userQuery.orWhere(uid, value)) + + /** + * Search for user + */ + const user = await userQuery.first() + if (!user) { + const data = { field: 'uid', validation: 'exists', value } + + throw this.Validator.ValidationException.validationFailed([ + { + message: this._makeCustomMessage('uid.exists', data, 'Unable to locate user'), + field: 'uid', + validation: 'exists' + } + ]) + } + + return user + } + + /** + * Creates a new user account and email verification token + * for them. + * + * This method will fire `user::created` event. + * + * @method register + * + * @param {Object} payload + * @param {Function} callback + * + * @return {User} + * + * @example + * ```js + * const payload = request.only(['email', 'password', 'password_confirmation']) + * await Persona.register(payload) + * ``` + */ + async register (payload, callback) { + await this.runValidation(payload, this.registerationRules(), 'register') + this.massageRegisterationData(payload) + + if (typeof (callback) === 'function') { + await callback(payload) + } + + const user = await this._model.create(payload) + + /** + * Get email verification token for the user + */ + const token = await this.generateToken(user, 'email') + + /** + * Fire new::user event to app to wire up events + */ + this.Event.fire('user::created', { user, token }) + + return user + } + + /** + * Verifies user credentials + * + * @method verify + * + * @param {Object} payload + * @param {Function} callback + * + * @return {User} + * + * @example + * ```js + * const payload = request.only(['uid', 'password']) + * await Persona.verify(payload) + * ``` + */ + async verify (payload, callback) { + await this.runValidation(payload, this.loginRules(), 'verify') + const user = await this.getUserByUids(payload.uid) + + const enteredPassword = this._getPassword(payload) + const userPassword = this._getPassword(user) + + if (typeof (callback) === 'function') { + await callback(user, enteredPassword) + } + + await this.verifyPassword(enteredPassword, userPassword) + + return user + } + + /** + * Verifies the user email address using a unique + * token associated to their account + * + * @method verifyEmail + * + * @param {String} token + * + * @return {User} + * + * @example + * ```js + * const token = request.input('token') + * await Persona.verifyEmail(token) + * ``` + */ + async verifyEmail (token) { + const tokenRow = await this.getToken(token, 'email') + if (!tokenRow) { + throw InvalidTokenException.invalidToken() + } + + const user = tokenRow.getRelated('user') + + /** + * Update user account only when in the newAccountState + */ + if (user.account_status === this.config.newAccountState) { + user.account_status = this.config.verifiedAccountState + await user.save() + } + + return user + } + + /** + * Updates the user email address and fires an event for same. This + * method will fire `email::changed` event. + * + * @method updateEmail + * + * @param {Object} user + * @param {String} newEmail + * + * @return {User} + * + * @example + * ```js + * const user = auth.user + * const newEmail = request.input('email') + * + * if (user.email !== newEmail) { + * await Persona.updateEmail(user, newEmail) + * } + * ``` + */ + async updateEmail (user, newEmail) { + await this.runValidation({ [this.config.email]: newEmail }, this.updateEmailRules(user.primaryKeyValue), 'emailUpdate') + + const oldEmail = this._getEmail(user) + + /** + * Updating user details + */ + user.account_status = this.config.newAccountState + this._setEmail(user, newEmail) + await user.save() + + /** + * Getting a new token for verifying the email and firing + * the event + */ + const token = await this.generateToken(user, 'email') + this.Event.fire('email::changed', { user, oldEmail, token }) + + return user + } + + /** + * Update user profile. Updating passwords is not allowed here. Also + * if email is provided, then this method will internally call + * `updateEmail`. + * + * @method updateProfile + * + * @param {Object} user + * @param {Object} payload + * + * @return {User} + * + * @example + * ```js + * const user = auth.user + * const payload = request.only(['firstname', 'lastname', 'email']) + * + * await Persona.updateProfile(user, payload) + * ``` + */ + async updateProfile (user, payload) { + /** + * Do not allow changing passwords here. Password flow needs + * old password to be verified + */ + if (this._getPassword(payload)) { + throw new Error('Changing password is not allowed via updateProfile method. Instead use updatePassword') + } + + const newEmail = this._getEmail(payload) + const oldEmail = this._getEmail(user) + + /** + * Update new props with the user attributes + */ + user.merge(payload) + + if (newEmail && oldEmail !== newEmail) { + /** + * We need to reset the user email, since we are calling + * updateEmail and it needs user old email address + */ + this._setEmail(user, oldEmail) + await this.updateEmail(user, newEmail) + } else { + await user.save() + } + + return user + } + + /** + * Updates the user password. This method will emit `password::changed` event. + * + * @method updatePassword + * + * @param {Object} user + * @param {Object} payload + * + * @return {User} + * + * @example + * ```js + * const user = auth.user + * const payload = request.only(['old_password', 'password', 'password_confirmation']) + * + * await Persona.updatePassword(user, payload) + * ``` + */ + async updatePassword (user, payload) { + await this.runValidation(payload, this.updatePasswordRules(), 'passwordUpdate') + + const oldPassword = payload[this._oldPasswordField] + const newPassword = this._getPassword(payload) + const existingOldPassword = this._getPassword(user) + + await this.verifyPassword(oldPassword, existingOldPassword, this._oldPasswordField) + + this._setPassword(user, newPassword) + await user.save() + + this.Event.fire('password::changed', { user }) + + return user + } + + /** + * Finds the user using one of their uids and then fires + * `forgot::password` event with a temporary token + * to update the password. + * + * @method forgotPassword + * + * @param {String} email + * + * @return {void} + * + * @example + * ```js + * const email = request.input('email') + * await Persona.forgotPassword(email) + * ``` + */ + async forgotPassword (uid) { + const user = await this.getUserByUids(uid) + const token = await this.generateToken(user, 'password') + + this.Event.fire('forgot::password', { user, token }) + } + + /** + * Updates the password for user using a pre generated token. This method + * will fire `password::recovered` event. + * + * @method updatePasswordByToken + * + * @param {String} token + * @param {Object} payload + * + * @return {User} + * + * @example + * ```js + * const token = request.input('token') + * const payload = request.only(['password', 'password_confirmation']) + * + * await Persona.updatePasswordByToken(token, payload) + * ``` + */ + async updatePasswordByToken (token, payload) { + await this.runValidation(payload, this.updatePasswordRules(false), 'passwordUpdate') + + const tokenRow = await this.getToken(token, 'password') + if (!tokenRow) { + throw InvalidTokenException.invalidToken() + } + + const user = tokenRow.getRelated('user') + this._setPassword(user, this._getPassword(payload)) + + this.Event.fire('password::recovered', { user }) + await user.save() + + return user + } +} + +module.exports = Persona diff --git a/test/persona.spec.js b/test/persona.spec.js new file mode 100644 index 0000000..86be257 --- /dev/null +++ b/test/persona.spec.js @@ -0,0 +1,712 @@ +'use strict' + +const test = require('japa') +const moment = require('moment') + +const setup = require('./setup') +const Persona = require('../src/Persona') + +function getUser () { + return use('App/Models/User') +} + +test.group('Persona', (group) => { + group.before(async () => { + await setup.wire() + await setup.migrateUp() + }) + + group.beforeEach(async () => { + this.persona = new Persona(use('Config'), use('Validator'), use('Event'), use('Hash')) + await use('Database').beginGlobalTransaction() + }) + + group.afterEach(() => { + use('Database').rollbackGlobalTransaction() + }) + + group.after(async () => { + await setup.migrateDown() + }) + + test('get registeration rules', async (assert) => { + assert.deepEqual(this.persona.registerationRules(), { + email: 'required|email|unique:users,email', + password: 'required|confirmed' + }) + }) + + test('get registeration rules when uids are multiple', async (assert) => { + this.persona.config.uids = ['username', 'email'] + + assert.deepEqual(this.persona.registerationRules(), { + email: 'required|email|unique:users,email', + username: 'required|unique:users,username', + password: 'required|confirmed' + }) + }) + + test('get login rules', async (assert) => { + assert.deepEqual(this.persona.loginRules(), { + uid: 'required', + password: 'required' + }) + }) + + test('throw validation error when user email is missing', async (assert) => { + assert.plan(1) + + try { + await this.persona.register({}) + } catch (error) { + assert.deepEqual(error.messages, [ + { + message: 'required validation failed on password', + field: 'password', + validation: 'required' + }, + { + message: 'required validation failed on email', + field: 'email', + validation: 'required' + } + ]) + } + }) + + test('throw validation error when email is already taken', async (assert) => { + await getUser().create({ email: 'virk@adonisjs.com' }) + assert.plan(1) + + try { + await this.persona.register({ + email: 'virk@adonisjs.com', + password: 'secret', + password_confirmation: 'secret' + }) + } catch (error) { + assert.deepEqual(error.messages, [ + { + message: 'unique validation failed on email', + field: 'email', + validation: 'unique' + } + ]) + } + }) + + test('create user account, token and emit event', async (assert) => { + const Event = use('Event') + Event.fake() + + const user = await this.persona.register({ + email: 'virk@adonisjs.com', + password: 'secret', + password_confirmation: 'secret' + }) + + const recentEvent = Event.pullRecent() + assert.equal(recentEvent.event, 'user::created') + assert.deepEqual(recentEvent.data[0].user, user) + assert.exists(recentEvent.data[0].token) + + Event.restore() + assert.equal(user.account_status, 'pending') + }) + + test('return error when during login uid or password is missing', async (assert) => { + assert.plan(1) + + try { + await this.persona.verify({ + }) + } catch (error) { + assert.deepEqual(error.messages, [ + { + message: 'required validation failed on uid', + field: 'uid', + validation: 'required' + }, + { + message: 'required validation failed on password', + field: 'password', + validation: 'required' + } + ]) + } + }) + + test('return error unable to locate user with given uids', async (assert) => { + assert.plan(1) + + try { + await this.persona.verify({ + uid: 'foo@bar.com', + password: 'hello' + }) + } catch (error) { + assert.deepEqual(error.messages, [ + { + message: 'Unable to locate user', + field: 'uid', + validation: 'exists' + } + ]) + } + }) + + test('return error when user password is incorrect', async (assert) => { + await getUser().create({ email: 'foo@bar.com', password: 'secret' }) + assert.plan(1) + + try { + await this.persona.verify({ + uid: 'foo@bar.com', + password: 'hello' + }) + } catch (error) { + assert.deepEqual(error.messages, [ + { + message: 'Invalid password', + field: 'password', + validation: 'mis_match' + } + ]) + } + }) + + test('return user when everything matches', async (assert) => { + await getUser().create({ email: 'foo@bar.com', password: 'secret' }) + + const verifiedUser = await this.persona.verify({ + uid: 'foo@bar.com', + password: 'secret' + }) + + assert.equal(verifiedUser.id, 1) + }) + + test('return error when unable to find email token inside db', async (assert) => { + assert.plan(2) + + try { + await this.persona.verifyEmail('hello') + } catch ({ message, name }) { + assert.equal(message, 'The token is invalid or expired') + assert.equal(name, 'InvalidTokenException') + } + }) + + test('return error token is found but is expired', async (assert) => { + const user = await getUser().create({ email: 'foo@bar.com' }) + + await use('Database').table('tokens').insert({ + token: 'hello', + type: 'email', + user_id: user.id, + is_revoked: false, + created_at: moment().subtract(2, 'days').format('YYYY-MM-DD HH:mm:ss'), + updated_at: moment().subtract(2, 'days').format('YYYY-MM-DD HH:mm:ss') + }) + + assert.plan(2) + + try { + await this.persona.verifyEmail('hello') + } catch ({ message, name }) { + assert.equal(message, 'The token is invalid or expired') + assert.equal(name, 'InvalidTokenException') + } + }) + + test('return error token is found but of wrong type', async (assert) => { + const user = await getUser().create({ email: 'foo@bar.com' }) + + await use('Database').table('tokens').insert({ + token: 'hello', + type: 'password', + user_id: user.id, + is_revoked: false, + created_at: moment().format('YYYY-MM-DD HH:mm:ss'), + updated_at: moment().format('YYYY-MM-DD HH:mm:ss') + }) + + assert.plan(2) + + try { + await this.persona.verifyEmail('hello') + } catch ({ message, name }) { + assert.equal(message, 'The token is invalid or expired') + assert.equal(name, 'InvalidTokenException') + } + }) + + test('set user account to active when token is valid', async (assert) => { + const user = await getUser().create({ email: 'foo@bar.com' }) + + await use('Database').table('tokens').insert({ + token: 'hello', + type: 'email', + is_revoked: false, + user_id: user.id, + created_at: moment().format('YYYY-MM-DD HH:mm:ss'), + updated_at: moment().format('YYYY-MM-DD HH:mm:ss') + }) + + await this.persona.verifyEmail('hello') + + await user.reload() + assert.equal(user.account_status, 'active') + }) + + test('do not set to active when initial state is not pending', async (assert) => { + const user = await getUser().create({ email: 'foo@bar.com', account_status: 'inactive' }) + + await use('Database').table('tokens').insert({ + token: 'hello', + type: 'email', + is_revoked: false, + user_id: user.id, + created_at: moment().format('YYYY-MM-DD HH:mm:ss'), + updated_at: moment().format('YYYY-MM-DD HH:mm:ss') + }) + + await this.persona.verifyEmail('hello') + + await user.reload() + assert.equal(user.account_status, 'inactive') + }) + + test('throw error when trying to update password using updateProfile', async (assert) => { + assert.plan(1) + + const user = await getUser().create({ + email: 'foo@bar.com', + account_status: 'inactive', + password: 'secret' + }) + + try { + await this.persona.updateProfile(user, { password: 'hello' }) + } catch ({ message }) { + assert.equal(message, 'Changing password is not allowed via updateProfile method. Instead use updatePassword') + } + }) + + test('update user profile', async (assert) => { + const user = await getUser().create({ + email: 'foo@bar.com', + account_status: 'inactive', + password: 'secret' + }) + + await this.persona.updateProfile(user, { firstname: 'virk' }) + + await user.reload() + assert.equal(user.firstname, 'virk') + }) + + test('get updateEmail validation rules', async (assert) => { + assert.deepEqual(this.persona.updateEmailRules(1), { + email: 'email|unique:users,email,id,1' + }) + }) + + test('when updating email make sure its valid', async (assert) => { + assert.plan(1) + + const user = await getUser().create({ + email: 'foo@bar.com', + account_status: 'inactive', + password: 'secret' + }) + + try { + await this.persona.updateProfile(user, { email: 'haha' }) + } catch (error) { + assert.deepEqual(error.messages, [{ + message: 'email validation failed on email', + field: 'email', + validation: 'email' + }]) + } + }) + + test('when updating email make sure its not taken by anyone else', async (assert) => { + assert.plan(1) + + const user = await getUser().create({ + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + await getUser().create({ + email: 'baz@bar.com', + account_status: 'active', + password: 'secret' + }) + + try { + await this.persona.updateProfile(user, { email: 'baz@bar.com' }) + } catch (error) { + assert.deepEqual(error.messages, [{ + message: 'unique validation failed on email', + field: 'email', + validation: 'unique' + }]) + } + }) + + test('set user account status to pending when email is changed', async (assert) => { + const Event = use('Event') + Event.fake() + + const user = await getUser().create({ + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + await this.persona.updateProfile(user, { firstname: 'virk', email: 'baz@bar.com' }) + + await user.reload() + assert.equal(user.firstname, 'virk') + assert.equal(user.account_status, 'pending') + + const recentEvent = Event.pullRecent() + assert.equal(recentEvent.event, 'email::changed') + assert.deepEqual(recentEvent.data[0].user, user) + assert.deepEqual(recentEvent.data[0].oldEmail, 'foo@bar.com') + assert.exists(recentEvent.data[0].token) + Event.restore() + + const tokens = await user.tokens().fetch() + assert.equal(tokens.size(), 1) + }) + + test('do not set account to pending when same email is set', async (assert) => { + const user = await getUser().create({ + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + await this.persona.updateProfile(user, { firstname: 'virk', email: 'foo@bar.com' }) + + await user.reload() + assert.equal(user.firstname, 'virk') + assert.equal(user.account_status, 'active') + + const tokens = await user.tokens().fetch() + assert.equal(tokens.size(), 0) + }) + + test('get update password rules', async (assert) => { + const rules = this.persona.updatePasswordRules() + + assert.deepEqual(rules, { + old_password: 'required', + password: 'required|confirmed' + }) + }) + + test('make sure old password is set', async (assert) => { + assert.plan(1) + + const user = await getUser().create({ + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + try { + await this.persona.updatePassword(user, {}) + } catch (error) { + assert.deepEqual(error.messages, [ + { + field: 'password', + validation: 'required', + message: 'required validation failed on password' + }, + { + field: 'old_password', + validation: 'required', + message: 'required validation failed on old_password' + } + ]) + } + }) + + test('make sure old password is correct', async (assert) => { + assert.plan(1) + + const user = await getUser().create({ + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + try { + await this.persona.updatePassword(user, { old_password: 'foo', password: 'newsecret', password_confirmation: 'newsecret' }) + } catch (error) { + assert.deepEqual(error.messages, [ + { + field: 'old_password', + validation: 'mis_match', + message: 'Invalid password' + } + ]) + } + }) + + test('update user password and fire password::changed event', async (assert) => { + const Event = use('Event') + Event.fake() + + const user = await getUser().create({ + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + await this.persona.updatePassword(user, { old_password: 'secret', password: 'newsecret', password_confirmation: 'newsecret' }) + + const recentEvent = Event.pullRecent() + assert.equal(recentEvent.event, 'password::changed') + assert.deepEqual(recentEvent.data[0].user, user) + Event.restore() + + await user.reload() + const verified = await use('Hash').verify('newsecret', user.password) + assert.isTrue(verified) + }) + + test('return error when unable to locate user with the uid', async (assert) => { + assert.plan(1) + + try { + await this.persona.forgotPassword('foo@bar.com') + } catch (error) { + assert.deepEqual(error.messages, [ + { + field: 'uid', + message: 'Unable to locate user', + validation: 'exists' + } + ]) + } + }) + + test('generate forget password token when able to locate user', async (assert) => { + const Event = use('Event') + Event.fake() + + const user = await getUser().create({ + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + await user.reload() + + await this.persona.forgotPassword('foo@bar.com') + + const recentEvent = Event.pullRecent() + assert.equal(recentEvent.event, 'forgot::password') + assert.deepEqual(recentEvent.data[0].user.toJSON(), user.toJSON()) + assert.exists(recentEvent.data[0].token) + + Event.restore() + + const tokens = await user.tokens().fetch() + assert.equal(tokens.size(), 1) + assert.equal(tokens.first().token, recentEvent.data[0].token) + }) + + test('updatePasswordByToken make sure new password exists', async (assert) => { + assert.plan(1) + + try { + await this.persona.updatePasswordByToken('hello', {}) + } catch (error) { + assert.deepEqual(error.messages, [ + { + field: 'password', + message: 'required validation failed on password', + validation: 'required' + } + ]) + } + }) + + test('updatePasswordByToken make sure new password is confirmed', async (assert) => { + assert.plan(1) + + try { + await this.persona.updatePasswordByToken('hello', { password: 'foobar' }) + } catch (error) { + assert.deepEqual(error.messages, [ + { + field: 'password', + message: 'confirmed validation failed on password', + validation: 'confirmed' + } + ]) + } + }) + + test('updatePasswordByToken make sure new token is valid', async (assert) => { + assert.plan(1) + + try { + await this.persona.updatePasswordByToken('hello', { password: 'foobar', password_confirmation: 'foobar' }) + } catch ({ message }) { + assert.equal(message, 'The token is invalid or expired') + } + }) + + test('updatePasswordByToken make sure new token type is password', async (assert) => { + assert.plan(1) + + const user = await getUser().create({ + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + await use('Database').table('tokens').insert({ + token: 'hello', + type: 'email', + user_id: user.id, + is_revoked: false, + created_at: moment().format('YYYY-MM-DD HH:mm:ss'), + updated_at: moment().format('YYYY-MM-DD HH:mm:ss') + }) + + try { + await this.persona.updatePasswordByToken('hello', { password: 'foobar', password_confirmation: 'foobar' }) + } catch ({ message }) { + assert.equal(message, 'The token is invalid or expired') + } + }) + + test('updatePasswordByToken make sure new token is not expired', async (assert) => { + assert.plan(1) + + const user = await getUser().create({ + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + await use('Database').table('tokens').insert({ + token: 'hello', + type: 'password', + user_id: user.id, + is_revoked: false, + created_at: moment().subtract(2, 'days').format('YYYY-MM-DD HH:mm:ss'), + updated_at: moment().subtract(2, 'days').format('YYYY-MM-DD HH:mm:ss') + }) + + try { + await this.persona.updatePasswordByToken('hello', { password: 'foobar', password_confirmation: 'foobar' }) + } catch ({ message }) { + assert.equal(message, 'The token is invalid or expired') + } + }) + + test('update user password when token is valid', async (assert) => { + const Event = use('Event') + Event.fake() + + const user = await getUser().create({ + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + await use('Database').table('tokens').insert({ + token: 'hello', + type: 'password', + user_id: user.id, + is_revoked: false, + created_at: moment().format('YYYY-MM-DD HH:mm:ss'), + updated_at: moment().format('YYYY-MM-DD HH:mm:ss') + }) + + await this.persona.updatePasswordByToken('hello', { password: 'newsecret', password_confirmation: 'newsecret' }) + await user.reload() + + const recentEvent = Event.pullRecent() + assert.equal(recentEvent.event, 'password::recovered') + assert.deepEqual(recentEvent.data[0].user.toJSON(), user.toJSON()) + Event.restore() + + await user.reload() + const verified = await use('Hash').verify('newsecret', user.password) + assert.isTrue(verified) + }) + + test('get user when any of the uid matches', async (assert) => { + await getUser().create({ + username: 'virk', + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + this.persona.config.uids = ['username', 'email'] + + const user = await this.persona.getUserByUids('virk') + const user1 = await this.persona.getUserByUids('foo@bar.com') + + assert.deepEqual(user.toJSON(), user1.toJSON()) + }) + + test('generate token do not regenerate token when one already exists', async (assert) => { + const user = await getUser().create({ + username: 'virk', + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + const token = await this.persona.generateToken(user, 'email') + const token1 = await this.persona.generateToken(user, 'email') + assert.equal(token, token1) + }) + + test('generate token do regenerate token when one of different types', async (assert) => { + const user = await getUser().create({ + username: 'virk', + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + const token = await this.persona.generateToken(user, 'email') + const token1 = await this.persona.generateToken(user, 'password') + assert.notEqual(token, token1) + }) + + test('generate token do regenerate token when for different users', async (assert) => { + const user = await getUser().create({ + username: 'virk', + email: 'foo@bar.com', + account_status: 'active', + password: 'secret' + }) + + const user1 = await getUser().create({ + username: 'nikk', + email: 'nikk@bar.com', + account_status: 'active', + password: 'secret' + }) + + const token = await this.persona.generateToken(user, 'email') + const token1 = await this.persona.generateToken(user1, 'email') + assert.notEqual(token, token1) + }) +}) diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 0000000..105076b --- /dev/null +++ b/test/setup.js @@ -0,0 +1,87 @@ +'use strict' + +/** + * adonis-persona + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. +*/ + +process.env.SILENT_ENV = true + +const path = require('path') +const { registrar, ioc } = require('@adonisjs/fold') +const { setupResolver, Helpers } = require('@adonisjs/sink') + +module.exports = { + wire: async function () { + setupResolver() + ioc.bind('Adonis/Src/Helpers', () => new Helpers(path.join(__dirname, '..', 'app'))) + + await registrar.providers([ + '@adonisjs/framework/providers/AppProvider', + '@adonisjs/lucid/providers/LucidProvider', + '@adonisjs/validator/providers/ValidatorProvider' + ]).registerAndBoot() + + ioc.singleton('App/Models/Token', (app) => { + const Model = app.use('Model') + class Token extends Model { + user () { + return this.belongsTo('App/Models/User') + } + } + Token._bootIfNotBooted() + return Token + }) + + ioc.singleton('App/Models/User', (app) => { + const Model = app.use('Model') + class User extends Model { + tokens () { + return this.hasMany('App/Models/Token') + } + + static boot () { + super.boot() + this.addHook('beforeSave', async (userinstance) => { + if (userinstance.dirty.password) { + userinstance.password = await use('Hash').make(userinstance.dirty.password) + } + }) + } + } + User._bootIfNotBooted() + return User + }) + }, + + async migrateUp () { + await use('Database').schema.createTable('users', (table) => { + table.increments() + table.string('username').unique() + table.string('email').unique().notNull() + table.string('firstname').nullable() + table.string('lastname').nullable() + table.string('password').unique() + table.enum('account_status', ['pending', 'active', 'inactive']).defaultsTo('pending') + table.timestamps() + }) + + await use('Database').schema.createTable('tokens', (table) => { + table.increments() + table.integer('user_id') + table.string('token').notNull() + table.string('type').notNull() + table.boolean('is_revoked').defaultsTo(false) + table.timestamps() + }) + }, + + async migrateDown () { + await use('Database').schema.dropTableIfExists('users') + await use('Database').schema.dropTableIfExists('tokens') + } +}