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

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

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

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

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

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

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

في ما يلي مثال على عملية تنفيذ تستخدِم "مجموعة تطوير البرامج (SDK) للمشرف" لإبطال رمز إعادة التحديث لمستخدم معيّن. لإعداد حزمة Admin 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}`);
  });

جافا

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))

انتقال

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 التي تبحث عن الإبطال بدلاً من استخدام "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.');
});

جافا

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.
    }
  });

جافا

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.
    }
}

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

في حال إبطال الرمز المميّز من خلال حزمة Admin 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!' })
      });
    }
  });
});