نیاز داشتم چند وبسایت مستقل طوری رفتار کنند که انگار یک سامانهٔ حساب کاربری واحد هستند، بدون اینکه جداسازی معمول مرورگر تضعیف شود. کاربر میتوانست در یک دامنه وارد شود، بعداً دامنهٔ دیگری را مستقیماً باز کند، روی ورود بزند و بدون وارد کردن دوبارهٔ اطلاعات ورود، کار را همانجا ادامه دهد.
محدودیت مهم این بود که اینها دامنههای ریشهٔ مستقل بودند. کوکی نشستی که یک دامنهٔ ریشه ایجاد کرده است را نمیتوان بهسادگی برای یک دامنهٔ ریشهٔ نامرتبط نیز معتبر کرد. وقتی دو مفهومی را که اغلب با هم مخلوط میشوند از هم جدا کردم، استدلال دربارهٔ طراحی بسیار سادهتر شد: هویت سراسری و نشست محلی مرورگر.
مدل حاصل ساده است: بکاند یک هویت حساب مشترک را میشناسد، هر دامنه نشست وابسته به میزبان خودش را دارد، و یک واگذاری کوتاهعمر و یکبارمصرف به یک مبدأ مورداعتماد و احرازشده اجازه میدهد ایجاد نشست جدیدی را در یک مبدأ مورداعتماد دیگر مجاز کند.
هویت سراسری به کوکی مشترک نیاز ندارد
حتی وقتی نشستهای مرورگر محلیاند، حساب میتواند سراسری باشد.
هر سایت یک کوکی نشست مختص میزبان نگه میدارد. کوکی صادرشده توسط یک دامنهٔ ریشه را نمیتوان با ویژگی Domain به یک دامنهٔ ریشهٔ نامرتبط گسترش داد. این ویژگی فقط میتواند دامنهٔ کوکی را در همان سلسلهمراتب دامنهای گسترش دهد که میزبان صادرکننده مجاز به استفاده از آن است.
برای نشستی که باید به یک میزبان محدود بماند، کوکی با پیشوند __Host- انتخاب مناسبی است. از نظر مفهومی:
Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=Laxویژگیهای مهم، همین مرزها هستند:
- سایت A نمیتواند کوکی نشست مختص میزبان سایت B را بخواند.
- سایت B نمیتواند کوکی نشست مختص میزبان سایت A را بخواند.
- با این حال، هر دو نشست میتوانند در بکاند به همان هویت مشترک برسند.
بنابراین SSO بین دامنهای نباید تلاش کند کوکی را از یک سایت به سایت دیگر منتقل کند. باید یک اثبات موقت منتقل کند که نشان میدهد یک منبع مورداعتماد قبلاً کاربر را احراز هویت کرده است.
الگوی واگذاری یکبارمصرف
منبع احرازشده یک عملیات کوچک برای واگذاری ارائه میکند. مرورگر فقط زمانی آن را فراخوانی میکند که انتقال احراز هویت بین سایتها لازم باشد.
بکاند یک تیکت تصادفیِ رمزنگاریشده تولید میکند، مقدار خام را فقط یک بار به مرورگر میدهد، و فقط هش آن را همراه با حداقل اطلاعات لازم برای اعتبارسنجی انتقال ذخیره میکند:
{
tokenHash,
userId,
sourceOrigin,
targetSite,
returnPath,
expiresAt
}تیکت باید کوتاهعمر باشد و فقط برای یک مقصد اعتبار داشته باشد. درخواست صدور در سمت منبع نیز باید از قبل احرازشده و در برابر CSRF محافظتشده باشد. مقصد درخواستی و محل بازگشت باید پیش از صدور هر چیزی اعتبارسنجی شوند.
سپس مرورگر یک POST سطحبالا به مقصد میفرستد. مقصد تیکت را مصرف میکند، هویت مشترک کاربر را به دست میآورد، نشست محلی خودش را میسازد و مرورگر را به مسیر موردنظر تغییرمسیر میدهد.
از نظر مفهومی:
record = consumeOnce({
tokenHash: hash(ticket),
targetSite: currentSite,
sourceOrigin: requestOrigin,
notExpired: true
})
if (!record) deny()
user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)تیکت، نشست مقصد نیست. فقط یک مجوز یکبارمصرف برای ایجاد همان نشست مقصد است.
مصرف اتمی پنجرهٔ استفادهٔ مجدد را میبندد
در یک پیادهسازی ضعیفتر ممکن است تیکت بارگذاری شود، اعتبارسنجی شود، نشست ساخته شود و بعد از آن تیکت حذف شود. این کار پنجرهای ایجاد میکند که در آن دو درخواست میتوانند همان رکوردِ هنوز معتبر را مشاهده کنند.
طراحی امنتر این است که اعتبارسنجی و بیاعتبارسازی در یک عملیات مصرف اتمی ترکیب شوند. اگر هیچ رکوردی منطبق نباشد، احراز هویت شکست میخورد. اگر رکوردی برگردد، آن تیکت پیش از ایجاد نشست از مجموعهٔ قابلاستفاده حذف شده است.
این کار همچنین اجازه میدهد مقصد چند شرط را به همان عملیات گره بزند: مقصد موردانتظار، مبدأ منبع موردانتظار، هش توکن و زمان انقضا.
اعتبارسنجی مبدأ باید هنگام خطا به رد درخواست منتهی شود
گره زدن تیکت به مبدأ یک ویژگی مفید دیگر اضافه میکند: تیکتی که از طریق یک منبع مورداعتماد صادر شده است نباید از مبدأیی نامرتبط قابل مصرف باشد.
بررسی مبدأ باید بخشی از خود شرط مصرف باشد. یعنی درخواست از مبدأ اشتباه، تیکت معتبر را مصرف نمیکند و منبع مشروع همچنان میتواند بعداً واگذاری را کامل کند.
یک نکتهٔ مهم وجود دارد: تضمینی نیست که هدر Origin در همهٔ زمینههای مرورگر شامل یک رشتهٔ مبدأ عادی باشد. بعضی درخواستها ممکن است بدون مقدار قابلاستفاده برسند و زمینههای مبهم میتوانند Origin: null تولید کنند.
قاعدهٔ من این است که Origin مفقود یا مبهم را شکستِ بررسی مبدأ در نظر بگیرم، مگر اینکه برنامه یک سازوکار جایگزین و صریح برای اعتبارسنجی داشته باشد. مقدار تحتاللفظی null نباید صرفاً برای سازگاری به یک مبدأ مورداعتماد تبدیل شود.
این یک انتخاب مبتنی بر رد در حالت خطاست. اگر یک جریان مشروع باید زمینهای بدون مبدأ قابلاستفاده را پشتیبانی کند، این استثنا باید صریح طراحی شود، نه اینکه بررسی عمومی بیسروصدا ضعیف شود.
اعتبار واگذاری را از URL دور نگه دارید
من ترجیح میدهم تیکت موقت را در یک POST فرمِ سطحبالا بفرستم، نه اینکه آن را در رشتهٔ پرسوجو قرار دهم.
یک نسخهٔ عمومی در سمت کاربر چنین است:
const form = document.createElement('form');
form.method = 'POST';
form.action = targetOrigin + '/auth/consume';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'ticket';
input.value = ticket;
form.append(input);
document.body.append(form);
form.submit();مقصد تیکت را از بدنهٔ POST موردانتظار میپذیرد و تلاشهای معادل برای ارائهٔ آن از طریق URL را رد میکند.
این کار اعتبار را بیخطر نمیکند، اما آن را از URLهای معمول ناوبری دور نگه میدارد و احتمال افشای ناخواسته از طریق تاریخچهٔ URL، لینکهای کپیشده، ارجاعدهندهها و لاگهای مبتنی بر URL را کاهش میدهد.
لینکهای بینسایتیِ احرازشده میتوانند واگذاری را خودکار انجام دهند
اگر سایت فعلی از قبل بداند که کاربر احراز هویت شده است، یک لینک عادی به سایت مورداعتماد دیگری میتواند در صورت نیاز واگذاری را انجام دهد.
لینک همچنان باید یک href واقعی داشته باشد. SSO تقویت ناوبری عادی است، نه جایگزین آن.
یک جریان سادهشده در سمت کاربر چنین است:
if (session.status !== 'authenticated') {
return; // ordinary browser navigation
}
if (!isTrustedTarget(destination)) {
return;
}
event.preventDefault();
const ticket = await requestHandoff({
targetSite,
returnPath: destinationPath
});
postTicketToTarget(ticket);نسخهٔ عملیاتی باید علاوه بر این در برابر ارسالهای تکراری محافظت کند، مقصد را با یک فهرست مورداعتماد تطبیق دهد، و اگر واگذاری قابل ایجاد نبود به ناوبری عادی برگردد.
رفتار بومی مرورگر باید بومی باقی بماند. کلیک با دکمهٔ میانی، کلیک با کلیدهای اصلاحگر، دانلودها و لینکهایی که برای زمینهٔ مرور دیگری در نظر گرفته شدهاند نباید بیسروصدا به درخواستهای احراز هویت تبدیل شوند.
دکمهٔ ورود در سایت دوم به یک صادرکنندهٔ شناختهشده نیاز دارد
حالت سختتر وقتی شروع میشود که کاربر سایت B را مستقیماً باز میکند. سایت B نشست محلی ندارد، پس بهدرستی بازدیدکننده را ناشناس در نظر میگیرد، حتی اگر مرورگر هنوز نشست معتبری در سایت A داشته باشد.
سایت B نمیتواند کوکی مختص میزبان سایت A را بررسی کند. همچنین نمیتواند به یک سایت C دلخواه تغییرمسیر دهد و انتظار داشته باشد آن سایت نشستهای متعلق به بقیهٔ سامانه را کشف کند. هر دامنهٔ ریشهٔ نامرتبط فقط میتواند کوکیهای متعلق به خودش را ببیند.
بنابراین جریان ورود برای کاربر ناشناس به یک صادرکنندهٔ شناختهشده نیاز دارد: یا یک مبدأ احراز هویت مرکزی، یا یک سایت مورداعتماد مشخص که انتظار میرود نشست ورود قابلاستفادهٔ مجدد را نگه دارد.
برای حالت سادهٔ انتقال از سایت A به سایت B، توالی چنین است:
- کاربر در سایت A وارد میشود.
- بعداً کاربر سایت B را مستقیماً باز میکند.
- سایت B نشست ندارد، پس ورود را نشان میدهد.
- کاربر روی ورود کلیک میکند.
- مرورگر یک ناوبری سطحبالا به سایت A انجام میدهد که صادرکنندهٔ انتخابشده است.
- سایت A میتواند کوکی نشست مختص میزبان خودش را بخواند.
- چون آن نشست هنوز معتبر است، سایت A فوراً یک واگذاری یکبارمصرف برای سایت B صادر میکند.
- مرورگر تیکت را با POST به سایت B میفرستد.
- سایت B نشست محلی خودش را ایجاد میکند و کاربر را به صفحهٔ درخواستی برمیگرداند.
از دید کاربر، این فقط یک تغییرمسیر سریع به سایت A و بازگشت است. نیازی به رمز عبور نیست، چون سایت A از قبل همان نشستی را دارد که اجازهٔ مشاهدهاش را دارد.
محدودیت نیز به همان اندازه مهم است: اگر کاربر فقط در سایت C وارد شده باشد، تغییرمسیر سایت B به سایت A کمکی نمیکند، مگر اینکه سایت A یک واسط مرکزی واقعی برای احراز هویت باشد که مستقل از بقیه از وضعیت ورود آگاه است. پایگاه دادهٔ مشترک کاربران باعث مشترک شدن نشستهای مرورگر نمیشود.
چرا یک صادرکنندهٔ مرکزی سامانه را سادهتر میکند
با افزایش تعداد سایتهای مستقل، یک صادرکنندهٔ مرکزی نیاز به کشف نشست در دامنههای دلخواه را از بین میبرد.
بدون آن، سایت B که کاربر را ناشناس میبیند باید بهنحوی تشخیص دهد کدام سایت دیگر شاید در حال حاضر کوکی مفیدی داشته باشد. سایت A نمیتواند کوکی سایت C را ببیند، سایت C نمیتواند کوکی سایت A را ببیند، و سایت B نیز هیچکدام را نمیتواند ببیند.
یک صادرکنندهٔ مرکزی برای هر تلاش ورود ناشناس یک مسیر قابلپیشبینی فراهم میکند:
Site B
→ authentication issuer
→ issuer session exists?
yes → create one-time handoff
no → show normal login
→ Site B consumes handoff
→ Site B creates local sessionصادرکننده لازم نیست بقیهٔ برنامه را سرویس دهد. مسئولیت مرتبط آن این است که مالک نشست احراز هویت قابلاستفادهٔ مجدد باشد و واگذاریهایی با دامنهٔ بسیار محدود برای سایتهای مورداعتماد صادر کند.
در یک سامانهٔ کوچکتر، یکی از سایتهای موجود میتواند این نقش را بر عهده بگیرد. در یک معماری گستردهتر، مبدأ اختصاصی احراز هویت میتواند مدل اعتماد را قابلفهمتر کند.
هر بازدیدکننده را از صادرکننده عبور ندهید
جریان صادرکننده نباید برای هر مشاهدهٔ ناشناس صفحه بهصورت خودکار اجرا شود.
تغییرمسیر هر بازدیدکننده به دامنهای دیگر فقط برای بررسی وجود نشست فعلی، ناوبری بیهوده، تأخیر بیشتر، حالتهای شکست بیشتر، تحلیلهای پرنویزتر و رفتار ذخیرهسازی موقت پیچیدهتری ایجاد میکند.
این رویکرد برای صفحات عمومی و حساس به SEO نیز مناسب نیست. خزنده یا بازدیدکنندهٔ عادی که یک URL عمومی را درخواست میکند باید همان صفحهٔ عمومی را مستقیماً دریافت کند، نه اینکه ابتدا مجبور شود از یک مسیر انحرافی احراز هویت عبور کند.
قاعدهای که من استفاده میکنم این است:
هنگام بارگذاری صفحه صادرکننده را بررسی نکنید. احراز هویت بین دامنهای را فقط پس از اقدام صریح برای ورود یا یک ناوبری عمدی بین سایتها از یک نشست احرازشده آغاز کنید.
این کار ترافیک عمومی را ساده نگه میدارد و در عین حال احراز هویت را برای کاربری که از قبل در صادرکنندهٔ انتخابشده نشست معتبری دارد سریع میکند.
اعتبارسنجی مسیر بازگشت بیشتر از یک بررسی پیشوند دقت میخواهد
واگذاری معمولاً باید به خاطر بسپارد مرورگر بعد از آن باید کجا فرود بیاید. اگر این مقدار بهعنوان URL دلخواه و تحت کنترل کاربر در نظر گرفته شود، میتواند به تغییرمسیر باز تبدیل شود.
هرجا ممکن باشد، امنترین طراحی این است که اصلاً URL کامل مقصد پذیرفته نشود. یک شناسهٔ کوتاه یا نگاشت سمت سرور برای مقصدهای شناختهشده، مقدار تجزیه و اعتبارسنجی URL موردنیاز را کاهش میدهد.
اگر مجبورید یک مسیر نسبی بپذیرید، آن را با دقت اعتبارسنجی کنید و به سایت مقصد محدود نگه دارید.
یک نمونه که عمداً ساده شده است — و روال کامل اعتبارسنجی تغییرمسیر نیست — چنین است:
function isSafeReturnPath(value) {
if (!value.startsWith('/')) return false;
if (value.startsWith('//')) return false;
if (value.includes('\\\\')) return false;
const base = 'https://example.invalid';
const parsed = new URL(value, base);
return parsed.origin === base;
}یک پیادهسازی واقعی همچنین باید رفتار بازگشایی، ورودی بدساخت، نویسههای کنترلی، نرمالسازی، قواعد مسیریابی برنامه و هر تبدیلی را که پروکسی یا فریمورک پیش از تولید تغییرمسیر اعمال میکند در نظر بگیرد.
اصل امنیتی از جزئیات تجزیهگر سادهتر است: واگذاری میتواند یک محل مجاز در برنامهٔ مقصد را انتخاب کند، نه یک مقصد خارجی دلخواه را.
حالتهای شکست مهمتر از مسیر موفقاند
ورود موفق فقط ثابت میکند مسیر پایه کار میکند. آزمونهای مفیدتر، مرزهای اعتماد را میسنجند.
من رفتار را در مواردی مانند اینها بررسی کردم:
- تلاش یک منبع احرازنشده برای صدور واگذاری؛
- نبود محافظت CSRF؛
- مبدأ مفقود، مبهم یا غیرمنتظره؛
- مقصد غیرمورداعتماد؛
- مقصد بازگشت ناامن؛
- ارائهٔ تیکت به مقصد اشتباه؛
- تلاش برای مصرف از مبدأ اشتباه؛
- استفادهٔ مجدد از تیکت؛
- انقضای تیکت؛
- کاربری که دیگر وجود ندارد؛
- ارائهٔ تیکت از طریق URL بهجای بدنهٔ POST موردانتظار.
دو اصل ثابت برای آزمون مستقیم بسیار مفیدند: یک درخواست ردشده نباید بهطور تصادفی تیکتی را که هنوز برای جریان مشروع معتبر است از بین ببرد، و یک مصرف موفق باید همان تیکت را بلافاصله غیرقابلاستفاده کند.
پروتکل بدون یکپارچهسازیهای دوتایی مقیاس میگیرد
با اضافه شدن سایتهای بیشتر، طراحی واگذاری اساساً تغییر نمیکند.
هر سایت مشارکتکننده به همان قرارداد کوچک نیاز دارد:
- یک شناسهٔ منطقی پایدار؛
- یک مبدأ عمومی مورداعتماد؛
- دسترسی به هویت مشترک حساب؛
- عملیات صدور واگذاری برای منابع احرازشده؛
- عملیات مصرف واگذاری؛
- فهرست سایتهای مورداعتماد؛
- نشست محلی وابسته به میزبان؛
- سیاست امن برای مقصد بازگشت؛
- و یک صادرکنندهٔ شناختهشده برای جریانهای ناشناس ورود.
انتخاب مهم برای مقیاسپذیری، پرهیز از منطق احراز هویت سفارشی برای هر جفت سایت است. سایتی که از قبل احراز شده است میتواند همان نوع واگذاری را برای سایت مورداعتماد دیگری صادر کند. سایت ناشناس نیز میتواند مرورگر را به صادرکنندهٔ شناختهشده بفرستد.
به این ترتیب سامانه بر یک پروتکل استوار میماند، نه بر ماتریسی در حال رشد از حالتهای خاص.
قابلانتقال بودن ورود از انتشار خروج جداست
نشستهای مستقل وابسته به میزبان یک تمایز مفید دیگر ایجاد میکنند: قابلانتقال کردن ورود، بهطور خودکار خروج سراسری را تعریف نمیکند.
اگر کاربر از سایت A خارج شود، نشست محلی سایت دیگری ممکن است همچنان معتبر بماند، مگر اینکه بکاند عمداً آن را لغو کند. این یک سیاست مدیریت نشست است، نه شکست واگذاری SSO.
سامانه میتواند بین خروج محلی، خروج سراسری یا مدیریت صریح نشست انتخاب کند. جدا نگه داشتن این تصمیم از واگذاری، استدلال دربارهٔ هر دو سازوکار را سادهتر میکند.
مدلی که من استفاده میکنم
وقتی دیگر مسئله را «اشتراکگذاری کوکیهای ورود بین دامنهها» توصیف نکردم، معماری سادهتر شد.
یک هویت سراسری وجود دارد، هر دامنه مالک نشست مرورگر خودش است، و یک واگذاری کوتاهعمر و یکبارمصرف به یک مبدأ مورداعتماد و احرازشده اجازه میدهد ایجاد نشست محلی دیگری را مجاز کند.
برای ورود مستقیم به سایتی که کاربر را ناشناس میبیند، یک قاعدهٔ دیگر مهم است: مرورگر باید به صادرکنندهای فرستاده شود که واقعاً بتواند نشست موجود کاربر را ببیند. اگر کاربر قبلاً در آن صادرکننده وارد شده باشد، رفتوبرگشت میتواند تقریباً نامحسوس باشد. اگر صادرکننده نشست معتبری نداشته باشد، باید به احراز هویت عادی برگردد، نه اینکه وانمود کند نشست یک دامنهٔ نامرتبط دیگر قابل مشاهده است.
این مدل مرزهای دامنهای مرورگر را حفظ میکند، از تغییرمسیرهای غیرضروری احراز هویت برای بازدیدکنندگان عمومی جلوگیری میکند و همچنان تجربهٔ موردنظر را میدهد: در صادرکننده وارد شوید، بعداً سایت مورداعتماد دیگری را باز کنید، روی ورود بزنید و بدون وارد کردن دوبارهٔ رمز عبور با یک نشست محلی جدید برگردید.