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

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

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

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

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

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

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

في ما يلي مثال على عملية تنفيذ تستخدم "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('Tokens revoked at: {0}'.format(revocation_second))

Go

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. لهذا السبب، يُعد إجراء هذا الفحص على الخادم عملية مكلفة، ويتطلب رحلة ذهاب وعودة إضافية للشبكة. يمكنك تجنب إرسال طلب الشبكة هذا من خلال إعداد قواعد أمان Firebase التي تتحقق من الإبطال بدلاً من استخدام SDK للمشرف لإجراء الفحص.

رصد إبطال الرمز المميّز للمعرّف في قواعد أمان Firebase

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

عدِّل البيانات الوصفية الخاصة بالمستخدم في "قاعدة بيانات Firebase في الوقت الفعلي".

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

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

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

{
  "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.

لإعداد "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

Go

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!' })
      });
    }
  });
});