diff --git a/src/app.d.ts b/src/app.d.ts index 183345d..0b04506 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -12,7 +12,7 @@ declare global { api: ApiClient['api']; parseApiResponse: typeof parseApiResponse; getAuthedUser: () => Promise | null>; - getAuthedUserOrThrow: () => Promise>; + getAuthedUserOrThrow: (redirectTo: string) => Promise>; } // interface PageData {} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index eb08419..5db84b1 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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; } diff --git a/src/lib/server/api/controllers/iam.controller.ts b/src/lib/server/api/controllers/iam.controller.ts index 58b3b39..4b45dac 100644 --- a/src/lib/server/api/controllers/iam.controller.ts +++ b/src/lib/server/api/controllers/iam.controller.ts @@ -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' }); - }); } } diff --git a/src/lib/server/api/dtos/login.dto.ts b/src/lib/server/api/dtos/login.dto.ts new file mode 100644 index 0000000..3019e10 --- /dev/null +++ b/src/lib/server/api/dtos/login.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const loginDto = z.object({ + email: z.string().email() +}); + +export type LoginDto = z.infer; diff --git a/src/lib/server/api/dtos/register-email.dto.ts b/src/lib/server/api/dtos/register-email.dto.ts deleted file mode 100644 index 462c82e..0000000 --- a/src/lib/server/api/dtos/register-email.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod'; - -export const registerEmailDto = z.object({ - email: z.string().email() -}); - -export type RegisterEmailDto = z.infer; diff --git a/src/lib/server/api/dtos/signin-email.dto.ts b/src/lib/server/api/dtos/signin-email.dto.ts deleted file mode 100644 index e04a046..0000000 --- a/src/lib/server/api/dtos/signin-email.dto.ts +++ /dev/null @@ -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; diff --git a/src/lib/server/api/dtos/verify-login.dto.ts b/src/lib/server/api/dtos/verify-login.dto.ts new file mode 100644 index 0000000..c7385e3 --- /dev/null +++ b/src/lib/server/api/dtos/verify-login.dto.ts @@ -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; diff --git a/src/lib/server/api/middlewares/auth.middleware.ts b/src/lib/server/api/middlewares/auth.middleware.ts index dc89240..1fb1ed2 100644 --- a/src/lib/server/api/middlewares/auth.middleware.ts +++ b/src/lib/server/api/middlewares/auth.middleware.ts @@ -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 = 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(); -}); +}) \ No newline at end of file diff --git a/src/lib/server/api/middlewares/require-auth.middleware.ts b/src/lib/server/api/middlewares/require-auth.middleware.ts new file mode 100644 index 0000000..1dfb5b9 --- /dev/null +++ b/src/lib/server/api/middlewares/require-auth.middleware.ts @@ -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(); +}); \ No newline at end of file diff --git a/src/lib/server/api/services/authentication.service.ts b/src/lib/server/api/services/authentication.service.ts new file mode 100644 index 0000000..5f09507 --- /dev/null +++ b/src/lib/server/api/services/authentication.service.ts @@ -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 + }) + } + + + + +} diff --git a/src/lib/server/api/services/email-verification.service.ts b/src/lib/server/api/services/email-verification.service.ts new file mode 100644 index 0000000..b60d73b --- /dev/null +++ b/src/lib/server/api/services/email-verification.service.ts @@ -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 + }) + } + + +} diff --git a/src/lib/server/api/services/iam.service.ts b/src/lib/server/api/services/iam.service.ts index c78b340..eb6ea11 100644 --- a/src/lib/server/api/services/iam.service.ts +++ b/src/lib/server/api/services/iam.service.ts @@ -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 - }) - } } diff --git a/src/routes/(auth)/register/+page.server.ts b/src/routes/(auth)/register/+page.server.ts index de2ef34..a0612a8 100644 --- a/src/routes/(auth)/register/+page.server.ts +++ b/src/routes/(auth)/register/+page.server.ts @@ -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 }; },