web-dev-qa-db-fra.com

Gestion des jetons d'identification Firebase côté client avec JavaScript vanille

J'écris une application Firebase en JavaScript Vanilla. J'utilise l'authentification Firebase et FirebaseUI for Web. J'utilise Firebase Cloud Functions pour implémenter un serveur qui reçoit les demandes pour mes itinéraires de page et renvoie le code HTML restitué. Je ne parviens pas à trouver la meilleure pratique pour utiliser mes jetons d'identifiant authentifié côté client afin d'accéder aux itinéraires protégés desservis par ma fonction Cloud Firebase.

Je crois comprendre le flux de base: l'utilisateur se connecte, ce qui signifie qu'un jeton ID est envoyé au client, où il est reçu dans le rappel onAuthStateChanged, puis inséré dans le champ Authorization de toute nouvelle demande HTTP avec le préfixe approprié, et puis vérifié par le serveur lorsque l'utilisateur tente d'accéder à un itinéraire protégé.

Je ne comprends pas ce que je devrais faire avec le jeton ID à l'intérieur du rappel onAuthStateChanged, ni comment je devrais modifier le code JavaScript côté client pour modifier les en-têtes de la demande si nécessaire.

J'utilise Firebase Cloud Functions pour gérer les demandes de routage. Voici mon functions/index.js, qui exporte la méthode app à laquelle toutes les demandes sont redirigées et où les jetons ID sont vérifiés:

const functions = require('firebase-functions')
const admin = require('firebase-admin')
const express = require('express')
const cookieParser = require('cookie-parser')
const cors = require('cors')

const app = express()
app.use(cors({ Origin: true }))
app.use(cookieParser())

admin.initializeApp(functions.config().firebase)

const firebaseAuthenticate = (req, res, next) => {
  console.log('Check if request is authorized with Firebase ID token')

  if ((!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) &&
    !req.cookies.__session) {
    console.error('No Firebase ID token was passed as a Bearer token in the Authorization header.',
      'Make sure you authorize your request by providing the following HTTP header:',
      'Authorization: Bearer <Firebase ID Token>',
      'or by passing a "__session" cookie.')
    res.status(403).send('Unauthorized')
    return
  }

  let idToken
  if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
    console.log('Found "Authorization" header')
    // Read the ID Token from the Authorization header.
    idToken = req.headers.authorization.split('Bearer ')[1]
  } else {
    console.log('Found "__session" cookie')
    // Read the ID Token from cookie.
    idToken = req.cookies.__session
  }

  admin.auth().verifyIdToken(idToken).then(decodedIdToken => {
    console.log('ID Token correctly decoded', decodedIdToken)
    console.log('token details:', JSON.stringify(decodedIdToken))

    console.log('User email:', decodedIdToken.firebase.identities['google.com'][0])

    req.user = decodedIdToken
    return next()
  }).catch(error => {
    console.error('Error while verifying Firebase ID token:', error)
    res.status(403).send('Unauthorized')
  })
}

const meta = `<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link type="text/css" rel="stylesheet" href="https://cdn.firebase.com/libs/firebaseui/2.6.0/firebaseui.css" />

const logic = `<!-- Intialization -->
<script src="https://www.gstatic.com/firebasejs/4.10.0/firebase.js"></script>
<script src="/init.js"></script>

<!-- Authentication -->
<script src="https://cdn.firebase.com/libs/firebaseui/2.6.0/firebaseui.js"></script>
<script src="/auth.js"></script>`

app.get('/', (request, response) => {
  response.send(`<html>
  <head>
    <title>Index</title>

    ${meta}
  </head>
  <body>
    <h1>Index</h1>

    <a href="/user/fake">Fake User</a>

    <div id="firebaseui-auth-container"></div>

    ${logic}
  </body>
</html>`)
})

app.get('/user/:name', firebaseAuthenticate, (request, response) => {
  response.send(`<html>
  <head>
    <title>User - ${request.params.name}</title>

    ${meta}
  </head>
  <body>
    <h1>User ${request.params.name}</h1>

    ${logic}
  </body>
</html>`)
})

exports.app = functions.https.onRequest(app)

Her est mon functions/package.json, qui décrit la configuration du serveur traitant les requêtes HTTP implémentées en tant que fonction Firebase Cloud:

{
  "name": "functions",
  "description": "Cloud Functions for Firebase",
  "scripts": {
    "lint": "./node_modules/.bin/eslint .",
    "serve": "firebase serve --only functions",
    "Shell": "firebase experimental:functions:Shell",
    "start": "npm run Shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log"
  },
  "dependencies": {
    "cookie-parser": "^1.4.3",
    "cors": "^2.8.4",
    "eslint-config-standard": "^11.0.0-beta.0",
    "eslint-plugin-import": "^2.8.0",
    "eslint-plugin-node": "^6.0.0",
    "eslint-plugin-standard": "^3.0.1",
    "firebase-admin": "~5.8.1",
    "firebase-functions": "^0.8.1"
  },
  "devDependencies": {
    "eslint": "^4.12.0",
    "eslint-plugin-promise": "^3.6.0"
  },
  "private": true
}

Voici mon firebase.json, qui redirige toutes les demandes de page vers ma fonction exportée app:

{
  "functions": {
    "predeploy": [
      "npm --prefix $RESOURCE_DIR run lint"
    ]
  },
  "hosting": {
    "public": "public",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "function": "app"
      }
    ]
  }
}

Voici mon public/auth.js, où le jeton est demandé et reçu sur le client. C'est là que je reste coincé:

/* global firebase, firebaseui */

const uiConfig = {
  // signInSuccessUrl: '<url-to-redirect-to-on-success>',
  signInOptions: [
    // Leave the lines as is for the providers you want to offer your users.
    firebase.auth.GoogleAuthProvider.PROVIDER_ID,
    // firebase.auth.FacebookAuthProvider.PROVIDER_ID,
    // firebase.auth.TwitterAuthProvider.PROVIDER_ID,
    // firebase.auth.GithubAuthProvider.PROVIDER_ID,
    firebase.auth.EmailAuthProvider.PROVIDER_ID
    // firebase.auth.PhoneAuthProvider.PROVIDER_ID
  ],
  callbacks: {
    signInSuccess () { return false }
  }
  // Terms of service url.
  // tosUrl: '<your-tos-url>'
}
const ui = new firebaseui.auth.AuthUI(firebase.auth())
ui.start('#firebaseui-auth-container', uiConfig)

firebase.auth().onAuthStateChanged(function (user) {
  if (user) {
    firebase.auth().currentUser.getIdToken().then(token => {
      console.log('You are an authorized user.')

      // This is insecure. What should I do instead?
      // document.cookie = '__session=' + token
    })
  } else {
    console.warn('You are an unauthorized user.')
  }
})

Que dois-je faire des jetons d'identification authentifiés côté client?

Les cookies/stockage local/webStorage ne semblent pas entièrement sécurisables, du moins pas de manière relativement simple et évolutive. Il peut y avoir un processus simple basé sur un cookie qui est aussi sécurisé que d'inclure directement le jeton dans un en-tête de requête, mais je n'ai pas été en mesure de trouver le code que je pourrais facilement appliquer à Firebase pour le faire.

Je sais comment inclure des jetons dans les requêtes AJAX, comme:

var xhr = new XMLHttpRequest()
xhr.open('GET', URL)
xmlhttp.setRequestHeader("Authorization", 'Bearer ' + token)
xhr.onload = function () {
    if (xhr.status === 200) {
        alert('Success: ' + xhr.responseText)
    }
    else {
        alert('Request failed.  Returned status of ' + xhr.status)
    }
}
xhr.send()

Cependant, je ne souhaite pas créer une seule application de page, je ne peux donc pas utiliser AJAX. Je n'arrive pas à comprendre comment insérer le jeton dans l'en-tête des demandes de routage normales, comme celles déclenchées en cliquant sur une balise d'ancrage avec une variable href valide. Dois-je intercepter ces demandes et les modifier d'une manière ou d'une autre?

Quelle est la meilleure pratique pour une sécurité évolutive côté client dans une application Firebase for Web qui ne soit pas une application à page unique? Je n'ai pas besoin d'un flux d'authentification complexe. Je suis prêt à renoncer à la flexibilité pour un système de sécurité fiable et simple à mettre en œuvre.

14
David Y. Stephenson

Pourquoi les cookies ne sont pas sécurisés?

  1. Les données de cookie peuvent être facilement gâchées, si un développeur est assez stupide pour enregistrer le rôle de l'utilisateur connecté dans un cookie, l'utilisateur peut facilement modifier ses données de cookie, document.cookie = "role=admin". (voila!) 
  2. Les données des cookies peuvent être facilement récupérées par un pirate informatique par une attaque XSS et ce dernier peut se connecter à votre compte.
  3. Les données de cookie peuvent être facilement collectées à partir de votre navigateur et votre colocataire peut voler votre cookie et vous connecter à votre ordinateur depuis son ordinateur.
  4. Toute personne surveillant votre trafic réseau peut collecter votre cookie si vous n'utilisez pas SSL.

Avez-vous besoin d'être inquiet?

  1. Nous ne stockons rien de stupide dans le cookie que l'utilisateur peut modifier pour obtenir un accès non autorisé.
  2. Si un pirate informatique peut récupérer les données des cookies par attaque XSS, il peut également récupérer le jeton Auth si nous n'utilisons pas d'application à page unique (car nous allons stocker le jeton quelque part, par exemple, le stockage local).
  3. Votre colocataire peut également récupérer vos données de stockage locales.
  4. Toute personne surveillant votre réseau peut également récupérer votre en-tête d'autorisation à moins d'utiliser SSL. Le cookie et l'autorisation sont tous deux envoyés sous forme de texte brut dans l'en-tête http.

Que devrions nous faire? 

  1. Si nous stockons le jeton quelque part, les cookies ne présentent aucun avantage en termes de sécurité. Les jetons Auth sont les mieux adaptés aux applications comportant une seule page ajoutant une sécurité supplémentaire ou lorsque les cookies ne sont pas une option disponible.
  2. Si une personne surveille le trafic réseau, nous devrions héberger notre site avec SSL. Les cookies et les en-têtes http ne peuvent pas être interceptés si SSL est utilisé.
  3. Si nous utilisons une application à une seule page, nous ne devrions pas stocker le jeton, mais le conserver dans une variable JS et créer une demande ajax avec un en-tête d'autorisation. Si vous utilisez jQuery, vous pouvez ajouter un gestionnaire beforeSend au global ajaxSetup qui envoie l'en-tête de jeton Auth chaque fois que vous effectuez une demande ajax.

    var token = false; /* you will set it when authorized */
    $.ajaxSetup({
        beforeSend: function(xhr) {
            /* check if token is set or retrieve it */
            if(token){
                xhr.setRequestHeader('Authorization', 'Bearer ' + token);
            }
        }
    });
    

Si nous voulons utiliser des cookies

Si nous ne voulons pas implémenter une seule application de page et nous en tenir aux cookies, vous avez le choix entre deux options.

  1. Cookies non persistants (ou de session): Les cookies non persistants n'ont pas de date de validité/d'expiration maximale et sont supprimés lorsque l'utilisateur ferme la fenêtre du navigateur, ce qui le rend tellement préférable dans les situations où la sécurité est en jeu.
  2. Cookies persistants: Les cookies persistants sont ceux avec une date de péremption/date d'expiration maximale. Ces cookies persistent jusqu'à la fin de la période. Les cookies persistants sont préférés lorsque vous voulez que le cookie existe même si l'utilisateur ferme le navigateur et revient le lendemain, empêchant ainsi l'authentification à chaque fois et améliorant l'expérience de l'utilisateur.
document.cookie = '__session=' + token  /* Non-Persistent */
document.cookie = '__session=' + token + ';max-age=' + (3600*24*7) /* Persistent 1 week */

Persistant ou non persistant, le choix dépend entièrement du projet. Et dans le cas de cookies persistants, le maximum d’âge devrait être équilibré, il ne devrait pas durer un mois, ni une heure. 1 ou 2 semaines me semblent une meilleure option.

2
Munim Munna

Vous êtes trop sceptique quant au stockage du jeton d'identification Firebase dans un cookie. En le stockant dans un cookie, il serait envoyé avec chaque demande à votre fonction Firebase Cloud.

Jeton d'identification Firebase:

Créé par Firebase lorsqu'un utilisateur se connecte à une application Firebase. Ces jetons sont des JWT signés qui identifient de manière sécurisée un utilisateur dans un projet Firebase. Ces jetons contiennent des informations de profil de base pour un utilisateur, y compris la chaîne d'ID de l'utilisateur, propre au projet Firebase. L'intégrité des jetons ID pouvant être vérifiée, vous pouvez les envoyer à un serveur dorsal pour identifier l'utilisateur actuellement connecté.

Comme indiqué dans la définition d'un jeton Firebase ID, l'intégrité du jeton peut être vérifiée. Par conséquent, son stockage et son envoi sur votre serveur doivent être sécurisés. Le problème se pose en ce que vous ne souhaitez pas avoir besoin de fournir ce jeton dans l'en-tête d'authentification pour chaque demande adressée à votre fonction Firebase Cloud, car vous souhaitez éviter d'utiliser des demandes de routage AJAX.

Cela ramène à l’utilisation de cookies, car les cookies sont automatiquement envoyés avec les requêtes du serveur. Ils ne sont pas aussi dangereux que vous le pensez. Firebase a même un exemple d’application appelée " Pages générées côté serveur avec modèles de guidon et sessions utilisateur " qui utilise des cookies de session pour l’envoi du jeton d’identification Firebase.

Vous pouvez voir leur exemple de ceci ici :

// Express middleware that checks if a Firebase ID Tokens is passed in the `Authorization` HTTP
// header or the `__session` cookie and decodes it.
// The Firebase ID token needs to be passed as a Bearer token in the Authorization HTTP header like this:
// `Authorization: Bearer <Firebase ID Token>`.
// When decoded successfully, the ID Token content will be added as `req.user`.
const validateFirebaseIdToken = (req, res, next) => {
    console.log('Check if request is authorized with Firebase ID token');

    return getIdTokenFromRequest(req, res).then(idToken => {
        if (idToken) {
            return addDecodedIdTokenToRequest(idToken, req);
        }
        return next();
    }).then(() => {
        return next();
    });
};

/**
 * Returns a Promise with the Firebase ID Token if found in the Authorization or the __session cookie.
 */
function getIdTokenFromRequest(req, res) {
    if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
        console.log('Found "Authorization" header');
        // Read the ID Token from the Authorization header.
        return Promise.resolve(req.headers.authorization.split('Bearer ')[1]);
    }
    return new Promise((resolve, reject) => {
        cookieParser(req, res, () => {
            if (req.cookies && req.cookies.__session) {
                console.log('Found "__session" cookie');
                // Read the ID Token from cookie.
                resolve(req.cookies.__session);
            } else {
                resolve();
            }
        });
    });
}

Cela vous permettrait de ne pas avoir besoin de AJAX et de permettre aux itinéraires d'être gérés par votre fonction Cloud Firebase. Assurez-vous simplement de jeter un coup d'œil au modèle de Firebase où ils vérifient l'en-tête de chaque page .

<script>
    function checkCookie() {
    // Checks if it's likely that there is a signed-in Firebase user and the session cookie expired.
    // In that case we'll hide the body of the page until it will be reloaded after the cookie has been set.
    var hasSessionCookie = document.cookie.indexOf('__session=') !== -1;
    var isProbablySignedInFirebase = typeof Object.keys(localStorage).find(function (key) {
            return key.startsWith('firebase:authUser')
}) !== 'undefined';
    if (!hasSessionCookie && isProbablySignedInFirebase) {
        var style = document.createElement('style');
    style.id = '__bodyHider';
        style.appendChild(document.createTextNode('body{display: none}'));
    document.head.appendChild(style);
}
}
checkCookie();
    document.addEventListener('DOMContentLoaded', function() {
        // Make sure the Firebase ID Token is always passed as a cookie.
        firebase.auth().addAuthTokenListener(function (idToken) {
            var hadSessionCookie = document.cookie.indexOf('__session=') !== -1;
            document.cookie = '__session=' + idToken + ';max-age=' + (idToken ? 3600 : 0);
            // If there is a change in the auth state compared to what's in the session cookie we'll reload after setting the cookie.
            if ((!hadSessionCookie && idToken) || (hadSessionCookie && !idToken)) {
                window.location.reload(true);
            } else {
                // In the rare case where there was a user but it could not be signed in (for instance the account has been deleted).
                // We un-hide the page body.
                var style = document.getElementById('__bodyHider');
                if (style) {
                    document.head.removeChild(style);
                }
            }
        });
    });
</script>
1
mootrichard

Utiliser Générer une bibliothèque de jetons sécurisés et ajouter un jeton directement ( Charge d’authentification personnalisée ):

var token = tokenGenerator.createToken({ "uid": "1234", "isModerator": true });

Vos données de jeton sont uid (ou app_user_id) et isModerator à l'intérieur d'une expression de règle, par exemple:

{
  "rules": {
    ".read": true,
    "$comment": {
      ".write": "(!data.exists() && newData.child('user_id').val() == auth.uid) || auth.isModerator == true"
    }
  }
}
0
user5377037