diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-07-10 20:50:19 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-07-12 12:20:41 +0000 |
| commit | 140311d7419fa2192e5149df8f589c3c3733a399 (patch) | |
| tree | ddf532bbf09e4f7c947854b5515c0e8674030645 /packages/trpc | |
| parent | 385f9f0b055678420e820b8ed30e595871630e58 (diff) | |
| download | karakeep-140311d7419fa2192e5149df8f589c3c3733a399.tar.zst | |
feat: Support forget and reset password
Diffstat (limited to 'packages/trpc')
| -rw-r--r-- | packages/trpc/email.ts | 112 | ||||
| -rw-r--r-- | packages/trpc/routers/users.test.ts | 291 | ||||
| -rw-r--r-- | packages/trpc/routers/users.ts | 158 |
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 }; + }), }); |
