使用 vanilla JavaScript 在客户端处理 Firebase ID 令牌

Handling Firebase ID tokens on the client side with vanilla JavaScript

我正在用 vanilla JavaScript 编写一个 Firebase 应用程序。我正在为 Web 使用 Firebase 身份验证和 FirebaseUI。我正在使用 Firebase Cloud Functions 来实现一个服务器,该服务器接收对我的页面路由的请求并 returns 呈现 HTML。我正在努力寻找在客户端使用经过身份验证的 ID 令牌访问由我的 Firebase 云函数提供的受保护路由的最佳实践。

我相信我理解基本流程:用户登录,这意味着一个 ID 令牌被发送到客户端,它在 onAuthStateChanged 回调中被接收,然后插入到 Authorization 任何具有正确前缀的新 HTTP 请求的字段,然后在用户尝试访问受保护的路由时由服务器检查。

我不明白我应该如何处理 onAuthStateChanged 回调中的 ID 令牌,或者我应该如何修改我的客户端 JavaScript 以修改请求 headers必要的。

我正在使用 Firebase Cloud Functions 来处理路由请求。这是我的 functions/index.js,它导出所有请求都重定向到的 app 方法以及检查 ID 令牌的位置:

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 is my functions/package.json,它描述了处理作为 Firebase 云函数实现的 HTTP 请求的服务器的配置:

{
  "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.')
  }
})

我应该如何处理客户端经过身份验证的 ID 令牌?

Cookies/localStorage/webStorage 似乎不是完全安全的,至少不是我能找到的任何相对简单和可扩展的方式。可能有一个简单的 cookie-based 过程,它与直接在请求中包含令牌一样安全 header,但我无法找到可以轻松应用于 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。我无法弄清楚如何将令牌插入到正常路由请求的 header 中,例如通过单击具有有效 href 的锚标记触发的请求。我应该拦截这些请求并以某种方式修改它们吗?

在非单页应用程序的 Firebase for Web 应用程序中,可扩展客户端安全性的最佳实践是什么?我不需要复杂的身份验证流程。我愿意为我可以信任和简单实施的安全系统牺牲灵活性。

Why cookies are not secured?

  1. Cookie 数据很容易被篡改,如果开发人员愚蠢到将登录用户的角色存储在 cookie 中,则用户可以轻松更改其 cookie 数据,document.cookie = "role=admin"。 (瞧!)
  2. 黑客可以通过XSS攻击轻松获取Cookie数据并登录您的帐户。
  3. 可以很容易地从您的浏览器收集 Cookie 数据,您的室友可以窃取您的 cookie 并从他的计算机上以您的身份登录。
  4. 如果您未使用 SSL,任何监视您的网络流量的人都可以收集您的 cookie。

Do you need to be concerned?

  1. 我们不会在 cookie 中存储任何愚蠢的东西,用户可以修改这些内容以获得任何未经授权的访问。
  2. 如果黑客可以通过 XSS 攻击 pick-up cookie 数据,如果我们不使用单页应用程序,他也可以获取 Auth 令牌(因为我们会将令牌存储在某个地方,例如 localstorage)。
  3. 你的室友也可以获取你的本地存储数据。
  4. 任何监控您网络的人也可以获取您的授权 header,除非您使用 SSL。 Cookie 和 Authorization 都以纯文本形式在 http header.
  5. 中发送

What should we do?

  1. 如果我们将令牌存储在某个地方,则与 cookie 相比没有安全优势,Auth 令牌最适合添加额外安全性的单页应用程序或 cookie 不可用的地方。
  2. 如果我们担心有人监控网络流量,我们应该使用 SSL 托管我们的网站。如果使用 SSL,则无法拦截 Cookie 和 http-headers。
  3. 如果我们使用单页应用程序,我们不应该将令牌存储在任何地方,只需将其保存在 JS 变量中并创建 ajax 请求授权 header。如果您使用的是 jQuery,则可以向全局 ajaxSetup 添加一个 beforeSend 处理程序,它会在您创建时发送 Auth 令牌 header任何 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);
            }
        }
    });
    

If we want to use Cookies

如果我们不想实现单页应用程序并坚持使用 cookie,那么有两个选项可供选择。

  1. Non-Persistent(或session)cookies: Non-persistent cookies没有max-life/expiration日期,当用户使用时被删除关闭浏览器 window,因此在涉及安全性的情况下更可取。
  2. 持久性 cookie: 持久性 cookie 是具有 max-life/expiration 日期的那些。这些 cookie 会一直保留到时间段结束。如果您希望即使用户关闭浏览器并在第二天返回时 cookie 仍然存在,则首选持久性 cookie,从而防止每次都进行身份验证并改善用户体验。
document.cookie = '__session=' + token  /* Non-Persistent */
document.cookie = '__session=' + token + ';max-age=' + (3600*24*7) /* Persistent 1 week */

持久性或 Non-Persistent 使用哪一个,选择完全取决于项目。在持久性 cookie 的情况下,max-age 应该是平衡的,不应该是一个月或一个小时。 1 或 2 周对我来说是更好的选择。

使用Generating a Secure Token libraries and add token directly (Custom auth payload):

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

你的token数据是uid(或app_user_id)和isModerator里面的规则表达式,例如:

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

您过于怀疑将 Firebase ID 令牌存储在 cookie 中。通过将它存储在 cookie 中,它会随每个请求发送到您的 Firebase 云功能。

Firebase ID 令牌:

Created by Firebase when a user signs in to a Firebase app. These tokens are signed JWTs that securely identify a user in a Firebase project. These tokens contain basic profile information for a user, including the user's ID string, which is unique to the Firebase project. Because the integrity of ID tokens can be verified, you can send them to a backend server to identify the currently signed-in user.

正如 Firebase ID 令牌定义中所述,可以验证令牌的完整性,因此存储和发送到您的服务器应该是安全的。出现问题是因为您不想在身份验证 header 中为每个对 Firebase 云功能的请求提供此令牌,因为您希望避免使用 AJAX 请求进行路由。

这又回到了使用 cookie 的情况,因为 cookie 是随服务器请求自动发送的。它们并不像您想象的那样危险。 Firebase 甚至有一个名为“Server-side generated pages w/ Handlebars templating and user sessions”的示例应用程序,它利用 session cookie 发送 Firebase ID 令牌。

你可以看到他们的例子 here:

// 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 的模板,他们在每个 page.

上检查 header
<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>