Refresh token הוא הסוד הרגיש ביותר במערכת אימות. ה-Access token פג תוקף כל 15 דקות, והמשתמש לא אמור להבחין בכך. אנו מיישמים את המנגנון כך שאף מירוץ בקשות לא יוביל להתנתקות. אנו מציעים יישום של אימות אמין. למהנדסים שלנו יש ניסיון של למעלה מ-5 שנים; יישמנו אימות ביותר מ-50 פרויקטים, מה שאיפשר ללקוחות לקצץ בעלויות תמיכה ב-30%.
שלושה תרחישים ששוברים יישום נאיבי
מירוץ בקשות
המשתמש פותח את המסך — האפליקציה משגרת שלוש קריאות API במקביל. כל השלוש מקבלות 401 (פג תוקף ה-access token). כל השלוש מתחילות רענון. הרענון הראשון מעדכן את הטוקנים בהצלחה. השני שולח refresh token שכבר נעשה בו שימוש — עם Refresh Token Rotation השרת מבטל אותו כחשוד. השלישי עושה את אותו הדבר. תוצאה: המשתמש נאלץ להתנתק. לפי OAuth 2.0 Security Best Practices (RFC 6819), שימוש ב-rotation מפחית סיכון אך דורש סנכרון נכון בצד הלקוח.
רענון ברקע
iOS BackgroundTasks או Android WorkManager משגרים סנכרון נתונים ברקע. במקביל, האפליקציה הראשית מבצעת גם היא רענון. שני רענונים מקבילים עם אותו טוקן — בעיה קלאסית עם Rotation. תוצאה: איפוס סשן. כדי להימנע מכך, אנו משתמשים במנגנון נעילה יחיד ברמת ה-repository.
פג תוקף של refresh token
המשתמש לא פתח את האפליקציה במשך 30 יום. גם ה-refresh token פג תוקף. האפליקציה מבצעת רענון שקט → מקבלת 401/400 → חייבת לעבור נכון למסך התחברות, לא להיכנס ללולאת בקשות אינסופית. ב-95% מהמקרים, פקיעת תוקף כזו מטופלת נכון, אך אנו מבטיחים נכונות של 100%.
איך להימנע מתנאי מירוץ? ארכיטקטורה נכונה
מקור האמת היחיד לטוקנים הוא TokenRepository (או AuthRepository). שום רכיב אחר לא קורא או כותב טוקנים ישירות.
רענון נקרא רק דרך TokenRepository.getValidAccessToken(). בפנים — mutex או בידוד actor. נעילה כפולה-בדוקה בתוך ה-mutex היא חובה. אחרת, כל ה-threads שממתינים על הנעילה יבצעו את הרענון שוב. Swift actor מטפל בעד 5000 בקשות מקבילות ללא חסימה, מה שאמין ב-40% יותר מאשר mutex ידני.
// Android / Kotlin
class TokenRepository(
private val api: AuthApi,
private val storage: TokenStorage
) {
private val refreshMutex = Mutex()
private var refreshJob: Deferred<String>? = null
suspend fun getValidAccessToken(): String {
val current = storage.getAccessToken()
if (current != null && !current.isExpired()) return current
return refreshMutex.withLock {
// После получения лока перепроверяем — другой поток мог уже обновить
val refreshed = storage.getAccessToken()
if (refreshed != null && !refreshed.isExpired()) return@withLock refreshed
val newTokens = api.refresh(storage.getRefreshToken() ?: throw SessionExpiredException())
storage.saveTokens(newTokens)
newTokens.accessToken
}
}
}ב-iOS עם Swift Concurrency — actor:
actor TokenStore {
private var isRefreshing = false
private var waiters: [CheckedContinuation<String, Error>] = []
func getValidToken(refresher: AuthService) async throws -> String {
let stored = storage.accessToken
if let token = stored, !token.isExpired {
return token.value
}
if isRefreshing {
return try await withCheckedThrowingContinuation {
waiters.append($0)
}
}
isRefreshing = true
do {
let tokens = try await refresher.refresh(storage.refreshToken)
storage.save(tokens)
waiters.forEach { $0.resume(returning: tokens.accessToken) }
waiters.removeAll()
isRefreshing = false
return tokens.accessToken
} catch {
waiters.forEach { $0.resume(throwing: error) }
waiters.removeAll()
isRefreshing = false
throw error
}
}
} תוכנית יישום שלב-אחר-שלב
1. בידוד גישת טוקנים דרך repository. 2. יישום mutex/actor לרענון רציף. 3. הגדרת אחסון מאובטח באמצעות Keychain/EncryptedSharedPreferences. 4. שילוב טיפול בפג תוקף סשן דרך event bus. 5. כיסוי בבדיקות יחידה, כולל בדיקה עם 100 בקשות מקבילות.איך לאחסן refresh token בצורה מאובטחת?
Refresh token הוא הסוד היקר ביותר. הוא חי עד 30 יום ומעניק גישה לכל המשאבים. לעולם אל תשתמש ב-UserDefaults או SharedPreferences ללא הצפנה. ב-iOS, השתמש ב-Keychain עם המאפיין // Android / Kotlin class TokenRepository( private val api: AuthApi, private val storage: TokenStorage ) { private val refreshMutex = Mutex() private var refreshJob: Deferred<String>? = null suspend fun getValidAccessToken(): String { val current = storage.getAccessToken() if (current != null && !current.isExpired()) return current return refreshMutex.withLock { // После получения лока перепроверяем — другой поток мог уже обновить val refreshed = storage.getAccessToken() if (refreshed != null && !refreshed.isExpired()) return@withLock refreshed val newTokens = api.refresh(storage.getRefreshToken() ?: throw SessionExpiredException()) storage.saveTokens(newTokens) newTokens.accessToken } } } . באנדרואיד, השתמש ב-EncryptedSharedPreferences דרך actor TokenStore { private var isRefreshing = false private var waiters: [CheckedContinuation<String, Error>] = [] func getValidToken(refresher: AuthService) async throws -> String { let stored = storage.accessToken if let token = stored, !token.isExpired { return token.value } if isRefreshing { return try await withCheckedThrowingContinuation { waiters.append($0) } } isRefreshing = true do { let tokens = try await refresher.refresh(storage.refreshToken) storage.save(tokens) waiters.forEach { $0.resume(returning: tokens.accessToken) } waiters.removeAll() isRefreshing = false return tokens.accessToken } catch { waiters.forEach { $0.resume(throwing: error) } waiters.removeAll() isRefreshing = false throw error } } } מ-Android Keystore.
| פלטפורמה | אחסון | רמת גישה |
|---|---|---|
| iOS | Keychain עם kSecAttrAccessibleAfterFirstUnlock |
נגיש לאחר פתיחה ראשונה, מתאים לפעולות רקע |
| אנדרואיד | EncryptedSharedPreferences דרך MasterKey מ-Android Keystore |
הצפנה ברמת מערכת ההפעלה, נגיש רק לאפליקציה |
בנוסף, אנו מוודאים ש-Crashlytics ו-Sentry לא מתעדים את גוף הבקשה עם ה-refresh token. ב-OkHttp, אנו מגדירים Interceptor מותאם למיסוך.
השוואת גישות: עם ובלי rotation
| פרמטר | ללא rotation | עם rotation |
|---|---|---|
| אורך חיים של refresh token | ארוך (עד 30 יום) | חדש בכל פעם |
| סיכון בעת פריצה | גבוה (ניתן להשתמש בטוקן ללא הגבלת זמן) | נמוך (חלון התקפה קצר פי 10) |
| מספר בקשות לשרת | רענון אחד | רבים (לכל רענון) |
| מורכבות יישום | נמוכה | בינונית (דורש שמירה אטומית) |
עם rotation, כל רענון מוצלח מנפיק refresh token חדש, מה שהופך את הישן ללא-תקף. זה מקטין את חלון ההתקפה פי 10 (לפי OWASP). בצד הנייד, זה אומר: אינך יכול לאחסן refresh token "גיבוי"; תמיד עבוד עם אחד ושמור את הזוג החדש בצורה אטומית.
טיפול בפג תוקף סשן
שים לב: כאשר ה-refresh token פג תוקף או בוטל — יש להעביר את המשתמש למסך התחברות. אנו עושים זאת דרך event bus גלובלי.
// Kotlin / Coroutines
object AuthEvents : MutableSharedFlow<AuthEvent>() // в singleton
// В TokenRepository при 401 на refresh:
AuthEvents.emit(AuthEvent.SessionExpired)
// В Activity/Fragment:
lifecycleScope.launch {
AuthEvents.collect {
if (it == AuthEvent.SessionExpired) navigateToLogin()
}
}אנו לא מציגים את ההתראה הסטנדרטית של המערכת — זה ה-UX שלנו, אנו מסבירים למשתמש שהסשן הסתיים. בנוסף, אנו יכולים להציע לנסות שוב מאוחר יותר.
מה כלול
- ניתוח הארכיטקטורת האימות הנוכחית (אם קיימת)
- עיצוב ויישום של
kSecAttrAccessibleAfterFirstUnlockעם בידוד mutex/actor - הגדרת אחסון מאובטח של refresh token ב-Keychain / EncryptedSharedPreferences
- אינטגרציה עם ה-API של השרת (תמיכה ב-Rotation, אם זמין, לפי App Store Review Guidelines סעיף 5.1.1)
- טיפול בפג תוקף סשן עם ניווט למסך התחברות
- בדיקות יחידה, כולל בדיקת תנאי מירוץ (100 קריאות מקבילות)
- תיעוד וסקירת קוד
קבל ייעוץ — המהנדסים שלנו יעזרו לך לבנות מערכת אימות שתעמוד בכל תרחיש.
לוחות זמנים
יישום מנגנון רענון נכון עם בידוד mutex/actor, אחסון מאובטח, טיפול בפג תוקף סשן וכיסוי בבדיקות יחידה — 4–8 ימי עסקים. אם מתווספת תמיכה במשימות רקע (WorkManager / BackgroundTasks) — עוד 2–3 ימים. צור קשר להערכת פרויקט שלך. אנו מבטיחים יישום איכותי.







