aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-07-10 20:50:19 +0000
committerMohamed Bassem <me@mbassem.com>2025-07-12 12:20:41 +0000
commit140311d7419fa2192e5149df8f589c3c3733a399 (patch)
treeddf532bbf09e4f7c947854b5515c0e8674030645 /packages/trpc
parent385f9f0b055678420e820b8ed30e595871630e58 (diff)
downloadkarakeep-140311d7419fa2192e5149df8f589c3c3733a399.tar.zst
feat: Support forget and reset password
Diffstat (limited to 'packages/trpc')
-rw-r--r--packages/trpc/email.ts112
-rw-r--r--packages/trpc/routers/users.test.ts291
-rw-r--r--packages/trpc/routers/users.ts158
3 files changed, 515 insertions, 46 deletions
diff --git a/packages/trpc/email.ts b/packages/trpc/email.ts
index ded23ed8..1c0b8800 100644
--- a/packages/trpc/email.ts
+++ b/packages/trpc/email.ts
@@ -1,9 +1,8 @@
import { randomBytes } from "crypto";
-import { and, eq } from "drizzle-orm";
import { createTransport } from "nodemailer";
import { db } from "@karakeep/db";
-import { verificationTokens } from "@karakeep/db/schema";
+import { passwordResetTokens, verificationTokens } from "@karakeep/db/schema";
import serverConfig from "@karakeep/shared/config";
export async function sendVerificationEmail(email: string, name: string) {
@@ -70,45 +69,6 @@ If you didn't create an account with us, please ignore this email.
await transporter.sendMail(mailOptions);
}
-export async function verifyEmailToken(
- email: string,
- token: string,
-): Promise<boolean> {
- const verificationToken = await db.query.verificationTokens.findFirst({
- where: (vt, { and, eq }) =>
- and(eq(vt.identifier, email), eq(vt.token, token)),
- });
-
- if (!verificationToken) {
- return false;
- }
-
- if (verificationToken.expires < new Date()) {
- // Clean up expired token
- await db
- .delete(verificationTokens)
- .where(
- and(
- eq(verificationTokens.identifier, email),
- eq(verificationTokens.token, token),
- ),
- );
- return false;
- }
-
- // Clean up used token
- await db
- .delete(verificationTokens)
- .where(
- and(
- eq(verificationTokens.identifier, email),
- eq(verificationTokens.token, token),
- ),
- );
-
- return true;
-}
-
export async function sendInviteEmail(
email: string,
token: string,
@@ -169,3 +129,73 @@ If you weren't expecting this invitation, you can safely ignore this email.
await transporter.sendMail(mailOptions);
}
+
+export async function sendPasswordResetEmail(
+ email: string,
+ name: string,
+ userId: string,
+) {
+ if (!serverConfig.email.smtp) {
+ throw new Error("SMTP is not configured");
+ }
+
+ const token = randomBytes(32).toString("hex");
+ const expires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
+
+ // Store password reset token
+ await db.insert(passwordResetTokens).values({
+ userId,
+ token,
+ expires,
+ });
+
+ const transporter = createTransport({
+ host: serverConfig.email.smtp.host,
+ port: serverConfig.email.smtp.port,
+ secure: serverConfig.email.smtp.secure,
+ auth:
+ serverConfig.email.smtp.user && serverConfig.email.smtp.password
+ ? {
+ user: serverConfig.email.smtp.user,
+ pass: serverConfig.email.smtp.password,
+ }
+ : undefined,
+ });
+
+ const resetUrl = `${serverConfig.publicUrl}/reset-password?token=${encodeURIComponent(token)}`;
+
+ const mailOptions = {
+ from: serverConfig.email.smtp.from,
+ to: email,
+ subject: "Reset your password",
+ html: `
+ <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+ <h2>Password Reset Request</h2>
+ <p>Hi ${name},</p>
+ <p>You requested to reset your password for your Karakeep account. Click the link below to reset your password:</p>
+ <p>
+ <a href="${resetUrl}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
+ Reset Password
+ </a>
+ </p>
+ <p>If the button doesn't work, you can copy and paste this link into your browser:</p>
+ <p><a href="${resetUrl}">${resetUrl}</a></p>
+ <p>This link will expire in 1 hour.</p>
+ <p>If you didn't request a password reset, please ignore this email. Your password will remain unchanged.</p>
+ </div>
+ `,
+ text: `
+Hi ${name},
+
+You requested to reset your password for your Karakeep account. Visit this link to reset your password:
+${resetUrl}
+
+This link will expire in 1 hour.
+
+If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
+ `,
+ };
+
+ await transporter.sendMail(mailOptions);
+ return token;
+}
diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts
index 21ee3a7b..03e5d590 100644
--- a/packages/trpc/routers/users.test.ts
+++ b/packages/trpc/routers/users.test.ts
@@ -1,11 +1,46 @@
-import { assert, beforeEach, describe, expect, test } from "vitest";
-
-import { assets, AssetTypes, bookmarks } from "@karakeep/db/schema";
+import { eq } from "drizzle-orm";
+import { assert, beforeEach, describe, expect, test, vi } from "vitest";
+
+import {
+ assets,
+ AssetTypes,
+ bookmarks,
+ passwordResetTokens,
+ users,
+} from "@karakeep/db/schema";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import type { CustomTestContext } from "../testUtils";
+import * as emailModule from "../email";
import { defaultBeforeEach, getApiCaller } from "../testUtils";
+// Mock server config with all required properties - MUST be before any imports that use config
+vi.mock("@karakeep/shared/config", async (original) => {
+ const mod = (await original()) as typeof import("@karakeep/shared/config");
+ return {
+ ...mod,
+ default: {
+ ...mod.default,
+ email: {
+ smtp: {
+ host: "test-smtp.example.com",
+ port: 587,
+ secure: false,
+ user: "test@example.com",
+ password: "test-password",
+ from: "test@example.com",
+ },
+ },
+ },
+ };
+});
+
+// Mock email functions
+vi.mock("../email", () => ({
+ sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined),
+ sendVerificationEmail: vi.fn().mockResolvedValue(undefined),
+}));
+
beforeEach<CustomTestContext>(defaultBeforeEach(false));
describe("User Routes", () => {
@@ -475,4 +510,254 @@ describe("User Routes", () => {
),
).toBe(true);
});
+
+ describe("Password Reset", () => {
+ test<CustomTestContext>("forgotPassword - successful email sending", async ({
+ unauthedAPICaller,
+ }) => {
+ // Create a user first
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "reset@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ // With mocked email service, this should succeed
+ const result = await unauthedAPICaller.users.forgotPassword({
+ email: "reset@test.com",
+ });
+
+ expect(result.success).toBe(true);
+
+ // Verify that the email function was called with correct parameters
+ expect(emailModule.sendPasswordResetEmail).toHaveBeenCalledWith(
+ "reset@test.com",
+ "Test User",
+ user.id,
+ );
+ });
+
+ test<CustomTestContext>("forgotPassword - non-existing user", async ({
+ unauthedAPICaller,
+ }) => {
+ // Should not reveal if user exists or not
+ const result = await unauthedAPICaller.users.forgotPassword({
+ email: "nonexistent@test.com",
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ test<CustomTestContext>("forgotPassword - OAuth user (no password)", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ // Create a user without password (OAuth user)
+ await db.insert(users).values({
+ name: "OAuth User",
+ email: "oauth@test.com",
+ password: null,
+ });
+
+ // Should not send reset email for OAuth users
+ const result = await unauthedAPICaller.users.forgotPassword({
+ email: "oauth@test.com",
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ test<CustomTestContext>("resetPassword - valid token", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ // Create a user
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "validreset@test.com",
+ password: "oldpass123",
+ confirmPassword: "oldpass123",
+ });
+
+ // Create a password reset token directly in the database
+ const token = "valid-reset-token";
+ const expires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
+
+ await db.insert(passwordResetTokens).values({
+ userId: user.id,
+ token,
+ expires,
+ });
+
+ // Reset the password
+ const result = await unauthedAPICaller.users.resetPassword({
+ token,
+ newPassword: "newpass123",
+ });
+
+ expect(result.success).toBe(true);
+
+ // Verify the token was consumed (deleted)
+ const remainingTokens = await db
+ .select()
+ .from(passwordResetTokens)
+ .where(eq(passwordResetTokens.token, token));
+
+ expect(remainingTokens).toHaveLength(0);
+
+ // The password reset was successful if we got here without errors
+ });
+
+ test<CustomTestContext>("resetPassword - invalid token", async ({
+ unauthedAPICaller,
+ }) => {
+ await expect(
+ unauthedAPICaller.users.resetPassword({
+ token: "invalid-token",
+ newPassword: "newpass123",
+ }),
+ ).rejects.toThrow(/Invalid or expired reset token/);
+ });
+
+ test<CustomTestContext>("resetPassword - expired token", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ // Create a user
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "expiredtoken@test.com",
+ password: "oldpass123",
+ confirmPassword: "oldpass123",
+ });
+
+ // Create an expired password reset token
+ const token = "expired-reset-token";
+ const expires = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago (expired)
+
+ await db.insert(passwordResetTokens).values({
+ userId: user.id,
+ token,
+ expires,
+ });
+
+ await expect(
+ unauthedAPICaller.users.resetPassword({
+ token,
+ newPassword: "newpass123",
+ }),
+ ).rejects.toThrow(/Invalid or expired reset token/);
+
+ // Verify the expired token was cleaned up
+ const remainingTokens = await db
+ .select()
+ .from(passwordResetTokens)
+ .where(eq(passwordResetTokens.token, token));
+
+ expect(remainingTokens).toHaveLength(0);
+ });
+
+ test<CustomTestContext>("resetPassword - user not found", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ // Create a user first, then delete them to create an orphaned token
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "orphaned@test.com",
+ password: "oldpass123",
+ confirmPassword: "oldpass123",
+ });
+
+ // Create a password reset token
+ const token = "orphaned-token";
+ const expires = new Date(Date.now() + 60 * 60 * 1000);
+
+ await db.insert(passwordResetTokens).values({
+ userId: user.id,
+ token,
+ expires,
+ });
+
+ // Delete the user to make the token orphaned
+ // Due to foreign key cascade, this will also delete the token
+ // So we expect "Invalid or expired reset token" instead of "User not found"
+ await db.delete(users).where(eq(users.id, user.id));
+
+ await expect(
+ unauthedAPICaller.users.resetPassword({
+ token,
+ newPassword: "newpass123",
+ }),
+ ).rejects.toThrow(/Invalid or expired reset token/);
+ });
+ test<CustomTestContext>("resetPassword - password validation", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ // Create a user
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "validation@test.com",
+ password: "oldpass123",
+ confirmPassword: "oldpass123",
+ });
+
+ // Create a password reset token
+ const token = "validation-token";
+ const expires = new Date(Date.now() + 60 * 60 * 1000);
+
+ await db.insert(passwordResetTokens).values({
+ userId: user.id,
+ token,
+ expires,
+ });
+
+ // Try to reset with a password that's too short
+ await expect(
+ unauthedAPICaller.users.resetPassword({
+ token,
+ newPassword: "123", // Too short
+ }),
+ ).rejects.toThrow();
+ });
+
+ test<CustomTestContext>("resetPassword - token reuse prevention", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ // Create a user
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "reuse@test.com",
+ password: "oldpass123",
+ confirmPassword: "oldpass123",
+ });
+
+ // Create a password reset token
+ const token = "reuse-token";
+ const expires = new Date(Date.now() + 60 * 60 * 1000);
+
+ await db.insert(passwordResetTokens).values({
+ userId: user.id,
+ token,
+ expires,
+ });
+
+ // Use the token once
+ await unauthedAPICaller.users.resetPassword({
+ token,
+ newPassword: "newpass123",
+ });
+
+ // Try to use the same token again
+ await expect(
+ unauthedAPICaller.users.resetPassword({
+ token,
+ newPassword: "anotherpass123",
+ }),
+ ).rejects.toThrow(/Invalid or expired reset token/);
+ });
+ });
});
diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts
index ebe7d96f..79f06057 100644
--- a/packages/trpc/routers/users.ts
+++ b/packages/trpc/routers/users.ts
@@ -11,13 +11,16 @@ import {
bookmarks,
bookmarkTags,
highlights,
+ passwordResetTokens,
tagsOnBookmarks,
users,
userSettings,
+ verificationTokens,
} from "@karakeep/db/schema";
import { deleteUserAssets } from "@karakeep/shared/assetdb";
import serverConfig from "@karakeep/shared/config";
import {
+ zResetPasswordSchema,
zSignUpSchema,
zUpdateUserSettingsSchema,
zUserSettingsSchema,
@@ -26,7 +29,7 @@ import {
} from "@karakeep/shared/types/users";
import { generatePasswordSalt, hashPassword, validatePassword } from "../auth";
-import { sendVerificationEmail, verifyEmailToken } from "../email";
+import { sendPasswordResetEmail, sendVerificationEmail } from "../email";
import {
adminProcedure,
authedProcedure,
@@ -36,6 +39,46 @@ import {
router,
} from "../index";
+export async function verifyEmailToken(
+ db: Context["db"],
+ email: string,
+ token: string,
+): Promise<boolean> {
+ const verificationToken = await db.query.verificationTokens.findFirst({
+ where: (vt, { and, eq }) =>
+ and(eq(vt.identifier, email), eq(vt.token, token)),
+ });
+
+ if (!verificationToken) {
+ return false;
+ }
+
+ if (verificationToken.expires < new Date()) {
+ // Clean up expired token
+ await db
+ .delete(verificationTokens)
+ .where(
+ and(
+ eq(verificationTokens.identifier, email),
+ eq(verificationTokens.token, token),
+ ),
+ );
+ return false;
+ }
+
+ // Clean up used token
+ await db
+ .delete(verificationTokens)
+ .where(
+ and(
+ eq(verificationTokens.identifier, email),
+ eq(verificationTokens.token, token),
+ ),
+ );
+
+ return true;
+}
+
export async function createUserRaw(
db: Context["db"],
input: {
@@ -563,7 +606,7 @@ export const usersAppRouter = router({
}),
)
.mutation(async ({ input, ctx }) => {
- const isValid = await verifyEmailToken(input.email, input.token);
+ const isValid = await verifyEmailToken(ctx.db, input.email, input.token);
if (!isValid) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -639,4 +682,115 @@ export const usersAppRouter = router({
});
}
}),
+ forgotPassword: publicProcedure
+ .use(
+ createRateLimitMiddleware({
+ name: "users.forgotPassword",
+ windowMs: 15 * 60 * 1000,
+ maxRequests: 3,
+ }),
+ )
+ .input(
+ z.object({
+ email: z.string().email(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ if (!serverConfig.email.smtp) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email service is not configured",
+ });
+ }
+
+ const user = await ctx.db.query.users.findFirst({
+ where: eq(users.email, input.email),
+ });
+
+ if (!user) {
+ // Don't reveal if user exists or not for security
+ return { success: true };
+ }
+
+ // Only send reset email for users with passwords (local accounts)
+ if (!user.password) {
+ return { success: true };
+ }
+
+ try {
+ await sendPasswordResetEmail(input.email, user.name, user.id);
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to send password reset email:", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to send password reset email",
+ });
+ }
+ }),
+ resetPassword: publicProcedure
+ .use(
+ createRateLimitMiddleware({
+ name: "users.resetPassword",
+ windowMs: 5 * 60 * 1000,
+ maxRequests: 10,
+ }),
+ )
+ .input(zResetPasswordSchema)
+ .mutation(async ({ input, ctx }) => {
+ const token = input.token;
+ const resetToken = await ctx.db.query.passwordResetTokens.findFirst({
+ where: eq(passwordResetTokens.token, token),
+ with: {
+ user: {
+ columns: {
+ id: true,
+ },
+ },
+ },
+ });
+
+ if (!resetToken) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid or expired reset token",
+ });
+ }
+
+ if (resetToken.expires < new Date()) {
+ // Clean up expired token
+ await ctx.db
+ .delete(passwordResetTokens)
+ .where(eq(passwordResetTokens.token, token));
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid or expired reset token",
+ });
+ }
+
+ if (!resetToken.user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ // Generate new password hash
+ const newSalt = generatePasswordSalt();
+ const hashedPassword = await hashPassword(input.newPassword, newSalt);
+
+ // Update user password
+ await ctx.db
+ .update(users)
+ .set({
+ password: hashedPassword,
+ salt: newSalt,
+ })
+ .where(eq(users.id, resetToken.user.id));
+
+ await ctx.db
+ .delete(passwordResetTokens)
+ .where(eq(passwordResetTokens.token, token));
+ return { success: true };
+ }),
});