Completed password reset flow.

This commit is contained in:
Bradley Shellnut 2025-01-04 21:13:36 -08:00
parent bfe7b8a28a
commit f4aa035779
12 changed files with 357 additions and 61 deletions

View file

@ -3,3 +3,5 @@ import { z } from 'zod';
export const resetPasswordEmailDto = z.object({ export const resetPasswordEmailDto = z.object({
email: z.string().trim().email().max(64, { message: 'Email must be less than 64 characters' }), email: z.string().trim().email().max(64, { message: 'Email must be less than 64 characters' }),
}); });
export type ResetPasswordRequestDto = z.infer<typeof resetPasswordEmailDto>;

View file

@ -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<typeof resetPasswordNewPasswordDto>;

View file

@ -1,5 +1,8 @@
import { z } from 'zod'; import { z } from 'zod';
export const resetPasswordTokenDto = z.object({ export const resetPasswordCodeDto = z.object({
token: z.string().trim().min(6).max(6), email: z.string().trim().email(),
code: z.string().trim().min(6).max(6),
}); });
export type ResetPasswordCodeDto = z.infer<typeof resetPasswordCodeDto>;

View file

@ -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 { zValidator } from '@hono/zod-validator';
import { inject, injectable } from '@needle-di/core';
import { openApi } from 'hono-zod-openapi'; import { openApi } from 'hono-zod-openapi';
import { createLoginRequestDto } from '../../../dtos/login/create-login-request.dto'; 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 { 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 { rateLimit } from '../common/middleware/rate-limit.middleware';
import { LoggerService } from '../common/services/logger.service'; 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() @injectable()
export class IamController extends Controller { export class IamController extends Controller {
constructor( constructor(
private loggerService = inject(LoggerService), private loggerService = inject(LoggerService),
private loginRequestsService = inject(LoginRequestsService), private loginRequestsService = inject(LoginRequestsService),
private resetPasswordRequestsService = inject(ResetPasswordRequestsService),
private sessionsService = inject(SessionsService), private sessionsService = inject(SessionsService),
) { ) {
super(); super();
@ -43,6 +47,18 @@ export class IamController extends Controller {
await this.sessionsService.invalidateSession(''); await this.sessionsService.invalidateSession('');
this.sessionsService.deleteSessionCookie(); this.sessionsService.deleteSessionCookie();
return c.json({ message: 'logout' }); 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' });
}); });
} }
} }

View file

@ -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<ResetPasswordRequest | null> {
const hashedCode = await this.redis.get({
prefix: this.prefix,
key: email.toLowerCase()
});
if (!hashedCode) return null;
return { email, hashedCode: hashedCode };
}
}

View file

@ -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);
}
}

View file

@ -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 { export class LoginVerificationEmail implements EmailTemplate {
constructor(private readonly token: string) { } constructor(private readonly token: string) { }

View file

@ -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*/ `
<html lang='en'>
<head>
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>Message</title>
</head>
<body>
<p class='title'>Reset your password</p>
<p>
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.</p>
<div class='center'>
<p class='token-title'>Verification Code</p>
<p class='token-text'>${this.code}</p>
<p class='token-subtext'>(This code is valid for 15 minutes)</p>
</div>
</body>
<style>
.title { font-size: 24px; font-weight: 700; } .token-text { font-size: 24px; font-weight: 700;
margin-top: 8px; } .token-title { font-size: 18px; font-weight: 700; margin-bottom: 0px; }
.center { display: flex; justify-content: center; align-items: center; flex-direction: column;}
.token-subtext { font-size: 12px; margin-top: 0px; }
</style>
</html>
`
}
}

View file

@ -1,3 +1,7 @@
export const alreadySignedInMessage = {
type: 'success',
message: 'You are already signed in',
} as const;
export const notSignedInMessage = { export const notSignedInMessage = {
type: 'error', type: 'error',
message: 'You are not signed in', message: 'You are not signed in',

View file

@ -38,6 +38,13 @@ export const actions: Actions = {
const loginForm = await superValidate(event, zod(signinDto)); 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); const { error } = await locals.api.iam.login.$post({ json: loginForm.data }).then(locals.parseApiResponse);
console.log('Login error', error); console.log('Login error', error);
if (error) { if (error) {
@ -46,13 +53,6 @@ export const actions: Actions = {
return setError(loginForm, 'identifier', 'An error occurred while logging in.'); 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.identifier = '';
loginForm.data.password = ''; loginForm.data.password = '';

View file

@ -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 { StatusCodes } from '$lib/utils/status-codes';
import { resetPasswordEmailDto, resetPasswordTokenDto } from '$lib/dtos/reset-password';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { redirect } from 'sveltekit-flash-message/server'; import { redirect } from 'sveltekit-flash-message/server';
import { zod } from 'sveltekit-superforms/adapters'; import { zod } from 'sveltekit-superforms/adapters';
import { setError, superValidate } from 'sveltekit-superforms/server'; import { setError, superValidate } from 'sveltekit-superforms/server';
import type { PageServerLoad } from './$types'; 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 { return {
emailForm: await superValidate(zod(resetPasswordEmailDto)), emailForm: await superValidate(zod(resetPasswordEmailDto)),
tokenForm: await superValidate(zod(resetPasswordTokenDto)), tokenForm: await superValidate(zod(resetPasswordCodeDto)),
newPasswordForm: await superValidate(zod(resetPasswordNewPasswordDto)),
}; };
}; };
export const actions = { export const actions = {
passwordReset: async (event) => { passwordResetRequest: async (event) => {
const { request, locals } = event; const { request, locals } = event;
const authedUser = await locals.getAuthedUser(); const authedUser = await locals.getAuthedUser();
if (!authedUser) { if (authedUser) {
throw redirect(302, '/login', notSignedInMessage, event); throw redirect(302, '/', alreadySignedInMessage, event);
} }
const emailForm = await superValidate(request, zod(resetPasswordEmailDto)); const emailForm = await superValidate(request, zod(resetPasswordEmailDto));
if (!emailForm.valid) { if (!emailForm.valid) {
return fail(StatusCodes.BAD_REQUEST, { emailForm }); return fail(StatusCodes.BAD_REQUEST, { emailForm });
} }
// const error = {};
// // const { error } = await locals.api.iam.login.request.$post({ json: emailRegisterForm.data }).then(locals.parseApiResponse); const { error } = await locals.api.iam.password.reset.request.$post({ json: emailForm.data }).then(locals.parseApiResponse);
// if (error) { if (error) {
// return setError(emailForm, 'email', error); return setError(emailForm, 'email', error);
// } }
return { emailForm }; return { emailForm };
}, },
verifyToken: async (event) => { verifyCode: async (event) => {
const { request, locals } = event; const { request, locals } = event;
const authedUser = await locals.getAuthedUser(); const authedUser = await locals.getAuthedUser();
if (!authedUser) { if (authedUser) {
throw redirect(302, '/login', notSignedInMessage, event); 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) { if (!tokenForm.valid) {
return fail(StatusCodes.BAD_REQUEST, { tokenForm }); 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) { if (error) {
return setError(tokenForm, 'token', 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);
}
}; };

View file

@ -1,36 +1,48 @@
<script lang="ts"> <script lang="ts">
import * as Card from "$lib/components/ui/card/index.js"; import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as Form from '$lib/components/ui/form/index.js'; import * as Form from '$lib/components/ui/form/index.js';
import * as InputOTP from '$lib/components/ui/input-otp/index.js'; import * as InputOTP from '$lib/components/ui/input-otp/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import { receive, send } from '$lib/utils/pageCrossfade'; import { resetPasswordCodeDto, resetPasswordEmailDto } from '$lib/dtos/reset-password';
import { resetPasswordEmailDto, resetPasswordTokenDto } from '$lib/dtos/reset-password'; import { resetPasswordNewPasswordDto } from '$lib/dtos/reset-password/reset-password-new-password.dto';
import { superForm } from 'sveltekit-superforms'; import { superForm } from 'sveltekit-superforms';
import { zodClient } from 'sveltekit-superforms/adapters'; import { zodClient } from 'sveltekit-superforms/adapters';
const { data } = $props(); const { data } = $props();
let showTokenVerification = $state(false); let resetEmailStep = $state('email-reset');
const emailResetForm = superForm(data.emailForm, { const sf_email_reset = superForm(data.emailForm, {
validators: zodClient(resetPasswordEmailDto), validators: zodClient(resetPasswordEmailDto),
resetForm: false, resetForm: false,
onUpdated: ({ form }) => { onUpdated: ({ form }) => {
if (form.valid) { if (form.valid) {
showTokenVerification = true; resetEmailStep = 'token-verification';
$emailFormData.email = form.data.email; $tokenFormData.email = form.data.email;
} }
}, },
}); });
const tokenVerificationForm = superForm(data.tokenForm, { const sf_token_verification = superForm(data.tokenForm, {
validators: zodClient(resetPasswordTokenDto), validators: zodClient(resetPasswordCodeDto),
resetForm: false, resetForm: false,
onUpdated: ({ form }) => {
if (form.valid) {
resetEmailStep = 'new-password';
$newPasswordFormData.email = form.data.email;
}
},
}); });
const { form: emailFormData, enhance: emailResetEnhance } = emailResetForm; const sf_new_password = superForm(data.newPasswordForm, {
const { form: tokenFormData, enhance: tokenEnhance } = tokenVerificationForm; validators: zodClient(resetPasswordNewPasswordDto),
resetForm: false,
});
const { form: emailFormData, enhance: emailResetEnhance } = sf_email_reset;
const { form: tokenFormData, enhance: tokenEnhance } = sf_token_verification;
const { form: newPasswordFormData, enhance: newPasswordEnhance } = sf_new_password;
</script> </script>
<Card.Root class="mx-auto max-w-sm"> <Card.Root class="mx-auto max-w-sm">
@ -40,18 +52,20 @@
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<div class="grid gap-4"> <div class="grid gap-4">
{#if showTokenVerification} {#if resetEmailStep === 'email-reset'}
{@render tokenForm()}
{:else}
{@render emailForm()} {@render emailForm()}
{:else if resetEmailStep === 'token-verification'}
{@render codeForm()}
{:else}
{@render resetPasswordForm()}
{/if} {/if}
</div> </div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
{#snippet emailForm()} {#snippet emailForm()}
<form method="POST" action="?/passwordReset" use:emailResetEnhance class="grid gap-4"> <form method="POST" action="?/passwordResetRequest" use:emailResetEnhance class="grid gap-4">
<Form.Field form={emailResetForm} name="email"> <Form.Field form={sf_email_reset} name="email">
<Form.Control> <Form.Control>
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label for="email">Email</Form.Label> <Form.Label for="email">Email</Form.Label>
@ -70,14 +84,14 @@
</form> </form>
{/snippet} {/snippet}
{#snippet tokenForm()} {#snippet codeForm()}
<form method="POST" action="?/verifyToken" use:tokenEnhance class="space-y-4"> <form method="POST" action="?/verifyCode" use:tokenEnhance class="space-y-4">
<input hidden value={$tokenFormData.token} name="email" /> <input hidden value={$tokenFormData.email} name="email" />
<Form.Field form={tokenVerificationForm} name="token"> <Form.Field form={sf_token_verification} name="code">
<Form.Control> <Form.Control>
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label for="token">Enter the token that was sent to your email</Form.Label> <Form.Label for="code">Enter the token that was sent to your email</Form.Label>
<InputOTP.Root maxlength={6} {...props} bind:value={$tokenFormData.token}> <InputOTP.Root maxlength={6} {...props} bind:value={$tokenFormData.code}>
{#snippet children({ cells })} {#snippet children({ cells })}
<InputOTP.Group> <InputOTP.Group>
{#each cells as cell} {#each cells as cell}
@ -93,4 +107,41 @@
</Form.Field> </Form.Field>
<Button class="w-full" type="submit">Submit</Button> <Button class="w-full" type="submit">Submit</Button>
</form> </form>
{/snippet} {/snippet}
{#snippet resetPasswordForm()}
<form method="POST" action="?/resetPassword" use:newPasswordEnhance class="space-y-4">
<input hidden value={$newPasswordFormData.email} name="email" />
<Form.Field form={sf_new_password} name="password">
<Form.Control>
{#snippet children({ props })}
<Form.Label for="password">Password</Form.Label>
<Input
{...props}
type="password"
id="password"
name="password"
bind:value={$newPasswordFormData.password}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={sf_new_password} name="confirm_password">
<Form.Control>
{#snippet children({ props })}
<Form.Label for="confirm_password">Confirm Password</Form.Label>
<Input
{...props}
type="password"
id="confirm_password"
name="confirm_password"
bind:value={$newPasswordFormData.confirm_password}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Button class="w-full" type="submit">Submit</Button>
</form>
{/snippet}