Skip to content

Commit 5048ca2

Browse files
committed
impl(api): verify email and reset password handlers in confirmationCode.
1 parent 3923d02 commit 5048ca2

File tree

14 files changed

+556
-60
lines changed

14 files changed

+556
-60
lines changed

packages/models/tests/subscriptions.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ describe("transactions", () => {
5858

5959
const res = await subscriptions.find({ users: [u1.id, u3.id] });
6060
expect(res.total).to.eq(3);
61-
expect(res.list).to.deep.members([subs[0], subs[1], subs[4]]);
61+
for (const sub of [subs[0], subs[1], subs[4]]) {
62+
expect(res.list).to.deep.contain(sub);
63+
}
6264
});
6365
});
6466

packages/types/src/confirmationCode.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,20 @@ export type VerifyPhoneCodePayload = {
4747
code: number;
4848
method: IUser.NotificationMethodLiteral;
4949
};
50+
51+
export type SendCodeEmailPayload = {
52+
email: string;
53+
};
54+
55+
export type ConfirmPasswordCodePayload = {
56+
userId: number;
57+
code: number;
58+
};
59+
60+
export type ConfirmPasswordCodeApiResponse = {
61+
token: string;
62+
};
63+
64+
export type VerifyEmailPayload = {
65+
code: number;
66+
};

packages/types/src/token.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ export type AuthTokenEmail = {
3737
user: number;
3838
};
3939

40+
export type CodeEmail = {
41+
type: Type.VerifyEmail | Type.ForgetPassword;
42+
user: number;
43+
};
44+
4045
export type VerifyEmailJwtPayload = {
4146
type: Type.VerifyEmail;
4247
user: number;

packages/utils/src/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export const NOTIFICATION_METHOD_TO_PURPOSE: Record<
8585
[IUser.NotificationMethod.Telegram]: IConfirmationCode.Purpose.VerifyTelegram,
8686
};
8787

88-
export const NOTIFICATION_METHOD_LITERAL_TO_NOTIFICATION_METHOD: Record<
88+
export const NOTIFICATION_METHOD_LITERAL_TO_ENUM: Record<
8989
IUser.NotificationMethodLiteral,
9090
IUser.NotificationMethod
9191
> = {

services/server/fixtures/db.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,12 @@ export async function user(payload?: Partial<IUser.CreatePayload>) {
8888
name: payload?.name || faker.internet.username(),
8989
password: hashPassword(payload?.password || "Password@8"),
9090
birthYear: payload?.birthYear || faker.number.int({ min: 2000, max: 2024 }),
91-
role: payload?.role || (sample(Object.values(IUser.Role)) as IUser.Role),
91+
role:
92+
payload?.role ||
93+
(sample(
94+
Object.values(IUser.Role).filter((i) => typeof i === "number")
95+
) as IUser.Role),
96+
verifiedEmail: payload?.verifiedEmail || false,
9297
});
9398
}
9499

services/server/src/handlers/confirmationCode.ts

Lines changed: 197 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,32 @@
11
import {
2-
bad,
2+
emailAlreadyVerified,
33
expiredVerificationCode,
44
forbidden,
55
invalidVerificationCode,
6+
notfound,
67
unresolvedPhone,
78
} from "@/lib/error";
89
import { confirmationCodes, knex, users } from "@litespace/models";
910
import { IConfirmationCode, IUser } from "@litespace/types";
1011
import {
11-
CONFIRMATION_CODE_VALIDITY_MINUTES,
1212
isUser,
13+
CONFIRMATION_CODE_VALIDITY_MINUTES,
1314
NOTIFICATION_METHOD_LITERAL_TO_KAFKA_TOPIC,
14-
NOTIFICATION_METHOD_LITERAL_TO_NOTIFICATION_METHOD,
15+
NOTIFICATION_METHOD_LITERAL_TO_ENUM,
1516
NOTIFICATION_METHOD_LITERAL_TO_PURPOSE,
1617
} from "@litespace/utils";
1718
import { NextFunction, Request, Response } from "express";
1819
import zod from "zod";
1920
import dayjs from "@/lib/dayjs";
2021
import safeRequest from "express-async-handler";
2122
import { first } from "lodash";
22-
import { generateConfirmationCode } from "@/lib/confirmationCodes";
23+
import { generateConfirmationCode, selectPhone } from "@/lib/confirmationCodes";
2324
import { messenger } from "@/lib/messenger";
24-
import { withPhone } from "@/lib/user";
2525
import { producer } from "@/lib/kafka";
26-
import { unionOfLiterals } from "@/validation/utils";
26+
import { id, unionOfLiterals, email } from "@/validation/utils";
27+
import { sendBackgroundMessage } from "@/workers";
28+
import { jwtSecret } from "@/constants";
29+
import { encodeAuthJwt } from "@litespace/auth";
2730

2831
const method = unionOfLiterals<IUser.NotificationMethodLiteral>([
2932
"whatsapp",
@@ -40,6 +43,17 @@ const verifyNotificationMethodCodePayload = zod.object({
4043
method,
4144
});
4245

46+
const sendCodePayload = zod.object({ email });
47+
48+
const confirmPasswordCodePayload = zod.object({
49+
userId: id,
50+
code: zod.number(),
51+
});
52+
53+
const verifyEmailPayload = zod.object({
54+
code: zod.number(),
55+
});
56+
4357
async function sendVerifyPhoneCode(
4458
req: Request,
4559
res: Response,
@@ -51,21 +65,22 @@ async function sendVerifyPhoneCode(
5165

5266
const payload: IConfirmationCode.SendVerifyPhoneCodePayload =
5367
sendVerifyNotificationMethodCodePayload.parse(req.body);
54-
const { valid, update, phone } = withPhone(user.phone, payload.phone);
5568

56-
if (!valid || !phone) return next(bad("Invalid or missing phone number"));
57-
if (update) await users.update(user.id, { phone });
69+
const phone = selectPhone(user.phone, payload.phone);
70+
if (phone instanceof Error) return next(phone);
71+
if (!user.phone) await users.update(user.id, { phone });
5872

5973
const purpose = NOTIFICATION_METHOD_LITERAL_TO_PURPOSE[payload.method];
6074

61-
// Remove any confirmation code that blongs to the current user under the same
75+
// Remove any confirmation code that belongs to the current user under the same
6276
// purpose. This way users will only have one code under a given purpose at
63-
// any given point of time.
77+
// any point of time.
6478
await confirmationCodes.delete({
6579
users: [user.id],
6680
purposes: [purpose],
6781
});
6882

83+
// Store the new code in the db
6984
const code = await confirmationCodes.create({
7085
userId: user.id,
7186
purpose,
@@ -76,13 +91,14 @@ async function sendVerifyPhoneCode(
7691
.toISOString(),
7792
});
7893

94+
// If the method is telegram; we need to resolve the number first with telegram Api
7995
const resolvedPhone =
8096
payload.method !== "telegram" ||
8197
(await messenger.telegram.resolvePhone({ phone }).then((phone) => !!phone));
8298
if (!resolvedPhone) return next(unresolvedPhone());
8399

100+
// Send the notification using Kafka Producer
84101
const topic = NOTIFICATION_METHOD_LITERAL_TO_KAFKA_TOPIC[payload.method];
85-
86102
await producer.send({
87103
topic,
88104
messages: [
@@ -136,8 +152,7 @@ async function verifyPhoneCode(
136152
user.id,
137153
{
138154
verifiedPhone: true,
139-
notificationMethod:
140-
NOTIFICATION_METHOD_LITERAL_TO_NOTIFICATION_METHOD[method],
155+
notificationMethod: NOTIFICATION_METHOD_LITERAL_TO_ENUM[method],
141156
verifiedWhatsApp,
142157
verifiedTelegram,
143158
},
@@ -148,7 +163,175 @@ async function verifyPhoneCode(
148163
res.sendStatus(200);
149164
}
150165

166+
/**
167+
* This handler generates a random code, sets it in the database
168+
* and sends it to the user by mail.
169+
*/
170+
async function sendForgottenPasswordCode(
171+
req: Request,
172+
res: Response,
173+
next: NextFunction
174+
) {
175+
const { email }: IConfirmationCode.SendCodeEmailPayload =
176+
sendCodePayload.parse(req.body);
177+
178+
const user = await users.findByEmail(email);
179+
if (!user) return next(notfound.user());
180+
181+
// Remove any confirmation code that belongs to the current user
182+
// under the same purpose
183+
await confirmationCodes.delete({
184+
users: [user.id],
185+
purposes: [IConfirmationCode.Purpose.ResetPassword],
186+
});
187+
188+
// Generate and store the new code in the db
189+
const { code } = await confirmationCodes.create({
190+
userId: user.id,
191+
purpose: IConfirmationCode.Purpose.ResetPassword,
192+
code: generateConfirmationCode(),
193+
expiresAt: dayjs
194+
.utc()
195+
.add(CONFIRMATION_CODE_VALIDITY_MINUTES, "minutes")
196+
.toISOString(),
197+
});
198+
199+
if (user) {
200+
sendBackgroundMessage({
201+
type: "send-forget-password-code-email",
202+
payload: { email: user.email, code },
203+
});
204+
}
205+
206+
res.status(200).send();
207+
}
208+
209+
/**
210+
* This handler gets a userId and code from users, verify the validity of the code
211+
* and generates, then send, a token, to the user, in order to be able to reset
212+
* the password.
213+
*/
214+
async function confirmForgottenPasswordCode(
215+
req: Request,
216+
res: Response,
217+
next: NextFunction
218+
) {
219+
const { userId, code }: IConfirmationCode.ConfirmPasswordCodePayload =
220+
confirmPasswordCodePayload.parse(req.body);
221+
222+
const user = await users.findById(userId);
223+
if (!user) return next(notfound.user());
224+
225+
// Check if the code is valid (exists in the db)
226+
const found = (
227+
await confirmationCodes.find({
228+
userId: user.id,
229+
purpose: IConfirmationCode.Purpose.ResetPassword,
230+
})
231+
)[0];
232+
233+
// TODO: count the number of tries, then block using this handler for while
234+
// for this specific userId, after a specific number of tries.
235+
if (!found || found.code !== code) return next(invalidVerificationCode());
236+
237+
// Ensure the code is not expired
238+
if (dayjs.utc(found.expiresAt).isBefore(dayjs.utc())) {
239+
await confirmationCodes.deleteById({ id: found.id });
240+
return next(expiredVerificationCode());
241+
}
242+
243+
// This token should be used by frontend reset password page
244+
// in order to be able to change the password
245+
const token = encodeAuthJwt(userId, jwtSecret);
246+
247+
// Remove the confirmation code for more data integridy and security
248+
await confirmationCodes.deleteById({ id: found.id });
249+
250+
const response: IConfirmationCode.ConfirmPasswordCodeApiResponse = { token };
251+
res.status(200).json(response);
252+
}
253+
254+
async function sendEmailVerificationCode(
255+
req: Request,
256+
res: Response,
257+
next: NextFunction
258+
) {
259+
const user = req.user;
260+
const allowed = isUser(user);
261+
if (!allowed) return next(forbidden());
262+
263+
if (user.verifiedEmail) return next(emailAlreadyVerified());
264+
265+
// Generate and store the new code in the db
266+
const { code } = await confirmationCodes.create({
267+
userId: user.id,
268+
purpose: IConfirmationCode.Purpose.VerifyEmail,
269+
code: generateConfirmationCode(),
270+
expiresAt: dayjs
271+
.utc()
272+
.add(CONFIRMATION_CODE_VALIDITY_MINUTES, "minutes")
273+
.toISOString(),
274+
});
275+
276+
sendBackgroundMessage({
277+
type: "send-user-verification-code-email",
278+
payload: {
279+
code,
280+
email: user.email,
281+
},
282+
});
283+
284+
res.status(200).send();
285+
}
286+
287+
/**
288+
* Despite the name, this function verify the email in the db as well
289+
*/
290+
async function confirmEmailVerificationCode(
291+
req: Request,
292+
res: Response,
293+
next: NextFunction
294+
) {
295+
const user = req.user;
296+
const allowed = isUser(user);
297+
if (!allowed) return next(forbidden());
298+
299+
if (user.verifiedEmail) return next(emailAlreadyVerified());
300+
301+
// get the code from the payload and verify it
302+
const { code }: IConfirmationCode.VerifyEmailPayload =
303+
verifyEmailPayload.parse(req.body);
304+
305+
const found = (
306+
await confirmationCodes.find({
307+
userId: user.id,
308+
purpose: IConfirmationCode.Purpose.VerifyEmail,
309+
})
310+
)[0];
311+
312+
// TODO: count the number of tries, then block using this handler for while
313+
// for this specific userId, after a specific number of tries.
314+
if (!found || found.code !== code) return next(invalidVerificationCode());
315+
316+
// Ensure the code is not expired
317+
if (dayjs.utc(found.expiresAt).isBefore(dayjs.utc())) {
318+
await confirmationCodes.deleteById({ id: found.id });
319+
return next(expiredVerificationCode());
320+
}
321+
322+
// update the database to mark the email as verified
323+
await users.update(user.id, { verifiedEmail: true });
324+
325+
res.status(200).send();
326+
}
327+
151328
export default {
152329
sendVerifyPhoneCode: safeRequest(sendVerifyPhoneCode),
153330
verifyPhoneCode: safeRequest(verifyPhoneCode),
331+
332+
sendForgottenPasswordCode: safeRequest(sendForgottenPasswordCode),
333+
confirmForgottenPasswordCode: safeRequest(confirmForgottenPasswordCode),
334+
335+
sendEmailVerificationCode: safeRequest(sendEmailVerificationCode),
336+
confirmEmailVerificationCode: safeRequest(confirmEmailVerificationCode),
154337
};

0 commit comments

Comments
 (0)