diff --git a/.claude/prompt/fix-token-storage-xss-headers.md b/.claude/prompt/fix-token-storage-xss-headers.md new file mode 100644 index 0000000..fa45d5b --- /dev/null +++ b/.claude/prompt/fix-token-storage-xss-headers.md @@ -0,0 +1,179 @@ +# سخت‌سازی امنیتی سایت عمومی: ذخیره‌ی توکن، XSS، هدرها، client_secret + +## پروژه + +`nobat724_front` (سایت عمومی). این پرامپت **cross-repo** است — پرامپت همتای backend: `clinicpro/.claude/prompt/fix-auth-token-hardening.md` که **اول** باید اجرا شود (قرارداد توکن را عوض می‌کند: `oauth/token` به‌جای `uuid` فیلد `grant` می‌گیرد و `verify-code` فیلد `grant` برمی‌گرداند). + +مرجع: گزارش امنیتی این session (OWASP Top 10). یافته‌های frontend: **C-2 (Critical)**، **C-3 (Critical)**، **H-2 (High)**، **H-3 (High)**. + +## زمینه + +ممیزی امنیتی این مشکلات را در سایت عمومی پیدا کرد: + +- **C-2:** `access_token` و `refresh_token` با `js-cookie` ست می‌شوند (`Cookies.set(...)`) — یعنی **غیر HttpOnly، بدون Secure، بدون SameSite**؛ هر اسکریپتی می‌تواند بخواندشان. با XSS → سرقت کامل توکن. +- **C-3:** بدنه‌ی بلاگ/کلینیک/پزشک با `dangerouslySetInnerHTML={{ __html: sanitizeHtml(...) }}` رندر می‌شود، اما `lib/sanitize.js` یک sanitizer **regex دستی و قابل دور زدن** است (مثلاً `` چون `` در لیست خطرناک نیست عبور می‌کند). → Stored XSS. +- **H-2:** `next.config.js` هیچ تابع `headers()` ندارد → بدون CSP/HSTS/X-Frame-Options/nosniff/Referrer-Policy/Permissions-Policy. +- **H-3:** توکن با `NEXT_PUBLIC_CLIENT_SECRET` گرفته می‌شود؛ هر مقدار `NEXT_PUBLIC_*` داخل bundle مرورگر inline و عمومی می‌شود. + +## مشکل / هدف + +refresh token را به کوکی **HttpOnly سمت سرور** ببر و توکن‌گیری/OAuth را در یک Route Handler سرور-ساید انجام بده (تا `client_secret` و `refresh_token` هرگز به مرورگر نروند)؛ sanitizer را با **DOMPurify** عوض کن؛ security headers را در `next.config.js` اضافه کن. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `services/api.js` | interceptor — خواندن `access_token` از کوکی | +| `services/response.js` | `getToken` که `client_secret` می‌فرستد | +| `components/register/verificationPage/SendReq.js` | ست‌کردن کوکی‌ها بعد از لاگین (`Cookies.set`) | +| `components/appointment/detail/SubmitData.js` | `Cookies.set("access_token"/"refresh_token")` | +| `components/dashboard/userAccount/.../ButtonSendData.js` | `Cookies.set("access_token")` | +| `lib/sanitize.js` | sanitizer regex فعلی | +| `lib/req.js` / `services/clinicApi.js` | فراخوانی‌های مصرف‌کننده‌ی توکن جدید | +| `app/api/auth/token/route.js` (جدید) | Route Handler سرور-ساید برای OAuth + ست کوکی HttpOnly | +| `next.config.js` | افزودن `headers()` | + +## وضعیت فعلی (کد واقعی) + +`services/response.js` — `client_secret` در درخواست: +```js +getToken: (grant_type, client_id, client_secret, uuid, code, scope = "nobat724") => { + // ... + formData.append("client_secret", client_secret); + // ... +} +``` + +`components/register/verificationPage/SendReq.js` (و مشابه در SubmitData.js): +```js +Cookies.set("access_token", response.access_token, cookieOptions); +Cookies.set("refresh_token", response.refresh_token, { expires: 7 }); +``` + +`services/api.js` interceptor: +```js +if (config.requireAuth) { + const token = Cookies.get("access_token"); + if (token) config.headers.Authorization = `Bearer ${token}`; +} +``` + +`lib/sanitize.js` — regex قابل دور زدن: +```js +const DANGEROUS_TAGS = ['script','iframe','object','embed','link','meta','base','form']; +export function sanitizeHtml(html) { + let sanitized = html; + DANGEROUS_TAGS.forEach((tag) => { /* regex replace */ }); + sanitized = sanitized.replace(/\s+on\w+\s*=\s*.../gi, ''); + return sanitized; +} +``` + +`next.config.js` — بدون `headers()`. + +## وظایف + +### ۱. OAuth + ست کوکی در یک Route Handler سرور-ساید (C-2, H-3) + +یک Route Handler بساز: `app/api/auth/token/route.js`. این هندلر: +- ورودی `{ grant }` (مطابق قرارداد جدید backend) را از body می‌گیرد. +- توکن را با `process.env.CLIENT_SECRET` (**بدون** `NEXT_PUBLIC`) و `process.env.CLIENT_ID` از backend (`oauth/token`) می‌گیرد. +- `refresh_token` را در کوکی **HttpOnly; Secure; SameSite=Lax; path=/** ست می‌کند. +- `access_token` را در پاسخ JSON برمی‌گرداند تا کلاینت در **memory** نگهش دارد (نه کوکی، نه localStorage). + +```js +import { cookies } from 'next/headers'; +import axios from 'axios'; + +export async function POST(req) { + const { grant } = await req.json(); + const res = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/oauth/token`, { + grant_type: 'mobile', + grant, + client_id: process.env.CLIENT_ID, + client_secret: process.env.CLIENT_SECRET, + }); + const { access_token, refresh_token, expires_in } = res.data; + const jar = cookies(); + jar.set('refresh_token', refresh_token, { + httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30, + }); + return Response.json({ access_token, expires_in }); +} +``` + +یک Route Handler refresh هم بساز (`app/api/auth/refresh/route.js`) که `refresh_token` را از کوکی HttpOnly می‌خواند، `oauth/token/refresh` را صدا می‌زند، کوکی جدید ست می‌کند و access token تازه برمی‌گرداند. و `app/api/auth/logout/route.js` که کوکی را پاک و `oauth/logout` را صدا می‌زند. + +> `services/response.js::getToken` که `client_secret` سمت کلاینت می‌فرستد را **حذف/جایگزین** کن با فراخوانی این Route Handler. هیچ `NEXT_PUBLIC_CLIENT_SECRET` در کد کلاینت نماند. + +### ۲. مدیریت access token در memory به‌جای کوکی JS (C-2) + +- همه‌ی `Cookies.set("access_token", ...)` و `Cookies.set("refresh_token", ...)` را حذف کن (SendReq.js, SubmitData.js, ButtonSendData.js و هرجای دیگر). +- access token را در یک ماژول in-memory نگه‌دار (مثلاً `lib/tokenStore.js` با یک متغیر و getter/setter، یا context). در `services/api.js` interceptor به‌جای `Cookies.get("access_token")` از این store بخوان. +- روی 401، interceptor اول `app/api/auth/refresh` را امتحان کند؛ اگر شکست خورد، logout و redirect. +- چون access token در memory با refresh صفحه پاک می‌شود، در bootstrap اپ (مثلاً یک Provider بالای درخت) یک‌بار `app/api/auth/refresh` صدا بزن تا از روی کوکی HttpOnly، access token تازه بگیری. + +> کوکی‌های `uuid`/`userInfo` که حساس نیستند می‌توانند بمانند ولی `userInfo` را به فیلدهای غیرحساس محدود کن. + +### ۳. جایگزینی sanitizer با DOMPurify (C-3) + +`isomorphic-dompurify` را نصب و `lib/sanitize.js` را بازنویسی کن (امضای `sanitizeHtml` حفظ شود تا `Caption.js`/`TextDetail.js`/`blog`/`clinic`/`doctor` تغییر نکنند): +```js +import DOMPurify from 'isomorphic-dompurify'; + +export function sanitizeHtml(html) { + if (!html || typeof html !== 'string') return ''; + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: ['p','br','strong','em','b','i','u','ul','ol','li','a','h2','h3','h4','blockquote','img','span','table','thead','tbody','tr','td','th'], + ALLOWED_ATTR: ['href','target','rel','src','alt','title'], + ALLOW_DATA_ATTR: false, + }); +} +// safeJsonParse را همان‌طور که هست نگه دار +``` +> `isomorphic-dompurify` در SSR (Server Component) و کلاینت هر دو کار می‌کند — مهم، چون این صفحات SSR هستند. + +### ۴. Security headers در next.config.js (H-2) + +تابع `headers()` به `nextConfig` اضافه کن: +```js +async headers() { + return [{ + source: '/(.*)', + headers: [ + { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { key: 'Content-Security-Policy', value: [ + "default-src 'self'", + "img-src 'self' https: data:", + "script-src 'self' 'unsafe-inline'", // اگر JSON-LD/Next نیاز داشت؛ در صورت امکان nonce + "style-src 'self' 'unsafe-inline'", + "font-src 'self' data:", + "connect-src 'self' https://api.clinic-pro.ir", + "frame-ancestors 'none'", + "object-src 'none'", + "base-uri 'self'", + ].join('; ') }, + ], + }]; +}, +``` +> CSP را با build و مرور صفحات اصلی (home/doctor/clinic/blog/panel) تست کن؛ اگر چیزی بلاک شد (MUI inline style, JSON-LD)، با `'unsafe-inline'` فقط برای style یا nonce برای script حلش کن — نه باز کردن کامل. + +## نکات مهم + +- **ترتیب:** اول پرامپت backend اجرا شود (قرارداد `grant`)، بعد این. تا قبل از آن، `oauth/token` همچنان `uuid` می‌خواهد؛ این پرامپت بر اساس قرارداد جدید (`grant`) نوشته شده. +- App Router؛ Route Handlerها سرور-ساید هستند و به `process.env.CLIENT_SECRET` دسترسی دارند بدون افشا به مرورگر. +- multi-domain را نشکن: `domain` کوکی را مثل کد فعلی بر اساس hostname ست کن (برای کوکی HttpOnly در Route Handler هم همان منطق domain را اعمال کن تا روی ساب‌دامین‌های شهرها کار کند). +- بعد از تغییر env، مطمئن شو `CLIENT_SECRET` و `CLIENT_ID` (بدون `NEXT_PUBLIC`) در محیط deploy ست شده‌اند (در `docker-compose.yml` از قبل `CLIENT_SECRET`/`CLIENT_ID` تعریف شده). +- تست: + - `npm run build` بدون خطا. + - جریان لاگین کامل: OTP → access token در memory، refresh در کوکی HttpOnly (در DevTools → Application → Cookies باید `HttpOnly` ✓ و `Secure` ✓ باشد). + - refresh صفحه → اپ از کوکی HttpOnly دوباره access token می‌گیرد و کاربر لاگین می‌ماند. + - در DevTools Console: `document.cookie` نباید `access_token`/`refresh_token` نشان دهد. + - بدنه‌ی بلاگ با payload تست `` رندر شود ولی اجرا نشود (DOMPurify پاکش کند). + - هدرهای امنیتی در Network tab روی پاسخ صفحات دیده شوند. + - در bundle مرورگر (`.next/static`) رشته‌ی `client_secret` یا مقدار آن نباشد. diff --git a/app/Providers.js b/app/Providers.js index 8313164..47c40e1 100644 --- a/app/Providers.js +++ b/app/Providers.js @@ -2,10 +2,23 @@ import { ThemeProvider } from "next-themes"; import { usePathname } from "next/navigation"; +import { useEffect } from "react"; +import Cookies from "js-cookie"; +import { setAccessToken } from "@/lib/tokenStore"; export function Providers({ children }) { const router = usePathname(); + useEffect(() => { + if (!Cookies.get("userInfo")) return; + fetch("/api/auth/refresh", { method: "POST" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (data?.access_token) setAccessToken(data.access_token); + }) + .catch(() => {}); + }, []); + return ( { + const logout = async () => { setLoading(true); + try { + await fetch("/api/auth/logout", { method: "POST" }); + } catch { + // clearing client state below is what matters + } removeToken(); router.replace("/login"); }; diff --git a/app/component/date/dateTime/SendAppo.js b/app/component/date/dateTime/SendAppo.js index 734caf9..a7ba9d6 100644 --- a/app/component/date/dateTime/SendAppo.js +++ b/app/component/date/dateTime/SendAppo.js @@ -4,7 +4,7 @@ import Cookies from "js-cookie"; function SendAppo({ hour, setStep, setSelectedSlot, date, setSelectedDate }) { const sendReq = () => { - const isLogged = Cookies.get("access_token"); + const isLogged = Cookies.get("userInfo"); // ذخیره کردن اطلاعات اسلات انتخاب شده if (hour && date) { diff --git a/app/dashboard/page.js b/app/dashboard/page.js index 2257b12..fedf132 100644 --- a/app/dashboard/page.js +++ b/app/dashboard/page.js @@ -8,6 +8,7 @@ import { redirect } from "next/navigation"; import { fetchReq } from "@/lib/req"; import { safeJsonParse } from "@/lib/sanitize"; import { buildPatientUser } from "@/lib/representationAdapters"; +import { getServerAccessToken } from "@/lib/serverToken"; export default async function Dashboard({ searchParams }) { const awaitedSearchParams = await searchParams; @@ -16,9 +17,9 @@ export default async function Dashboard({ searchParams }) { const ability = defineAbilitiesFor(user); const cookieStore = await cookies(); - const token = cookieStore.get("access_token"); + const token = await getServerAccessToken(); - if (!ability.can("access", "Dashboard")) { + if (!ability.can("access", "Dashboard") || !token) { removeToken(); return redirect("/login"); } @@ -27,7 +28,7 @@ export default async function Dashboard({ searchParams }) { // may still carry the OTP uuid, which 404s against user-profile. const userInfo = safeJsonParse(cookieStore.get("userInfo")?.value); const userUuid = userInfo?.uuid || cookieStore.get("uuid")?.value; - const authHeader = { headers: { Authorization: `Bearer ${token.value}` } }; + const authHeader = { headers: { Authorization: `Bearer ${token}` } }; const API = process.env.NEXT_PUBLIC_API_URL; const [profile, appointmentsRes, paymentsRes] = await Promise.all([ @@ -54,7 +55,7 @@ export default async function Dashboard({ searchParams }) { return ( { try { - const refreshToken = Cookies.get("refresh_token"); - if (!refreshToken) return false; - - const res = await fetch("/api/auth/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh_token: refreshToken }), - }); + const res = await fetch("/api/auth/refresh", { method: "POST" }); + if (!res.ok) return false; const response = await res.json(); if (response?.access_token) { - Cookies.set("access_token", response.access_token, { expires: 7 }); - if (response.refresh_token) { - Cookies.set("refresh_token", response.refresh_token, { expires: 7 }); - } + setAccessToken(response.access_token); return true; } return false; diff --git a/components/dashboard/userAccount/detailUser/index.js b/components/dashboard/userAccount/detailUser/index.js index 069dee1..351df2e 100644 --- a/components/dashboard/userAccount/detailUser/index.js +++ b/components/dashboard/userAccount/detailUser/index.js @@ -12,7 +12,6 @@ import Surgeries from "./surgeries"; import FamilyHistory from "./familyHistory"; import Relatives from "./relatives"; import { removeAdditionalKeysDashboard } from "@/helper"; -import Cookies from "js-cookie"; import { toast } from "react-toastify"; import LoadingComponent from "@/app/component/LoadingComponent"; @@ -37,11 +36,7 @@ function DetailUser({ user, isTurnsDetails, setIsTurnsDetails }) { const usedKays = removeAdditionalKeysDashboard(information); setLoading(true); request - .patchUserProfile( - usedKays, - information?.uuid, - Cookies.get("access_token") - ) + .patchUserProfile(usedKays, information?.uuid) .then(() => { setLoading(false); toast.success("اطلاعات با موفقیت ثبت شد"); diff --git a/components/dashboard/userAccount/detailUser/information/ButtonSendData.js b/components/dashboard/userAccount/detailUser/information/ButtonSendData.js index f884b88..316a2e7 100644 --- a/components/dashboard/userAccount/detailUser/information/ButtonSendData.js +++ b/components/dashboard/userAccount/detailUser/information/ButtonSendData.js @@ -1,14 +1,13 @@ import ArrowLeftWhiteD from "@/components/icons/ArrowLeftWhiteD"; import { changeDateType, - handleTimeExpiresToken, isValidIranNationalCode, removeAdditionalKeysDashboard, } from "@/helper"; import { request } from "@/services/response"; import { Button, CircularProgress } from "@mui/material"; -import Cookies from "js-cookie"; import { toast } from "react-toastify"; +import { setAccessToken } from "@/lib/tokenStore"; function ButtonSendData({ loading, @@ -31,21 +30,11 @@ function ButtonSendData({ .postUserProfile(changeDateType(information, true)) .then((response) => { if (response.uuid) { - fetch("/api/auth/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh_token: Cookies.get("refresh_token") }), - }) - .then((r) => r.json()) + fetch("/api/auth/refresh", { method: "POST" }) + .then((r) => (r.ok ? r.json() : null)) .then((res) => { if (res?.access_token) { - const expiresTime = handleTimeExpiresToken(res.expires_in); - Cookies.set("access_token", res.access_token, { - expires: expiresTime.accessTokenExpires, - path: "/", - secure: true, - sameSite: "strict", - }); + setAccessToken(res.access_token); } setInformation({ ...information, @@ -69,11 +58,7 @@ function ButtonSendData({ const usedKays = removeAdditionalKeysDashboard(information); request - .patchUserProfile( - changeDateType(usedKays, true), - information?.uuid, - Cookies.get("access_token") - ) + .patchUserProfile(changeDateType(usedKays, true), information?.uuid) .then(() => { setLoading(false); toast.success("اطلاعات با موفقیت ثبت شد"); diff --git a/components/layout/StLayout.js b/components/layout/StLayout.js index 0c98603..1f3734f 100644 --- a/components/layout/StLayout.js +++ b/components/layout/StLayout.js @@ -6,14 +6,14 @@ import { cookies } from "next/headers"; async function StLayout({ children, name }) { const { matchedCity } = await getStateInfo(); const cookieStore = await cookies(); - const token = cookieStore.get("access_token"); + const isLogged = Boolean(cookieStore.get("userInfo")); return (
-
+
diff --git a/components/layout/header/Content.js b/components/layout/header/Content.js index 26e1342..99b6913 100644 --- a/components/layout/header/Content.js +++ b/components/layout/header/Content.js @@ -18,7 +18,7 @@ function Content({ matchedCity, name, logged }) { const pathname = usePathname(); useEffect(() => { - setIsLogged(Boolean(Cookies.get("access_token"))); + setIsLogged(Boolean(Cookies.get("userInfo"))); }, [pathname]); return ( diff --git a/components/register/verificationPage/SendReq.js b/components/register/verificationPage/SendReq.js index 95e2a73..ad62c79 100644 --- a/components/register/verificationPage/SendReq.js +++ b/components/register/verificationPage/SendReq.js @@ -3,6 +3,7 @@ import { request } from "@/services/response"; import Cookies from "js-cookie"; import { toast } from "react-toastify"; import { handleTimeExpiresToken } from "@/helper"; +import { setAccessToken } from "@/lib/tokenStore"; function SendReq({ loading, @@ -44,14 +45,13 @@ function SendReq({ const isHttps = window.location.protocol === "https:"; const isLocalhost = hostname.includes("localhost"); - // تنظیمات پایه + // فقط داده‌های غیرحساس در کوکی JS؛ access_token در memory، refresh_token در کوکی HttpOnly سرور let cookieOptions = { path: "/", sameSite: "lax", - expires: expiresTime.accessTokenExpires, + expires: expiresTime.refreshTokenExpires, }; - // اگر روی دامنه اصلی و https بود if (!isLocalhost && isHttps) { cookieOptions = { ...cookieOptions, @@ -60,17 +60,8 @@ function SendReq({ }; } - Cookies.set("access_token", response.access_token, cookieOptions); - - Cookies.set("refresh_token", response.refresh_token, { - ...cookieOptions, - expires: expiresTime.refreshTokenExpires, - }); - - Cookies.set("uuid", uuid, { - ...cookieOptions, - expires: expiresTime.refreshTokenExpires, - }); + setAccessToken(response.access_token); + Cookies.set("uuid", uuid, cookieOptions); getInfo(response.access_token, cookieOptions); }; diff --git a/lib/auth.js b/lib/auth.js index 9c05f9f..abf7976 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -2,11 +2,8 @@ import { cookies } from "next/headers"; export async function getUser() { const cookieStore = await cookies(); - const raw = - cookieStore.get("access_token") && - cookieStore.get("refresh_token") && - cookieStore.get("uuid") && - cookieStore.get("userInfo"); - if (!raw) return null; - return raw; + const isAuthenticated = + cookieStore.get("refresh_token") && cookieStore.get("userInfo"); + if (!isAuthenticated) return null; + return cookieStore.get("userInfo"); } diff --git a/lib/refreshCookie.js b/lib/refreshCookie.js new file mode 100644 index 0000000..12d888e --- /dev/null +++ b/lib/refreshCookie.js @@ -0,0 +1,34 @@ +const COOKIE_NAME = "refresh_token"; + +function cookieDomain(host) { + if (!host) return undefined; + const hostname = host.split(":")[0]; + if (hostname.includes("localhost") || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { + return undefined; + } + return "." + hostname.split(".").slice(-2).join("."); +} + +export function setRefreshCookie(response, refreshToken, host, maxAge = 60 * 60 * 24 * 30) { + response.cookies.set(COOKIE_NAME, refreshToken, { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge, + domain: cookieDomain(host), + }); +} + +export function clearRefreshCookie(response, host) { + response.cookies.set(COOKIE_NAME, "", { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: 0, + domain: cookieDomain(host), + }); +} + +export { COOKIE_NAME }; diff --git a/lib/sanitize.js b/lib/sanitize.js index 549bf41..d03c61a 100644 --- a/lib/sanitize.js +++ b/lib/sanitize.js @@ -1,19 +1,17 @@ -const DANGEROUS_TAGS = ['script', 'iframe', 'object', 'embed', 'link', 'meta', 'base', 'form']; +import DOMPurify from "isomorphic-dompurify"; export function sanitizeHtml(html) { - if (!html || typeof html !== 'string') return ''; + if (!html || typeof html !== "string") return ""; - let sanitized = html; - - DANGEROUS_TAGS.forEach((tag) => { - const openClose = new RegExp(`<${tag}[\\s\\S]*?(?:<\\/${tag}>|/?>)`, 'gi'); - sanitized = sanitized.replace(openClose, ''); + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: [ + "p", "br", "strong", "em", "b", "i", "u", "ul", "ol", "li", "a", + "h2", "h3", "h4", "h5", "blockquote", "img", "span", "div", + "table", "thead", "tbody", "tr", "td", "th", + ], + ALLOWED_ATTR: ["href", "target", "rel", "src", "alt", "title"], + ALLOW_DATA_ATTR: false, }); - - sanitized = sanitized.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, ''); - sanitized = sanitized.replace(/(?:javascript|vbscript):/gi, ''); - - return sanitized; } export function safeJsonParse(str, fallback = null) { diff --git a/lib/serverToken.js b/lib/serverToken.js new file mode 100644 index 0000000..6f632ce --- /dev/null +++ b/lib/serverToken.js @@ -0,0 +1,19 @@ +import { cookies } from "next/headers"; +import { axiosInstance } from "@/lib/req"; + +export async function getServerAccessToken() { + const cookieStore = await cookies(); + const refreshToken = cookieStore.get("refresh_token")?.value; + if (!refreshToken) return null; + + try { + const res = await axiosInstance.post( + `${process.env.NEXT_PUBLIC_API_URL}/oauth/token/refresh`, + { refresh_token: refreshToken }, + { headers: { "Content-Type": "application/json", Authorization: "" } } + ); + return res.data?.access_token ?? null; + } catch { + return null; + } +} diff --git a/lib/tokenStore.js b/lib/tokenStore.js new file mode 100644 index 0000000..763800a --- /dev/null +++ b/lib/tokenStore.js @@ -0,0 +1,13 @@ +let accessToken = null; + +export function getAccessToken() { + return accessToken; +} + +export function setAccessToken(token) { + accessToken = token || null; +} + +export function clearAccessToken() { + accessToken = null; +} diff --git a/next.config.js b/next.config.js index 461abcb..263efe7 100644 --- a/next.config.js +++ b/next.config.js @@ -40,6 +40,34 @@ const nextConfig = { poweredByHeader: false, // Enable standalone output for Docker output: 'standalone', + async headers() { + const csp = [ + "default-src 'self'", + "img-src 'self' https: data: blob:", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self' data:", + "connect-src 'self' https://api.clinic-pro.ir https://back-dev.clinic-pro.ir", + "frame-ancestors 'none'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join('; '); + + return [ + { + source: '/(.*)', + headers: [ + { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { key: 'Content-Security-Policy', value: csp }, + ], + }, + ]; + }, }; module.exports = nextConfig; diff --git a/package-lock.json b/package-lock.json index 1030427..23a630e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "dayjs": "^1.11.19", "html2canvas": "^1.4.1", "i": "^0.3.7", + "isomorphic-dompurify": "^3.18.0", "jalaali-js": "^1.2.8", "jalali-moment": "^3.3.11", "js-cookie": "^3.0.5", @@ -68,6 +69,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -207,6 +255,18 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@casl/ability": { "version": "6.7.3", "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.3.tgz", @@ -229,6 +289,140 @@ "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz", + "integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", @@ -404,6 +598,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -1978,6 +2189,15 @@ ], "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2368,6 +2588,19 @@ "postcss-value-parser": "^4.0.2" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2510,6 +2743,19 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -2552,6 +2798,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -2620,11 +2872,10 @@ } }, "node_modules/dompurify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", - "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -2649,6 +2900,18 @@ "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -3066,6 +3329,18 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -3283,6 +3558,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -3302,6 +3583,19 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic-dompurify": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.18.0.tgz", + "integrity": "sha512-ajp0D8laIHeoYlhBTevpE2HUhqWaqLXFk6K/wV3Ok8kDraBZpZsifwVWaY8IfJntMRIo1VSksgKV+lXyet9Q7A==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.4.11", + "jsdom": "^29.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + } + }, "node_modules/jalaali-js": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz", @@ -3352,6 +3646,46 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3479,6 +3813,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/maplibre-gl": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.6.2.tgz", @@ -3525,6 +3868,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5886,6 +6235,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6203,6 +6564,15 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -6428,6 +6798,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -6584,6 +6963,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -6988,6 +7379,12 @@ "node": ">= 4.7.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -7113,6 +7510,24 @@ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, + "node_modules/tldts": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz", + "integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.3" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz", + "integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7125,6 +7540,30 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -7149,6 +7588,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/uninstall": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/uninstall/-/uninstall-0.0.0.tgz", @@ -7183,6 +7631,18 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -7192,6 +7652,38 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7228,6 +7720,21 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/package.json b/package.json index 3c31661..17ca12e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dayjs": "^1.11.19", "html2canvas": "^1.4.1", "i": "^0.3.7", + "isomorphic-dompurify": "^3.18.0", "jalaali-js": "^1.2.8", "jalali-moment": "^3.3.11", "js-cookie": "^3.0.5", @@ -35,6 +36,7 @@ "next": "^15.5.7", "next-themes": "^0.4.6", "npm": "^11.7.0", + "postcss": "^8.5.6", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react": "^18.3.1", @@ -49,7 +51,6 @@ "stylis-plugin-rtl": "^2.1.1", "swiper": "^12.0.3", "tailwindcss": "^3.4.19", - "postcss": "^8.5.6", "uninstall": "^0.0.0" }, "devDependencies": { @@ -60,4 +61,4 @@ "react": "^18.3.1", "react-dom": "^18.3.1" } -} \ No newline at end of file +} diff --git a/services/api.js b/services/api.js index 1028394..6c354d0 100644 --- a/services/api.js +++ b/services/api.js @@ -1,6 +1,7 @@ import axios from "axios"; import Cookies from "js-cookie"; import { toast } from "react-toastify"; +import { getAccessToken, setAccessToken, clearAccessToken } from "@/lib/tokenStore"; function extractErrorMessage(error) { const errors = error?.response?.data?.errors; @@ -17,48 +18,77 @@ const BASE_URL = process.env.NEXT_PUBLIC_API_URL; const api = axios.create({ baseURL: BASE_URL, - "X-CSRF-Token": "", headers: { "Content-Type": "application/json", }, }); -// اضافه کردن interceptor برای افزودن token به ریکوست‌هایی که نیاز به احراز هویت دارند +let refreshPromise = null; + +async function refreshAccessToken() { + if (!refreshPromise) { + refreshPromise = fetch("/api/auth/refresh", { method: "POST" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + const token = data?.access_token ?? null; + setAccessToken(token); + return token; + }) + .catch(() => null) + .finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +} + +function handleSessionExpired() { + clearAccessToken(); + const hostname = window.location.hostname; + const domain = hostname.includes("localhost") + ? undefined + : `.${hostname.split(".").slice(-2).join(".")}`; + const opts = { path: "/", ...(domain && { domain }) }; + ["uuid", "userInfo"].forEach((key) => Cookies.remove(key, opts)); + window.location.href = "/login"; +} + api.interceptors.request.use( (config) => { - // فقط اگر requireAuth در config تنظیم شده باشد، token اضافه می‌شود if (config.requireAuth) { - const token = Cookies.get("access_token"); + const token = getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } } return config; }, - (error) => { - return Promise.reject(error); - } + (error) => Promise.reject(error) ); api.interceptors.response.use( - (response) => { - return response.data; - }, - (error) => { - if (typeof window !== "undefined") { - if (error?.response?.status === 401) { - const hostname = window.location.hostname; - const domain = hostname.includes("localhost") - ? undefined - : `.${hostname.split(".").slice(-2).join(".")}`; - const opts = { path: "/", ...(domain && { domain }) }; - ["access_token", "refresh_token", "uuid", "userInfo"].forEach((key) => { - Cookies.remove(key, opts); - }); - window.location.href = "/login"; - } else if (!error?.config?.skipErrorToast) { - toast.error(extractErrorMessage(error)); + (response) => response.data, + async (error) => { + const original = error?.config; + + if ( + error?.response?.status === 401 && + original?.requireAuth && + !original._retried && + typeof window !== "undefined" + ) { + original._retried = true; + const token = await refreshAccessToken(); + if (token) { + original.headers = { ...original.headers, Authorization: `Bearer ${token}` }; + return api(original); } + handleSessionExpired(); + return Promise.reject(error); + } + + if (typeof window !== "undefined" && !error?.config?.skipErrorToast) { + toast.error(extractErrorMessage(error)); } return Promise.reject(error); } diff --git a/services/response.js b/services/response.js index f2c8888..dbe949a 100644 --- a/services/response.js +++ b/services/response.js @@ -11,30 +11,6 @@ export const request = { }, removeTokenHead ), - getToken: (grant_type, client_id, client_secret, uuid, code, scope = "nobat724") => { - const formData = new URLSearchParams(); - formData.append("grant_type", grant_type); - formData.append("client_id", client_id); - formData.append("client_secret", client_secret); - formData.append("uuid", uuid); - formData.append("code", code); - formData.append("scope", scope); - - return api.post("oauth/token", formData, { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: "", - }, - }); - }, - postRefreshToken: (formData) => { - return api.post("oauth/token", formData, { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: "", - }, - }); - }, getUserProfile: (uuid) => api.get(`/api/v1/user-profile/${uuid}`, { requireAuth: true }), postUserProfile: (data) => api.post("api/v1/user-profile", data, { requireAuth: true }), patchUserProfile: (data, uuid) => diff --git a/utils/index.js b/utils/index.js index d56fad2..9b8587d 100644 --- a/utils/index.js +++ b/utils/index.js @@ -1,10 +1,12 @@ import Cookies from "js-cookie"; +import { clearAccessToken } from "@/lib/tokenStore"; const removeToken = () => { - Cookies.remove("access_token", { path: "/" }); - Cookies.remove("refresh_token", { path: "/" }); Cookies.remove("uuid", { path: "/" }); Cookies.remove("userInfo", { path: "/" }); + if (typeof window !== "undefined") { + clearAccessToken(); + } }; export { removeToken };