Skip to content

Commit

Permalink
feat: add forgot password
Browse files Browse the repository at this point in the history
  • Loading branch information
DeVoresyah committed Dec 31, 2023
1 parent 4093412 commit 1d00ab8
Show file tree
Hide file tree
Showing 9 changed files with 120 additions and 9 deletions.
2 changes: 1 addition & 1 deletion app/Constants/passwordless-types.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const EMAIL_MAGICLINK_TYPES = ['signup', 'magiclink']
export const EMAIL_MAGICLINK_TYPES = ['signup', 'magiclink', 'recovery']
51 changes: 51 additions & 0 deletions app/Controllers/Http/v1/Auth/AuthsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import LoginWithPasswordValidator from 'App/Validators/v1/Auth/LoginWithPassword
import LoginWithOtpValidator from 'App/Validators/v1/Auth/LoginWithOtpValidator'
import LogoutValidator from 'App/Validators/v1/Auth/LogoutValidator'
import ResendValidator from 'App/Validators/v1/Auth/ResendValidator'
import ForgotPasswordValidator from 'App/Validators/v1/Auth/ForgotPasswordValidator'

// Models
import User from 'App/Models/User'
Expand Down Expand Up @@ -427,4 +428,54 @@ export default class AuthsController {
return response.api({ message: `704: ${e}` }, StatusCodes.INTERNAL_SERVER_ERROR)
}
}

public async forgotPassword({ request, response }: HttpContextContract) {
const payload = await request.validate(ForgotPasswordValidator)

const user = await User.findBy('email', payload.email)

if (!user) {
return response.api(
{ message: `We've sent recovery link to ${payload.email}.` },
StatusCodes.OK
)
}

if (!user.emailConfirmedAt) {
return response.api(
{ message: 'Please confirm your email.' },
StatusCodes.UNPROCESSABLE_ENTITY
)
}

if (user.recoverySentAt && StringTransform.isResendAvailable(user.recoverySentAt)) {
return response.api(
{ message: 'You can request new recovery link fter 2 minutes.' },
StatusCodes.TOO_MANY_REQUESTS
)
}

const otpCode = StringTransform.generateOtpNumber()
const confirmationToken = this.md5.generate(otpCode)

try {
user.recoveryToken = confirmationToken
await user.save()

this.mailer.sendRecovery(payload.email, confirmationToken, Env.get('APP_URL'))

user.recoverySentAt = DateTime.now()
await user.save()

return response.api(
{ message: `We've sent recovery link to ${payload.email}.` },
StatusCodes.OK
)
} catch (e) {
return response.api(
{ message: 'Error when sending recovery link.' },
StatusCodes.INTERNAL_SERVER_ERROR
)
}
}
}
23 changes: 20 additions & 3 deletions app/Controllers/Http/v1/Auth/VerifiesController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,15 @@ export default class VerifiesController {
const payload = await request.validate(VerifyMagicLinkValidator)
const headers = request.headers()

const user = await User.findBy('confirmation_token', payload.token)
const userQuery = User.query()

if (payload.type === 'recovery') {
userQuery.where('recovery_token', payload.token)
} else {
userQuery.where('confirmation_token', payload.token)
}

const user = await userQuery.first()

if (!user) {
return response
Expand All @@ -166,7 +174,11 @@ export default class VerifiesController {

// Check if session is invalid
// OTP Code should valid for 60 mins
if (StringTransform.isOtpExpired(user.confirmationSentAt)) {
if (
StringTransform.isOtpExpired(
payload.type === 'recovery' ? user.recoverySentAt : user.confirmationSentAt
)
) {
return response
.redirect()
.withQs({
Expand All @@ -182,7 +194,12 @@ export default class VerifiesController {
const lastSignedAt = DateTime.now()

user.lastSignInAt = lastSignedAt
user.confirmationToken = null

if (payload.type === 'recovery') {
user.recoveryToken = null
} else {
user.confirmationToken = null
}

if (payload.type === 'signup') {
user.emailConfirmedAt = lastSignedAt
Expand Down
2 changes: 1 addition & 1 deletion app/Models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default class User extends BaseModel {
public confirmationSentAt: DateTime

@column({ serializeAs: null })
public recoveryToken: string
public recoveryToken: string | null

@column.dateTime()
public recoverySentAt: DateTime
Expand Down
29 changes: 26 additions & 3 deletions app/Services/ResendService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default class ResendService {
try {
const msgToSend = {
to: email,
from: 'noreply@stmikunboxlabs.ac.id',
from: Env.get('RESEND_EMAIL_SENDER')!,
subject: 'Confirm Your Email',
html: `<h3>Confirm your email</h3>\n
<p>Follow this link to confirm your email:<br>\n
Expand All @@ -32,11 +32,34 @@ export default class ResendService {
}
}

public async sendRecovery(email: string, token: string, redirectUri: string) {
try {
const msgToSend = {
to: email,
from: Env.get('RESEND_EMAIL_SENDER')!,
subject: 'Reset Your Password',
html: `<h3>Reset Password</h3>\n
<p>Follow this link to reset the password for account:<br>\n
<a href="${Env.get(
'APP_URL'
)}/auth/v1/verify?token=${token}&type=recovery&redirect=${redirectUri}">Reset password</a>`,
}

const resendResp = await this.resend.emails.send(msgToSend)

Logger.info('resend', resendResp)

return 'Email sent.'
} catch (e) {
Logger.error(`2034: ${e}`)
}
}

public async sendOtp(email: string, token: string) {
try {
const msgToSend = {
to: email,
from: 'noreply@stmikunboxlabs.ac.id',
from: Env.get('RESEND_EMAIL_SENDER')!,
subject: 'Verification OTP Code',
html: `<h3>Your Verification OTP Code</h3>\n
<p>Please enter this code:</p>
Expand All @@ -58,7 +81,7 @@ export default class ResendService {
try {
const msgToSend = {
to: email,
from: 'noreply@stmikunboxlabs.ac.id',
from: Env.get('RESEND_EMAIL_SENDER')!,
subject: 'Login Verification',
html: `<h3>Login Verification</h3>\n
<p>Follow this link to login into your account:<br>\n
Expand Down
16 changes: 16 additions & 0 deletions app/Validators/v1/Auth/ForgotPasswordValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { schema, rules, CustomMessages } from '@ioc:Adonis/Core/Validator'
import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'

export default class ForgotPasswordValidator {
constructor(protected ctx: HttpContextContract) {}

public schema = schema.create({
email: schema.string({}, [rules.required(), rules.email()]),
redirect_uri: schema.string(),
})

public messages: CustomMessages = {
required: '{{ field }} cannot be empty.',
email: '{{ field }} should be a valid email.',
}
}
4 changes: 3 additions & 1 deletion app/Validators/v1/Auth/ResendValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ export default class ResendValidator {
}),
})

public messages: CustomMessages = {}
public messages: CustomMessages = {
email: '{{ field }} should be a valid email.',
}
}
1 change: 1 addition & 0 deletions env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default Env.rules({
TWILIO_ACCOUNT_SID: Env.schema.string.optional(),
TWILIO_AUTH_TOKEN: Env.schema.string.optional(),
TWILIO_FROM_NUMBER: Env.schema.string.optional(),
RESEND_EMAIL_SENDER: Env.schema.string.optional(),
RESEND_API_KEY: Env.schema.string.optional(),
VONAGE_API_KEY: Env.schema.string.optional(),
VONAGE_SECRET_KEY: Env.schema.string.optional(),
Expand Down
1 change: 1 addition & 0 deletions routes/auth/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Route.group(() => {
Route.post('/login/password', 'AuthsController.signInWithPassword')
Route.post('/login/otp', 'AuthsController.signInWithOtp')
Route.post('/resend', 'AuthsController.resend')
Route.post('/forgot-password', 'AuthsController.forgotPassword')
Route.delete('/logout', 'AuthsController.signOut').middleware('userSession')
require('./verify')
})
Expand Down

0 comments on commit 1d00ab8

Please sign in to comment.