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({
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';
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<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 { 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' });
});
}
}

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 {
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 = {
type: 'error',
message: 'You are not signed in',

View file

@ -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 = '';

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

View file

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