combined all iam services

This commit is contained in:
rykuno 2024-08-07 10:19:21 -05:00
parent a2a3f3faf3
commit ebc752233c
4 changed files with 58 additions and 155 deletions

View file

@ -8,13 +8,10 @@ import { signInEmailDto } from '../../../dtos/signin-email.dto';
import { updateEmailDto } from '../../../dtos/update-email.dto';
import { verifyEmailDto } from '../../../dtos/verify-email.dto';
import { registerEmailDto } from '../../../dtos/register-email.dto';
import { EmailVerificationsService } from '../services/email-verifications.service';
import { LoginRequestsService } from '../services/login-requests.service';
import type { HonoTypes } from '../common/types/hono.type';
import type { Controller } from '../common/inferfaces/controller.interface';
import { limiter } from '../middlewares/rate-limiter.middlware';
import { requireAuth } from '../middlewares/auth.middleware';
import TestJob from '../jobs/test.job';
@injectable()
export class IamController implements Controller {
@ -22,29 +19,23 @@ export class IamController implements Controller {
constructor(
@inject(IamService) private iamService: IamService,
@inject(LoginRequestsService) private loginRequestsService: LoginRequestsService,
@inject(EmailVerificationsService) private emailVerificationsService: EmailVerificationsService,
@inject(LuciaProvider) private lucia: LuciaProvider,
@inject(TestJob) private testJob: TestJob
) { }
routes() {
return this.controller
.get('/user', async (c) => {
const user = c.var.user;
console.log('uwu')
this.testJob.queue('green');
// this.testJob.worker();
return c.json({ user: user });
})
.post('/login/request', zValidator('json', registerEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const { email } = c.req.valid('json');
await this.loginRequestsService.create({ email });
await this.iamService.createLoginRequest({ email });
return c.json({ message: 'Verification email sent' });
})
.post('/login/verify', zValidator('json', signInEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const { email, token } = c.req.valid('json');
const session = await this.loginRequestsService.verify({ email, token });
const session = await this.iamService.verifyLoginRequest({ email, token });
const sessionCookie = this.lucia.createSessionCookie(session.id);
setCookie(c, sessionCookie.name, sessionCookie.value, {
path: sessionCookie.attributes.path,
@ -74,14 +65,14 @@ export class IamController implements Controller {
})
.patch('/email', requireAuth, zValidator('json', updateEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const json = c.req.valid('json');
await this.emailVerificationsService.dispatchEmailVerificationRequest(c.var.user.id, json.email);
await this.iamService.dispatchEmailVerificationRequest(c.var.user.id, json.email);
return c.json({ message: 'Verification email sent' });
})
// this could also be named to use custom methods, aka /email#verify
// https://cloud.google.com/apis/design/custom_methods
.post('/email/verification', requireAuth, zValidator('json', verifyEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const json = c.req.valid('json');
await this.emailVerificationsService.processEmailVerificationRequest(c.var.user.id, json.token);
await this.iamService.processEmailVerificationRequest(c.var.user.id, json.token);
return c.json({ message: 'Verified and updated' });
});
}

View file

@ -1,67 +0,0 @@
import { inject, injectable } from 'tsyringe';
import { MailerService } from './mailer.service';
import { TokensService } from './tokens.service';
import { UsersRepository } from '../repositories/users.repository';
import { EmailVerificationsRepository } from '../repositories/email-verifications.repository';
import { DatabaseProvider } from '../providers/database.provider';
import { EmailChangeNoticeEmail } from '../emails/email-change-notice.email';
import { LoginVerificationEmail } from '../emails/login-verification.email';
import { BadRequest } from '../common/exceptions';
@injectable()
export class EmailVerificationsService {
constructor(
@inject(DatabaseProvider) private readonly db: DatabaseProvider,
@inject(TokensService) private readonly tokensService: TokensService,
@inject(MailerService) private readonly mailerService: MailerService,
@inject(UsersRepository) private readonly usersRepository: UsersRepository,
@inject(EmailVerificationsRepository) private readonly emailVerificationsRepository: EmailVerificationsRepository,
) { }
// These steps follow the process outlined in OWASP's "Changing A User's Email Address" guide.
// https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#changing-a-users-registered-email-address
async dispatchEmailVerificationRequest(userId: string, requestedEmail: string) {
// generate a token and expiry
const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm')
const user = await this.usersRepository.findOneByIdOrThrow(userId)
// create a new email verification record
await this.emailVerificationsRepository.create({ requestedEmail, userId, hashedToken, expiresAt: expiry })
// A confirmation-required email message to the proposed new address, instructing the user to
// confirm the change and providing a link for unexpected situations
this.mailerService.send({
to: requestedEmail,
email: new LoginVerificationEmail(token)
})
// A notification-only email message to the current address, alerting the user to the impending change and
// providing a link for an unexpected situation.
this.mailerService.send({
to: user.email,
email: new EmailChangeNoticeEmail()
})
}
async processEmailVerificationRequest(userId: string, token: string) {
const validRecord = await this.findAndBurnEmailVerificationToken(userId, token)
if (!validRecord) throw BadRequest('Invalid token');
await this.usersRepository.update(userId, { email: validRecord.requestedEmail, verified: true });
}
private async findAndBurnEmailVerificationToken(userId: string, token: string) {
return this.db.transaction(async (trx) => {
// find a valid record
const emailVerificationRecord = await this.emailVerificationsRepository.trxHost(trx).findValidRecord(userId);
if (!emailVerificationRecord) return null;
// check if the token is valid
const isValidRecord = await this.tokensService.verifyHashedToken(emailVerificationRecord.hashedToken, token);
if (!isValidRecord) return null
// burn the token if it is valid
await this.emailVerificationsRepository.trxHost(trx).deleteById(emailVerificationRecord.id)
return emailVerificationRecord
})
}
}

View file

@ -10,6 +10,8 @@ import { LoginVerificationEmail } from '../emails/login-verification.email';
import { DatabaseProvider } from '../providers/database.provider';
import { BadRequest } from '../common/exceptions';
import { WelcomeEmail } from '../emails/welcome.email';
import { EmailVerificationsRepository } from '../repositories/email-verifications.repository';
import { EmailChangeNoticeEmail } from '../emails/email-change-notice.email';
@injectable()
export class IamService {
@ -20,7 +22,7 @@ export class IamService {
@inject(MailerService) private readonly mailerService: MailerService,
@inject(UsersRepository) private readonly usersRepository: UsersRepository,
@inject(LoginRequestsRepository) private readonly loginRequestsRepository: LoginRequestsRepository,
@inject(EmailVerificationsRepository) private readonly emailVerificationsRepository: EmailVerificationsRepository,
) { }
async createLoginRequest(data: RegisterEmailDto) {
@ -46,6 +48,41 @@ export class IamService {
return this.lucia.createSession(existingUser.id, {});
}
// These steps follow the process outlined in OWASP's "Changing A User's Email Address" guide.
// https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#changing-a-users-registered-email-address
async dispatchEmailVerificationRequest(userId: string, requestedEmail: string) {
// generate a token and expiry
const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm')
const user = await this.usersRepository.findOneByIdOrThrow(userId)
// create a new email verification record
await this.emailVerificationsRepository.create({ requestedEmail, userId, hashedToken, expiresAt: expiry })
// A confirmation-required email message to the proposed new address, instructing the user to
// confirm the change and providing a link for unexpected situations
this.mailerService.send({
to: requestedEmail,
email: new LoginVerificationEmail(token)
})
// A notification-only email message to the current address, alerting the user to the impending change and
// providing a link for an unexpected situation.
this.mailerService.send({
to: user.email,
email: new EmailChangeNoticeEmail()
})
}
async processEmailVerificationRequest(userId: string, token: string) {
const validRecord = await this.findAndBurnEmailVerificationToken(userId, token)
if (!validRecord) throw BadRequest('Invalid token');
await this.usersRepository.update(userId, { email: validRecord.requestedEmail, verified: true });
}
async logout(sessionId: string) {
return this.lucia.invalidateSession(sessionId);
}
// Create a new user and send a welcome email - or other onboarding process
private async handleNewUserRegistration(email: string) {
const newUser = await this.usersRepository.create({ email, verified: true })
@ -71,7 +108,21 @@ export class IamService {
})
}
async logout(sessionId: string) {
return this.lucia.invalidateSession(sessionId);
private async findAndBurnEmailVerificationToken(userId: string, token: string) {
return this.db.transaction(async (trx) => {
// find a valid record
const emailVerificationRecord = await this.emailVerificationsRepository.trxHost(trx).findValidRecord(userId);
if (!emailVerificationRecord) return null;
// check if the token is valid
const isValidRecord = await this.tokensService.verifyHashedToken(emailVerificationRecord.hashedToken, token);
if (!isValidRecord) return null
// burn the token if it is valid
await this.emailVerificationsRepository.trxHost(trx).deleteById(emailVerificationRecord.id)
return emailVerificationRecord
})
}
}

View file

@ -1,72 +0,0 @@
import { inject, injectable } from 'tsyringe';
import { MailerService } from './mailer.service';
import { TokensService } from './tokens.service';
import { LuciaProvider } from '../providers/lucia.provider';
import { UsersRepository } from '../repositories/users.repository';
import type { SignInEmailDto } from '../dtos/signin-email.dto';
import type { RegisterEmailDto } from '../dtos/register-email.dto';
import { LoginRequestsRepository } from '../repositories/login-requests.repository';
import { LoginVerificationEmail } from '../emails/login-verification.email';
import { DatabaseProvider } from '../providers/database.provider';
import { BadRequest } from '../common/exceptions';
import { WelcomeEmail } from '../emails/welcome.email';
@injectable()
export class LoginRequestsService {
constructor(
@inject(LuciaProvider) private readonly lucia: LuciaProvider,
@inject(DatabaseProvider) private readonly db: DatabaseProvider,
@inject(TokensService) private readonly tokensService: TokensService,
@inject(MailerService) private readonly mailerService: MailerService,
@inject(UsersRepository) private readonly usersRepository: UsersRepository,
@inject(LoginRequestsRepository) private readonly loginRequestsRepository: LoginRequestsRepository,
) { }
async create(data: RegisterEmailDto) {
// generate a token, expiry date, and hash
const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm');
// save the login request to the database - ensuring we save the hashedToken
await this.loginRequestsRepository.create({ email: data.email, hashedToken, expiresAt: expiry });
// send the login request email
await this.mailerService.send({ email: new LoginVerificationEmail(token), to: data.email });
}
async verify(data: SignInEmailDto) {
const validLoginRequest = await this.fetchValidRequest(data.email, data.token);
if (!validLoginRequest) throw BadRequest('Invalid token');
let existingUser = await this.usersRepository.findOneByEmail(data.email);
if (!existingUser) {
const newUser = await this.handleNewUserRegistration(data.email);
return this.lucia.createSession(newUser.id, {});
}
return this.lucia.createSession(existingUser.id, {});
}
// Create a new user and send a welcome email - or other onboarding process
private async handleNewUserRegistration(email: string) {
const newUser = await this.usersRepository.create({ email, verified: true })
await this.mailerService.send({ email: new WelcomeEmail(), to: newUser.email });
// TODO: add whatever onboarding process or extra data you need here
return newUser
}
// Fetch a valid request from the database, verify the token and burn the request if it is valid
private async fetchValidRequest(email: string, token: string) {
return await this.db.transaction(async (trx) => {
// fetch the login request
const loginRequest = await this.loginRequestsRepository.trxHost(trx).findOneByEmail(email)
if (!loginRequest) return null;
// check if the token is valid
const isValidRequest = await this.tokensService.verifyHashedToken(loginRequest.hashedToken, token);
if (!isValidRequest) return null
// if the token is valid, burn the request
await this.loginRequestsRepository.trxHost(trx).deleteById(loginRequest.id);
return loginRequest
})
}
}