إدارة جلسات المستخدمين

جلسات "Firebase Authentication" طويلة الأمد. في كل مرة يسجّل فيها المستخدم الدخول، يتم إرسال بيانات اعتماده إلى الخلفية في Firebase Authentication واستبدالها برمز مميز لتعريف Firebase (رمز JWT) ورمز مميز لإعادة التحميل. تكون الرموز المميّزة لتعريف Firebase قصيرة الأجل وتستمر لمدة ساعة، ويمكن استخدام الرمز المميّز لإعادة التحميل لاسترداد رموز مميّزة جديدة للتعريف. تنتهي صلاحية الرموز المميزة لإعادة التحميل فقط في الحالات التالية:

  • تم حذف المستخدم
  • المستخدم غير مفعَّل
  • تم رصد تغيير كبير في حساب المستخدم. ويشمل ذلك أحداثًا مثل تعديلات كلمة المرور أو عنوان البريد الإلكتروني.

تتيح حزمة تطوير البرامج (SDK) لمشرف Firebase إمكانية إبطال الرموز المميزة لإعادة التحميل لمستخدم محدّد. بالإضافة إلى ذلك، تتوفّر أيضًا واجهة برمجة تطبيقات للتحقّق من إبطال الرمز المميّز للمعرّف. تتيح لك هذه الإمكانات التحكّم بشكل أكبر في جلسات المستخدمين. تتيح حزمة تطوير البرامج (SDK) إضافة قيود لمنع استخدام الجلسات في ظروف مريبة، كما توفّر آلية للاسترداد في حال سرقة الرموز المميّزة.

إبطال الرموز المميّزة لإعادة التحميل

يمكنك إبطال الرمز المميز لإعادة التحميل الحالي الخاص بالمستخدم عندما يبلّغ المستخدم عن فقدان جهاز أو سرقته. وبالمثل، إذا اكتشفت ثغرة أمنية عامة أو اشتبهت في حدوث تسرُّب واسع النطاق للرموز المميّزة النشطة، يمكنك استخدام واجهة برمجة التطبيقات listUsers للبحث عن جميع المستخدمين وإبطال رموزهم المميّزة للمشروع المحدّد.

تؤدي عمليات إعادة ضبط كلمة المرور أيضًا إلى إبطال الرموز المميزة الحالية للمستخدم، ولكن تعالج الخلفية في Firebase Authentication عملية الإبطال تلقائيًا في هذه الحالة. عند الإبطال، يتم تسجيل خروج المستخدم ويُطلب منه إعادة المصادقة.

في ما يلي مثال على عملية تنفيذ تستخدم Admin SDK لإبطال الرمز المميز لإعادة التحميل لمستخدم معيّن. لإعداد حزمة تطوير البرامج (SDK) للمشرف، يُرجى اتّباع التعليمات الواردة في صفحة الإعداد.

Node.js

// Revoke all refresh tokens for a specified user for whatever reason.
// Retrieve the timestamp of the revocation, in seconds since the epoch.
getAuth()
  .revokeRefreshTokens(uid)
  .then(() => {
    return getAuth().getUser(uid);
  })
  .then((userRecord) => {
    return new Date(userRecord.tokensValidAfterTime).getTime() / 1000;
  })
  .then((timestamp) => {
    console.log(`Tokens revoked at: ${timestamp}`);
  });

Java

FirebaseAuth.getInstance().revokeRefreshTokens(uid);
UserRecord user = FirebaseAuth.getInstance().getUser(uid);
// Convert to seconds as the auth_time in the token claims is in seconds too.
long revocationSecond = user.getTokensValidAfterTimestamp() / 1000;
System.out.println("Tokens revoked at: " + revocationSecond);

Python

# Revoke tokens on the backend.
auth.revoke_refresh_tokens(uid)
user = auth.get_user(uid)
# Convert to seconds as the auth_time in the token claims is in seconds.
revocation_second = user.tokens_valid_after_timestamp / 1000
print(f'Tokens revoked at: {revocation_second}')

متابعة

client, err := app.Auth(ctx)
if err != nil {
	log.Fatalf("error getting Auth client: %v\n", err)
}
if err := client.RevokeRefreshTokens(ctx, uid); err != nil {
	log.Fatalf("error revoking tokens for user: %v, %v\n", uid, err)
}
// accessing the user's TokenValidAfter
u, err := client.GetUser(ctx, uid)
if err != nil {
	log.Fatalf("error getting user %s: %v\n", uid, err)
}
timestamp := u.TokensValidAfterMillis / 1000
log.Printf("the refresh tokens were revoked at: %d (UTC seconds) ", timestamp)

#C

await FirebaseAuth.DefaultInstance.RevokeRefreshTokensAsync(uid);
var user = await FirebaseAuth.DefaultInstance.GetUserAsync(uid);
Console.WriteLine("Tokens revoked at: " + user.TokensValidAfterTimestamp);

رصد إبطال الرمز المميّز للتعريف

بما أنّ رموز Firebase المميّزة للتعريف هي رموز JWT عديمة الحالة، يمكنك تحديد ما إذا تم إبطال رمز مميّز فقط من خلال طلب حالة الرمز المميّز من الخلفية Firebase Authentication. لهذا السبب، فإنّ إجراء هذا الفحص على خادمك هو عملية مكلفة تتطلّب رحلة ذهاب وعودة إضافية عبر الشبكة. يمكنك تجنُّب إجراء طلب الشبكة هذا من خلال إعداد Firebase Security Rules للتحقّق من الإبطال بدلاً من استخدام Admin SDK لإجراء عملية التحقّق.

رصد إبطال الرمز المميّز للمعرّف في Firebase Security Rules

لكي نتمكّن من رصد إبطال رمز التعريف باستخدام "قواعد الأمان"، علينا أولاً تخزين بعض البيانات الوصفية الخاصة بالمستخدم.

تعديل البيانات الوصفية الخاصة بالمستخدم في Firebase Realtime Database

احفظ الطابع الزمني لإبطال الرمز المميّز لإعادة التحميل. هذا الإجراء مطلوب لتتبُّع إبطال الرمز المميّز للمعرّف من خلال Firebase Security Rules. يتيح ذلك إجراء عمليات تحقّق فعّالة داخل قاعدة البيانات. في عيّنات التعليمات البرمجية أدناه، استخدِم معرّف المستخدم ووقت الإبطال اللذين حصلت عليهما في القسم السابق.

Node.js

const metadataRef = getDatabase().ref('metadata/' + uid);
metadataRef.set({ revokeTime: utcRevocationTimeSecs }).then(() => {
  console.log('Database updated successfully.');
});

Java

DatabaseReference ref = FirebaseDatabase.getInstance().getReference("metadata/" + uid);
Map<String, Object> userData = new HashMap<>();
userData.put("revokeTime", revocationSecond);
ref.setValueAsync(userData);

Python

metadata_ref = firebase_admin.db.reference("metadata/" + uid)
metadata_ref.set({'revokeTime': revocation_second})

إضافة علامة اختيار إلى Firebase Security Rules

لفرض هذا التحقّق، يجب إعداد قاعدة لا تمنح العميل إذن الكتابة لتخزين وقت الإبطال لكل مستخدم. ويمكن تعديل ذلك باستخدام الطابع الزمني للتوقيت العالمي المتفق عليه لآخر مرة تم فيها الإبطال كما هو موضّح في الأمثلة السابقة:

{
  "rules": {
    "metadata": {
      "$user_id": {
        // this could be false as it is only accessed from backend or rules.
        ".read": "$user_id === auth.uid",
        ".write": "false",
      }
    }
  }
}

يجب ضبط القاعدة التالية لأي بيانات تتطلب وصولاً مصادقًا عليه. يسمح هذا المنطق فقط للمستخدمين الذين تمت مصادقتهم باستخدام رموز مميزة لمعرّفات غير ملغاة بالوصول إلى البيانات المحمية:

{
  "rules": {
    "users": {
      "$user_id": {
        ".read": "auth != null && $user_id === auth.uid && (
            !root.child('metadata').child(auth.uid).child('revokeTime').exists()
          || auth.token.auth_time > root.child('metadata').child(auth.uid).child('revokeTime').val()
        )",
        ".write": "auth != null && $user_id === auth.uid && (
            !root.child('metadata').child(auth.uid).child('revokeTime').exists()
          || auth.token.auth_time > root.child('metadata').child(auth.uid).child('revokeTime').val()
        )",
      }
    }
  }
}

رصد إبطال رمز التعريف المميّز في حزمة تطوير البرامج (SDK)

في الخادم، نفِّذ المنطق التالي لإبطال الرمز المميز لإعادة التحميل والتحقّق من صحة الرمز المميز للمعرّف:

عند التحقّق من رمز تعريف المستخدم، يجب تمرير علامة checkRevoked المنطقية الإضافية إلى verifyIdToken. إذا تم إبطال الرمز المميّز الخاص بالمستخدم، يجب تسجيل خروج المستخدم من البرنامج أو مطالبته بإعادة المصادقة باستخدام واجهات برمجة التطبيقات لإعادة المصادقة التي توفّرها حِزم تطوير البرامج (SDK) لبرنامج Firebase Authentication.

لإعداد حزمة SDK للمشرف في نظامك الأساسي، اتّبِع التعليمات الواردة في صفحة الإعداد. تتوفّر أمثلة على استرداد الرمز المميّز للتعريف في قسم verifyIdToken.

Node.js

// Verify the ID token while checking if the token is revoked by passing
// checkRevoked true.
let checkRevoked = true;
getAuth()
  .verifyIdToken(idToken, checkRevoked)
  .then((payload) => {
    // Token is valid.
  })
  .catch((error) => {
    if (error.code == 'auth/id-token-revoked') {
      // Token has been revoked. Inform the user to reauthenticate or signOut() the user.
    } else {
      // Token is invalid.
    }
  });

Java

try {
  // Verify the ID token while checking if the token is revoked by passing checkRevoked
  // as true.
  boolean checkRevoked = true;
  FirebaseToken decodedToken = FirebaseAuth.getInstance()
      .verifyIdToken(idToken, checkRevoked);
  // Token is valid and not revoked.
  String uid = decodedToken.getUid();
} catch (FirebaseAuthException e) {
  if (e.getAuthErrorCode() == AuthErrorCode.REVOKED_ID_TOKEN) {
    // Token has been revoked. Inform the user to re-authenticate or signOut() the user.
  } else {
    // Token is invalid.
  }
}

Python

try:
    # Verify the ID token while checking if the token is revoked by
    # passing check_revoked=True.
    decoded_token = auth.verify_id_token(id_token, check_revoked=True)
    # Token is valid and not revoked.
    uid = decoded_token['uid']
except auth.RevokedIdTokenError:
    # Token revoked, inform the user to reauthenticate or signOut().
    pass
except auth.UserDisabledError:
    # Token belongs to a disabled user record.
    pass
except auth.InvalidIdTokenError:
    # Token is invalid
    pass

متابعة

client, err := app.Auth(ctx)
if err != nil {
	log.Fatalf("error getting Auth client: %v\n", err)
}
token, err := client.VerifyIDTokenAndCheckRevoked(ctx, idToken)
if err != nil {
	if err.Error() == "ID token has been revoked" {
		// Token is revoked. Inform the user to reauthenticate or signOut() the user.
	} else {
		// Token is invalid
	}
}
log.Printf("Verified ID token: %v\n", token)

#C

try
{
    // Verify the ID token while checking if the token is revoked by passing checkRevoked
    // as true.
    bool checkRevoked = true;
    var decodedToken = await FirebaseAuth.DefaultInstance.VerifyIdTokenAsync(
        idToken, checkRevoked);
    // Token is valid and not revoked.
    string uid = decodedToken.Uid;
}
catch (FirebaseAuthException ex)
{
    if (ex.AuthErrorCode == AuthErrorCode.RevokedIdToken)
    {
        // Token has been revoked. Inform the user to re-authenticate or signOut() the user.
    }
    else
    {
        // Token is invalid.
    }
}

الاستجابة لإبطال الرمز المميّز على العميل

إذا تم إبطال الرمز المميّز من خلال مدير SDK، يتم إبلاغ العميل بالإبطال ومن المتوقّع أن يعيد المستخدم المصادقة أو يتم تسجيل خروجه:

function onIdTokenRevocation() {
  // For an email/password user. Prompt the user for the password again.
  let password = prompt('Please provide your password for reauthentication');
  let credential = firebase.auth.EmailAuthProvider.credential(
      firebase.auth().currentUser.email, password);
  firebase.auth().currentUser.reauthenticateWithCredential(credential)
    .then(result => {
      // User successfully reauthenticated. New ID tokens should be valid.
    })
    .catch(error => {
      // An error occurred.
    });
}

الأمان المتقدّم: فرض قيود على عناوين IP

من آليات الأمان الشائعة لرصد سرقة الرموز المميّزة تتبُّع مصادر عناوين IP للطلبات. على سبيل المثال، إذا كانت الطلبات تأتي دائمًا من عنوان IP نفسه (الخادم الذي يُجري الطلب)، يمكن فرض جلسات عنوان IP واحد. أو يمكنك إبطال الرمز المميّز للمستخدم إذا اكتشفت أنّ الموقع الجغرافي لعنوان IP الخاص بالمستخدم قد تغيّر فجأة أو إذا تلقّيت طلبًا من مصدر مريب.

لإجراء عمليات فحص الأمان استنادًا إلى عنوان IP، يجب فحص رمز التعريف لكل طلب تمّت المصادقة عليه والتحقّق مما إذا كان عنوان IP الخاص بالطلب يتطابق مع عناوين IP الموثوق بها السابقة أو يقع ضمن نطاق موثوق به قبل السماح بالوصول إلى البيانات المحظورة. على سبيل المثال:

app.post('/getRestrictedData', (req, res) => {
  // Get the ID token passed.
  const idToken = req.body.idToken;
  // Verify the ID token, check if revoked and decode its payload.
  admin.auth().verifyIdToken(idToken, true).then((claims) => {
    // Get the user's previous IP addresses, previously saved.
    return getPreviousUserIpAddresses(claims.sub);
  }).then(previousIpAddresses => {
    // Get the request IP address.
    const requestIpAddress = req.connection.remoteAddress;
    // Check if the request IP address origin is suspicious relative to previous
    // IP addresses. The current request timestamp and the auth_time of the ID
    // token can provide additional signals of abuse especially if the IP address
    // suddenly changed. If there was a sudden location change in a
    // short period of time, then it will give stronger signals of possible abuse.
    if (!isValidIpAddress(previousIpAddresses, requestIpAddress)) {
      // Invalid IP address, take action quickly and revoke all user's refresh tokens.
      revokeUserTokens(claims.uid).then(() => {
        res.status(401).send({error: 'Unauthorized access. Please login again!'});
      }, error => {
        res.status(401).send({error: 'Unauthorized access. Please login again!'});
      });
    } else {
      // Access is valid. Try to return data.
      getData(claims).then(data => {
        res.end(JSON.stringify(data);
      }, error => {
        res.status(500).send({ error: 'Server error!' })
      });
    }
  });
});