diff --git a/src/lib/dtos/reset-password/reset-password-email.dto.ts b/src/lib/dtos/reset-password/reset-password-email.dto.ts index c32ba45..d774fa2 100644 --- a/src/lib/dtos/reset-password/reset-password-email.dto.ts +++ b/src/lib/dtos/reset-password/reset-password-email.dto.ts @@ -3,3 +3,5 @@ import { z } from 'zod'; export const resetPasswordEmailDto = z.object({ email: z.string().trim().email().max(64, { message: 'Email must be less than 64 characters' }), }); + +export type ResetPasswordRequestDto = z.infer; diff --git a/src/lib/dtos/reset-password/reset-password-new-password.dto.ts b/src/lib/dtos/reset-password/reset-password-new-password.dto.ts new file mode 100644 index 0000000..e66c04b --- /dev/null +++ b/src/lib/dtos/reset-password/reset-password-new-password.dto.ts @@ -0,0 +1,13 @@ +import { refinePasswords } from '$lib/validations/account'; +import { z } from 'zod'; + +export const resetPasswordNewPasswordDto = z.object({ + email: z.string().trim().email(), + password: z.string({ required_error: 'Password is required' }).trim(), + confirm_password: z.string({ required_error: 'Confirm Password is required' }).trim(), +}) +.superRefine(({ confirm_password, password }, ctx) => { + return refinePasswords(confirm_password, password, ctx); +}); + +export type ResetPasswordNewPasswordDto = z.infer; diff --git a/src/lib/dtos/reset-password/reset-password-token.dto.ts b/src/lib/dtos/reset-password/reset-password-token.dto.ts index 9526c9f..fc267b3 100644 --- a/src/lib/dtos/reset-password/reset-password-token.dto.ts +++ b/src/lib/dtos/reset-password/reset-password-token.dto.ts @@ -1,5 +1,8 @@ import { z } from 'zod'; -export const resetPasswordTokenDto = z.object({ - token: z.string().trim().min(6).max(6), +export const resetPasswordCodeDto = z.object({ + email: z.string().trim().email(), + code: z.string().trim().min(6).max(6), }); + +export type ResetPasswordCodeDto = z.infer; diff --git a/src/lib/server/api/iam/iam.controller.ts b/src/lib/server/api/iam/iam.controller.ts index 8d23166..5a699d8 100644 --- a/src/lib/server/api/iam/iam.controller.ts +++ b/src/lib/server/api/iam/iam.controller.ts @@ -1,23 +1,27 @@ -import { inject, injectable } from '@needle-di/core'; +import { resetPasswordCodeDto, resetPasswordEmailDto } from '$lib/dtos/reset-password'; import { zValidator } from '@hono/zod-validator'; +import { inject, injectable } from '@needle-di/core'; import { openApi } from 'hono-zod-openapi'; import { createLoginRequestDto } from '../../../dtos/login/create-login-request.dto'; -import { LoginRequestsService } from '../iam/login-requests/login-requests.service'; -import { verifyLoginRequestDto } from '../../../dtos/login/verify-login-request.dto'; -import { SessionsService } from '../iam/sessions/sessions.service'; -import { authState } from '../common/middleware/auth.middleware'; -import { Controller } from '../common/factories/controllers.factory'; import { loginRequestDto } from '../../../dtos/login/login-request.dto'; -import { signInEmail } from './login-requests/routes/login.routes'; +import { signinDto } from '../../../dtos/login/signin.dto'; +import { verifyLoginRequestDto } from '../../../dtos/login/verify-login-request.dto'; +import { Controller } from '../common/factories/controllers.factory'; +import { authState } from '../common/middleware/auth.middleware'; import { rateLimit } from '../common/middleware/rate-limit.middleware'; import { LoggerService } from '../common/services/logger.service'; -import { signinDto } from '../../../dtos/login/signin.dto'; +import { LoginRequestsService } from '../iam/login-requests/login-requests.service'; +import { SessionsService } from '../iam/sessions/sessions.service'; +import { signInEmail } from './login-requests/routes/login.routes'; +import { ResetPasswordRequestsService } from './reset-password-requests/reset-password-requests.service'; +import { resetPasswordNewPasswordDto } from '$lib/dtos/reset-password/reset-password-new-password.dto'; @injectable() export class IamController extends Controller { constructor( private loggerService = inject(LoggerService), private loginRequestsService = inject(LoginRequestsService), + private resetPasswordRequestsService = inject(ResetPasswordRequestsService), private sessionsService = inject(SessionsService), ) { super(); @@ -43,6 +47,18 @@ export class IamController extends Controller { await this.sessionsService.invalidateSession(''); this.sessionsService.deleteSessionCookie(); return c.json({ message: 'logout' }); + }) + .post("/password/reset", authState('none'), zValidator('json', resetPasswordNewPasswordDto), async (c) => { + await this.resetPasswordRequestsService.resetPassword(c.req.valid('json')); + return c.json({ message: 'welcome' }); + }) + .post('/password/reset/request', authState('none'), zValidator('json', resetPasswordEmailDto), async (c) => { + await this.resetPasswordRequestsService.sendResetPasswordCode(c.req.valid('json')); + return c.json({ message: 'success' }); + }) + .post('/password/reset/verify', authState('none'), zValidator('json', resetPasswordCodeDto), async (c) => { + await this.resetPasswordRequestsService.verify(c.req.valid('json')); + return c.json({ message: 'success' }); }); } } diff --git a/src/lib/server/api/iam/reset-password-requests/reset-password-requests.repository.ts b/src/lib/server/api/iam/reset-password-requests/reset-password-requests.repository.ts new file mode 100644 index 0000000..76cb892 --- /dev/null +++ b/src/lib/server/api/iam/reset-password-requests/reset-password-requests.repository.ts @@ -0,0 +1,35 @@ +import { injectable } from '@needle-di/core'; +import { RedisRepository } from '../../common/factories/redis-repository.factory'; + +/* -------------------------------------------------------------------------- */ +/* Model */ +/* -------------------------------------------------------------------------- */ +type ResetPasswordRequest = { email: string; hashedCode: string }; + +/* -------------------------------------------------------------------------- */ +/* Repository */ +/* -------------------------------------------------------------------------- */ +@injectable() +export class ResetPasswordRequestsRepository extends RedisRepository<'password-reset-request'> { + async set(args: ResetPasswordRequest) { + return this.redis.setWithExpiry({ + prefix: this.prefix, + key: args.email.toLowerCase(), + value: args.hashedCode, + expiry: 60 * 15 + }); + } + + delete(email: string) { + return this.redis.delete({ prefix: this.prefix, key: email.toLowerCase() }); + } + + async get(email: string): Promise { + const hashedCode = await this.redis.get({ + prefix: this.prefix, + key: email.toLowerCase() + }); + if (!hashedCode) return null; + return { email, hashedCode: hashedCode }; + } +} diff --git a/src/lib/server/api/iam/reset-password-requests/reset-password-requests.service.ts b/src/lib/server/api/iam/reset-password-requests/reset-password-requests.service.ts new file mode 100644 index 0000000..f920a24 --- /dev/null +++ b/src/lib/server/api/iam/reset-password-requests/reset-password-requests.service.ts @@ -0,0 +1,104 @@ +import type { ResetPasswordCodeDto } from '$lib/dtos/reset-password'; +import type { ResetPasswordNewPasswordDto } from '$lib/dtos/reset-password/reset-password-new-password.dto'; +import { inject, injectable } from '@needle-di/core'; +import type { CreateLoginRequestDto } from '../../../../dtos/login/create-login-request.dto'; +import type { } from '../../../../dtos/login/verify-login-request.dto'; +import { LoggerService } from '../../common/services/logger.service'; +import { VerificationCodesService } from '../../common/services/verification-codes.service'; +import { BadRequest } from '../../common/utils/exceptions'; +import { MailerService } from '../../mail/mailer.service'; +import { ResetPasswordEmail } from '../../mail/templates/reset-password.template'; +import { UsersRepository } from '../../users/users.repository'; +import { UsersService } from '../../users/users.service'; +import { SessionsService } from '../sessions/sessions.service'; +import { ResetPasswordRequestsRepository } from './reset-password-requests.repository'; + +@injectable() +export class ResetPasswordRequestsService { + constructor( + private loggerService = inject(LoggerService), + private resetPasswordRequestsRepository = inject(ResetPasswordRequestsRepository), + private usersRepository = inject(UsersRepository), + private verificationCodesService = inject(VerificationCodesService), + private usersService = inject(UsersService), + private sessionsService = inject(SessionsService), + private mailer = inject(MailerService), + ) {} + + async verify({ email, code }: ResetPasswordCodeDto) { + // find the hashed verification code for the email + const resetPasswordRequest = await this.resetPasswordRequestsRepository.get(email); + + // if no hashed code is found, the request is invalid + if (!resetPasswordRequest) throw BadRequest('Invalid code'); + + // verify the code + const isValid = await this.verificationCodesService.verify({ + verificationCode: code, + hashedVerificationCode: resetPasswordRequest.hashedCode, + }); + + // if the code is invalid, throw an error + if (!isValid) { + throw BadRequest('Invalid code'); + } + + // burn the login request so it can't be used again + await this.resetPasswordRequestsRepository.delete(email); + + // check if the user already exists + const existingUser = await this.usersRepository.findOneByEmail(email); + + if (!existingUser) { + this.loggerService.log.debug('User not found for email', email); + throw BadRequest('Unable to reset password'); + } + + return true; + } + async sendResetPasswordCode({ email }: CreateLoginRequestDto) { + // check if the user already exists + const existingUser = await this.usersRepository.findOneByEmail(email); + + if (!existingUser) { + this.loggerService.log.debug(`User not found for email: ${email}`); + return; + } + + // remove any existing login requests + await this.resetPasswordRequestsRepository.delete(email); + + // generate a new verification code and hash + const { verificationCode, hashedVerificationCode } = await this.verificationCodesService.generateCodeWithHash(); + + // create a new login request + await this.resetPasswordRequestsRepository.set({ + email, + hashedCode: hashedVerificationCode, + }); + + // send the verification email + await this.mailer.send({ + to: email, + template: new ResetPasswordEmail(verificationCode), + }); + } + async resetPassword({ email, password, confirm_password }: ResetPasswordNewPasswordDto) { + if (password !== confirm_password) { + throw BadRequest('Passwords do not match'); + } + + // check if the user already exists + const existingUser = await this.usersRepository.findOneByEmail(email); + + if (!existingUser) { + throw BadRequest('Unable to reset password'); + } + + await this.usersService.updatePassword(existingUser.id, password); + return true; + } + private async authExistingUser({ userId }: { userId: string }) { + return this.sessionsService.createSession(userId); + } +} diff --git a/src/lib/server/api/mail/templates/login-verification.template.ts b/src/lib/server/api/mail/templates/login-verification.template.ts index 4de3a7f..47e0cc6 100644 --- a/src/lib/server/api/mail/templates/login-verification.template.ts +++ b/src/lib/server/api/mail/templates/login-verification.template.ts @@ -1,4 +1,4 @@ -import { type EmailTemplate } from "../interfaces/email-template.interface" +import type { EmailTemplate } from "../interfaces/email-template.interface" export class LoginVerificationEmail implements EmailTemplate { constructor(private readonly token: string) { } diff --git a/src/lib/server/api/mail/templates/reset-password.template.ts b/src/lib/server/api/mail/templates/reset-password.template.ts new file mode 100644 index 0000000..38ab051 --- /dev/null +++ b/src/lib/server/api/mail/templates/reset-password.template.ts @@ -0,0 +1,39 @@ +import type { EmailTemplate } from "../interfaces/email-template.interface" + +export class ResetPasswordEmail implements EmailTemplate { + constructor(private readonly code: string) { } + + subject(): string { + return 'Reset Password' + } + + html() { + return /*html*/ ` + + + + + Message + + +

Reset your password

+

+ Thanks for using example.com. You requested a password reset. Please enter the following + verification code when prompted. If you don't have an example.com an account, you can ignore + this message.

+
+

Verification Code

+

${this.code}

+

(This code is valid for 15 minutes)

+
+ + + + ` + } +} diff --git a/src/lib/utils/flashMessages.ts b/src/lib/utils/flashMessages.ts index b96f045..b493b73 100644 --- a/src/lib/utils/flashMessages.ts +++ b/src/lib/utils/flashMessages.ts @@ -1,3 +1,7 @@ +export const alreadySignedInMessage = { + type: 'success', + message: 'You are already signed in', +} as const; export const notSignedInMessage = { type: 'error', message: 'You are not signed in', diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts index 2d90367..4f8a6bf 100644 --- a/src/routes/(auth)/login/+page.server.ts +++ b/src/routes/(auth)/login/+page.server.ts @@ -38,6 +38,13 @@ export const actions: Actions = { const loginForm = await superValidate(event, zod(signinDto)); + if (!loginForm.valid) { + loginForm.data.password = ''; + return fail(StatusCodes.BAD_REQUEST, { + loginForm, + }); + } + const { error } = await locals.api.iam.login.$post({ json: loginForm.data }).then(locals.parseApiResponse); console.log('Login error', error); if (error) { @@ -46,13 +53,6 @@ export const actions: Actions = { return setError(loginForm, 'identifier', 'An error occurred while logging in.'); } - if (!loginForm.valid) { - loginForm.data.password = ''; - return fail(400, { - loginForm, - }); - } - loginForm.data.identifier = ''; loginForm.data.password = ''; diff --git a/src/routes/(auth)/password/reset/+page.server.ts b/src/routes/(auth)/password/reset/+page.server.ts index 2cfbb2b..f6984b8 100644 --- a/src/routes/(auth)/password/reset/+page.server.ts +++ b/src/routes/(auth)/password/reset/+page.server.ts @@ -1,56 +1,85 @@ -import { notSignedInMessage } from '$lib/utils/flashMessages'; +import { resetPasswordCodeDto, resetPasswordEmailDto } from '$lib/dtos/reset-password'; +import { alreadySignedInMessage, notSignedInMessage } from '$lib/utils/flashMessages'; import { StatusCodes } from '$lib/utils/status-codes'; -import { resetPasswordEmailDto, resetPasswordTokenDto } from '$lib/dtos/reset-password'; import { fail } from '@sveltejs/kit'; import { redirect } from 'sveltekit-flash-message/server'; import { zod } from 'sveltekit-superforms/adapters'; import { setError, superValidate } from 'sveltekit-superforms/server'; import type { PageServerLoad } from './$types'; +import { resetPasswordNewPasswordDto } from '$lib/dtos/reset-password/reset-password-new-password.dto'; + +export const load: PageServerLoad = async (event) => { + const { locals } = event; + const authedUser = await locals.getAuthedUser(); + if (authedUser) { + throw redirect(302, '/', alreadySignedInMessage, event); + } -export const load: PageServerLoad = async () => { return { emailForm: await superValidate(zod(resetPasswordEmailDto)), - tokenForm: await superValidate(zod(resetPasswordTokenDto)), + tokenForm: await superValidate(zod(resetPasswordCodeDto)), + newPasswordForm: await superValidate(zod(resetPasswordNewPasswordDto)), }; }; export const actions = { - passwordReset: async (event) => { + passwordResetRequest: async (event) => { const { request, locals } = event; const authedUser = await locals.getAuthedUser(); - if (!authedUser) { - throw redirect(302, '/login', notSignedInMessage, event); + if (authedUser) { + throw redirect(302, '/', alreadySignedInMessage, event); } const emailForm = await superValidate(request, zod(resetPasswordEmailDto)); if (!emailForm.valid) { return fail(StatusCodes.BAD_REQUEST, { emailForm }); } - // const error = {}; - // // const { error } = await locals.api.iam.login.request.$post({ json: emailRegisterForm.data }).then(locals.parseApiResponse); - // if (error) { - // return setError(emailForm, 'email', error); - // } + + const { error } = await locals.api.iam.password.reset.request.$post({ json: emailForm.data }).then(locals.parseApiResponse); + if (error) { + return setError(emailForm, 'email', error); + } return { emailForm }; }, - verifyToken: async (event) => { + verifyCode: async (event) => { const { request, locals } = event; const authedUser = await locals.getAuthedUser(); - if (!authedUser) { - throw redirect(302, '/login', notSignedInMessage, event); + if (authedUser) { + throw redirect(302, '/', alreadySignedInMessage, event); } - const tokenForm = await superValidate(request, zod(resetPasswordTokenDto)); + const tokenForm = await superValidate(request, zod(resetPasswordCodeDto)); + console.log('tokenForm', tokenForm); if (!tokenForm.valid) { return fail(StatusCodes.BAD_REQUEST, { tokenForm }); } - const error = {}; - // const { error } = await locals.api.iam.login.verify.$post({ json: emailSignInForm.data }).then(locals.parseApiResponse) + + const { error } = await locals.api.iam.password.reset.verify.$post({ json: tokenForm.data }).then(locals.parseApiResponse) + console.log('error', error); if (error) { return setError(tokenForm, 'token', error); } - redirect(301, '/'); + return { tokenForm }; }, + resetPassword: async (event) => { + const { request, locals } = event; + + const authedUser = await locals.getAuthedUser(); + if (authedUser) { + throw redirect(302, '/', alreadySignedInMessage, event); + } + + const newPasswordForm = await superValidate(request, zod(resetPasswordNewPasswordDto)); + if (!newPasswordForm.valid) { + return fail(StatusCodes.BAD_REQUEST, { newPasswordForm }); + } + const { error } = await locals.api.iam.password.reset.$post({ json: newPasswordForm.data }).then(locals.parseApiResponse); + if (error) { + return setError(newPasswordForm, 'password', error); + } + const message = { type: 'success', message: 'Successfully reset password!' } as const; + redirect(302, '/login', message, event); + } }; diff --git a/src/routes/(auth)/password/reset/+page.svelte b/src/routes/(auth)/password/reset/+page.svelte index db768d8..a49faaa 100644 --- a/src/routes/(auth)/password/reset/+page.svelte +++ b/src/routes/(auth)/password/reset/+page.svelte @@ -1,36 +1,48 @@ @@ -40,18 +52,20 @@
- {#if showTokenVerification} - {@render tokenForm()} - {:else} + {#if resetEmailStep === 'email-reset'} {@render emailForm()} + {:else if resetEmailStep === 'token-verification'} + {@render codeForm()} + {:else} + {@render resetPasswordForm()} {/if}
{#snippet emailForm()} -
- + + {#snippet children({ props })} Email @@ -70,14 +84,14 @@ {/snippet} -{#snippet tokenForm()} -
- - +{#snippet codeForm()} + + + {#snippet children({ props })} - Enter the token that was sent to your email - + Enter the token that was sent to your email + {#snippet children({ cells })} {#each cells as cell} @@ -93,4 +107,41 @@ -{/snippet} \ No newline at end of file +{/snippet} + +{#snippet resetPasswordForm()} +
+ + + + {#snippet children({ props })} + Password + + {/snippet} + + + + + + {#snippet children({ props })} + Confirm Password + + {/snippet} + + + + +
+{/snippet}