Control de acceso con reclamaciones personalizadas y reglas de seguridad

El SDK de Firebase Admin permite configurar atributos personalizados en las cuentas de usuario. Esto hace posible implementar varias estrategias de control de acceso, como el basado en funciones, en apps de Firebase. Estos atributos personalizados les pueden otorgar a los usuarios distintos niveles de acceso (funciones) cuyo cumplimiento se asegura mediante las reglas de seguridad de la aplicación.

Pueden definirse funciones de usuario para los siguientes casos comunes:

  • Otorgar privilegios administrativos a un usuario para que acceda a datos y recursos
  • Definir los diferentes grupos a los que pertenece un usuario
  • Proporcionar acceso de múltiples niveles:
    • Diferenciar entre suscriptores pagados y no pagados
    • Diferenciar entre moderadores y usuarios normales
    • Aplicación para profesor/estudiante y otras similares
  • Agregar un identificador adicional en un usuario. Por ejemplo, un usuario de Firebase podría estar vinculado a otro UID en otro sistema

Consideremos un caso en el que se desea limitar el acceso al nodo de base de datos "adminContent". Para hacerlo, se podría realizar una búsqueda de base de datos en una lista de usuarios admin. Sin embargo, se puede lograr el mismo objetivo de manera más eficiente mediante una reclamación de usuario personalizada llamada admin que tenga la siguiente regla de Realtime Database:

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

Las reclamaciones de usuario personalizadas son accesibles a través de los tokens de autenticación del usuario. En el ejemplo anterior, solo los usuarios que tengan admin establecido como verdadero en su reclamación de token tendrán acceso de lectura/escritura al nodo adminContent. Como el token de ID ya contiene estas aserciones, no se necesita ningún procesamiento ni búsqueda adicionales para verificar los permisos de administrador. Además, el token de identificación es un mecanismo confiable para entregar estas reclamaciones personalizadas. Todos los accesos autenticados deben validar el token de ID antes de procesar la solicitud asociada.

Los ejemplos y soluciones de código que se describen en esta página corresponden a las API de Firebase Auth del lado del cliente y a las API de Auth del lado del servidor proporcionadas por el SDK de Admin.

Establece y valida reclamaciones de usuario personalizadas a través del SDK de Admin

Las reclamaciones personalizadas pueden contener datos confidenciales; por lo tanto, solo deben configurarse desde un entorno de servidor con privilegios mediante el SDK de Firebase Admin.

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.

El objeto de reclamaciones personalizadas no debe contener nombres de clave reservados de OIDC ni nombres reservados de Firebase. La carga útil de las reclamaciones personalizadas no debe superar los 1,000 bytes.

Un token de ID enviado a un servidor de backend puede confirmar la identidad del usuario y su nivel de acceso mediante el SDK de Admin de la siguiente manera:

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

También puedes verificar las reclamaciones personalizadas existentes de un usuario, que están disponibles como propiedad en el objeto de usuario:

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"]);

Para borrar las reclamaciones personalizadas de un usuario, pasa el valor null a customClaims.

Propaga reclamaciones personalizadas al cliente

Después de que las nuevas reclamaciones se modifican en un usuario a través del SDK de Admin, se propagan a un usuario autenticado en el lado del cliente a través del token de ID de las siguientes maneras:

  • Un usuario accede o vuelve a autenticarse después de que se modifican las reclamaciones personalizadas. El token de ID emitido contendrá las últimas reclamaciones.
  • Una sesión de usuario existente obtiene su token de ID actualizado después de que caduca un token más antiguo.
  • Se llama a currentUser.getIdToken(true) para forzar la actualización de un token de ID.

Accede a las reclamaciones personalizadas en el cliente

Las reclamaciones personalizadas solo se pueden recuperar a través del token de ID del usuario. Según la función o el nivel de acceso del usuario, tal vez sea necesario acceder a estas reclamaciones para modificar la IU del cliente. Sin embargo, el acceso de backend siempre se debe hacer cumplir mediante el token de ID después de validarlo y analizar sus reclamaciones. Las reclamaciones personalizadas no se deben enviar directamente al backend, ya que no son confiables sin el token.

Una vez que las reclamaciones más recientes se hayan propagado al token de ID de un usuario, puedes recuperar el token de ID para obtenerlas:

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];
    }
  }
}];

Recomendaciones para las reclamaciones personalizadas

Las reclamaciones personalizadas solo se utilizan para proporcionar control de acceso. No están diseñadas para almacenar datos adicionales (como datos de perfil y otros datos personalizados). Si bien esto puede parecer un mecanismo conveniente para hacerlo, no es recomendable en absoluto. Las reclamaciones se almacenan en el token de ID y pueden causar problemas de rendimiento, puesto que todas las solicitudes autenticadas siempre contienen un token de ID de Firebase correspondiente al usuario que accedió.

  • Usa reclamaciones personalizadas solo para almacenar datos relacionados con el control de acceso de los usuarios. Todos los otros datos deben almacenarse por separado a través de la base de datos en tiempo real o algún otro tipo de almacenamiento en el servidor.
  • Las reclamaciones personalizadas tienen un tamaño limitado. Si se pasa una carga útil de reclamación personalizada superior a 1,000 bytes, se genera un error.

Ejemplos y casos prácticos

Los siguientes ejemplos ilustran reclamaciones personalizadas en el contexto de casos de uso de Firebase específicos.

Definición de roles a través de Firebase Functions durante la creación de un usuario

En este ejemplo, se establecen reclamaciones personalizadas en un usuario durante su creación mediante Cloud Functions.

Pueden agregarse reclamaciones personalizadas con Cloud Functions y propagarse de inmediato con Realtime Database. La función se llama solo durante el registro con un activador onCreate. Una vez que se establecen las reclamaciones personalizadas, se propagan a todas las sesiones existentes y futuras. La próxima vez que el usuario acceda con la credencial de usuario, el token contendrá las reclamaciones personalizadas.

Implementación del lado del cliente (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);
  }
});

Lógica de Cloud Functions

Se agrega un nuevo nodo de base de datos (metadatos/($uid)} con lectura/escritura restringida al usuario autenticado.

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

Reglas de la base de datos

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

Definición de roles a través de una solicitud HTTP

El siguiente ejemplo establece las reclamaciones personalizadas para un usuario que acaba de acceder mediante una solicitud HTTP.

Implementación del lado del cliente (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);
});

Implementación en backend (SDK de 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' }));
  }
});

Se puede usar el mismo flujo cuando se actualiza el nivel de acceso de un usuario existente. Tomemos como ejemplo un usuario no pagado que decide actualizar a una suscripción pagada. El token de ID del usuario se envía con la información de pago al servidor de backend a través de una solicitud HTTP. Cuando se procesa correctamente el pago, se configura el usuario como suscriptor pagado a través del SDK de Admin. Se muestra una respuesta HTTP correcta al cliente para forzar la actualización del token.

Define funciones mediante una secuencia de comandos de backend

Se puede configurar una secuencia de comandos recurrente (no iniciada en el cliente) para actualizar las reclamaciones personalizadas del usuario:

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

Las reclamaciones personalizadas también pueden modificarse de forma incremental a través del SDK de 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 = new Dictionary<string, object>(user.CustomClaims);
    // Add level.
    claims["level"] = 10;
    // Add custom claims for additional privileges.
    await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync(user.Uid, claims);
}