راه ظریفی که دواحراز اشتباه پیاده می‌شود

احراز دوعاملی شبیه دو چک مستقل به‌نظر می‌رسد: رمز عبور، بعد کد. اشتباه رایج پیاده‌سازی این است که با این دو مثل دو مرحله از یک صدور توکن رفتار کنید — رمز عبور را چک کن، یک JWT نشست واقعی برگردان، بعد فقط مسیرهای خاصی را پشت «آیا این کاربر امروز دواحراز را تکمیل کرده» ببند. این یک جریان تجربه‌ی کاربری است، نه یک مرز امنیتی. اگر وضعیت «در انتظار» در منطق برنامه زندگی کند نه در خود توکن، هر مسیر کدی که فراموش کند این وضعیت را چک کند، به کسی که فقط رمز عبوری را می‌دانسته یک نشست کاملاً احرازشده می‌دهد.

چیزی که WKFGo به‌جایش صادر می‌کند: توکنی که نمی‌شود با نشست اشتباهش گرفت

وقتی چک رمز عبور موفق می‌شود و حساب دواحراز روشن دارد، هندلر ورود یک توکن نشست معمولی صادر نمی‌کند. یک توکن متفاوت و محدودتر صادر می‌کند:

if user.TwoFactorEnabled {
    pending, perr := auth.GeneratePendingToken(user.ID)
    ...
    json.NewEncoder(w).Encode(map[string]interface{}{
        "twoFactorRequired": true,
        "token": pending,
    })
    return
}

بخش حیاتی این نیست که این توکن کوتاه‌عمر است — طراحی‌های ناامن زیادی از توکن‌های کوتاه‌عمر استفاده می‌کنند. این است که خود توکن یک ادعای مرحله درش پخته شده:

claims := Claims{
    UserID: userID,
    Stage:  StageTwoFactor,
    ...
}

و مسیر کدی که هر توکن حامل ورودی را به یک درخواست احرازشده تبدیل می‌کند — ResolveUserID، که هر مسیر محافظت‌شده در اپ استفاده می‌کند — همان مرحله را چک می‌کند و از رفتار با توکن StageTwoFactor مثل یک هویت واقعی خودداری می‌کند. این چک به‌ازای هر مسیر نیست که یک توسعه‌دهنده می‌توانست فراموش کند به یک نقطه‌پایانی جدید اضافه کند؛ در همان یک نقطه‌ی تنگنایی که هر درخواست از آن عبور می‌کند تا «وارد شده» حساب شود اجرا می‌شود. یک توکن در انتظار به‌معنای واقعی کلمه نمی‌تواند داشبورد باز کند، تسک فهرست کند، یا هیچ API احرازشده‌ای را صدا بزند — نه چون آن نقطه‌پایانی‌ها اتفاقاً بررسیش می‌کنند، بلکه چون چیزی که هویت را برای همه‌ی نقطه‌پایانی‌ها تعیین می‌کند صریحاً ردش می‌کند.

تکمیل عامل دوم

کلاینت توکن در انتظار به‌همراه یک کد TOTP (یا یک کد بازیابی) را به یک نقطه‌پایانی واحد و باریک‌محدود می‌فرستد:

r.HandleFunc("/api/2fa/login-verify", h.LoginVerify).Methods("POST").Name("TwoFALoginVerify")

ParsePendingToken امضای JWT را تأیید می‌کند و Stage == StageTwoFactor را قبل از استخراج شناسه‌ی کاربر تأیید می‌کند — پس این نقطه‌پایانی فقط توکن‌هایی را می‌پذیرد که واقعاً به‌عنوان چالش عامل دوم صادر شده‌اند، نه یک توکن نشست که اشتباهی دوباره استفاده شده. فقط بعد از معتبربودن کد TOTP، هندلر GenerateToken معمولی را صدا می‌زند، یک JWT نشست واقعی بدون محدودیت مرحله صادر می‌کند — همان لحظه‌ای که کاربر واقعاً، کاملاً، احراز شده است.

خود اعتبارسنجی TOTP

چک کد کمی لغزش ساعت را تحمل می‌کند — گام زمانی سی‌ثانیه‌ای فعلی و یکی بلافاصله قبل یا بعد را می‌پذیرد:

for _, delta := range []int64{0, -1, 1} {
    want, _ := code(secret, uint64(int64(counter)+delta))
    if subtle.ConstantTimeCompare([]byte(want), []byte(input)) == 1 {
        return true
    }
}

دو چیز اینجا فراتر از تحمل لغزش اهمیت دارد. این یک مقایسه‌ی زمان‌ثابت است (subtle.ConstantTimeCompare)، نه یک == ساده، که اهمیت دارد چون مقایسه‌ی رشته‌ی ساده‌لوحانه اطلاعات زمان‌بندی درباره‌ی تعداد کاراکترهای ابتداییِ منطبق نشت می‌دهد — یک کانال جانبی واقعی، هرچند باریک، برای حدس‌زدن کد درست سریع‌تر از حدس تصادفیِ محض. و کدهای بازیابی همان رفتار را می‌گیرند: ذخیره‌شده به‌صورت هش SHA-256، منطبق‌شده با همان مقایسه‌ی زمان‌ثابت، هرگز به‌صورت متن‌ساده ذخیره یا مقایسه نمی‌شوند.

اشتباهات رایج

ذخیره‌ی «دواحراز تأیید شد» به‌صورت یک پرچم بولی که به‌ازای هر مسیر چک می‌شود. این همان جریان بالاست، اشتباه انجام‌شده — درست کار می‌کند تا وقتی یک نقطه‌پایانی جدید چک را فراموش کند، و بعد واقعاً هیچ‌چیزی را اجرا نمی‌کند.

طولانی‌عمرکردن توکن در انتظار. توکن در انتظار یک پنجره‌ی باریک اعتماد است («این رمز عبور لحظه‌ای پیش درست بود») — هرچه بیشتر عمر کند، طولانی‌تر یک توکن در انتظارِ نشت‌کرده یا رهگیری‌شده برای مهاجمی که هنوز عامل دوم را ندارد مفید است، اما نباید تعداد نامحدود تلاش برای پیداکردنش داشته باشد.

استفاده از مقایسه‌ی غیرزمان‌ثابت برای کد یا کدهای بازیابی. این چیزی است که راحت نادیده گرفته می‌شود چون یک == ساده در هر تست عملکردی «کار می‌کند» — آسیب‌پذیری یک کانال جانبیِ زمان‌بندی است، نه یک باگ درستی، پس تا وقتی کسی مشخصاً دنبالش نگردد ظاهر نمی‌شود.

سؤالات متداول

اگر اپ احرازکننده‌ام را گم کنم چه؟

کدهای بازیابی — یک‌بار هنگام ثبت‌نام دواحراز ساخته‌شده، دقیقاً یک‌بار نشان‌داده‌شده — اجازه می‌دهند /api/2fa/login-verify را بدون کد TOTP تکمیل کنید. هرکدام یک‌بارمصرف و در برابر هش ذخیره‌شده‌اش منطبق می‌شود.

آیا توکن در انتظار بعد از یک تلاش ناموفق کد قابل‌استفاده‌ی مجدد است؟

بله، تا وقتی منقضی شود — اجازه دارید با همان چالش دوباره امتحان کنید به‌جای شروع دوباره از مرحله‌ی رمز عبور، اما مدت کوتاه توکن پنجره‌ی باز را محدود نگه می‌دارد.

آیا دواحراز چیزی درباره‌ی احراز کلید API یا MCP تغییر می‌دهد؟

نه — کلیدهای API شخصی (wk_…) و احراز MCP یک مسیر اعتبار کاملاً جدا هستند؛ دواحراز مشخصاً ورود تعاملی با رمز عبور را مسدود می‌کند، نه دسترسی برنامه‌نویسی‌شده‌ای که از قبل با یک کلید محدود شده.

جمع‌بندی

احراز دوعاملیِ واقعی یعنی سیستم ساختاری قادر نیست بدون عامل دوم یک نشست کامل از فقط رمز عبور صادر کند — نه «یک نشست صادر می‌کند و امیدوار است هر مسیر یادش بماند یک پرچم چک کند.» پختن این محدودیت در ادعاهای خود توکن، تأییدشده در همان یک نقطه‌ای که همه‌ی درخواست‌ها احراز می‌شوند، یک دسته‌ی کامل از باگ‌های «فراموش کردن اضافه‌کردن چک در این مسیر جدید» را حذف می‌کند.