Control de acceso con reclamos personalizados y reglas de seguridad

El SDK de administrador de Firebase 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 le 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 pagos y no pagos
    • diferenciar moderadores de usuarios regulares
    • 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, uno podría realizar una búsqueda de base de datos en una lista de usuarios administradores. Sin embargo, se puede lograr el mismo objetivo de manera más eficiente mediante un reclamo de usuario personalizado llamado admin con la siguiente regla de Realtime Database:

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

Los reclamos de usuarios personalizados son accesibles a través de los tokens de autenticación del usuario. En el ejemplo anterior, solo los usuarios con un valor true de admin en su declaración de token tendrían acceso de lectura/escritura al nodo adminContent. Como el token de ID ya contiene estas declaraciones, no se necesita de ningún procesamiento ni búsqueda adicional para verificar los permisos de administrador. Además, el token de identificación es un mecanismo confiable para entregar estos reclamos personalizados. Todos los accesos autenticados deben validar el token de identificación antes de procesar la solicitud asociada.

Establece y valida reclamos de usuario personalizados a través del SDK de Admin

Debido a que los reclamos personalizados pueden contener datos confidenciales, solo deben configurarse desde un entorno de servidor con privilegios mediante el SDK de administrador de Firebase. Puede establecer reclamos usando Node.js, como se muestra a continuación:

// Set admin privilege on the user corresponding to uid.
admin.auth().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.
});

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

Un token de identificación 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:

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

También puedes verificar los reclamos personalizados existentes de un usuario, los cuales están disponibles como propiedad en el objeto UserRecord:

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

Puedes borrar los reclamos personalizados de un usuario pasando null para customClaims.

Propaga reclamos personalizados al cliente

Después de que los nuevos reclamos 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 identificación de las siguientes maneras:

  • Un usuario accede o vuelve a autenticarse después de modificados los reclamos personalizados. El token de ID emitido contendrá los últimos reclamos.
  • Una sesión de usuario existente obtiene su token de ID actualizado después de que caduca un token más antiguo.
  • Se actualiza un token de ID llamando a currentUser.getIdToken(true).

Accede a los reclamos personalizados en el cliente

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

Una vez que los reclamos más recientes se hayan propagado al token de ID de un usuario, se puede recuperar el token de ID y analizar su carga útil (decodificada en base 64) para obtener los reclamos:

// https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding
firebase.auth().currentUser.getIdToken()
  .then((idToken) => {
     // Parse the ID token.
     const payload = JSON.parse(b64DecodeUnicode(idToken.split('.')[1]));
     // Confirm the user is an Admin.
     if (!!payload['admin']) {
       showAdminUI();
     }
  })
  .catch((error) => {
    console.log(error);

Recomendaciones para los reclamos personalizados

Los reclamos personalizados solo se utilizan para proporcionar control de acceso. No están diseñados 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. Los reclamos se almacenan en el token de ID y pueden causar problemas de rendimiento, puesto que todas las solicitudes autenticadas siempre contienen un token de identificación de Firebase correspondiente al usuario que accedió.

  • Usa reclamos personalizados 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.
  • Los reclamos personalizados tienen un tamaño limitado. Si se pasa una carga útil de reclamo personalizado superior a 1,000 bytes, se generará un error.

Ejemplos y casos prácticos

Los siguientes ejemplos ilustran reclamos personalizados 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 reclamos personalizados en un usuario durante su creación mediante Cloud Functions.

Pueden agregarse reclamos personalizados con Cloud Functions y propagarse de inmediato con Realtime Database. La función se llama solo durante el registro, mediante un disparador onCreate. Una vez que se establecen los reclamos personalizados, 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á los reclamos personalizados.

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 admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);

// On sign up.
exports.processSignUp = functions.auth.user().onCreate(event => {
  const user = event.data; // The Firebase user.
  // Check if user meets role criteria.
  if (user.email &&
      user.email.indexOf('@admin.example.com') != -1 &&
      user.emailVerified) {
    const customClaims = {
      admin: true,
      accessLevel: 9
    };
    // Set custom user claims on this newly created user.
    return admin.auth().setCustomUserClaims(user.uid, customClaims)
      .then(() => {
        // Update real-time database to notify client to force refresh.
        const metadataRef = admin.database().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.
        return 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 los reclamos personalizados 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', (req, res) => {
  // Get the ID token passed.
  const idToken = req.body.idToken;
  // Verify the ID token and decode its payload.
  admin.auth().verifyIdToken(idToken).then((claims) => {
    // Verify user is eligible for additional privileges.
    if (typeof claims.email !== 'undefined' &&
        typeof claims.email_verified !== 'undefined' &&
        claims.email_verified &&
        claims.email.indexOf('@admin.example.com') != -1) {
      // Add custom claims for additional privileges.
      admin.auth().setCustomUserClaims(claims.sub, {
        admin: true
      }).then(function() {
        // 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 pago que decide actualizar a una suscripción paga. El token de identificación del usuario se envía con la información de pago al servidor de backend a través de una solicitud HTTP. Cuando el pago se procesa correctamente, el usuario queda configurado como un suscriptor pago a través del SDK de Admin. Se devuelve una respuesta HTTP exitosa al cliente para forzar la actualización del token.

Definición de roles mediante una secuencia de comandos de backend

Se puede configurar una secuencia de comandos recurrente (no iniciada en el cliente) para actualizar los reclamos personalizados del usuario:

admin.auth().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 admin.auth().setCustomUserClaims(user.uid, {
      admin: true
    });
  }
}).catch((error) => {
  console.log(error);
});

Los reclamos personalizados también pueden modificarse de forma incremental a través del SDK de Admin:

admin.auth().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 admin.auth().setCustomUserClaims(user.uid, currentCustomClaims);
  }
}).catch((error) => {
  console.log(error);
});

Enviar comentarios sobre...

Si necesitas ayuda, visita nuestra página de asistencia.