Vitron-Front/app/routes/login.tsx
Arda Samadi 485f8d460a Security fixes: session hardening, open redirect, SSR path injection
- sessions: require REMIX_SECRET in prod (no "secret" fallback); base Secure
  cookie on NODE_ENV; add maxAge
- login: safeRedirect() blocks open-redirect via redirectTo (loader + action)
- SSR loaders: encodeURIComponent on user path params before internal API
  fetch (product, store product, seller)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:02:11 +03:30

344 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { authenticator } from "~/auth.server";
import {
ActionFunctionArgs,
json,
redirect,
LoaderFunctionArgs,
MetaFunction,
} from "@remix-run/node";
import { sessionStorage } from "~/sessions.server";
import { useServiceSendOtp, useServiceVerifyOtp } from "~/utils/RequestHandler";
import { useEffect, useState } from "react";
import verifyBanner from "app/assets/icons/login/verify-banner.svg";
import { Button } from "~/components/ui/button";
import { ArrowLeft, Loader2, Phone, RotateCcw } from "lucide-react";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "~/components/ui/input-otp";
import AppInput from "~/components/AppInput";
import { useToast } from "~/hooks/use-toast";
import { useSubmit, useLoaderData } from "@remix-run/react";
import { User } from "~/utils/token-manager.server";
// Only allow same-site, path-relative redirects. Rejects absolute URLs
// (https://evil.com) and protocol-relative URLs (//evil.com) to prevent
// open-redirect / phishing after login.
function safeRedirect(
to: FormDataEntryValue | string | null,
defaultRedirect = "/"
): string {
if (!to || typeof to !== "string") return defaultRedirect;
if (!to.startsWith("/") || to.startsWith("//") || to.startsWith("/\\")) {
return defaultRedirect;
}
return to;
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const redirectTo = safeRedirect(url.searchParams.get("redirectTo"));
return json({ redirectTo });
}
// Helper function to convert Persian numbers to English
const convertPersianToEnglish = (str: string): string => {
const persianNumbers = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
const englishNumbers = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
let result = str;
for (let i = 0; i < 10; i++) {
result = result.replace(
new RegExp(persianNumbers[i], "g"),
englishNumbers[i]
);
}
return result;
};
export default function LoginPage() {
const { redirectTo } = useLoaderData<typeof loader>();
const { toast } = useToast();
const [phoneNumber, setPhoneNumber] = useState("");
const [otpValue, setOtpValue] = useState("");
const [enableVerifySection, setEnableVerifySection] = useState(false);
const sendOtpMutation = useServiceSendOtp(
phoneNumber,
setEnableVerifySection
);
const verifyOtpMutation = useServiceVerifyOtp(phoneNumber, otpValue, toast);
const submit = useSubmit();
useEffect(() => {
if (
verifyOtpMutation.data &&
verifyOtpMutation.data.accessToken &&
verifyOtpMutation.data.refreshToken
) {
console.log("redirectTo", redirectTo);
submit(
{
phoneNumber: phoneNumber,
access_token: JSON.stringify(verifyOtpMutation.data.accessToken),
refresh_token: JSON.stringify(verifyOtpMutation.data.refreshToken),
redirectTo: redirectTo,
},
{
action: "/login",
method: "post",
encType: "application/x-www-form-urlencoded",
}
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [verifyOtpMutation.data]);
const [canResend, setCanResend] = useState(false);
useEffect(() => {
if (enableVerifySection) setCanResend(false);
}, [enableVerifySection]);
const handleTimerEnd = () => {
setCanResend(true);
};
return (
<div className="flex flex-col gap-6 px-[20px] py-12 items-center w-full">
{enableVerifySection ? (
<>
<div className="flex flex-col gap-4 w-full items-center">
<img alt="" src={verifyBanner} className="w-[85px] h-[70px]" />
<p className={"text-R18"}>کد تایید شماره موبایل</p>
<p className="text-BLACK2 text-R12">
کد ارسال شده به {phoneNumber} را وارد کنید:
</p>
<Button
variant="ghost"
className="text-BLUE"
size={"lg"}
onClick={() => setEnableVerifySection(false)}
>
ویرایش شماره موبایل
<ArrowLeft />
</Button>
</div>
<div className="w-full mt-2 flex justify-center">
<InputOTP
maxLength={4}
onComplete={(finalValue) => {
const convertedValue = convertPersianToEnglish(finalValue);
setOtpValue(convertedValue);
}}
value={otpValue}
onChange={(value) => {
const convertedValue = convertPersianToEnglish(value);
setOtpValue(convertedValue);
}}
>
<InputOTPGroup dir="ltr" className="gap-4">
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
</InputOTP>
</div>
<div className="gap-4 w-full flex flex-col items-center">
<Button
disabled={verifyOtpMutation.isPending || !(otpValue.length === 4)}
className="w-[90%]"
size={"xl"}
variant="dark"
onClick={() => verifyOtpMutation.mutate()}
>
{verifyOtpMutation.isPending ? (
<>
<Loader2 className="animate-spin" />
درحال تایید...
</>
) : (
"تایید شماره موبایل"
)}
</Button>
<div>
{!canResend ? (
<ResendTimer initialTime={120} onTimerEnd={handleTimerEnd} />
) : (
<Button
variant="link"
size="lg"
className="text-BLUE font-bold p-0 h-auto gap-1"
onClick={() => {
setCanResend(false);
setOtpValue("");
sendOtpMutation.mutate();
toast({
description: "کد مجددا ارسال گردید",
});
}}
>
<span className="text-B12">ارسال مجدد کد</span>
<RotateCcw className="size-5" />
</Button>
)}
</div>
</div>
</>
) : (
<>
<div className="flex flex-col gap-4 w-full items-center">
<p className="text-R18">به ویترون خوش آمدید!</p>
<p className="text-GRAY text-R14">
برای ادامه اطلاعات زیر را وارد کنید
</p>
</div>
<div className="w-full mt-8">
<AppInput
inputTitle={"شماره موبایل خود را وارد کنید:"}
inputIcon={Phone}
inputValue={phoneNumber}
inputPlaceholder={"مثل ۰۹۳۹۱۲۳۴۵۶۷"}
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Convert Persian numbers to English
const convertedValue = convertPersianToEnglish(value);
// Only allow English digits
if (/^\d*$/.test(convertedValue)) {
setPhoneNumber(convertedValue);
}
}}
/>
</div>
<Button
disabled={
sendOtpMutation.isPending ||
!(phoneNumber.length === 11 && phoneNumber.startsWith("0"))
}
size={"xl"}
variant={"dark"}
className="w-full"
onClick={() => {
sendOtpMutation.mutate();
}}
>
{sendOtpMutation.isPending ? (
<>
<Loader2 className="animate-spin" />
درحال ارسال کد
</>
) : (
"تایید و ادامه"
)}
</Button>
</>
)}
</div>
);
}
const ResendTimer = ({
initialTime = 120,
onTimerEnd,
}: {
initialTime?: number;
onTimerEnd?: () => void;
}) => {
const [timeLeft, setTimeLeft] = useState(initialTime);
useEffect(() => {
if (timeLeft <= 0) {
if (onTimerEnd) onTimerEnd();
return;
}
const timer = setInterval(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
return () => clearInterval(timer); // پاک کردن تایمر هنگام خروج از کامپوننت
}, [timeLeft, onTimerEnd]);
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds < 10 ? `0${remainingSeconds}` : remainingSeconds}`;
};
return (
<p className="text-GRAY text-R12">
{formatTime(timeLeft)} &nbsp;تا ارسال مجدد کد
</p>
);
};
export const meta: MetaFunction = () => {
const title = "ورود و ثبت نام | ویترون";
const description =
"ورود یا ثبت نام در ویترون - پلتفرم خرید آنلاین. دسترسی آسان و سریع به حساب کاربری خود برای تجربه خرید بهتر.";
const imageUrl =
"https://vitrownstatics.s3.ir-thr-at1.arvanstorage.ir/SVG-06.svg?versionId=";
return [
{ title },
{ name: "description", content: description },
{ name: "keywords", content: "ورود ویترون، ثبت نام، حساب کاربری، لاگین" },
{ name: "robots", content: "noindex, follow" },
// Open Graph
{ property: "og:type", content: "website" },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:image", content: imageUrl },
{ property: "og:site_name", content: "ویترون" },
{ property: "og:locale", content: "fa_IR" },
// Twitter Card
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
];
};
export async function action({ request }: ActionFunctionArgs) {
const clonedRequest = request.clone();
const formData = await clonedRequest.formData();
const redirectTo = safeRedirect(formData.get("redirectTo"));
try {
const user = (await authenticator.authenticate(
"vitrown-login",
request
)) as User;
if (!user.access_token) {
return redirect("/login");
}
const session = await sessionStorage.getSession(
request.headers.get("cookie")
);
const currentTime = Date.now();
// Store both tokens with their timestamps
session.set("user", {
access_token: user.access_token,
refresh_token: user.refresh_token,
access_token_timestamp: currentTime,
refresh_token_timestamp: currentTime,
});
return redirect(redirectTo, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session, {
expires: new Date(new Date().setMonth(new Date().getMonth() + 1)),
}),
},
});
} catch (error) {
console.log("error", error);
return json({ error: "Authentication failed" }, { status: 401 });
}
}