Skip to content

Commit

Permalink
Lesson 3 - Password Reset Flow
Browse files Browse the repository at this point in the history
  • Loading branch information
tomgobich committed Nov 20, 2022
1 parent e6ac2e4 commit d5b0305
Show file tree
Hide file tree
Showing 18 changed files with 999 additions and 14 deletions.
6 changes: 4 additions & 2 deletions .adonisrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"./commands",
"@adonisjs/core/build/commands/index.js",
"@adonisjs/repl/build/commands",
"@adonisjs/lucid/build/commands"
"@adonisjs/lucid/build/commands",
"@adonisjs/mail/build/commands"
],
"exceptionHandlerNamespace": "App/Exceptions/Handler",
"aliases": {
Expand All @@ -30,7 +31,8 @@
"@adonisjs/view",
"@adonisjs/shield",
"@adonisjs/lucid",
"@adonisjs/auth"
"@adonisjs/auth",
"@adonisjs/mail"
],
"metaFiles": [
{
Expand Down
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ PG_PORT=5432
PG_USER=lucid
PG_PASSWORD=
PG_DB_NAME=lucid
SMTP_PORT=587
17 changes: 17 additions & 0 deletions ace-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,23 @@
"description": "Drop all custom types (Postgres only)"
}
]
},
"make:mailer": {
"settings": {},
"commandPath": "@adonisjs/mail/build/commands/MakeMailer",
"commandName": "make:mailer",
"description": "Make a new mailer class",
"args": [
{
"type": "string",
"propertyName": "name",
"name": "name",
"required": true,
"description": "Name of the mailer class"
}
],
"aliases": [],
"flags": []
}
},
"aliases": {}
Expand Down
65 changes: 65 additions & 0 deletions app/Controllers/Http/PasswordResetController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import Mail from '@ioc:Adonis/Addons/Mail'
import Route from '@ioc:Adonis/Core/Route'
import Env from '@ioc:Adonis/Core/Env'
import { schema, rules } from '@ioc:Adonis/Core/Validator'
import User from 'App/Models/User'
import Token from 'App/Models/Token'

export default class PasswordResetController {
public async forgot({ view }: HttpContextContract) {
return view.render('password.forgot')
}

public async send({ request, response, session }: HttpContextContract) {
const emailSchema = schema.create({
email: schema.string([rules.email()])
})

const { email } = await request.validate({ schema: emailSchema })
const user = await User.findBy('email', email)
const token = await Token.generatePasswordResetToken(user)
const resetLink = Route.makeUrl('password.reset', [token])

if (user) {
await Mail.sendLater(message => {
message
.from('[email protected]')
.to(user.email)
.subject('Reset Your Password')
.html(`Reset your password by <a href="${Env.get('DOMAIN')}${resetLink}">clicking here</a>`)
})
}

session.flash('success', 'If an account matches the provided email, you will recieve a password reset link shortly')
return response.redirect().back()
}

public async reset({ view, params }: HttpContextContract) {
const token = params.token
const isValid = await Token.verify(token)

return view.render('password/reset', { isValid, token })
}

public async store({ request, response, session, auth }: HttpContextContract) {
const passwordSchema = schema.create({
token: schema.string(),
password: schema.string([rules.minLength(8)])
})

const { token, password } = await request.validate({ schema: passwordSchema })
const user = await Token.getPasswordResetUser(token)

if (!user) {
session.flash('error', 'Token expired or associated user could not be found')
return response.redirect().back()
}

await user.merge({ password }).save()
await auth.login(user)
await Token.expirePasswordResetTokens(user)

return response.redirect().toPath('/')
}
}
71 changes: 71 additions & 0 deletions app/Models/Token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { DateTime } from 'luxon'
import { BaseModel, BelongsTo, belongsTo, column } from '@ioc:Adonis/Lucid/Orm'
import { string } from '@ioc:Adonis/Core/Helpers'
import User from './User'

export default class Token extends BaseModel {
@column({ isPrimary: true })
public id: number

@column()
public userId: number | null

@column()
public type: string

@column()
public token: string

@column.dateTime()
public expiresAt: DateTime | null

@column.dateTime({ autoCreate: true })
public createdAt: DateTime

@column.dateTime({ autoCreate: true, autoUpdate: true })
public updatedAt: DateTime

@belongsTo(() => User)
public user: BelongsTo<typeof User>

public static async generatePasswordResetToken(user: User | null) {
const token = string.generateRandom(64)

if (!user) return token

await Token.expirePasswordResetTokens(user)
const record = await user.related('tokens').create({
type: 'PASSWORD_RESET',
expiresAt: DateTime.now().plus({ hour: 1 }),
token
})

return record.token
}

public static async expirePasswordResetTokens(user: User) {
await user.related('passwordResetTokens').query().update({
expiresAt: DateTime.now()
})
}

public static async getPasswordResetUser(token: string) {
const record = await Token.query()
.preload('user')
.where('token', token)
.where('expiresAt', '>', DateTime.now().toSQL())
.orderBy('createdAt', 'desc')
.first()

return record?.user
}

public static async verify(token: string) {
const record = await Token.query()
.where('expiresAt', '>', DateTime.now().toSQL())
.where('token', token)
.first()

return !!record
}
}
11 changes: 10 additions & 1 deletion app/Models/User.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { DateTime } from 'luxon'
import Hash from '@ioc:Adonis/Core/Hash'
import { column, beforeSave, BaseModel, belongsTo, BelongsTo, computed } from '@ioc:Adonis/Lucid/Orm'
import { column, beforeSave, BaseModel, belongsTo, BelongsTo, computed, hasMany, HasMany } from '@ioc:Adonis/Lucid/Orm'
import Role from './Role'
import Roles from 'App/Enums/Roles'
import Token from './Token'

export default class User extends BaseModel {
@column({ isPrimary: true })
Expand Down Expand Up @@ -34,6 +35,14 @@ export default class User extends BaseModel {
@belongsTo(() => Role)
public role: BelongsTo<typeof Role>

@hasMany(() => Token)
public tokens: HasMany<typeof Token>

@hasMany(() => Token, {
onQuery: query => query.where('type', 'PASSWORD_RESET')
})
public passwordResetTokens: HasMany<typeof Token>

@beforeSave()
public static async hashPassword (user: User) {
if (user.$dirty.password) {
Expand Down
56 changes: 56 additions & 0 deletions config/mail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Config source: https://git.io/JvgAf
*
* Feel free to let us know via PR, if you find something broken in this contract
* file.
*/

import Env from '@ioc:Adonis/Core/Env'
import { mailConfig } from '@adonisjs/mail/build/config'

export default mailConfig({
/*
|--------------------------------------------------------------------------
| Default mailer
|--------------------------------------------------------------------------
|
| The following mailer will be used to send emails, when you don't specify
| a mailer
|
*/
mailer: 'smtp',

/*
|--------------------------------------------------------------------------
| Mailers
|--------------------------------------------------------------------------
|
| You can define or more mailers to send emails from your application. A
| single `driver` can be used to define multiple mailers with different
| config.
|
| For example: Postmark driver can be used to have different mailers for
| sending transactional and promotional emails
|
*/
mailers: {
/*
|--------------------------------------------------------------------------
| Smtp
|--------------------------------------------------------------------------
|
| Uses SMTP protocol for sending email
|
*/
smtp: {
driver: 'smtp',
host: Env.get('SMTP_HOST'),
port: Env.get('SMTP_PORT'),
auth: {
user: Env.get('SMTP_USERNAME'),
pass: Env.get('SMTP_PASSWORD'),
type: 'login',
}
},
},
})
13 changes: 13 additions & 0 deletions contracts/mail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Contract source: https://git.io/JvgAT
*
* Feel free to let us know via PR, if you find something broken in this contract
* file.
*/

import { InferMailersFromConfig } from '@adonisjs/mail/build/config'
import mailConfig from '../config/mail'

declare module '@ioc:Adonis/Addons/Mail' {
interface MailersList extends InferMailersFromConfig<typeof mailConfig> {}
}
25 changes: 25 additions & 0 deletions database/migrations/1668806613838_tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import BaseSchema from '@ioc:Adonis/Lucid/Schema'

export default class extends BaseSchema {
protected tableName = 'tokens'

public async up () {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('user_id').unsigned().references('id').inTable('users').onDelete('CASCADE')
table.string('type').notNullable()
table.string('token', 64).notNullable()

/**
* Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL
*/
table.timestamp('expires_at', { useTz: true })
table.timestamp('created_at', { useTz: true })
table.timestamp('updated_at', { useTz: true })
})
}

public async down () {
this.schema.dropTable(this.tableName)
}
}
6 changes: 6 additions & 0 deletions env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import Env from '@ioc:Adonis/Core/Env'
export default Env.rules({
HOST: Env.schema.string({ format: 'host' }),
PORT: Env.schema.number(),
DOMAIN: Env.schema.string(),
APP_KEY: Env.schema.string(),
APP_NAME: Env.schema.string(),
CACHE_VIEWS: Env.schema.boolean(),
Expand All @@ -30,4 +31,9 @@ export default Env.rules({
PG_USER: Env.schema.string(),
PG_PASSWORD: Env.schema.string.optional(),
PG_DB_NAME: Env.schema.string(),

SMTP_HOST: Env.schema.string({ format: 'host' }),
SMTP_PORT: Env.schema.number(),
SMTP_USERNAME: Env.schema.string(),
SMTP_PASSWORD: Env.schema.string(),
})
Loading

0 comments on commit d5b0305

Please sign in to comment.