Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions backend/__tests__/api/controllers/user.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,66 @@ describe("user controller test", () => {
});
});
});
describe("getUser", () => {
const getUserMock = vi.spyOn(UserDal, "getUser");
const updateEmailMock = vi.spyOn(UserDal, "updateEmail");
beforeEach(() => {
getUserMock.mockReset();
updateEmailMock.mockReset();
});
it("should update emailVerified if undefined", async () => {
//GIVEN
getUserMock.mockResolvedValue({
uid,
email: "old",
} as any);
mockAuth.modifyToken({ email_verified: false, email: "old" });

//WHEN
await mockApp
.get("/users")
.set("Authorization", `Bearer ${uid}`)
.expect(200);

//THEN
expect(updateEmailMock).toHaveBeenCalledWith(uid, "old", false);
});
it("should update emailVerified if changed", async () => {
//GIVEN
getUserMock.mockResolvedValue({
uid,
emailVerified: false,
email: "old",
} as any);
mockAuth.modifyToken({ email_verified: true, email: "next" });

//WHEN
await mockApp
.get("/users")
.set("Authorization", `Bearer ${uid}`)
.expect(200);

//THEN
expect(updateEmailMock).toHaveBeenCalledWith(uid, "next", true);
});
it("should not update emailVerified if not changed", async () => {
//GIVEN
getUserMock.mockResolvedValue({
uid,
emailVerified: false,
} as any);
mockAuth.modifyToken({ email_verified: false });

//WHEN
await mockApp
.get("/users")
.set("Authorization", `Bearer ${uid}`)
.expect(200);

//THEN
expect(updateEmailMock).not.toHaveBeenCalled();
});
});
describe("sendVerificationEmail", () => {
const adminGetUserMock = vi.fn();
const adminGenerateVerificationLinkMock = vi.fn();
Expand Down
23 changes: 22 additions & 1 deletion backend/__tests__/dal/user.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ describe("UserDal", () => {
expect(insertedUser.email).toBe(newUser.email);
expect(insertedUser.uid).toBe(newUser.uid);
expect(insertedUser.name).toBe(newUser.name);
expect(insertedUser.emailVerified).toBe(false);
});

it("should error if the user already exists", async () => {
Expand Down Expand Up @@ -1167,14 +1168,34 @@ describe("UserDal", () => {
});
it("should update", async () => {
//given
const { uid } = await UserTestData.createUser({ email: "init" });
const { uid } = await UserTestData.createUser({
email: "init",
emailVerified: true,
});

//when
await expect(UserDAL.updateEmail(uid, "next")).resolves.toBe(true);

//then
const read = await UserDAL.getUser(uid, "read");
expect(read.email).toEqual("next");
expect(read.emailVerified).toEqual(false);
});

it("should update email and isVerified", async () => {
//given
const { uid } = await UserTestData.createUser({
email: "init",
emailVerified: false,
});

//when
await expect(UserDAL.updateEmail(uid, "next", true)).resolves.toBe(true);

//then
const read = await UserDAL.getUser(uid, "read");
expect(read.email).toEqual("next");
expect(read.emailVerified).toEqual(true);
});
});
describe("resetPb", () => {
Expand Down
3 changes: 3 additions & 0 deletions backend/__tests__/middlewares/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const mockDecodedToken: DecodedIdToken = {
uid: "123456789",
email: "[email protected]",
iat: 0,
email_verified: true,
} as DecodedIdToken;

vi.spyOn(AuthUtils, "verifyIdToken").mockResolvedValue(mockDecodedToken);
Expand Down Expand Up @@ -62,6 +63,7 @@ describe("middlewares/auth", () => {
type: "None",
uid: "",
email: "",
emailVerified: false,
},
},
};
Expand Down Expand Up @@ -122,6 +124,7 @@ describe("middlewares/auth", () => {
expect(decodedToken?.type).toBe("Bearer");
expect(decodedToken?.email).toBe(mockDecodedToken.email);
expect(decodedToken?.uid).toBe(mockDecodedToken.uid);
expect(decodedToken?.emailVerified).toBe(mockDecodedToken.email_verified);
expect(nextFunction).toHaveBeenCalledOnce();

expect(prometheusIncrementAuthMock).toHaveBeenCalledWith("Bearer");
Expand Down
50 changes: 50 additions & 0 deletions backend/src/api/controllers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import {
UpdateUserNameRequest,
UpdateUserProfileRequest,
UpdateUserProfileResponse,
VerifyEmailRequest,
} from "@monkeytype/contracts/users";
import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time";
import { MonkeyRequest } from "../types";
Expand Down Expand Up @@ -158,6 +159,7 @@ export async function sendVerificationEmail(
);
})
).emailVerified;

if (isVerified) {
throw new MonkeyError(400, "Email already verified");
}
Expand Down Expand Up @@ -240,6 +242,42 @@ export async function sendVerificationEmail(
return new MonkeyResponse("Email sent", null);
}

export async function verifyEmail(
req: MonkeyRequest<undefined, VerifyEmailRequest>
): Promise<MonkeyResponse> {
const { email } = req.body;

const user = await UserDAL.findPartialByEmail(email, [
"email",
"emailVerified",
]);
if (user === undefined || user.emailVerified === true) {
throw new MonkeyError(400, "cannot verify", "verify email");
}

const { data: firebaseUser } = await tryCatch(
FirebaseAdmin().auth().getUserByEmail(email)
);

if (firebaseUser === undefined || firebaseUser === null) {
throw new MonkeyError(404, "not found", "verify email");
}

await UserDAL.updateEmail(
firebaseUser.uid,
email,
firebaseUser.emailVerified
);

void addImportantLog(
"user_verify_email",
`emailVerified changed to ${firebaseUser.emailVerified} for email ${email}`,
firebaseUser.uid
);

return new MonkeyResponse("emailVerify updated.", null);
}

export async function sendForgotPasswordEmail(
req: MonkeyRequest<undefined, ForgotPasswordEmailRequest>
): Promise<MonkeyResponse> {
Expand Down Expand Up @@ -605,6 +643,18 @@ export async function getUser(req: MonkeyRequest): Promise<GetUserResponse> {
);
delete relevantUserInfo.customThemes;

// soft-migrate user.emailVerified for existing users, update status if it has changed
const { email, emailVerified } = req.ctx.decodedToken;
if (emailVerified !== undefined && emailVerified !== userInfo.emailVerified) {
await UserDAL.updateEmail(uid, email, emailVerified);
userInfo.emailVerified = emailVerified;
void addImportantLog(
"user_verify_email",
`soft-migrate emailVerified changed to ${emailVerified} for email ${email}`,
uid
);
}

const userData: User = {
...relevantUserInfo,
resultFilterPresets,
Expand Down
3 changes: 3 additions & 0 deletions backend/src/api/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ export default s.router(usersContract, {
handler: async (r) =>
callController(UserController.sendVerificationEmail)(r),
},
verifyEmail: {
handler: async (r) => callController(UserController.verifyEmail)(r),
},
forgotPasswordEmail: {
handler: async (r) =>
callController(UserController.sendForgotPasswordEmail)(r),
Expand Down
24 changes: 22 additions & 2 deletions backend/src/dal/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export async function addUser(
custom: {},
},
testActivity: {},
emailVerified: false,
};

const result = await getUsersCollection().updateOne(
Expand Down Expand Up @@ -231,9 +232,14 @@ export async function updateQuoteRatings(

export async function updateEmail(
uid: string,
email: string
email: string,
emailVerified: boolean = false
): Promise<boolean> {
await updateUser({ uid }, { $set: { email } }, { stack: "update email" });
await updateUser(
{ uid },
{ $set: { email, emailVerified } },
{ stack: "update email" }
);

return true;
}
Expand Down Expand Up @@ -274,6 +280,20 @@ export async function findByName(name: string): Promise<DBUser | undefined> {
)[0];
}

export async function findPartialByEmail<K extends keyof DBUser>(
email: string,
fields: K[]
): Promise<Pick<DBUser, K> | undefined> {
const projection = new Map(fields.map((it) => [it, 1]));
return (
await getUsersCollection()
.find({ email }, { projection })
.collation({ locale: "en", strength: 1 })
.limit(1)
.toArray()
)[0];
}

export async function isNameAvailable(
name: string,
uid: string
Expand Down
2 changes: 2 additions & 0 deletions backend/src/middlewares/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type DecodedToken = {
type: "Bearer" | "ApeKey" | "None" | "GithubWebhook";
uid: string;
email: string;
emailVerified?: boolean;
};

const DEFAULT_OPTIONS: RequestAuthenticationOptions = {
Expand Down Expand Up @@ -189,6 +190,7 @@ async function authenticateWithBearerToken(
type: "Bearer",
uid: decodedToken.uid,
email: decodedToken.email ?? "",
emailVerified: decodedToken.email_verified,
};
} catch (error) {
if (
Expand Down
22 changes: 20 additions & 2 deletions frontend/src/email-handler.html
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@
signInWithEmailAndPassword,
} from "firebase/auth";

import { envConfig } from "./ts/constants/env-config";

function isPasswordStrong(password) {
const hasCapital = !!password.match(/[A-Z]/);
const hasNumber = !!password.match(/[\d]/);
Expand All @@ -188,10 +190,26 @@
}

function handleVerifyEmail(actionCode, continueUrl) {
applyActionCode(Auth, actionCode)
.then((resp) => {
var email = null;
checkActionCode(Auth, actionCode)
.then((info) => {
// Get the email address
email = info["data"]["email"];

return applyActionCode(Auth, actionCode);
})

.then(async (resp) => {
// Email address has been verified.

await fetch(envConfig.backendUrl + "/users/verifyEmail", {
method: "POST",
body: JSON.stringify({ email }),
headers: {
"Content-Type": "application/json",
},
});

$("main .preloader .icon").html(`<i class="fas fa-fw fa-check"></i>`);
$("main .preloader .text").text(
`Your email address has been verified`
Expand Down
5 changes: 5 additions & 0 deletions packages/contracts/src/rate-limit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,11 @@ export const limits = {
max: 1,
},

userVerifyEmail: {
window: 15 * 60 * 1000, //15 minutes
max: 1,
},

userForgotPasswordEmail: {
window: "minute",
max: 1,
Expand Down
19 changes: 19 additions & 0 deletions packages/contracts/src/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,11 @@ export const ReportUserRequestSchema = z.object({
});
export type ReportUserRequest = z.infer<typeof ReportUserRequestSchema>;

export const VerifyEmailRequestSchema = z.object({
email: UserEmailSchema,
});
export type VerifyEmailRequest = z.infer<typeof VerifyEmailRequestSchema>;

export const ForgotPasswordEmailRequestSchema = z.object({
captcha: z.string(),
email: UserEmailSchema,
Expand Down Expand Up @@ -872,6 +877,20 @@ export const usersContract = c.router(
rateLimit: "userRequestVerificationEmail",
}),
},
verifyEmail: {
summary: "verify email",
description: "Verify the user email",
method: "POST",
path: "/verifyEmail",
body: VerifyEmailRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
authenticationOptions: { isPublic: true },
rateLimit: "userVerifyEmail",
}),
},
forgotPasswordEmail: {
summary: "send forgot password email",
description: "Send a forgot password email",
Expand Down
1 change: 1 addition & 0 deletions packages/schemas/src/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export const UserSchema = z.object({
quoteMod: QuoteModSchema.optional(),
resultFilterPresets: z.array(ResultFiltersSchema).optional(),
testActivity: TestActivitySchema.optional(),
emailVerified: z.boolean().optional(),
});
export type User = z.infer<typeof UserSchema>;

Expand Down