Обработка токенов Firebase ID на стороне клиента с помощью ванильного JavaScript

Я пишу приложение Firebase в ванильном JavaScript. Я использую Firebase Authentication и FirebaseUI для Интернета. Я использую Firebase Cloud Functions для реализации сервера, который получает запросы на мои маршруты страниц и возвращает отображаемый HTML. Я изо всех сил стараюсь найти оптимальную практику использования моих токенов с идентификационными данными на стороне клиента для доступа к защищенным маршрутам, которые обслуживаются моей облачной функцией Firebase.

Я считаю, что я понимаю основной поток: пользователь входит в систему, что означает, что токен идентификатора отправляется клиенту, где он получен в onAuthStateChanged а затем вставлен в поле Authorization любого нового HTTP-запроса с соответствующим префиксом и затем проверяется сервером, когда пользователь пытается получить доступ к защищенному маршруту.

Я не понимаю, что я должен делать с токеном ID внутри обратного вызова onAuthStateChanged или как мне следует модифицировать JavaScript на стороне клиента, чтобы изменить заголовки запросов, когда это необходимо.

Я использую Firebase Cloud Functions для обработки запросов на маршрутизацию. Вот мои functions/index.js, которые экспортируют метод app которому перенаправлены все запросы, и где отмечены маркеры идентификаторов:

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="#" onclick="location.href='https://cdn.firebase.com/libs/firebaseui/2.6.0/firebaseui.css'; return false;" />

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)

Это мои functions/package.json, в котором описывается конфигурация сервера, обрабатывающего HTTP-запросы, реализованные в виде облачной функции Firebase:

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

Вот мой firebase.json, который перенаправляет все запросы на страницу в мою экспортированную функцию app:

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

Вот мой public/auth.js, где токен запрашивается и принимается на клиенте. Здесь я застреваю:

/* 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.')
  }
})

Что делать с аутентифицированными маркерами идентификаторов на стороне клиента?

Cookies/localStorage/webStorage не кажутся полностью защищенными, по крайней мере, не в относительно простом и масштабируемом способе, который я могу найти. Может быть простой процесс на основе файлов cookie, который так же безопасен, как и непосредственно, в том числе маркер в заголовке запроса, но я не смог найти код, который я мог бы легко применить к Firebase для этого.

Я знаю, как включать токены в запросы AJAX, например:

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()

Однако я не хочу делать одностраничное приложение, поэтому я не могу использовать AJAX. Я не могу понять, как вставить токен в заголовок обычных запросов на маршрутизацию, например, те, которые запускаются нажатием на тег привязки с действующим href. Должен ли я перехватить эти запросы и каким-то образом их изменить?

Какова наилучшая практика для масштабируемой защиты на стороне клиента в Firebase для веб-приложения, которая не является одностраничным приложением? Мне не нужен комплексный поток аутентификации. Я готов пожертвовать гибкостью для системы безопасности, которой я могу доверять и реализовать просто.

Ответ 1

Почему файлы cookie не защищены?

  1. Данные cookie можно легко смягчить, если разработчик достаточно глуп, чтобы сохранить зарегистрированную роль пользователя в cookie, пользователь может легко изменить свои данные cookie, document.cookie = "role=admin". (Вуаля!)
  2. Данные cookie могут быть легко взломаны хакером с помощью атаки XSS, и он может войти в вашу учетную запись.
  3. Данные cookie могут быть легко собраны в вашем браузере, и ваш сосед по комнате может украсть ваш файл cookie и войти в систему, как вы со своего компьютера.
  4. Любой, кто контролирует ваш сетевой трафик, может собирать ваш файл cookie, если вы не используете SSL.

Вам нужно беспокоиться?

  1. Мы не храним ничего глупого в cookie, который пользователь может изменить, чтобы получить какой-либо несанкционированный доступ.
  2. Если хакер может собирать данные cookie с помощью атаки XSS, он также может выбрать токен Auth, если мы не используем одностраничное приложение (потому что мы будем хранить токен где-нибудь, например localstorage).
  3. Ваш сосед по комнате может также забрать ваши локальные данные.
  4. Любой, кто следит за вашей сетью, также может забрать свой заголовок авторизации, если вы не используете SSL. Cookie и авторизация отправляются как обычный текст в заголовке http.

Что нам делать?

  1. Если мы где-то сохраняем токен, нет никаких преимуществ в отношении безопасности над кукисами, токен Auth лучше всего подходит для приложений с одной страницей, добавляя дополнительную защиту или когда файлы cookie недоступны.
  2. Если нас беспокоит кто-то, кто следит за сетевым трафиком, мы должны разместить наш сайт с помощью SSL. Cookies и http-заголовки не могут быть перехвачены, если используется SSL.
  3. Если мы используем одностраничное приложение, мы не должны хранить токен в любом месте, просто сохраняем его в переменной JS и создаем запрос ajax с заголовком авторизации. Если вы используете jQuery, вы можете добавить обработчик beforeSend к глобальному ajaxSetup который отправляет заголовок маркера Auth всякий раз, когда вы делаете какой-либо запрос 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);
            }
        }
    });
    

Если мы хотим использовать Cookies

Если мы не хотим внедрять одностраничное приложение и придерживаться файлов cookie, то есть два варианта.

  1. Непостоянные (или сеансовые) файлы cookie: Нестабильные файлы cookie не имеют максимальной продолжительности жизни/срока действия и удаляются, когда пользователь закрывает окно браузера, что делает его настолько предпочтительным в ситуациях безопасности.
  2. Стойкие куки файлы: постоянными куками являются те, у которых максимальная продолжительность жизни/срок действия. Эти файлы cookie сохраняются до истечения периода времени. Персистентные куки файлы предпочтительнее, если вы хотите, чтобы cookie существовал, даже если пользователь закрывает браузер и возвращается на следующий день, тем самым предотвращая аутентификацию каждый раз и улучшая работу пользователя.
document.cookie = '__session=' + token  /* Non-Persistent */
document.cookie = '__session=' + token + ';max-age=' + (3600*24*7) /* Persistent 1 week */

Постоянный или непостоянный, который можно использовать, выбор полностью зависит от проекта. И в случае постоянных файлов cookie максимальный возраст должен быть сбалансированным, это не должно быть месяца или часа. 1 или 2 недели выглядят лучше для меня.

Ответ 2

Вы слишком скептически относитесь к хранению маркера ID Firebase в файле cookie. Сохраняя его в файле cookie, он будет отправлен с каждым запросом в вашу Firebase Cloud.

Идентификатор идентификатора Firebase:

Создано Firebase, когда пользователь входит в приложение Firebase. Эти жетоны являются подписанными JWT, которые надежно идентифицируют пользователя в проекте Firebase. Эти токены содержат основную информацию профиля для пользователя, включая строку идентификатора пользователя, которая уникальна для проекта Firebase. Поскольку целостность идентификационных маркеров ID может быть проверена, вы можете отправить их на серверный сервер, чтобы идентифицировать пользователя, который в настоящий момент вошел в систему.

Как указано в определении токена ID Firebase, целостность токена может быть проверена, поэтому его следует хранить в безопасном месте и отправлять на сервер. Проблема возникает из-за того, что вы не хотите предоставлять этот токен в заголовке Authentication для каждого запроса вашей облачной функции Firebase, так как вы хотите избежать использования запросов AJAX для маршрутизации.

Это возвращает его к использованию файлов cookie, поскольку файлы cookie автоматически отправляются с запросами сервера. Они не так опасны, как вы думаете. Firebase даже имеет пример приложения " Созданные на стороне сервера страницы с шаблонами Handlebars tempating и пользовательские сеансы ", в которых используются куки сеанса для отправки идентификатора идентификатора Firebase.

Вы можете увидеть их пример здесь:

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

Это позволит вам не нуждаться в AJAX и позволяет маршрутам управлять вашей облачной функцией Firebase. Просто не забудьте взглянуть на шаблон Firebase, где они проверяют заголовок на каждой странице.

<script>
    function checkCookie() {
    // Checks if it 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 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>

Ответ 3

Используйте Генерирование защищенных Token-библиотек и добавьте токен непосредственно (Пользовательская полезная нагрузка для справки):

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

Ваши данные токена - это uid (или app_user_id) и isModerator внутри выражения правила, например:

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