Skip to content

Commit

Permalink
feat: add passwordless login
Browse files Browse the repository at this point in the history
  • Loading branch information
DeVoresyah committed Dec 29, 2023
1 parent d54430c commit 6fd78e5
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 18 deletions.
1 change: 1 addition & 0 deletions app/Constants/passwordless-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const EMAIL_MAGICLINK_TYPES = ['signup', 'magiclink']
80 changes: 77 additions & 3 deletions app/Controllers/Http/v1/Auth/AuthsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import TwilioService from 'App/Services/TwilioService'
// Validators
import RegisterWithPasswordValidator from 'App/Validators/v1/Auth/RegisterWithPasswordValidator'
import LoginWithPasswordValidator from 'App/Validators/v1/Auth/LoginWithPasswordValidator'
import LoginWithOtpValidator from 'App/Validators/v1/Auth/LoginWithOtpValidator'

// Models
import User from 'App/Models/User'
Expand Down Expand Up @@ -76,10 +77,9 @@ export default class AuthsController {
? {
email: user.email,
email_verified: false,
phone_verified: false,
}
: {
email_verified: false,
phone: user.phone,
phone_verified: false,
},
email: user?.email ?? undefined,
Expand All @@ -95,7 +95,7 @@ export default class AuthsController {

if (result.user && result.identity) {
if (payload.email) {
this.mailer.sendEmailVerification(payload.email, confirmationToken, 'http://localhost:3333')
this.mailer.sendVerification(payload.email, confirmationToken, 'http://localhost:3333')
} else {
if (payload.channel === 'whatsapp') {
this.twilio.sendOtpWhatsapp(otpCode, payload.phone!)
Expand Down Expand Up @@ -219,4 +219,78 @@ export default class AuthsController {
return response.api({ message: 'Internal server error.' }, StatusCodes.INTERNAL_SERVER_ERROR)
}
}

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

const userQuery = User.query()

if (!payload.email && !payload.phone) {
return response.api({ message: 'Credential cannot be empty.' }, StatusCodes.BAD_REQUEST)
}

if (payload.email) {
userQuery.where('email', payload.email)
}

if (payload.phone) {
userQuery.where('phone', payload.phone)
}

const user = await userQuery.first()

if (!user) {
return response.api({ message: 'Invalid credentials.' }, StatusCodes.UNAUTHORIZED)
}

console.log(user.toJSON())

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

if (payload.phone && !user.phoneConfirmedAt) {
return response.api({ message: 'Please confirm your phone.' }, StatusCodes.FORBIDDEN)
}

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

user.confirmationToken = confirmationToken
user.confirmationSentAt = DateTime.now()

await user.save()

if (payload.email) {
if (!payload.options?.redirect_uri) {
this.mailer.sendOtp(payload.email, otpCode)

return response.api(
{ message: `Verification otp code has been sent to ${payload.email}` },
StatusCodes.OK
)
}

this.mailer.sendMagicLink(payload.email, confirmationToken, payload.options.redirect_uri)

return response.api(
{ message: `Verification link has been sent to ${payload.email}` },
StatusCodes.OK
)
}

if (payload.phone) {
if (payload.options?.channel === 'whatsapp') {
this.twilio.sendOtpWhatsapp(otpCode, payload.phone)

return response.api(
{ message: `OTP Code has been sent to ${payload.phone}` },
StatusCodes.OK
)
}

this.twilio.sendOtpSms(otpCode, payload.phone)
return response.api({ message: `OTP Code has been sent to ${payload.phone}` }, StatusCodes.OK)
}
}
}
45 changes: 33 additions & 12 deletions app/Controllers/Http/v1/Auth/VerifiesController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import JwtService from 'App/Services/JwtService'
import { DateTime } from 'luxon'
import Database from '@ioc:Adonis/Lucid/Database'
import { cuid } from '@ioc:Adonis/Core/Helpers'
import { EMAIL_MAGICLINK_TYPES } from 'App/Constants/passwordless-types'

// Validators
import VerifyOtpValidator from 'App/Validators/v1/Verify/VerifyOtpValidator'
Expand Down Expand Up @@ -61,7 +62,7 @@ export default class VerifiesController {
}

// Validate OTP Code
const validateOtp = await this.md5.verify(payload.otp, user.confirmationToken)
const validateOtp = await this.md5.verify(payload.otp, user.confirmationToken ?? '')

if (!validateOtp) {
return response.api({ message: 'Invalid OTP Code.' }, StatusCodes.UNAUTHORIZED)
Expand All @@ -72,16 +73,6 @@ export default class VerifiesController {

const lastSignedAt = DateTime.now()

user.lastSignInAt = lastSignedAt

if (payload.type === 'sms' || payload.type === 'whatsapp') {
user.phoneConfirmedAt = lastSignedAt
} else {
user.emailConfirmedAt = lastSignedAt
}

await user.save()

const identity = await Identity.query({ client: trx })
.where('user_id', user.id)
.andWhere(
Expand All @@ -91,6 +82,28 @@ export default class VerifiesController {
.first()

identity!.lastSignInAt = lastSignedAt
user.lastSignInAt = lastSignedAt
user.confirmationToken = null

if (payload.type === 'sms' || payload.type === 'whatsapp') {
if (user.phoneConfirmedAt === null) {
user.phoneConfirmedAt = lastSignedAt
identity!.identity_data = {
...identity!.identity_data,
phone_verified: true,
}
}
} else {
if (user.emailConfirmedAt === null) {
user.emailConfirmedAt = lastSignedAt
identity!.identity_data = {
...identity!.identity_data,
email_verified: true,
}
}
}

await user.save()
await identity?.save()

const session = await Session.create(
Expand Down Expand Up @@ -167,6 +180,7 @@ export default class VerifiesController {
const lastSignedAt = DateTime.now()

user.lastSignInAt = lastSignedAt
user.confirmationToken = null

if (payload.type === 'signup') {
user.emailConfirmedAt = lastSignedAt
Expand All @@ -176,10 +190,17 @@ export default class VerifiesController {

const identity = await Identity.query({ client: trx })
.where('user_id', user.id)
.andWhere('provider', payload.type === 'signup' ? 'email' : 'phone')
.andWhere('provider', EMAIL_MAGICLINK_TYPES.includes(payload.type) ? 'email' : 'phone')
.first()

identity!.lastSignInAt = lastSignedAt

if (payload.type === 'signup') {
identity!.identity_data = {
...identity!.identity_data,
email_verified: true,
}
}
await identity?.save()

const session = await Session.create(
Expand Down
2 changes: 1 addition & 1 deletion app/Models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default class User extends BaseModel {
public emailConfirmedAt: DateTime

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

@column.dateTime()
public confirmationSentAt: DateTime
Expand Down
47 changes: 46 additions & 1 deletion app/Services/ResendService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default class ResendService {
this.resend = new Resend(Env.get('RESEND_API_KEY'))
}

public async sendEmailVerification(email: string, token: string, redirectUri: string) {
public async sendVerification(email: string, token: string, redirectUri: string) {
try {
const msgToSend = {
to: email,
Expand All @@ -31,4 +31,49 @@ export default class ResendService {
Logger.error(`2034: ${e}`)
}
}

public async sendOtp(email: string, token: string) {
try {
const msgToSend = {
to: email,
from: 'noreply@stmikunboxlabs.ac.id',
subject: 'Verification OTP Code',
html: `<h3>Your Verification OTP Code</h3>\n
<p>Please enter this code:</p>
<p><b>${token}</b></p>
<p>DON'T SHARE THIS CODE TO ANYONE.</p>`,
}

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

Logger.info('resend', resendResp)

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

public async sendMagicLink(email: string, token: string, redirectUri: string) {
try {
const msgToSend = {
to: email,
from: 'noreply@stmikunboxlabs.ac.id',
subject: 'Login Verification',
html: `<h3>Login Verification</h3>\n
<p>Follow this link to login into your account:<br>\n
<a href="${Env.get(
'APP_URL'
)}/auth/v1/verify?token=${token}&type=magiclink&redirect=${redirectUri}">Log In</a>`,
}

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

Logger.info('resend', resendResp)

return 'Email sent.'
} catch (e) {
Logger.error(`2034: ${e}`)
}
}
}
2 changes: 1 addition & 1 deletion app/Services/SendgridService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export default class SendgridService {
SgMail.setApiKey(Env.get('SENDGRID_API_KEY')!)
}

public async sendEmailVerification(email: string, token: string, redirectUri: string) {
public async sendVerification(email: string, token: string, redirectUri: string) {
try {
const msgToSend = {
to: email,
Expand Down
19 changes: 19 additions & 0 deletions app/Validators/v1/Auth/LoginWithOtpValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { schema, rules, CustomMessages } from '@ioc:Adonis/Core/Validator'
import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'

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

public schema = schema.create({
email: schema.string.optional({}, [rules.email()]),
phone: schema.string.optional(),
options: schema.object.optional().members({
redirect_uri: schema.string.optional(),
channel: schema.enum.optional(['sms', 'whatsapp']),
}),
})

public messages: CustomMessages = {
email: '{{ field }} should be a valid email.',
}
}
1 change: 1 addition & 0 deletions routes/auth/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Route from '@ioc:Adonis/Core/Route'
Route.group(() => {
Route.post('/register', 'AuthsController.signUpWithPassword')
Route.post('/login/password', 'AuthsController.signInWithPassword')
Route.post('/login/otp', 'AuthsController.signInWithOtp')
require('./verify')
})
.prefix('/v1')
Expand Down

0 comments on commit 6fd78e5

Please sign in to comment.