Skip to content

Commit

Permalink
Lesson 4: adding ability to require email verification
Browse files Browse the repository at this point in the history
  • Loading branch information
tomgobich committed Dec 3, 2022
1 parent d5b0305 commit f4c0698
Show file tree
Hide file tree
Showing 13 changed files with 180 additions and 9 deletions.
7 changes: 7 additions & 0 deletions app/Controllers/Http/AuthController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default class AuthController {
const user = await User.create(data)

await auth.login(user)
await user.sendVerifyEmail()

return response.redirect().toPath('/')
}
Expand All @@ -27,6 +28,12 @@ export default class AuthController {
return response.redirect().back()
}

if (auth.user && session.has('isVerifyingEmail')) {
// verify their email
auth.user.isEmailVerified = true
await auth.user.save()
}

return response.redirect().toPath('/')
}

Expand Down
6 changes: 3 additions & 3 deletions app/Controllers/Http/PasswordResetController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default class PasswordResetController {

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

return view.render('password/reset', { isValid, token })
}
Expand All @@ -49,7 +49,7 @@ export default class PasswordResetController {
})

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

if (!user) {
session.flash('error', 'Token expired or associated user could not be found')
Expand All @@ -58,7 +58,7 @@ export default class PasswordResetController {

await user.merge({ password }).save()
await auth.login(user)
await Token.expirePasswordResetTokens(user)
await Token.expireTokens(user, 'passwordResetTokens')

return response.redirect().toPath('/')
}
Expand Down
34 changes: 34 additions & 0 deletions app/Controllers/Http/VerifyEmailController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import Token from 'App/Models/Token'

export default class VerifyEmailController {
public async index({ view, auth }: HttpContextContract) {
await auth.user?.sendVerifyEmail()
return view.render('emails/verify')
}

public async verify({ response, session, params, auth }: HttpContextContract) {
const user = await Token.getTokenUser(params.token, 'VERIFY_EMAIL')
const isMatch = user?.id === auth.user?.id

// if token is valid and bound to a user, but user is not authenticated
if (user && !auth.user) {
// return to login page & verify email after successful login
session.put('isVerifyingEmail', true)
return response.redirect().toPath('/')
}

// if token is invalid, not bound to a user, or does not match the auth user
if (!user || !isMatch) {
// handle invalid token
session.flash('token', 'Your token is invalid or expired')
return response.redirect().toRoute('verify.email')
}

user.isEmailVerified = true
await user.save()
await Token.expireTokens(user, 'verifyEmailTokens')

return response.redirect().toPath('/')
}
}
39 changes: 39 additions & 0 deletions app/Mailers/VerifyEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { BaseMailer, MessageContract } from '@ioc:Adonis/Addons/Mail'
import User from 'App/Models/User'
import Env from '@ioc:Adonis/Core/Env'
import Route from '@ioc:Adonis/Core/Route'

export default class VerifyEmail extends BaseMailer {
constructor(private user: User, private token: string) {
super()
}
/**
* WANT TO USE A DIFFERENT MAILER?
*
* Uncomment the following line of code to use a different
* mailer and chain the ".options" method to pass custom
* options to the send method
*/
// public mailer = this.mail.use()

/**
* The prepare method is invoked automatically when you run
* "VerifyEmail.send".
*
* Use this method to prepare the email message. The method can
* also be async.
*/
public prepare(message: MessageContract) {
const domain = Env.get('DOMAIN')
const path = Route.makeUrl('verify.email.verify', [this.token])
const url = domain + path
message
.subject('Please Verify Your Email')
.from('[email protected]')
.to(this.user.email)
.html(`
Please click the following link to verify your email
<a href="${url}">Verify email</a>
`)
}
}
12 changes: 12 additions & 0 deletions app/Middleware/VerifiedEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'

export default class VerifiedEmail {
public async handle({ auth, view }: HttpContextContract, next: () => Promise<void>) {
if (auth.user && !auth.user.isEmailVerified) {
view.share({ nonVerifiedEmail: true })
}

// code for middleware goes here. ABOVE THE NEXT CALL
await next()
}
}
27 changes: 22 additions & 5 deletions app/Models/Token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { BaseModel, BelongsTo, belongsTo, column } from '@ioc:Adonis/Lucid/Orm'
import { string } from '@ioc:Adonis/Core/Helpers'
import User from './User'

type TokenType = 'PASSWORD_RESET' | 'VERIFY_EMAIL'

export default class Token extends BaseModel {
@column({ isPrimary: true })
public id: number
Expand All @@ -28,12 +30,25 @@ export default class Token extends BaseModel {
@belongsTo(() => User)
public user: BelongsTo<typeof User>

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

await Token.expireTokens(user, 'verifyEmailTokens')
const record = await user.related('tokens').create({
type: 'VERIFY_EMAIL',
expiresAt: DateTime.now().plus({ hours: 24 }),
token
})

return record.token
}

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

if (!user) return token

await Token.expirePasswordResetTokens(user)
await Token.expireTokens(user, 'passwordResetTokens')
const record = await user.related('tokens').create({
type: 'PASSWORD_RESET',
expiresAt: DateTime.now().plus({ hour: 1 }),
Expand All @@ -43,27 +58,29 @@ export default class Token extends BaseModel {
return record.token
}

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

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

return record?.user
}

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

return !!record
Expand Down
14 changes: 14 additions & 0 deletions app/Models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { column, beforeSave, BaseModel, belongsTo, BelongsTo, computed, hasMany,
import Role from './Role'
import Roles from 'App/Enums/Roles'
import Token from './Token'
import VerifyEmail from 'App/Mailers/VerifyEmail'

export default class User extends BaseModel {
@column({ isPrimary: true })
Expand All @@ -21,6 +22,9 @@ export default class User extends BaseModel {
@column()
public rememberMeToken: string | null

@column()
public isEmailVerified: boolean = false

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

Expand All @@ -43,10 +47,20 @@ export default class User extends BaseModel {
})
public passwordResetTokens: HasMany<typeof Token>

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

@beforeSave()
public static async hashPassword (user: User) {
if (user.$dirty.password) {
user.password = await Hash.make(user.password)
}
}

public async sendVerifyEmail() {
const token = await Token.generateVerifyEmailToken(this)
await new VerifyEmail(this, token).sendLater()
}
}
1 change: 1 addition & 0 deletions database/migrations/1667756129692_users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default class extends BaseSchema {
table.string('email', 255).notNullable().unique()
table.string('password', 180).notNullable()
table.string('remember_me_token').nullable()
table.boolean('is_email_verified').notNullable().defaultTo(false)

/**
* Uses timestampz for PostgreSQL and DATETIME2 for MSSQL
Expand Down
14 changes: 14 additions & 0 deletions resources/views/emails/verify.edge
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@layout('layouts/app')

@section('content')

<div class="column">
{{ inspect(flashMessages.all()) }}

<h1>Please Verify Your Email</h1>
<p>
We've send you an email with a link to verify your email, please click that link to continue.
</p>
</div>

@endsection
6 changes: 6 additions & 0 deletions resources/views/layouts/app.edge
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@
</style>
</head>
<body>
@if (nonVerifiedEmail)
<p>
Please verify your email by <a href="{{ route('verify.email') }}">clicking here</a>
</p>
@endif

<main>
@!section('content')
</main>
Expand Down
19 changes: 19 additions & 0 deletions resources/views/test.edge
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@layout('layouts/app')

@section('content')

<div class="column">
{{ inspect(flashMessages.all()) }}

@if (auth.user)
@if (nonVerifiedEmail)
<h1>You're Email Is NOT Verified</h1>
@else
<h1>You're Email Is Verified</h1>
@endif
@else
<h1>You're Not Authenticated</h1>
@endif
</div>

@endsection
3 changes: 2 additions & 1 deletion start/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,6 @@ Server.middleware.register([
*/
Server.middleware.registerNamed({
auth: () => import('App/Middleware/Auth'),
role: () => import('App/Middleware/Role')
role: () => import('App/Middleware/Role'),
verifyEmail: () => import('App/Middleware/VerifiedEmail')
})
7 changes: 7 additions & 0 deletions start/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,17 @@ Route.get('/', async ({ view }) => {
return view.render('welcome')
})

Route.get('/test', async ({ view }) => {
return view.render('test')
}).middleware(['verifyEmail'])

Route.post('/auth/register', 'AuthController.register').as('auth.register')
Route.post('/auth/login', 'AuthController.login').as('auth.login')
Route.get('/auth/logout', 'AuthController.logout').as('auth.logout')

Route.get('/verify/email', 'VerifyEmailController.index').as('verify.email').middleware(['auth'])
Route.get('/verify/email/:token', 'VerifyEmailController.verify').as('verify.email.verify')

Route.get('/password/forgot', 'PasswordResetController.forgot').as('password.forgot')
Route.post('/password/send', 'PasswordResetController.send').as('password.send')
Route.get('/password/reset/:token', 'PasswordResetController.reset').as('password.reset')
Expand Down

0 comments on commit f4c0698

Please sign in to comment.