Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite authentication flow. #31

Merged
merged 3 commits into from
Jun 1, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 177 additions & 70 deletions src/firebase/functions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,98 +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();

// 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.
res.redirect(302, "/login.html");
return;
// 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 = '<noid>';
var username = '<nouser>';
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,
});
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)`
);
}
};

// Validate the cookie, and get user info from it.
const idToken = req.cookies.__session;
// 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().verifyIdToken(idToken);
} catch (error) {
// Invalid login, use logout to clear it.
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.
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 validateSessionCookie = 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();
return;
}
// No access, force logout.
res.redirect(302, "/logout.html");
} catch (err) {
// Not a member, force logout.
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(
`validateSessionCookie 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(validateSessionCookie);
app.get('*', serveContent);

exports.site = functions.https.onRequest(app);
3 changes: 2 additions & 1 deletion src/firebase/functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 17 additions & 4 deletions src/firebase/public/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<title>Carbon: Login</title>
<script src="/__/firebase/7.14.0/firebase-app.js"></script>
<script src="/__/firebase/7.14.0/firebase-auth.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>
<script type="text/javascript">
// See https://carbon-lang.dev/__/firebase/init.js.
// We customize authDomain.
Expand All @@ -21,14 +22,26 @@
"storageBucket": "carbon-language.appspot.com"
});

function postIdToken(idToken) {
// POST to session login endpoint.
return $.ajax({
type: "POST",
url: "/loginSession",
dataType: "json",
data: {idToken: idToken},
contentType: "application/x-www-form-urlencoded",
});
};

function login() {
var provider = new firebase.auth.GithubAuthProvider();
firebase.auth().signInWithPopup(provider).then(function(result) {
var user = result.user;
user.getIdToken(true).then(function(token) {
document.cookie = '__session=' + token + ';max-age=25920000';
window.location.href = window.location.href.replace(
"/login.html", "/index.html");
user.getIdToken(true).then(function(idToken) {
postIdToken(idToken).then(function(result) {
window.location.href = window.location.href.replace(
"/login.html", "/index.html");
});
});
});
};
Expand Down