From 52631e736bf0de2ebc56038a5ac0fa8db05cf69e Mon Sep 17 00:00:00 2001 From: jonmeow <46229924+jonmeow@users.noreply.github.com> Date: Thu, 28 May 2020 13:36:38 -0700 Subject: [PATCH 1/3] Trying to get cookie working --- src/firebase/functions/index.js | 30 ++++++++++++++++++++++++++---- src/firebase/public/login.html | 23 ++++++++++++++++++----- src/firebase/public/logout.html | 4 ++-- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/firebase/functions/index.js b/src/firebase/functions/index.js index 73a0f61f78feb..72c793e9fed7a 100644 --- a/src/firebase/functions/index.js +++ b/src/firebase/functions/index.js @@ -22,21 +22,39 @@ const { Octokit } = require('@octokit/rest'); const {Storage} = require('@google-cloud/storage'); const gcs = new Storage(); +app.post('/loginSession', (req, res) => { + // Get the ID token passed and the CSRF token. + const idToken = req.body.idToken.toString(); + // Create a 14-day session (the limit). + const expiresIn = 14 * 24 * 60 * 60 * 1000; + admin.auth().createSessionCookie(idToken, {expiresIn}) + .then((sessionCookie) => { + console.log("loginSession: Created session cookie"); + const options = {maxAge: expiresIn, httpOnly: true, secure: true}; + res.cookie('session', sessionCookie, options); + res.end(JSON.stringify({status: 'success'})); + }, error => { + console.log("loginSession: Rejecting due to: " + error); + res.redirect(302, "/logout.html"); + }); +}); + // Checks for a session cookie. If present, the content will be added as // req.user. const validateFirebaseIdToken = async (req, res, next) => { - if (!req.cookies || !req.cookies.__session) { + if (!req.cookies || !req.cookies.session) { // Not logged in. + console.log("validateFirebaseIdToken: No cookie"); res.redirect(302, "/login.html"); return; } - // Validate the cookie, and get user info from it. - const idToken = req.cookies.__session; try { - req.user = await admin.auth().verifyIdToken(idToken); + req.user = + await admin.auth().verifySessionCookie(req.cookies.session, true); } catch (error) { // Invalid login, use logout to clear it. + console.log("validateFirebaseIdToken: Invalid session: " + error); res.redirect(302, "/logout.html"); return; } @@ -58,6 +76,7 @@ const validateFirebaseIdToken = async (req, res, next) => { }); if (ghUser.length < 1 || ghUser[0].id != wantId) { // An issue with the GitHub ID. + console.log("validateFirebaseIdToken: GitHub ID issue: " + wantId); res.redirect(302, "/logout.html"); } const username = ghUser[0].login; @@ -70,12 +89,15 @@ const validateFirebaseIdToken = async (req, res, next) => { }); if (member && member.state == 'active' && member.user.id == wantId) { next(); + console.log("validateFirebaseIdToken: Accepted"); return; } // No access, force logout. + console.log("validateFirebaseIdToken: Not an active member: " + wantId); res.redirect(302, "/logout.html"); } catch (err) { // Not a member, force logout. + console.log("validateFirebaseIdToken: Not a member: " + wantId); res.redirect(302, "/logout.html"); } }; diff --git a/src/firebase/public/login.html b/src/firebase/public/login.html index 547ef522b16cb..c4dc1d52d79af 100644 --- a/src/firebase/public/login.html +++ b/src/firebase/public/login.html @@ -9,6 +9,7 @@ Carbon: Login + diff --git a/src/firebase/public/logout.html b/src/firebase/public/logout.html index 365395b0ed9cb..b07f93b4fd69b 100644 --- a/src/firebase/public/logout.html +++ b/src/firebase/public/logout.html @@ -23,7 +23,7 @@ function redirect() { // Re-clear the cookie, just in case. - document.cookie = "__session=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + document.cookie = "session=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; window.location.href = window.location.href.replace( "/logout.html", "/login.html"); }; @@ -34,7 +34,7 @@ }); window.onload = function() { - document.cookie = "__session=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + document.cookie = "session=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; firebase.auth().signOut(); }; From dde55472d573dd4288c536d713c5d90274be6075 Mon Sep 17 00:00:00 2001 From: jonmeow <46229924+jonmeow@users.noreply.github.com> Date: Fri, 29 May 2020 14:05:09 -0700 Subject: [PATCH 2/3] Rewrite authentication flow. - Use session tokens for: - The 14-day expiry vs previous 1-hour - Validate GitHub identity once per-session - Use caching for performance - Improve logging. --- src/firebase/functions/index.js | 267 ++++++++++++++++++---------- src/firebase/functions/package.json | 3 +- src/firebase/public/login.html | 2 +- src/firebase/public/logout.html | 4 +- 4 files changed, 181 insertions(+), 95 deletions(-) diff --git a/src/firebase/functions/index.js b/src/firebase/functions/index.js index 72c793e9fed7a..9ec98b3e358ed 100644 --- a/src/firebase/functions/index.js +++ b/src/firebase/functions/index.js @@ -8,120 +8,205 @@ const functions = require('firebase-functions'); const admin = require('firebase-admin'); admin.initializeApp(); const cookieParser = require('cookie-parser')(); -const cors = require('cors')({origin: true}); +const cors = require('cors')({ origin: true }); const path = require('path'); const express = require('express'); const app = express(); -const {SecretManagerServiceClient} = require('@google-cloud/secret-manager'); -const secrets = new SecretManagerServiceClient(); +const cache = require('js-cache'); +const contentCache = new cache(); +const sessionCache = new cache(); -const { Octokit } = require('@octokit/rest'); - -const {Storage} = require('@google-cloud/storage'); +const { Storage } = require('@google-cloud/storage'); const gcs = new Storage(); -app.post('/loginSession', (req, res) => { - // Get the ID token passed and the CSRF token. - const idToken = req.body.idToken.toString(); - // Create a 14-day session (the limit). - const expiresIn = 14 * 24 * 60 * 60 * 1000; - admin.auth().createSessionCookie(idToken, {expiresIn}) - .then((sessionCookie) => { - console.log("loginSession: Created session cookie"); - const options = {maxAge: expiresIn, httpOnly: true, secure: true}; - res.cookie('session', sessionCookie, options); - res.end(JSON.stringify({status: 'success'})); - }, error => { - console.log("loginSession: Rejecting due to: " + error); - res.redirect(302, "/logout.html"); +// Validates that the GitHub user associated with the ID token is in the +// 'carbon-language' organization. +const validateIdToken = async (idToken) => { + const startTime = new Date(); + var result = 'unknown'; + var gitHubId = ''; + var username = ''; + try { + var user = null; + try { + user = await admin.auth().verifyIdToken(idToken); + } catch (error) { + return false; + } + gitHubId = user.firebase.identities['github.com'][0]; + + // The associated secret is attached to the CarbonLangInfra GitHub account. + const { SecretManagerServiceClient } = + require('@google-cloud/secret-manager'); + const secrets = new SecretManagerServiceClient(); + const [secret] = await secrets.accessSecretVersion({ + name: + 'projects/985662022432/secrets/github-org-lookup-token-for-www/versions/latest', + }); + + const { Octokit } = require('@octokit/rest'); + const octokit = new Octokit({ + auth: secret.payload.data.toString('utf8'), + }); + + const { data: ghUser } = await octokit.users.list({ + since: gitHubId - 1, + per_page: 1, }); -}); - -// Checks for a session cookie. If present, the content will be added as -// req.user. -const validateFirebaseIdToken = async (req, res, next) => { - if (!req.cookies || !req.cookies.session) { - // Not logged in. - console.log("validateFirebaseIdToken: No cookie"); - res.redirect(302, "/login.html"); - return; + if (ghUser.length < 1 || ghUser[0].id != gitHubId) { + result = 'Failed to fetch matching GitHub ID'; + return false; + } + username = ghUser[0].login; + + try { + const { data: member } = await octokit.orgs.getMembership({ + org: 'carbon-language', + username: username, + }); + if (member && member.state == 'active' && member.user.id == gitHubId) { + result = 'Pass'; + return true; + } + result = 'Not an active member'; + return false; + } catch (err) { + result = 'Not a member'; + return false; + } + + return false; // Should be unreachable. + } finally { + const elapsed = new Date() - startTime; + console.log( + `validateIdToken: ${result} (${gitHubId}/${username}; ${elapsed}ms)` + ); } +}; +// Handles a user logging into a session. +const loginSession = async (req, res) => { + const startTime = new Date(); + var result = 'unknown'; try { - req.user = - await admin.auth().verifySessionCookie(req.cookies.session, true); - } catch (error) { - // Invalid login, use logout to clear it. - console.log("validateFirebaseIdToken: Invalid session: " + error); - res.redirect(302, "/logout.html"); - return; - } + // Get the ID token passed and the CSRF token. + const idToken = req.body.idToken.toString(); + const isOk = await validateIdToken(idToken); + if (!isOk) { + result = 'validateIdToken failed'; + res.redirect(302, '/logout.html'); + return; + } + + // Create a 14-day session (the limit). + const expiresIn = 14 * 24 * 60 * 60 * 1000; + var sessionCookie; + try { + sessionCookie = await admin + .auth() + .createSessionCookie(idToken, { expiresIn }); + } catch (error) { + result = `createSessionCookie failed: ${error}`; + res.redirect(302, '/logout.html'); + return; + } - // Load the GitHub auth token. The associated secret is attached to the - // CarbonLangInfra GitHub account. - const [secret] = await secrets.accessSecretVersion({ - name: 'projects/985662022432/secrets/github-org-lookup-token-for-www/versions/latest', - }); - const octokit = new Octokit({ - auth: secret.payload.data.toString('utf8'), - }); - - // Translate the GitHub ID to a username. - const wantId = req.user.firebase.identities["github.com"][0]; - const { data: ghUser } = await octokit.users.list({ - since: wantId - 1, - per_page: 1, - }); - if (ghUser.length < 1 || ghUser[0].id != wantId) { - // An issue with the GitHub ID. - console.log("validateFirebaseIdToken: GitHub ID issue: " + wantId); - res.redirect(302, "/logout.html"); + result = 'Pass'; + const options = { maxAge: expiresIn, httpOnly: true, secure: true }; + res.cookie('__session', sessionCookie, options); + res.end(JSON.stringify({ status: 'success' })); + } finally { + const elapsed = new Date() - startTime; + console.log(`loginSessior: ${result} (${elapsed}ms)`); } - const username = ghUser[0].login; +}; - // Validate that the GitHub user is in carbon-language. +// Checks for a session cookie. +const validateSessionToken = async (req, res, next) => { + const startTime = new Date(); + var result = 'unknown'; try { - const { data: member } = await octokit.orgs.getMembership({ - org: 'carbon-language', - username: username, - }); - if (member && member.state == 'active' && member.user.id == wantId) { + if (!req.cookies || !req.cookies.__session) { + // Not logged in. + result = 'No __session cookie'; + res.redirect(302, '/login.html'); + return; + } + const sessionCookie = req.cookies.__session; + + if (sessionCache.get(sessionCookie)) { + result = 'Pass (cache hit)'; next(); - console.log("validateFirebaseIdToken: Accepted"); return; } - // No access, force logout. - console.log("validateFirebaseIdToken: Not an active member: " + wantId); - res.redirect(302, "/logout.html"); - } catch (err) { - // Not a member, force logout. - console.log("validateFirebaseIdToken: Not a member: " + wantId); - res.redirect(302, "/logout.html"); + + try { + const user = await admin.auth().verifySessionCookie(sessionCookie, false); + } catch (error) { + // Invalid login, use logout to clear it. + result = `Invalid __session: ${error}`; + res.redirect(302, '/logout.html'); + return; + } + + result = 'Pass (cache miss)'; + // Cache positive results for an hour. + sessionCache.set(sessionCookie, true, 60 * 60 * 1000); + next(); + } finally { + const elapsed = new Date() - startTime; + console.log( + `validateSessionToken at ${req.path}: ${result} (${elapsed}ms)` + ); } }; -app.use(cors); -app.use(cookieParser); -app.use(validateFirebaseIdToken); +// Handles serving content from GCS. +const serveContent = async (req, res) => { + const startTime = new Date(); + var result = 'unknown'; + try { + // Remove the prefix /, and default to index.html. + var file = req.path.replace(/^(\/)/, ''); + if (file === '') { + file = 'index.html'; + } + res.type(path.extname(file)); + + // Check cache. + var cacheHit = "hit"; + var contents = contentCache.get(file); + if (!contents) { + cacheHit = "miss"; + // Serve the requested data from the carbon-lang bucket. + const bucket = gcs.bucket('gs://www.carbon-lang.dev'); + var contents; + try { + const data = await bucket.file(file).download(); + contents = data[0]; + // Cache content for 15 minutes. + contentCache.set(file, contents, 5 * 60 * 1000); + } catch (error) { + result = `Error: ${error}`; + res.status(404).send('Not found'); + return; + } + } -app.get('*', (req, res) => { - // Remove the prefix /, and default to index.html. - var file = req.path.replace(/^(\/)/, ""); - if (file === "") { - file = "index.html"; + result = `Pass (cache ${cacheHit}; ${contents.length} bytes)`; + res.send(contents); + } finally { + const elapsed = new Date() - startTime; + console.log(`serveContent at ${req.path}: ${result} (${elapsed}ms)`); } - res.type(path.extname(file)); - // Serve the requested data from the carbon-lang bucket. - const bucket = gcs.bucket("gs://www.carbon-lang.dev"); - const stream = bucket.file(file).createReadStream(); - //stream.on('error', function(err) { res.status(404).send(err.message); }); - stream.on('error', function(err) { - console.log(err.message); - res.status(404).send("Not found"); - }); - stream.pipe(res); -}); +}; + +app.use(cors); +app.use(cookieParser); +app.post('/loginSession', loginSession); +app.use(validateSessionToken); +app.get('*', serveContent); exports.site = functions.https.onRequest(app); diff --git a/src/firebase/functions/package.json b/src/firebase/functions/package.json index 7c4d5764d08fa..9457f0819b7bd 100644 --- a/src/firebase/functions/package.json +++ b/src/firebase/functions/package.json @@ -18,7 +18,8 @@ "cookie-parser": "^1.4.5", "cors": "^2.8.5", "firebase-admin": "^8.10.0", - "firebase-functions": "^3.6.1" + "firebase-functions": "^3.6.2", + "js-cache": "^1.0.3" }, "devDependencies": { "firebase-functions-test": "^0.2.0" diff --git a/src/firebase/public/login.html b/src/firebase/public/login.html index c4dc1d52d79af..4bf124959fa6f 100644 --- a/src/firebase/public/login.html +++ b/src/firebase/public/login.html @@ -47,7 +47,7 @@ }; window.onload = function() { - document.cookie = "session=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + document.cookie = "__session=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; firebase.auth().signOut(); }; diff --git a/src/firebase/public/logout.html b/src/firebase/public/logout.html index b07f93b4fd69b..365395b0ed9cb 100644 --- a/src/firebase/public/logout.html +++ b/src/firebase/public/logout.html @@ -23,7 +23,7 @@ function redirect() { // Re-clear the cookie, just in case. - document.cookie = "session=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + document.cookie = "__session=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; window.location.href = window.location.href.replace( "/logout.html", "/login.html"); }; @@ -34,7 +34,7 @@ }); window.onload = function() { - document.cookie = "session=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + document.cookie = "__session=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; firebase.auth().signOut(); }; From 3b1da2c5bf77422a33457d183003af65b8b86e45 Mon Sep 17 00:00:00 2001 From: jonmeow <46229924+jonmeow@users.noreply.github.com> Date: Mon, 1 Jun 2020 11:13:19 -0700 Subject: [PATCH 3/3] validateSessionToken -> validateSessionCookie --- src/firebase/functions/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/firebase/functions/index.js b/src/firebase/functions/index.js index 9ec98b3e358ed..9c2c9aa158921 100644 --- a/src/firebase/functions/index.js +++ b/src/firebase/functions/index.js @@ -124,7 +124,7 @@ const loginSession = async (req, res) => { }; // Checks for a session cookie. -const validateSessionToken = async (req, res, next) => { +const validateSessionCookie = async (req, res, next) => { const startTime = new Date(); var result = 'unknown'; try { @@ -158,7 +158,7 @@ const validateSessionToken = async (req, res, next) => { } finally { const elapsed = new Date() - startTime; console.log( - `validateSessionToken at ${req.path}: ${result} (${elapsed}ms)` + `validateSessionCookie at ${req.path}: ${result} (${elapsed}ms)` ); } }; @@ -206,7 +206,7 @@ const serveContent = async (req, res) => { app.use(cors); app.use(cookieParser); app.post('/loginSession', loginSession); -app.use(validateSessionToken); +app.use(validateSessionCookie); app.get('*', serveContent); exports.site = functions.https.onRequest(app);