بناء تطبيقات آمنة: مصادقة المستخدمين وحفظ البيانات باستخدام Firebase
عند الشروع في بناء أي تطبيق، تواجه المطورين مجموعة كبيرة من الاعتبارات، والتي غالبًا ما تركز على الجزء الخاص بالعميل (client-side) من المشروع. ولكن عندما تبدأ في التفكير في الخادم (server) لتطبيقك، يمكن أن تصبح الأمور معقدة للغاية. إحدى الطرق الفعالة لتخفيف هذا الضغط هي استخدام منصة Firebase، وبالتحديد ميزتين أساسيتين:
- مصادقة المستخدمين باستخدام
Firebase Auth. - تخزين البيانات باستخدام
Realtime Database.
في هذا المقال، ستتعلم:
- كيفية بناء تطبيق أندرويد بلغة
Kotlinيقوم بمصادقة المستخدمين عبرFirebase Auth. - كيفية استخدام مكتبة
Retrofit2لإجراء طلبات إلى الخادم الخاص بك. - كيفية بناء خادم باستخدام
Node.jsمع إطار عملExpress، والذي سيتلقى الطلبات من تطبيقك ويجلب البيانات من قاعدة بياناتRealtime DatabaseفيFirebase.
قد تبدو هذه المهمة بسيطة للوهلة الأولى، لكنها ليست كذلك. هناك الكثير من الإعدادات التي يجب إجراؤها، وعلينا التعامل مع العديد من التكوينات المختلفة. ومع ذلك، سأوضح لك بعض الأخطاء الشائعة التي ستساعدك على توفير الوقت وتجنب الإحباط. ثق بي، سترغب في التعلم من أخطائي. إذا كنت ترغب في تخطي الشروحات التفصيلية، يمكنك التوجه إلى نهاية المقال للاطلاع على الكود المصدري الكامل عبر الروابط المرفقة.
لنبدأ!
إعداد مشروعك: الواجهة الأمامية والخلفية
سيتألف تطبيقنا من واجهة أمامية (front end) وواجهة خلفية (back end). من منظور الواجهة الأمامية، سيكون هناك صفحة لتسجيل الدخول/الاشتراك، وصفحة أخرى لجلب/إرسال بيانات عشوائية إلى قاعدة بياناتنا. سنستخدم هنا Firebase Authentication للتحقق من صحة المستخدمين المسجلين.
خيارات مصادقة المستخدمين في Firebase
هناك عدة طرق لمصادقة المستخدمين عبر Firebase:
- البريد الإلكتروني وكلمة المرور (
Email & Password). - حسابات
Google/Facebook/Twitter/Github(وهو ما يسمى بـFederated Identity Provider Identification). - رقم الهاتف.
- مصادقة مخصصة (
Custom authorization). - مصادقة مجهولة (
Anonymous authorization).
في تطبيقنا، سنستخدم خيار البريد الإلكتروني وكلمة المرور (Email & Password)، لأنه النهج الأكثر وضوحًا والأكثر شيوعًا في معظم الحالات. ستتم هذه المصادقة في جانب العميل (client)، ولن تكون هناك حاجة لأي اتصال بالواجهة الخلفية (back end) لهذه المهمة.
لإجراء طلبات إلى خادمنا، سنستخدم مكتبة Retrofit2 عن طريق إرسال طلبات GET. في هذه الطلبات، سنرسل البيانات التي تحتاج إلى التحديث جنبًا إلى جنب مع رمز مميز (token) (المزيد عن الرمز المميز في قسم الخادم).
دور الواجهة الخلفية وقاعدة البيانات
من جانب الواجهة الخلفية، يتولى خادمنا مسؤولية قبول الطلبات من المستخدمين باستخدام تطبيقنا، سواء لجلب أو حفظ أو حذف البيانات (عمليات CRUD). لتمكين المستخدمين المصادق عليهم من الوصول إلى قاعدة البيانات، سنحتاج إلى استخدام Firebase Admin SDK. سيوفر لنا هذا الإطار وصولاً إلى واجهة برمجة تطبيقات (API) للتحقق من صحة المستخدمين المصادق عليهم وتمرير الطلبات إلى قاعدة بياناتنا. سنقوم بحفظ بيانات المستخدمين باستخدام Firebase Realtime Database. بعد الانتهاء من جميع الإعدادات في الواجهة الخلفية، سنقوم بنشرها عبر Heroku.

بناء واجهة المستخدم (UI) للعميل
بعد فتح مشروع Kotlin جديد، نحتاج إلى استيراد بعض التبعيات (dependencies). أولاً وقبل كل شيء، يجب عليك إضافة Firebase إلى مشروعك. اتبع الخطوات الموضحة هنا للقيام بذلك. بمجرد الانتهاء، أضف التبعية التالية إلى ملف build.gradle الخاص بتطبيقك:
implementation 'com.google.firebase:firebase-auth:19.4.0'
عندما يفتح المستخدمون التطبيق، يمكنهم إما تسجيل الدخول (login) أو الاشتراك (signup) إذا كانت هذه هي المرة الأولى لهم. بما أننا اتفقنا على أن المستخدمين سيتم التحقق من صحة بياناتهم بناءً على مزيج من بريدهم الإلكتروني وكلمة المرور، فسنقوم بإنشاء نشاط (activity) بسيط يحتوي على حقلي إدخال نص (EditTexts) لهذا الغرض تحديدًا. سيكون لدينا أيضًا زرين للإشارة إلى خيار الاشتراك أو تسجيل الدخول.
<?xml version="1.0" encoding="utf-8"?>
< androidx.constraintlayout.widget.ConstraintLayout xmlns:android = "http://schemas.android.com/apk/res/android"
xmlns:app = "http://schemas.android.com/apk/res-auto"
xmlns:tools = "http://schemas.android.com/tools"
android:orientation = "vertical"
android:layout_width = "match_parent"
android:layout_height = "match_parent" >
< EditText android:id = "@+id/email_edit_text"
android:layout_width = "wrap_content"
android:layout_height = "wrap_content"
android:ems = "10"
android:hint = "Enter your email"
android:inputType = "textEmailAddress"
app:layout_constraintBottom_toBottomOf = "parent"
app:layout_constraintEnd_toEndOf = "parent"
app:layout_constraintStart_toStartOf = "parent"
app:layout_constraintTop_toTopOf = "parent"
app:layout_constraintVertical_bias = "0.153" />
< EditText android:id = "@+id/password_edit_text"
android:layout_width = "wrap_content"
android:layout_height = "wrap_content"
android:ems = "10"
android:hint = "Enter your password"
android:inputType = "textPassword"
app:layout_constraintBottom_toBottomOf = "parent"
app:layout_constraintEnd_toEndOf = "parent"
app:layout_constraintStart_toStartOf = "parent"
app:layout_constraintTop_toBottomOf = "@+id/email_edit_text"
app:layout_constraintVertical_bias = "0.046" />
< Button android:id = "@+id/Login"
android:layout_width = "wrap_content"
android:layout_height = "wrap_content"
android:text = "Login"
android:background = "#39e600"
android:onClick = "loginUser"
app:layout_constraintBottom_toBottomOf = "parent"
app:layout_constraintEnd_toEndOf = "parent"
app:layout_constraintHorizontal_bias = "0.139"
app:layout_constraintStart_toStartOf = "parent"
app:layout_constraintTop_toBottomOf = "@+id/password_edit_text"
app:layout_constraintVertical_bias = "0.146" />
< Button android:id = "@+id/Signup"
android:layout_width = "wrap_content"
android:layout_height = "wrap_content"
android:text = "Signup"
android:background = "#4d94ff"
android:onClick = "signupUser"
app:layout_constraintBottom_toBottomOf = "parent"
app:layout_constraintEnd_toEndOf = "parent"
app:layout_constraintHorizontal_bias = "0.647"
app:layout_constraintStart_toEndOf = "@+id/Login"
app:layout_constraintTop_toBottomOf = "@+id/password_edit_text"
app:layout_constraintVertical_bias = "0.146" />
</ androidx.constraintlayout.widget.ConstraintLayout >
package com.tomerpacific.todo.activities
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.tasks.OnCompleteListener
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.UserProfileChangeRequest
import com.tomerpacific.todo.R
class LoginActivity : AppCompatActivity () {
private var userEmail : String = ""
private var userPassword: String = ""
override fun onCreate (savedInstanceState: Bundle ?) {
super .onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
// START 1 ----------------------
// findViewById<EditText>(R.id.email_edit_text).apply {
// setOnEditorActionListener {_, actionId, keyEvent ->
// if (actionId == EditorInfo.IME_ACTION_SEARCH || actionId == EditorInfo.IME_ACTION_DONE || keyEvent == null || keyEvent.keyCode == KeyEvent.KEYCODE_ENTER) {
// userEmail = text.toString()
// }
// false
// }
// setOnFocusChangeListener {view, gainedFoucs ->
// userEmail = text.toString()
// }
// }
// findViewById<EditText>(R.id.password_edit_text).apply {
// setOnEditorActionListener {_, actionId, keyEvent ->
// if (actionId == EditorInfo.IME_ACTION_SEARCH || actionId == EditorInfo.IME_ACTION_DONE || keyEvent == null || keyEvent.keyCode == KeyEvent.KEYCODE_ENTER) {
// userPassword = text.toString()
// }
// false
// }
// setOnFocusChangeListener {view, gainedFoucs ->
// userPassword = text.toString()
// }
// }
// END 1 ----------------------------------------
// }
}
override fun onStart () {
super .onStart()
FirebaseAuth.getInstance().currentUser?.let {
Intent( this @LoginActivity , MainActivity:: class .java).apply {
startActivity(this)
}
}
}
// START 2 -----------------------
// fun loginUser (view : View ) {
// if (userEmail.isEmpty() || userPassword.isEmpty()) {
// Toast.makeText( this , "Please make sure to fill in your email and password" , Toast.LENGTH_SHORT).show()
// return
// }
// FirebaseAuth.getInstance().signInWithEmailAndPassword(userEmail, userPassword)
// .addOnCompleteListener( this ) { task ->
// if (task.isSuccessful) {
// updateFirebaseUserDisplayName()
// } else {
// Toast.makeText( this , "An error has occurred during login. Please try again later." , Toast.LENGTH_SHORT).show()
// }
// }
// }
// END 2 -----------------------------
//
// START 3 ---------------------------
// fun signupUser (view: View ) {
// if (userEmail.isEmpty() || userPassword.isEmpty()) {
// Toast.makeText( this , "Please make sure to fill in your email and password" , Toast.LENGTH_SHORT).show()
// return
// }
// FirebaseAuth.getInstance().createUserWithEmailAndPassword(userEmail, userPassword)
// .addOnCompleteListener( this ) { task ->
// if (task.isSuccessful) {
// updateFirebaseUserDisplayName()
// } else {
// Toast.makeText( this , "An error has occurred during signup. Please try again later." , Toast.LENGTH_SHORT).show()
// }
// }
// }
private fun updateFirebaseUserDisplayName () {
FirebaseAuth.getInstance().currentUser?.apply {
val profileUpdates : UserProfileChangeRequest = UserProfileChangeRequest.Builder().setDisplayName(userEmail).build()
updateProfile(profileUpdates)?.addOnCompleteListener(OnCompleteListener {
when (it.isSuccessful) {
true -> apply {
Intent( this @LoginActivity , MainActivity:: class .java).apply {
startActivity(this)
finish()
}
}
false -> Toast.makeText(this@LoginActivity, "Login has failed" , Toast.LENGTH_SHORT).show()
}
})
}
}
// END 3 -------------------------------------
}
دعنا نلقي نظرة على ما يحدث في الكود أعلاه. نقوم بإرفاق مستمعين (listeners) لحقول إدخال النص (EditTexts) لتحديد متى فقدت التركيز أو عندما ضغط المستخدم على زر الإنجاز (done button). تقوم الدالة loginUser بمسؤولية مصادقة المستخدم بناءً على بيانات اعتماده السابقة (باستخدام واجهة برمجة التطبيقات signInWithEmailAndPassword). بينما تستخدم الدالة signupUser واجهة برمجة التطبيقات createUserWithEmailAndPassword. يمكنك ملاحظة أننا تجاوزنا طريقة دورة الحياة (lifecycle method) onStart لتحديد متى يعود المستخدم إلى التطبيق وتحديث واجهة المستخدم (UI) بشكل مناسب إذا كان المستخدم مسجل الدخول بالفعل.
عند تشغيل تطبيقنا، سنرى هذا:

هذا كان الجزء السهل. قبل الانتقال إلى كتابة المنطق للتواصل مع الواجهة الخلفية، دعنا نبني الواجهة الخلفية أولاً.

إعداد الخادم باستخدام Node.js و Express
سنستخدم إطار عمل Express عند بناء خادمنا. أدناه تجد قالبًا لهذا الخادم والذي يضيف أيضًا رؤوس (headers) لتجاوز أي مشكلات في سياسة أصل الموارد المشتركة (CORS issues) قد نواجهها:
const express = require('express')
var bodyParser = require('body-parser')
const app = express()
var port = process.env.PORT || 3000
app.use(bodyParser.urlencoded())
app.use(function(req, res, next) {
res.setHeader('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Methods', 'GET, OPTIONS')
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.header('Access-Control-Allow-Credentials', true)
return next()
});
app.listen(port, () => console.log(`Example app listening at http://localhost:${port}`))
دمج Firebase مع خادم Node.js
على غرار ما فعلناه في جانب العميل، نحتاج أيضًا إلى إضافة Firebase إلى خادم Node.js الخاص بنا. إذا تذكرت الخطوات التي اتخذتها لإنشاء مشروع في Firebase واخترت مشروع أندرويد، فستحتاج إلى إضافة تطبيق آخر إلى هذا المشروع سيمثل خادمنا.
بالنقر على “Add App” (إضافة تطبيق) في الشاشة الرئيسية لوحدة تحكم Firebase:

ستُعرض عليك منصة للاختيار منها:

يجب عليك اختيار خيار الويب (الذي يحمل أيقونة </>). بعد إجراء التكوين الأولي داخل وحدة تحكم Firebase، ستحتاج إلى إضافة كائن التكوين (configuration object) إلى مشروعك:
var firebaseConfig = {
apiKey: "API_KEY",
authDomain: "PROJECT_ID.firebaseapp.com",
databaseURL: "https://PROJECT_ID.firebaseio.com",
projectId: "PROJECT_ID",
storageBucket: "PROJECT_ID.appspot.com",
messagingSenderId: "SENDER_ID",
appId: "APP_ID",
measurementId: "G-MEASUREMENT_ID",
};
سنضع هذه التكوينات في ملفنا الرئيسي (app.js):
const express = require('express')
var bodyParser = require('body-parser')
const app = express()
var port = process.env.PORT || 3000
<--- FIREBASE CONFIG --->
var firebaseConfig = {
apiKey: "API_KEY",
authDomain: "PROJECT_ID.firebaseapp.com",
databaseURL: "https://PROJECT_ID.firebaseio.com",
projectId: "PROJECT_ID",
storageBucket: "PROJECT_ID.appspot.com",
messagingSenderId: "SENDER_ID",
appId: "APP_ID",
measurementId: "G-MEASUREMENT_ID",
};
// Initialize Firebase
firebase.initializeApp(firebaseConfig);
<---- END FIREBASE CONFIG --->
app.use(bodyParser.urlencoded())
app.use(function(req, res, next) {
res.setHeader('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Methods', 'GET, OPTIONS')
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.header('Access-Control-Allow-Credentials', true)
return next()
});
app.listen(port, () => console.log(`Example app listening at http://localhost:${port}`))
قد تتساءل: “أنا أحفظ كل هذه المعلومات السرية في العميل. ستكون مرئية للجميع!”. هذا صحيح تمامًا، ولكن في حالة Firebase، لا بأس بذلك.
تكوين قاعدة البيانات في Firebase Realtime Database
نحن نقترب، ولكن لا يزال لدينا المزيد من التكوينات لإجرائها. هذه المرة تتعلق بقاعدة بياناتنا في الوقت الفعلي (Realtime Database). توجه إلى وحدة تحكم Firebase واختر المشروع الذي أنشأته سابقًا في هذا المقال. في القائمة اليسرى، سترى خيار Realtime Database. انقر عليه.

بعد ذلك، على الجانب الأيمن، ستظهر نافذة تحتوي على علامات التبويب التالية:

تحت علامة التبويب Data، ستجد عنوان URL لقاعدة بياناتك. تذكره لأننا سنحتاج إلى استخدامه لاحقًا. علامة التبويب الأخرى المهمة التي يجب النظر إليها هي علامة التبويب Rules. تحدد هذه القواعد من لديه حق الوصول إلى قاعدة بياناتك وما يمكنه فعله هناك. في البداية (ولأغراض الاختبار)، تكون القواعد هناك متساهلة جدًا وتسمح لأي شخص بالقراءة والكتابة من قاعدة بياناتك. قبل أن تجعل تطبيقك مباشرًا، تأكد من تحديث هذه القواعد بشيء أكثر تقييدًا. لا تقلق، سترى مثالاً على ذلك.
إعداد Firebase Admin SDK
بعد ذلك، نحتاج إلى إعداد Firebase Admin SDK. بما أننا قمنا بالفعل بإعداد الأشياء الضرورية في وحدة تحكم Firebase، نحتاج إلى تثبيت حزمة firebase-admin.
npm install firebase-admin --save
الآن نحتاج إلى إنشاء مفتاح خاص (private key) لأن مشروعنا هو حساب خدمة (service account). داخل وحدة تحكم Firebase Console، اتبع هذه الخطوات:
أولاً، بجوار “Project Overview” (نظرة عامة على المشروع)، يوجد رمز ترس. انقر عليه واختر “Project Settings” (إعدادات المشروع):

ثم انقر على علامة التبويب “Service Accounts” (حسابات الخدمة)، وانقر على زر “Create Service Account” (إنشاء حساب خدمة).

اختر Node.js كمقتطف التكوين (configuration snippet)، وانقر على “Generate new private key” (إنشاء مفتاح خاص جديد). ضع هذا الملف داخل مشروعك وقم بتغيير المسار إليه في مقتطف الكود المقدم من Firebase.
⚠️ هام: تأكد من استبعاد هذا الملف في ملف .gitignore الخاص بك وعدم تحميله أبدًا إلى أي مستودع عام.
بجمع كل ذلك معًا، سيبدو ملف app.js الخاص بنا كما يلي:
const express = require('express')
var bodyParser = require('body-parser')
const app = express()
var port = process.env.PORT || 3000
<--- FIREBASE CONFIG --->
var firebaseConfig = {
apiKey: "API_KEY",
authDomain: "PROJECT_ID.firebaseapp.com",
databaseURL: "https://PROJECT_ID.firebaseio.com",
projectId: "PROJECT_ID",
storageBucket: "PROJECT_ID.appspot.com",
messagingSenderId: "SENDER_ID",
appId: "APP_ID",
measurementId: "G-MEASUREMENT_ID",
};
// Initialize Firebase
firebase.initializeApp(firebaseConfig);
<---- END FIREBASE CONFIG --->
const serviceAccount = require("PATH_TO_YOUR_SERVICE_ACCOUNT_FILE.json");
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
databaseURL: "URL_TO_DATABASE"
});
app.use(bodyParser.urlencoded())
app.use(function(req, res, next) {
res.setHeader('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Methods', 'GET, OPTIONS')
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.header('Access-Control-Allow-Credentials', true)
return next()
});
app.listen(port, () => console.log(`app listening at http://localhost:${port}`))
تذكر عنوان URL لقاعدة البيانات الذي ذكرته سابقًا؟ ستحتاج إلى إدراجه داخل الكائن الذي تمرره إلى طريقة initializeApp الخاصة بـ Firebase admin.
إنشاء نقطة نهاية (Endpoint) ونشر الخادم
لقد كان هناك الكثير من الإعدادات! الآن، خادمنا قادر على العمل، لكنه لن يفعل أي شيء لأنه لا توجد نقطة نهاية (endpoint) مكونة. لإصلاح هذا الوضع، دعنا نحدد إحدى نقاط النهاية لدينا:
app.get('/getData', function (req, res) {
if (req.headers.authtoken) {
admin.auth().verifyIdToken(req.headers.authtoken)
.then(() => {
var database = admin.database()
var uid = req.query.uid
database.ref('/users/' + uid).once('value')
.then(function(snapshot) {
var data = snapshot.val() ? snapshot.val() : []
res.status(200).send({ our_data: data})
}).catch(function(error) {
res.status(500).json({ error: error})
})
}).catch(() => {
res.status(403).send('Unauthorized')
})
} else {
res.status(403).send('Unauthorized')
}
})
نقطة النهاية لدينا تسمى getData، ويمكنك أن ترى أنه قبل القيام بأي منطق آخر، نقوم باستخراج الرمز المميز للمصادقة (authtoken) المرسل والتحقق منه باستخدام Firebase admin. إذا سارت الأمور بشكل صحيح، ننتقل إلى الحصول على معرف المستخدم (user's ID) واستخدامه لجلب بيانات المستخدم من قاعدة البيانات.
إجراء الطلبات من جانب العميل باستخدام Retrofit2
كما ذكرت سابقًا، سنستخدم مكتبة Retrofit2 لإجراء طلباتنا إلى الخادم. لن أخوض في تفاصيل حول كيفية استخدام Retrofit2 هنا (هناك الكثير من المقالات التي تفعل ذلك)، لذا ستجد أدناه التنفيذ القياسي لإجراء طلبات الشبكة باستخدام Retrofit2.
fun fetchDataFromDB() {
val user = FirebaseAuth.getInstance().currentUser
if (user != null) {
user.getIdToken(false).addOnCompleteListener{
if (it.isSuccessful) {
val token = it.result?.token
val retrofit = Retrofit.Builder()
.baseUrl(TodoConstants.BASE_URL_FOR_REQUEST)
.addConverterFactory(GsonConverterFactory.create())
.build()
val service = retrofit.create(DataService::class.java)
val call = service.getData(token, getUserUUID())
call.enqueue(object: Callback<Result> {
override fun onResponse(call: Call<Result>, response: Response<Result>) {
if (response.isSuccessful) {
val body = response.body() as Result
//Here we have the data sent back from the server
}
}
override fun onFailure(call: Call<Result>, t: Throwable) {
// Handle failure
}
})
}
}
}
}
لاحظ أنه بعد الحصول على كائن FirebaseUser، نستخدم طريقة getIdToken لاستخراج الرمز المميز (token) الذي سيتم إرساله إلى الخادم. بنفس الطريقة، يمكننا إنشاء طلب GET آخر لتعيين البيانات في قاعدة بياناتنا.
وهذا كل شيء! شكرًا لمتابعتك. يستند هذا المقال إلى تجربتي الشخصية عند بناء تطبيقي الخاص. يمكنك التحقق منه أدناه (الكود المصدري متاح أيضًا):
الخلاصة التقنية
يُعد Firebase حلاً قويًا ومتكاملًا لمصادقة المستخدمين وإدارة البيانات، خاصة للمطورين الذين يسعون لتقليل تعقيدات الواجهة الخلفية. من خلال Firebase Auth وRealtime Database، يمكن بناء تطبيقات آمنة وسريعة الاستجابة بكفاءة. استخدام Firebase Admin SDK على الخادم يضمن التحقق الآمن من هوية المستخدمين، بينما توفر مكتبات مثل Retrofit2 في جانب العميل واجهة سلسة للتفاعل مع الخادم. هذه المنصة لا توفر الوقت والجهد فحسب، بل تمكن المطورين أيضًا من التركيز على تجربة المستخدم وقيمة المنتج الأساسية، مع ضمان قابلية التوسع والأمان.