add reset password email and route
This commit is contained in:
parent
fff3c3ca50
commit
d36c34dd17
12 changed files with 433 additions and 68 deletions
2
bun.lock
2
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=="],
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
"rotate": true,
|
||||
"maxFiles": 3,
|
||||
"fileNameFormat": "yyyy-MM-dd",
|
||||
|
||||
"console": true,
|
||||
"consoleColor": true,
|
||||
|
|
47
src/environment/mailer/templates/forgot-password.html
Normal file
47
src/environment/mailer/templates/forgot-password.html
Normal file
|
@ -0,0 +1,47 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{subject}}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0066cc;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Password Reset - {{companyName}}</h1>
|
||||
|
||||
<p>Hi {{displayName}},</p>
|
||||
|
||||
<p>You requested a password reset for your account. Click the link below to reset your password:</p>
|
||||
|
||||
<p><a href="{{resetUrl}}">{{resetUrl}}</a></p>
|
||||
|
||||
<p>{{willExpire}} for security reasons.</p>
|
||||
|
||||
<p>If you did not request this password reset, please ignore this email. Your password will remain unchanged.</p>
|
||||
|
||||
<p>Questions? Contact {{supportEmail}}</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
The {{companyName}} team</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<p><small>User ID: {{id}} | © {{currentYear}} {{companyName}}</small></p>
|
||||
|
||||
</body>
|
||||
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Welcome to {{companyName}}!</h2>
|
||||
<h1>Welcome to {{companyName}}!</h1>
|
||||
|
||||
<p>Hi {{displayName}},</p>
|
||||
|
||||
<p>Thanks for signing up! Please verify your email address to activate your account:</p>
|
||||
<p>Please verify your email address:</p>
|
||||
|
||||
<p><a href="{{verificationUrl}}" class="button">Verify Email Address</a></p>
|
||||
<p><a href="{{verificationUrl}}">{{verificationUrl}}</a></p>
|
||||
|
||||
<div class="expiry-notice">
|
||||
<strong>⏰ Important:</strong> {{willExpire}} for security reasons.
|
||||
</div>
|
||||
<p>{{willExpire}} for security reasons.</p>
|
||||
|
||||
<p><strong>If the button doesn't work:</strong></p>
|
||||
<p>Copy and paste this link into your browser:</p>
|
||||
<div class="fallback-url">{{verificationUrl}}</div>
|
||||
<p>Questions? Contact {{supportEmail}}</p>
|
||||
|
||||
<p>Once verified, you'll have full access to your {{companyName}} account!</p>
|
||||
|
||||
<p>Questions? Reply to this email or contact us at {{supportEmail}}</p>
|
||||
<p>Best regards,<br>
|
||||
The {{companyName}} team</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<p>Best regards,<br>The {{companyName}} team</p>
|
||||
<p><small>User ID: {{id}} | © {{currentYear}} {{companyName}}</small></p>
|
||||
|
||||
<div class="footer">
|
||||
<p>User ID: {{id}}</p>
|
||||
<p>© {{currentYear}} {{companyName}}. All rights reserved.</p>
|
||||
<p><small>This is an automated message. Please do not reply directly to this email.</small></p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
154
src/routes/user/forgot/password.ts
Normal file
154
src/routes/user/forgot/password.ts
Normal file
|
@ -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<Response> {
|
||||
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 };
|
190
src/routes/user/reset/password.ts
Normal file
190
src/routes/user/reset/password.ts
Normal file
|
@ -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<Response> {
|
||||
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 };
|
|
@ -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,
|
||||
};
|
||||
|
|
1
types/server/requests/user/forgot/index.ts
Normal file
1
types/server/requests/user/forgot/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./password";
|
5
types/server/requests/user/forgot/password.ts
Normal file
5
types/server/requests/user/forgot/password.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
interface ForgotPasswordRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export type { ForgotPasswordRequest };
|
|
@ -5,3 +5,5 @@ export * from "./login";
|
|||
export * from "./verify";
|
||||
|
||||
export * from "./update";
|
||||
export * from "./forgot";
|
||||
export * from "./reset";
|
||||
|
|
1
types/server/requests/user/reset/index.ts
Normal file
1
types/server/requests/user/reset/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from "./password";
|
18
types/server/requests/user/reset/password.ts
Normal file
18
types/server/requests/user/reset/password.ts
Normal file
|
@ -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 };
|
Loading…
Add table
Add a link
Reference in a new issue