Contrôler l'accès avec des revendications personnalisées et des règles de sécurité

Le SDK Firebase Admin permet de définir des attributs personnalisés sur les comptes utilisateur. Cela permet d'implémenter différentes stratégies de contrôle des accès, y compris le contrôle des accès basé sur les rôles, dans les applications Firebase. Ces attributs personnalisés peuvent accorder aux utilisateurs différents niveaux d'accès (rôles), qui sont ensuite appliqués dans les règles de sécurité d'une application.

Vous pouvez définir des rôles utilisateur pour les cas courants suivants:

  • Accorder à un utilisateur des droits d'administrateur pour accéder aux données et aux ressources
  • Définir les différents groupes auxquels un utilisateur appartient
  • Fournir un accès à plusieurs niveaux :
    • Distinguer les abonnés payants des abonnés non payants
    • Différencier les modérateurs des utilisateurs normaux
    • Application enseignant/élève, etc.
  • Ajoutez un identifiant supplémentaire à un utilisateur. Par exemple, un utilisateur Firebase peut être mappé sur un autre UID dans un autre système.

Supposons que vous souhaitiez limiter l'accès au nœud de base de données "adminContent". Vous pouvez le faire à l'aide d'une recherche dans la base de données sur une liste d'utilisateurs administrateurs. Toutefois, vous pouvez atteindre le même objectif plus efficacement à l'aide d'une revendication utilisateur personnalisée appelée admin avec la règle Realtime Database suivante:

{
  "rules": {
    "adminContent": {
      ".read": "auth.token.admin === true",
      ".write": "auth.token.admin === true",
    }
  }
}

Les revendications utilisateur personnalisées sont accessibles via les jetons d'authentification de l'utilisateur. Dans l'exemple ci-dessus, seuls les utilisateurs dont admin est défini sur "true" dans leur revendication de jeton auront un accès en lecture/écriture au nœud adminContent. Comme le jeton d'identité contient déjà ces assertions, aucun traitement ni recherche supplémentaire n'est nécessaire pour vérifier les autorisations d'administrateur. De plus, le jeton d'ID est un mécanisme de confiance pour diffuser ces revendications personnalisées. Tous les accès authentifiés doivent valider le jeton d'ID avant de traiter la requête associée.

Les exemples de code et les solutions décrits sur cette page s'appuient à la fois sur les API Firebase Auth côté client et les API Auth côté serveur fournies par le SDK Admin.

Définir et valider des revendications utilisateur personnalisées via le SDK Admin

Les revendications personnalisées peuvent contenir des données sensibles. Elles ne doivent donc être définies qu'à partir d'un environnement de serveur privilégié par le SDK Admin Firebase.

Node.js

// Set admin privilege on the user corresponding to uid.

getAuth()
  .setCustomUserClaims(uid, { admin: true })
  .then(() => {
    // The new custom claims will propagate to the user's ID token the
    // next time a new one is issued.
  });

Java

// Set admin privilege on the user corresponding to uid.
Map<String, Object> claims = new HashMap<>();
claims.put("admin", true);
FirebaseAuth.getInstance().setCustomUserClaims(uid, claims);
// The new custom claims will propagate to the user's ID token the
// next time a new one is issued.

Python

# Set admin privilege on the user corresponding to uid.
auth.set_custom_user_claims(uid, {'admin': True})
# The new custom claims will propagate to the user's ID token the
# next time a new one is issued.

Go

// Get an auth client from the firebase.App
client, err := app.Auth(ctx)
if err != nil {
	log.Fatalf("error getting Auth client: %v\n", err)
}

// Set admin privilege on the user corresponding to uid.
claims := map[string]interface{}{"admin": true}
err = client.SetCustomUserClaims(ctx, uid, claims)
if err != nil {
	log.Fatalf("error setting custom claims %v\n", err)
}
// The new custom claims will propagate to the user's ID token the
// next time a new one is issued.

C#

// Set admin privileges on the user corresponding to uid.
var claims = new Dictionary<string, object>()
{
    { "admin", true },
};
await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync(uid, claims);
// The new custom claims will propagate to the user's ID token the
// next time a new one is issued.

L'objet des revendications personnalisées ne doit pas contenir de noms de clés réservés par OIDC ni de noms réservés par Firebase. La charge utile des revendications personnalisées ne doit pas dépasser 1 000 octets.

Un jeton d'ID envoyé à un serveur backend peut confirmer l'identité et le niveau d'accès de l'utilisateur à l'aide du SDK Admin comme suit:

Node.js

// Verify the ID token first.
getAuth()
  .verifyIdToken(idToken)
  .then((claims) => {
    if (claims.admin === true) {
      // Allow access to requested admin resource.
    }
  });

Java

// Verify the ID token first.
FirebaseToken decoded = FirebaseAuth.getInstance().verifyIdToken(idToken);
if (Boolean.TRUE.equals(decoded.getClaims().get("admin"))) {
  // Allow access to requested admin resource.
}

Python

# Verify the ID token first.
claims = auth.verify_id_token(id_token)
if claims['admin'] is True:
    # Allow access to requested admin resource.
    pass

Go

// Verify the ID token first.
token, err := client.VerifyIDToken(ctx, idToken)
if err != nil {
	log.Fatal(err)
}

claims := token.Claims
if admin, ok := claims["admin"]; ok {
	if admin.(bool) {
		//Allow access to requested admin resource.
	}
}

C#

// Verify the ID token first.
FirebaseToken decoded = await FirebaseAuth.DefaultInstance.VerifyIdTokenAsync(idToken);
object isAdmin;
if (decoded.Claims.TryGetValue("admin", out isAdmin))
{
    if ((bool)isAdmin)
    {
        // Allow access to requested admin resource.
    }
}

Vous pouvez également vérifier les revendications personnalisées existantes d'un utilisateur, qui sont disponibles en tant que propriété sur l'objet utilisateur:

Node.js

// Lookup the user associated with the specified uid.
getAuth()
  .getUser(uid)
  .then((userRecord) => {
    // The claims can be accessed on the user record.
    console.log(userRecord.customClaims['admin']);
  });

Java

// Lookup the user associated with the specified uid.
UserRecord user = FirebaseAuth.getInstance().getUser(uid);
System.out.println(user.getCustomClaims().get("admin"));

Python

# Lookup the user associated with the specified uid.
user = auth.get_user(uid)
# The claims can be accessed on the user record.
print(user.custom_claims.get('admin'))

Go

// Lookup the user associated with the specified uid.
user, err := client.GetUser(ctx, uid)
if err != nil {
	log.Fatal(err)
}
// The claims can be accessed on the user record.
if admin, ok := user.CustomClaims["admin"]; ok {
	if admin.(bool) {
		log.Println(admin)
	}
}

C#

// Lookup the user associated with the specified uid.
UserRecord user = await FirebaseAuth.DefaultInstance.GetUserAsync(uid);
Console.WriteLine(user.CustomClaims["admin"]);

Vous pouvez supprimer les revendications personnalisées d'un utilisateur en transmettant la valeur null pour customClaims.

Propager des revendications personnalisées vers le client

Une fois que de nouvelles revendications ont été modifiées sur un utilisateur via le SDK Admin, elles sont transmises à un utilisateur authentifié côté client via le jeton d'ID comme suit:

  • Un utilisateur se connecte ou se réauthentifie une fois les revendications personnalisées modifiées. Le jeton d'ID émis en conséquence contiendra les dernières revendications.
  • Le jeton d'ID d'une session utilisateur existante est actualisé après l'expiration d'un jeton plus ancien.
  • Un jeton d'ID est actualisé de force en appelant currentUser.getIdToken(true).

Accéder aux revendications personnalisées sur le client

Les revendications personnalisées ne peuvent être récupérées que via le jeton d'identification de l'utilisateur. L'accès à ces revendications peut être nécessaire pour modifier l'UI du client en fonction du rôle ou du niveau d'accès de l'utilisateur. Toutefois, l'accès au backend doit toujours être appliqué via le jeton d'ID après l'avoir validé et analysé ses revendications. Les revendications personnalisées ne doivent pas être envoyées directement au backend, car elles ne peuvent pas être approuvées en dehors du jeton.

Une fois que les dernières revendications ont été propagées au jeton d'ID d'un utilisateur, vous pouvez les obtenir en récupérant le jeton d'ID:

JavaScript

firebase.auth().currentUser.getIdTokenResult()
  .then((idTokenResult) => {
     // Confirm the user is an Admin.
     if (!!idTokenResult.claims.admin) {
       // Show admin UI.
       showAdminUI();
     } else {
       // Show regular user UI.
       showRegularUI();
     }
  })
  .catch((error) => {
    console.log(error);
  });

Android

user.getIdToken(false).addOnSuccessListener(new OnSuccessListener<GetTokenResult>() {
  @Override
  public void onSuccess(GetTokenResult result) {
    boolean isAdmin = result.getClaims().get("admin");
    if (isAdmin) {
      // Show admin UI.
      showAdminUI();
    } else {
      // Show regular user UI.
      showRegularUI();
    }
  }
});

Swift

user.getIDTokenResult(completion: { (result, error) in
  guard let admin = result?.claims?["admin"] as? NSNumber else {
    // Show regular user UI.
    showRegularUI()
    return
  }
  if admin.boolValue {
    // Show admin UI.
    showAdminUI()
  } else {
    // Show regular user UI.
    showRegularUI()
  }
})

Objective-C

user.getIDTokenResultWithCompletion:^(FIRAuthTokenResult *result,
                                      NSError *error) {
  if (error != nil) {
    BOOL *admin = [result.claims[@"admin"] boolValue];
    if (admin) {
      // Show admin UI.
      [self showAdminUI];
    } else {
      // Show regular user UI.
      [self showRegularUI];
    }
  }
}];

Bonnes pratiques concernant les revendications personnalisées

Les revendications personnalisées ne sont utilisées que pour le contrôle des accès. Ils ne sont pas conçus pour stocker des données supplémentaires (telles que le profil et d'autres données personnalisées). Bien que cela puisse sembler être un mécanisme pratique, nous vous déconseillons vivement de le faire, car ces revendications sont stockées dans le jeton d'ID et peuvent entraîner des problèmes de performances, car toutes les requêtes authentifiées contiennent toujours un jeton d'ID Firebase correspondant à l'utilisateur connecté.

  • Utilisez des revendications personnalisées pour stocker des données uniquement pour contrôler l'accès des utilisateurs. Toutes les autres données doivent être stockées séparément via la base de données en temps réel ou un autre stockage côté serveur.
  • La taille des revendications personnalisées est limitée. Si vous transmettez une charge utile de revendications personnalisées supérieure à 1 000 octets, une erreur sera générée.

Exemples et cas d'utilisation

Les exemples suivants illustrent les revendications personnalisées dans le contexte de cas d'utilisation Firebase spécifiques.

Définir des rôles via Firebase Functions lors de la création d'un utilisateur

Dans cet exemple, des revendications personnalisées sont définies sur un utilisateur lors de sa création à l'aide de Cloud Functions.

Vous pouvez ajouter des revendications personnalisées à l'aide de Cloud Functions et les propager immédiatement avec Realtime Database. La fonction n'est appelée qu'à l'inscription à l'aide d'un déclencheur onCreate. Une fois les revendications personnalisées définies, elles se propagent à toutes les sessions existantes et futures. La prochaine fois que l'utilisateur se connectera avec ses identifiants, le jeton contiendra les revendications personnalisées.

Implémentation côté client (JavaScript)

const provider = new firebase.auth.GoogleAuthProvider();
firebase.auth().signInWithPopup(provider)
.catch(error => {
  console.log(error);
});

let callback = null;
let metadataRef = null;
firebase.auth().onAuthStateChanged(user => {
  // Remove previous listener.
  if (callback) {
    metadataRef.off('value', callback);
  }
  // On user login add new listener.
  if (user) {
    // Check if refresh is required.
    metadataRef = firebase.database().ref('metadata/' + user.uid + '/refreshTime');
    callback = (snapshot) => {
      // Force refresh to pick up the latest custom claims changes.
      // Note this is always triggered on first call. Further optimization could be
      // added to avoid the initial trigger when the token is issued and already contains
      // the latest claims.
      user.getIdToken(true);
    };
    // Subscribe new listener to changes on that node.
    metadataRef.on('value', callback);
  }
});

Logique Cloud Functions

Un nouveau nœud de base de données (metadata/($uid)} avec des droits de lecture/écriture limités à l'utilisateur authentifié est ajouté.

const functions = require('firebase-functions');
const { initializeApp } = require('firebase-admin/app');
const { getAuth } = require('firebase-admin/auth');
const { getDatabase } = require('firebase-admin/database');

initializeApp();

// On sign up.
exports.processSignUp = functions.auth.user().onCreate(async (user) => {
  // Check if user meets role criteria.
  if (
    user.email &&
    user.email.endsWith('@admin.example.com') &&
    user.emailVerified
  ) {
    const customClaims = {
      admin: true,
      accessLevel: 9
    };

    try {
      // Set custom user claims on this newly created user.
      await getAuth().setCustomUserClaims(user.uid, customClaims);

      // Update real-time database to notify client to force refresh.
      const metadataRef = getDatabase().ref('metadata/' + user.uid);

      // Set the refresh time to the current UTC timestamp.
      // This will be captured on the client to force a token refresh.
      await  metadataRef.set({refreshTime: new Date().getTime()});
    } catch (error) {
      console.log(error);
    }
  }
});

Règles de base de données

{
  "rules": {
    "metadata": {
      "$user_id": {
        // Read access only granted to the authenticated user.
        ".read": "$user_id === auth.uid",
        // Write access only via Admin SDK.
        ".write": false
      }
    }
  }
}

Définir des rôles via une requête HTTP

L'exemple suivant définit des revendications utilisateur personnalisées sur un nouvel utilisateur connecté via une requête HTTP.

Implémentation côté client (JavaScript)

const provider = new firebase.auth.GoogleAuthProvider();
firebase.auth().signInWithPopup(provider)
.then((result) => {
  // User is signed in. Get the ID token.
  return result.user.getIdToken();
})
.then((idToken) => {
  // Pass the ID token to the server.
  $.post(
    '/setCustomClaims',
    {
      idToken: idToken
    },
    (data, status) => {
      // This is not required. You could just wait until the token is expired
      // and it proactively refreshes.
      if (status == 'success' && data) {
        const json = JSON.parse(data);
        if (json && json.status == 'success') {
          // Force token refresh. The token claims will contain the additional claims.
          firebase.auth().currentUser.getIdToken(true);
        }
      }
    });
}).catch((error) => {
  console.log(error);
});

Implémentation du backend (SDK Admin)

app.post('/setCustomClaims', async (req, res) => {
  // Get the ID token passed.
  const idToken = req.body.idToken;

  // Verify the ID token and decode its payload.
  const claims = await getAuth().verifyIdToken(idToken);

  // Verify user is eligible for additional privileges.
  if (
    typeof claims.email !== 'undefined' &&
    typeof claims.email_verified !== 'undefined' &&
    claims.email_verified &&
    claims.email.endsWith('@admin.example.com')
  ) {
    // Add custom claims for additional privileges.
    await getAuth().setCustomUserClaims(claims.sub, {
      admin: true
    });

    // Tell client to refresh token on user.
    res.end(JSON.stringify({
      status: 'success'
    }));
  } else {
    // Return nothing.
    res.end(JSON.stringify({ status: 'ineligible' }));
  }
});

Le même flux peut être utilisé pour mettre à niveau le niveau d'accès d'un utilisateur existant. Prenons l'exemple d'un utilisateur qui passe d'un abonnement sans frais à un abonnement payant. Le jeton d'ID de l'utilisateur est envoyé avec les informations de paiement au serveur backend via une requête HTTP. Une fois le paiement traité, l'utilisateur est défini comme abonné payant via le SDK Admin. Une réponse HTTP positive est renvoyée au client pour forcer l'actualisation du jeton.

Définir des rôles via un script backend

Un script récurrent (non lancé à partir du client) peut être défini pour s'exécuter afin de mettre à jour les revendications personnalisées de l'utilisateur:

Node.js

getAuth()
  .getUserByEmail('user@admin.example.com')
  .then((user) => {
    // Confirm user is verified.
    if (user.emailVerified) {
      // Add custom claims for additional privileges.
      // This will be picked up by the user on token refresh or next sign in on new device.
      return getAuth().setCustomUserClaims(user.uid, {
        admin: true,
      });
    }
  })
  .catch((error) => {
    console.log(error);
  });

Java

UserRecord user = FirebaseAuth.getInstance()
    .getUserByEmail("user@admin.example.com");
// Confirm user is verified.
if (user.isEmailVerified()) {
  Map<String, Object> claims = new HashMap<>();
  claims.put("admin", true);
  FirebaseAuth.getInstance().setCustomUserClaims(user.getUid(), claims);
}

Python

user = auth.get_user_by_email('user@admin.example.com')
# Confirm user is verified
if user.email_verified:
    # Add custom claims for additional privileges.
    # This will be picked up by the user on token refresh or next sign in on new device.
    auth.set_custom_user_claims(user.uid, {
        'admin': True
    })

Go

user, err := client.GetUserByEmail(ctx, "user@admin.example.com")
if err != nil {
	log.Fatal(err)
}
// Confirm user is verified
if user.EmailVerified {
	// Add custom claims for additional privileges.
	// This will be picked up by the user on token refresh or next sign in on new device.
	err := client.SetCustomUserClaims(ctx, user.UID, map[string]interface{}{"admin": true})
	if err != nil {
		log.Fatalf("error setting custom claims %v\n", err)
	}

}

C#

UserRecord user = await FirebaseAuth.DefaultInstance
    .GetUserByEmailAsync("user@admin.example.com");
// Confirm user is verified.
if (user.EmailVerified)
{
    var claims = new Dictionary<string, object>()
    {
        { "admin", true },
    };
    await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync(user.Uid, claims);
}

Vous pouvez également modifier les revendications personnalisées de manière incrémentielle via le SDK Admin:

Node.js

getAuth()
  .getUserByEmail('user@admin.example.com')
  .then((user) => {
    // Add incremental custom claim without overwriting existing claims.
    const currentCustomClaims = user.customClaims;
    if (currentCustomClaims['admin']) {
      // Add level.
      currentCustomClaims['accessLevel'] = 10;
      // Add custom claims for additional privileges.
      return getAuth().setCustomUserClaims(user.uid, currentCustomClaims);
    }
  })
  .catch((error) => {
    console.log(error);
  });

Java

UserRecord user = FirebaseAuth.getInstance()
    .getUserByEmail("user@admin.example.com");
// Add incremental custom claim without overwriting the existing claims.
Map<String, Object> currentClaims = user.getCustomClaims();
if (Boolean.TRUE.equals(currentClaims.get("admin"))) {
  // Add level.
  currentClaims.put("level", 10);
  // Add custom claims for additional privileges.
  FirebaseAuth.getInstance().setCustomUserClaims(user.getUid(), currentClaims);
}

Python

user = auth.get_user_by_email('user@admin.example.com')
# Add incremental custom claim without overwriting existing claims.
current_custom_claims = user.custom_claims
if current_custom_claims.get('admin'):
    # Add level.
    current_custom_claims['accessLevel'] = 10
    # Add custom claims for additional privileges.
    auth.set_custom_user_claims(user.uid, current_custom_claims)

Go

user, err := client.GetUserByEmail(ctx, "user@admin.example.com")
if err != nil {
	log.Fatal(err)
}
// Add incremental custom claim without overwriting existing claims.
currentCustomClaims := user.CustomClaims
if currentCustomClaims == nil {
	currentCustomClaims = map[string]interface{}{}
}

if _, found := currentCustomClaims["admin"]; found {
	// Add level.
	currentCustomClaims["accessLevel"] = 10
	// Add custom claims for additional privileges.
	err := client.SetCustomUserClaims(ctx, user.UID, currentCustomClaims)
	if err != nil {
		log.Fatalf("error setting custom claims %v\n", err)
	}

}

C#

UserRecord user = await FirebaseAuth.DefaultInstance
    .GetUserByEmailAsync("user@admin.example.com");
// Add incremental custom claims without overwriting the existing claims.
object isAdmin;
if (user.CustomClaims.TryGetValue("admin", out isAdmin) && (bool)isAdmin)
{
    var claims = user.CustomClaims.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
    // Add level.
    var level = 10;
    claims["level"] = level;
    // Add custom claims for additional privileges.
    await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync(user.Uid, claims);
}