refactored auth services

This commit is contained in:
rykuno 2024-09-03 22:18:38 -05:00
parent 80ba1c9861
commit b33590edad
13 changed files with 215 additions and 110 deletions

2
src/app.d.ts vendored
View file

@ -12,7 +12,7 @@ declare global {
api: ApiClient['api'];
parseApiResponse: typeof parseApiResponse;
getAuthedUser: () => Promise<Returned<User> | null>;
getAuthedUserOrThrow: () => Promise<Returned<User>>;
getAuthedUserOrThrow: (redirectTo: string) => Promise<Returned<User>>;
}
// interface PageData {}

View file

@ -18,13 +18,13 @@ const apiClient: Handle = async ({ event, resolve }) => {
/* ----------------------------- Auth functions ----------------------------- */
async function getAuthedUser() {
const { data } = await api.iam.user.$get().then(parseApiResponse)
const { data } = await api.users.me.$get().then(parseApiResponse)
return data && data.user;
}
async function getAuthedUserOrThrow() {
const { data } = await api.iam.user.$get().then(parseApiResponse);
if (!data || !data.user) throw redirect(StatusCodes.TEMPORARY_REDIRECT, '/');
async function getAuthedUserOrThrow(redirectTo = '/') {
const { data } = await api.users.me.$get().then(parseApiResponse);
if (!data || !data.user) throw redirect(StatusCodes.TEMPORARY_REDIRECT, redirectTo);
return data?.user;
}

View file

@ -1,20 +1,22 @@
import { setCookie } from 'hono/cookie';
import { inject, injectable } from 'tsyringe';
import { zValidator } from '@hono/zod-validator';
import { IamService } from '../services/iam.service';
import { limiter } from '../middlewares/rate-limiter.middlware';
import { requireAuth } from '../middlewares/auth.middleware';
import { requireAuth } from '../middlewares/require-auth.middleware';
import { Controler } from '../common/types/controller';
import { registerEmailDto } from '$lib/server/api/dtos/register-email.dto';
import { signInEmailDto } from '$lib/server/api/dtos/signin-email.dto';
import { updateEmailDto } from '$lib/server/api/dtos/update-email.dto';
import { verifyEmailDto } from '$lib/server/api/dtos/verify-email.dto';
import { LuciaService } from '../services/lucia.service';
import { AuthenticationService } from '../services/authentication.service';
import { EmailVerificationService } from '../services/email-verification.service';
import { loginDto } from '../dtos/login.dto';
import { verifyLoginDto } from '../dtos/verify-login.dto';
@injectable()
export class IamController extends Controler {
constructor(
@inject(IamService) private iamService: IamService,
@inject(AuthenticationService) private authenticationService: AuthenticationService,
@inject(EmailVerificationService) private emailVerificationService: EmailVerificationService,
@inject(LuciaService) private luciaService: LuciaService,
) {
super();
@ -22,18 +24,18 @@ export class IamController extends Controler {
routes() {
return this.controller
.get('/user', async (c) => {
.get('/me', async (c) => {
const user = c.var.user;
return c.json({ user: user });
})
.post('/login/request', zValidator('json', registerEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
.post('/login', zValidator('json', loginDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const { email } = c.req.valid('json');
await this.iamService.createLoginRequest({ email });
await this.authenticationService.createLoginRequest({ email });
return c.json({ message: 'Verification email sent' });
})
.post('/login/verify', zValidator('json', signInEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
.post('/login/verify', zValidator('json', verifyLoginDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const { email, token } = c.req.valid('json');
const session = await this.iamService.verifyLoginRequest({ email, token });
const session = await this.authenticationService.verifyLoginRequest({ email, token });
const sessionCookie = this.luciaService.lucia.createSessionCookie(session.id);
setCookie(c, sessionCookie.name, sessionCookie.value, {
path: sessionCookie.attributes.path,
@ -46,9 +48,21 @@ export class IamController extends Controler {
});
return c.json({ message: 'ok' });
})
.patch('/email', requireAuth, zValidator('json', updateEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const json = c.req.valid('json');
await this.emailVerificationService.create(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/verify', requireAuth, zValidator('json', verifyEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const json = c.req.valid('json');
await this.emailVerificationService.verify(c.var.user.id, json.token);
return c.json({ message: 'Verified and updated' });
})
.post('/logout', requireAuth, async (c) => {
const sessionId = c.var.session.id;
await this.iamService.logout(sessionId);
await this.authenticationService.logout(sessionId);
const sessionCookie = this.luciaService.lucia.createBlankSessionCookie();
setCookie(c, sessionCookie.name, sessionCookie.value, {
path: sessionCookie.attributes.path,
@ -61,17 +75,5 @@ export class IamController extends Controler {
});
return c.json({ status: 'success' });
})
.patch('/email', requireAuth, zValidator('json', updateEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const json = c.req.valid('json');
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.iamService.processEmailVerificationRequest(c.var.user.id, json.token);
return c.json({ message: 'Verified and updated' });
});
}
}

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
export const loginDto = z.object({
email: z.string().email()
});
export type LoginDto = z.infer<typeof loginDto>;

View file

@ -1,7 +0,0 @@
import { z } from 'zod';
export const registerEmailDto = z.object({
email: z.string().email()
});
export type RegisterEmailDto = z.infer<typeof registerEmailDto>;

View file

@ -1,8 +0,0 @@
import { z } from 'zod';
export const signInEmailDto = z.object({
email: z.string().email(),
token: z.string()
});
export type SignInEmailDto = z.infer<typeof signInEmailDto>;

View file

@ -0,0 +1,8 @@
import { z } from 'zod';
export const verifyLoginDto = z.object({
email: z.string().email(),
token: z.string()
});
export type VerifyLoginDto = z.infer<typeof verifyLoginDto>;

View file

@ -1,8 +1,6 @@
import type { MiddlewareHandler } from 'hono';
import { createMiddleware } from 'hono/factory';
import { verifyRequestOrigin } from 'lucia';
import type { Session, User } from 'lucia';
import { Unauthorized } from '../common/exceptions';
import type { HonoTypes } from '../common/types/hono';
import { container } from 'tsyringe';
import { LuciaService } from '../services/lucia.service';
@ -41,15 +39,4 @@ export const validateAuthSession: MiddlewareHandler<HonoTypes> = createMiddlewar
c.set("session", session);
c.set("user", user);
return next();
})
export const requireAuth: MiddlewareHandler<{
Variables: {
session: Session;
user: User;
};
}> = createMiddleware(async (c, next) => {
const user = c.var.user;
if (!user) throw Unauthorized('You must be logged in to access this resource');
return next();
});
})

View file

@ -0,0 +1,15 @@
import type { MiddlewareHandler } from "hono";
import { createMiddleware } from "hono/factory";
import type { Session, User } from "lucia";
import { Unauthorized } from "../common/exceptions";
export const requireAuth: MiddlewareHandler<{
Variables: {
session: Session;
user: User;
};
}> = createMiddleware(async (c, next) => {
const user = c.var.user;
if (!user) throw Unauthorized('You must be logged in to access this resource');
return next();
});

View file

@ -0,0 +1,80 @@
import { inject, injectable } from 'tsyringe';
import { MailerService } from './mailer.service';
import { TokensService } from './tokens.service';
import { UsersRepository } from '../repositories/users.repository';
import type { VerifyLoginDto } from '../dtos/verify-login.dto';
import type { LoginDto } from '../dtos/login.dto';
import { LoginRequestsRepository } from '../repositories/login-requests.repository';
import { LoginVerificationEmail } from '../emails/login-verification.email';
import { BadRequest } from '../common/exceptions';
import { WelcomeEmail } from '../emails/welcome.email';
import { DrizzleService } from './drizzle.service';
import { LuciaService } from './lucia.service';
@injectable()
export class AuthenticationService {
constructor(
@inject(LuciaService) private readonly luciaService: LuciaService,
@inject(DrizzleService) private readonly drizzleService: DrizzleService,
@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 createLoginRequest(data: LoginDto) {
// 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 verifyLoginRequest(data: VerifyLoginDto) {
const validLoginRequest = await this.getValidLoginRequest(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.luciaService.lucia.createSession(newUser.id, {});
}
return this.luciaService.lucia.createSession(existingUser.id, {});
}
async logout(sessionId: string) {
return this.luciaService.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 })
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 getValidLoginRequest(email: string, token: string) {
return await this.drizzleService.db.transaction(async (trx) => {
// fetch the login request
const loginRequest = await this.loginRequestsRepository.findOneByEmail(email, trx)
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.deleteById(loginRequest.id, trx);
return loginRequest
})
}
}

View file

@ -0,0 +1,69 @@
import { inject, injectable } from 'tsyringe';
import { MailerService } from './mailer.service';
import { TokensService } from './tokens.service';
import { UsersRepository } from '../repositories/users.repository';
import { LoginVerificationEmail } from '../emails/login-verification.email';
import { BadRequest } from '../common/exceptions';
import { EmailVerificationsRepository } from '../repositories/email-verifications.repository';
import { EmailChangeNoticeEmail } from '../emails/email-change-notice.email';
import { DrizzleService } from './drizzle.service';
@injectable()
export class EmailVerificationService {
constructor(
@inject(DrizzleService) private readonly drizzleService: DrizzleService,
@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 create(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 verify(userId: string, token: string) {
const validRecord = await this.burnVerificationToken(userId, token)
if (!validRecord) throw BadRequest('Invalid token');
await this.usersRepository.update(userId, { email: validRecord.requestedEmail, verified: true });
}
private async burnVerificationToken(userId: string, token: string) {
return this.drizzleService.db.transaction(async (trx) => {
// find a valid record
const emailVerificationRecord = await this.emailVerificationsRepository.findValidRecord(userId, trx);
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.deleteById(emailVerificationRecord.id, trx)
return emailVerificationRecord
})
}
}

View file

@ -2,14 +2,12 @@ import { inject, injectable } from 'tsyringe';
import { MailerService } from './mailer.service';
import { TokensService } from './tokens.service';
import { UsersRepository } from '../repositories/users.repository';
import type { SignInEmailDto } from '../dtos/signin-email.dto';
import type { RegisterEmailDto } from '../dtos/register-email.dto';
import type { VerifyLoginDto } from '../dtos/verify-login.dto';
import type { LoginDto } from '../dtos/login.dto';
import { LoginRequestsRepository } from '../repositories/login-requests.repository';
import { LoginVerificationEmail } from '../emails/login-verification.email';
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';
import { DrizzleService } from './drizzle.service';
import { LuciaService } from './lucia.service';
@ -22,10 +20,9 @@ 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) {
async createLoginRequest(data: LoginDto) {
// 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
@ -34,7 +31,7 @@ export class IamService {
await this.mailerService.send({ email: new LoginVerificationEmail(token), to: data.email });
}
async verifyLoginRequest(data: SignInEmailDto) {
async verifyLoginRequest(data: VerifyLoginDto) {
const validLoginRequest = await this.getValidLoginRequest(data.email, data.token);
if (!validLoginRequest) throw BadRequest('Invalid token');
@ -48,37 +45,6 @@ export class IamService {
return this.luciaService.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.luciaService.lucia.invalidateSession(sessionId);
}
@ -108,21 +74,7 @@ export class IamService {
})
}
private async findAndBurnEmailVerificationToken(userId: string, token: string) {
return this.drizzleService.db.transaction(async (trx) => {
// find a valid record
const emailVerificationRecord = await this.emailVerificationsRepository.findValidRecord(userId, trx);
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.deleteById(emailVerificationRecord.id, trx)
return emailVerificationRecord
})
}
}

View file

@ -15,7 +15,7 @@ export const actions = {
register: async ({ locals, request }) => {
const emailRegisterForm = await superValidate(request, zod(registerFormSchema));
if (!emailRegisterForm.valid) return fail(StatusCodes.BAD_REQUEST, { emailRegisterForm });
const { error } = await locals.api.iam.login.request.$post({ json: emailRegisterForm.data }).then(locals.parseApiResponse);
const { error } = await locals.api.iam.login.$post({ json: emailRegisterForm.data }).then(locals.parseApiResponse);
if (error) return setError(emailRegisterForm, 'email', error);
return { emailRegisterForm };
},