diff --git a/bun.lock b/bun.lock index 36b828f..8736447 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ "@biomejs/biome", ], "packages": { - "@atums/echo": ["@atums/echo@1.0.3", "", { "dependencies": { "date-fns-tz": "^3.2.0" } }, "sha512-WQ2d4oWTaE+6VeLIu2FepmZipdwUrM+SiiO5moHhSsP4P+MaQCjq5qp34nwB/vOHv2jd9UcBzy27iUziTffCjg=="], + "@atums/echo": ["@atums/echo@1.0.6", "", { "dependencies": { "date-fns-tz": "^3.2.0" } }, "sha512-2v0coX0Ptau6pjh4aTJDXMMJ2z/Q+0r8tvLokjeyUnLWGOPMwg+i4saBrkvDtHvQbNiq/NiEwMFLCxeIlxEyLQ=="], "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], diff --git a/logger.json b/logger.json index 521b3bc..15b2c23 100644 --- a/logger.json +++ b/logger.json @@ -5,6 +5,7 @@ "rotate": true, "maxFiles": 3, + "fileNameFormat": "yyyy-MM-dd", "console": true, "consoleColor": true, diff --git a/src/environment/mailer/templates/forgot-password.html b/src/environment/mailer/templates/forgot-password.html new file mode 100644 index 0000000..b018ef5 --- /dev/null +++ b/src/environment/mailer/templates/forgot-password.html @@ -0,0 +1,47 @@ + + + + + + + {{subject}} + + + + +

Password Reset - {{companyName}}

+ +

Hi {{displayName}},

+ +

You requested a password reset for your account. Click the link below to reset your password:

+ +

{{resetUrl}}

+ +

{{willExpire}} for security reasons.

+ +

If you did not request this password reset, please ignore this email. Your password will remain unchanged.

+ +

Questions? Contact {{supportEmail}}

+ +

Best regards,
+ The {{companyName}} team

+ +
+ +

User ID: {{id}} | © {{currentYear}} {{companyName}}

+ + + + \ No newline at end of file diff --git a/src/environment/mailer/templates/register.html b/src/environment/mailer/templates/register.html index cd55ac0..499013f 100644 --- a/src/environment/mailer/templates/register.html +++ b/src/environment/mailer/templates/register.html @@ -11,89 +11,35 @@ max-width: 600px; margin: 0 auto; padding: 20px; - line-height: 1.6; - background-color: #1a1a1a; - color: #e0e0e0; - } - - h2 { - color: #4a9eff; - margin-bottom: 20px; + line-height: 1.5; } a { - color: #4a9eff; - } - - .button { - display: inline-block; - padding: 12px 24px; - background-color: #2d7a2d; - color: white; - text-decoration: none; - border-radius: 4px; - margin: 10px 0; - } - - .expiry-notice { - background-color: #2a2a2a; - border: 1px solid #444; - padding: 10px; - border-radius: 4px; - margin: 15px 0; - color: #ffa500; - } - - .fallback-url { - word-break: break-all; - background-color: #2a2a2a; - border: 1px solid #444; - padding: 8px; - border-radius: 4px; - font-family: monospace; - font-size: 12px; - color: #999; - } - - .footer { - margin-top: 30px; - padding-top: 20px; - border-top: 1px solid #444; - font-size: 12px; - color: #999; + color: #0066cc; } -

Welcome to {{companyName}}!

+

Welcome to {{companyName}}!

+

Hi {{displayName}},

-

Thanks for signing up! Please verify your email address to activate your account:

+

Please verify your email address:

-

Verify Email Address

+

{{verificationUrl}}

-
- ⏰ Important: {{willExpire}} for security reasons. -
+

{{willExpire}} for security reasons.

-

If the button doesn't work:

-

Copy and paste this link into your browser:

-
{{verificationUrl}}
+

Questions? Contact {{supportEmail}}

-

Once verified, you'll have full access to your {{companyName}} account!

- -

Questions? Reply to this email or contact us at {{supportEmail}}

+

Best regards,
+ The {{companyName}} team


-

Best regards,
The {{companyName}} team

+

User ID: {{id}} | © {{currentYear}} {{companyName}}

- \ No newline at end of file diff --git a/src/routes/user/forgot/password.ts b/src/routes/user/forgot/password.ts new file mode 100644 index 0000000..a6713a2 --- /dev/null +++ b/src/routes/user/forgot/password.ts @@ -0,0 +1,154 @@ +import { echo } from "@atums/echo"; +import { redis } from "bun"; +import { environment } from "#environment/config"; +import { extraValues } from "#environment/extra"; +import { cassandra } from "#lib/database"; +import { mailerService } from "#lib/mailer"; +import { isValidEmail } from "#lib/validation"; + +import type { + BaseResponse, + ExtendedRequest, + ForgotPasswordRequest, + RouteDef, +} from "#types/server"; + +const routeDef: RouteDef = { + method: "POST", + accepts: "application/json", + returns: "application/json", + needsBody: "json", +}; + +async function handler( + _request: ExtendedRequest, + requestBody: unknown, +): Promise { + try { + const { email } = requestBody as ForgotPasswordRequest; + + if (!email) { + const response: BaseResponse = { + code: 400, + success: false, + error: "Email is required", + }; + return Response.json(response, { status: 400 }); + } + + const emailValidation = isValidEmail(email); + if (!emailValidation.valid) { + const response: BaseResponse = { + code: 400, + success: false, + error: emailValidation.error || "Invalid email", + }; + return Response.json(response, { status: 400 }); + } + + const userQuery = ` + SELECT id, username, display_name, email, is_verified + FROM users WHERE email = ? LIMIT 1 + `; + const userResult = (await cassandra.execute(userQuery, [ + email.trim().toLowerCase(), + ])) as { + rows: Array<{ + id: string; + username: string; + display_name: string | null; + email: string; + is_verified: boolean; + }>; + }; + + if (!userResult?.rows || userResult.rows.length === 0) { + const response: BaseResponse = { + code: 200, + success: true, + message: "If the email exists, a password reset link has been sent", + }; + return Response.json(response, { status: 200 }); + } + + const user = userResult.rows[0]; + if (!user) { + const response: BaseResponse = { + code: 200, + success: true, + message: "If the email exists, a password reset link has been sent", + }; + return Response.json(response, { status: 200 }); + } + + const resetToken = Bun.randomUUIDv7(); + const resetKey = `password-reset:${resetToken}`; + + try { + await redis.set( + resetKey, + JSON.stringify({ + userId: user.id, + email: user.email, + }), + "EX", + 1 * 60 * 60, // 1 hour + ); + + const emailVariables = { + subject: `Password Reset - ${extraValues.companyName}`, + companyName: extraValues.companyName, + id: user.id, + displayName: user.display_name || user.username, + willExpire: "This link will expire in 1 hour", + resetUrl: `${environment.frontendFqdn}/user/reset-password?token=${resetToken}`, + supportEmail: extraValues.supportEmail, + currentYear: new Date().getFullYear(), + }; + + await mailerService.sendTemplateEmail( + user.email, + `Password Reset - ${extraValues.companyName}`, + "forgot-password", + emailVariables, + ); + + const response: BaseResponse = { + code: 200, + success: true, + message: "If the email exists, a password reset link has been sent", + }; + return Response.json(response, { status: 200 }); + } catch (error) { + await redis.del(resetKey).catch(() => {}); + + echo.error({ + message: "Failed to send password reset email", + error, + userId: user.id, + email: user.email, + }); + + const response: BaseResponse = { + code: 500, + success: false, + error: "Failed to send password reset email. Please try again.", + }; + return Response.json(response, { status: 500 }); + } + } catch (error) { + echo.error({ + message: "Password reset request failed", + error, + }); + + const response: BaseResponse = { + code: 500, + success: false, + error: "Internal server error", + }; + return Response.json(response, { status: 500 }); + } +} + +export { handler, routeDef }; diff --git a/src/routes/user/reset/password.ts b/src/routes/user/reset/password.ts new file mode 100644 index 0000000..8bd5ccc --- /dev/null +++ b/src/routes/user/reset/password.ts @@ -0,0 +1,190 @@ +import { echo } from "@atums/echo"; +import { redis } from "bun"; +import { sessionManager } from "#lib/auth"; +import { cassandra } from "#lib/database"; +import { isValidPassword } from "#lib/validation"; +import type { + ExtendedRequest, + ResetData, + ResetPasswordRequest, + ResetPasswordResponse, + RouteDef, + UserRow, +} from "#types/server"; + +const routeDef: RouteDef = { + method: "POST", + accepts: "application/json", + returns: "application/json", + needsBody: "json", +}; + +async function handler( + _request: ExtendedRequest, + requestBody: unknown, +): Promise { + try { + const { + token, + newPassword, + logoutAllSessions = true, + } = requestBody as ResetPasswordRequest; + + if (!token || !newPassword) { + const response: ResetPasswordResponse = { + code: 400, + success: false, + error: "Token and new password are required", + }; + return Response.json(response, { status: 400 }); + } + + const passwordValidation = isValidPassword(newPassword); + if (!passwordValidation.valid) { + const response: ResetPasswordResponse = { + code: 400, + success: false, + error: passwordValidation.error || "Invalid password", + }; + return Response.json(response, { status: 400 }); + } + + const resetKey = `password-reset:${token}`; + const resetDataRaw = await redis.get(resetKey); + + if (!resetDataRaw) { + const response: ResetPasswordResponse = { + code: 400, + success: false, + error: "Invalid or expired reset token", + }; + return Response.json(response, { status: 400 }); + } + + let resetData: ResetData; + try { + resetData = JSON.parse(resetDataRaw); + } catch { + await redis.del(resetKey); + const response: ResetPasswordResponse = { + code: 400, + success: false, + error: "Invalid reset token format", + }; + return Response.json(response, { status: 400 }); + } + + if (!resetData.userId || !resetData.email) { + await redis.del(resetKey); + const response: ResetPasswordResponse = { + code: 400, + success: false, + error: "Invalid reset data", + }; + return Response.json(response, { status: 400 }); + } + + const userQuery = ` + SELECT id, username, display_name, email, password, is_verified, created_at, updated_at + FROM users WHERE id = ? LIMIT 1 + `; + const userResult = (await cassandra.execute(userQuery, [ + resetData.userId, + ])) as { rows: UserRow[] }; + + if (!userResult?.rows || userResult.rows.length === 0) { + await redis.del(resetKey); + const response: ResetPasswordResponse = { + code: 404, + success: false, + error: "User not found", + }; + return Response.json(response, { status: 404 }); + } + + const user = userResult.rows[0]; + if (!user) { + await redis.del(resetKey); + const response: ResetPasswordResponse = { + code: 404, + success: false, + error: "User not found", + }; + return Response.json(response, { status: 404 }); + } + + if (user.email !== resetData.email) { + await redis.del(resetKey); + const response: ResetPasswordResponse = { + code: 400, + success: false, + error: "Reset token does not match current email address", + }; + return Response.json(response, { status: 400 }); + } + + const isSamePassword = await Bun.password.verify( + newPassword, + user.password, + ); + if (isSamePassword) { + const response: ResetPasswordResponse = { + code: 400, + success: false, + error: "New password must be different from current password", + }; + return Response.json(response, { status: 400 }); + } + + const hashedNewPassword = await Bun.password.hash(newPassword, { + algorithm: "argon2id", + memoryCost: 4096, + timeCost: 3, + }); + + const updateQuery = ` + UPDATE users + SET password = ?, updated_at = ? + WHERE id = ? + `; + await cassandra.execute(updateQuery, [ + hashedNewPassword, + new Date(), + user.id, + ]); + + await redis.del(resetKey); + + let invalidatedCount = 0; + if (logoutAllSessions) { + invalidatedCount = await sessionManager.invalidateAllSessionsForUser( + user.id, + ); + } + + const response: ResetPasswordResponse = { + code: 200, + success: true, + message: logoutAllSessions + ? `Password reset successfully. Logged out from ${invalidatedCount} session(s).` + : "Password reset successfully.", + loggedOutSessions: invalidatedCount, + }; + + return Response.json(response, { status: 200 }); + } catch (error) { + echo.error({ + message: "Password reset failed", + error, + }); + + const response: ResetPasswordResponse = { + code: 500, + success: false, + error: "Internal server error", + }; + return Response.json(response, { status: 500 }); + } +} + +export { handler, routeDef }; diff --git a/src/routes/user/update/password.ts b/src/routes/user/update/password.ts index 315b780..e7b6fa4 100644 --- a/src/routes/user/update/password.ts +++ b/src/routes/user/update/password.ts @@ -134,7 +134,7 @@ async function handler( const response: UpdatePasswordResponse = { code: 200, success: true, - message: `Password updated successfully. Logged out from ${invalidatedCount} sessions.`, + message: `Password updated successfully. Logged out from ${invalidatedCount} session(s).`, loggedOutSessions: invalidatedCount, }; @@ -185,7 +185,7 @@ async function handler( success: true, message: invalidatedCount > 0 - ? `Password updated successfully. Logged out from ${invalidatedCount} other sessions.` + ? `Password updated successfully. Logged out from ${invalidatedCount} other session(s).` : "Password updated successfully.", loggedOutSessions: invalidatedCount, }; diff --git a/types/server/requests/user/forgot/index.ts b/types/server/requests/user/forgot/index.ts new file mode 100644 index 0000000..df7d49b --- /dev/null +++ b/types/server/requests/user/forgot/index.ts @@ -0,0 +1 @@ +export * from "./password"; diff --git a/types/server/requests/user/forgot/password.ts b/types/server/requests/user/forgot/password.ts new file mode 100644 index 0000000..ef26b89 --- /dev/null +++ b/types/server/requests/user/forgot/password.ts @@ -0,0 +1,5 @@ +interface ForgotPasswordRequest { + email: string; +} + +export type { ForgotPasswordRequest }; diff --git a/types/server/requests/user/index.ts b/types/server/requests/user/index.ts index c835960..a1ba3ad 100644 --- a/types/server/requests/user/index.ts +++ b/types/server/requests/user/index.ts @@ -5,3 +5,5 @@ export * from "./login"; export * from "./verify"; export * from "./update"; +export * from "./forgot"; +export * from "./reset"; diff --git a/types/server/requests/user/reset/index.ts b/types/server/requests/user/reset/index.ts new file mode 100644 index 0000000..df7d49b --- /dev/null +++ b/types/server/requests/user/reset/index.ts @@ -0,0 +1 @@ +export * from "./password"; diff --git a/types/server/requests/user/reset/password.ts b/types/server/requests/user/reset/password.ts new file mode 100644 index 0000000..bd8da2b --- /dev/null +++ b/types/server/requests/user/reset/password.ts @@ -0,0 +1,18 @@ +import type { BaseResponse } from "../../base"; + +interface ResetPasswordRequest { + token: string; + newPassword: string; + logoutAllSessions?: boolean; +} + +interface ResetPasswordResponse extends BaseResponse { + loggedOutSessions?: number; +} + +interface ResetData { + userId: string; + email: string; +} + +export type { ResetPasswordRequest, ResetPasswordResponse, ResetData };