├── .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 | 
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 |
--------------------------------------------------------------------------------