diff --git a/join.js b/join.js index 59d33f1d..c776b7fe 100644 --- a/join.js +++ b/join.js @@ -125,9 +125,11 @@ async function handleOAuth2Code (request, response, next) { // If they match, use the code to request an OAuth2 access token. try { // Associate the new OAuth2 access token to the current session. - // TODO: Also save the `refreshToken` when it's fully supported. - const { accessToken } = await getOAuth2AccessToken(code, state); - oauth2Tokens[session.id] = accessToken; + oauth2Tokens[session.id] = await getOAuth2AccessToken(code, state); + + // Consider that this OAuth2 access token expires at some point. + //const created = Date.now(); + //const expires = created + 1000 * 3600 * 30; // 30 hours from now. } catch (error) { log('[fail] oauth2 access token', error); response.statusCode = 403; // Forbidden @@ -154,10 +156,15 @@ async function handleOAuth2Code (request, response, next) { async function ensureOAuth2Access (request, response, next) { const { session } = request; if (oauth2Tokens[session.id]) { - // This session has an OAuth2 access token, so it's authenticated. - // TODO: Also verify that the token is still valid, or renew it if not. - next(); - return; + const expires = oauth2Tokens[session.id].expires; + if (expires && expires > Date.now()) { + // This session has a valid OAuth2 access token, so it's authenticated. + next(); + return; + } + + // This sessions's OAuth2 access token has expired. + delete oauth2Tokens[session.id]; } // We can only use `http.ServerResponse`s to initiate OAuth2 authentication, @@ -254,7 +261,7 @@ function routeRequest (proxyParameters, request, response) { } // Use the Janitor API to get the mapping information of a given container port. -async function getMappedPort (accessToken, container, port) { +async function getMappedPort ({ accessToken }, container, port) { const parameters = { provider: 'janitor', accessToken: accessToken, diff --git a/lib/hosts.js b/lib/hosts.js index 5c87bb1e..cc02b4a4 100644 --- a/lib/hosts.js +++ b/lib/hosts.js @@ -206,10 +206,15 @@ exports.issueOAuth2AccessToken = async function (request) { const { scope: grant, token, tokenHash } = data; const scope = stringifyScopes(grant.scopes); + // Make this OAuth2 authorization expire at some point. + const created = Date.now(); + const expires = created + 1000 * 3600 * 30; // 30 hours from now. + const authorization = { client: client_id, - date: Date.now(), email: grant.email, + created, + expires, scope, }; @@ -222,7 +227,7 @@ exports.issueOAuth2AccessToken = async function (request) { db.save(); log(hostname, 'was granted access to', scope, 'by', authorization.email); - return { access_token: token, scope }; + return { access_token: token, created, expires, scope }; }; // Find the OAuth2 access scope authorized for a request's access token. @@ -246,7 +251,15 @@ exports.getOAuth2Scope = function (request) { return null; } - const { client, email, scope } = authorization; + const { client, email, scope, expires = null } = authorization; + if (!(expires && expires > Date.now())) { + // The OAuth2 authorization has expired, delete it. + log('[fail] deleting expired token:', authorization); + delete oauth2tokens[tokenHash]; + db.save(); + return null; + } + const hostname = exports.identify(client); if (!hostname) { // The authorized OAuth2 client doesn't exist anymore. diff --git a/lib/machines.js b/lib/machines.js index 1a3c0bec..a75615f7 100644 --- a/lib/machines.js +++ b/lib/machines.js @@ -5,6 +5,7 @@ const jsonpatch = require('fast-json-patch'); const db = require('./db'); const docker = require('./docker'); +const events = require('./events'); const log = require('./log'); const metrics = require('./metrics'); const streams = require('./streams'); @@ -217,6 +218,7 @@ exports.spawn = function (user, projectId, callback) { machine.status = 'started'; const now = Date.now(); + metrics.set(machine, 'spawned', now); metrics.push(project, 'spawn-time', [ now, now - time ]); const expires = now + maximumContainerLifetime; @@ -292,6 +294,16 @@ exports.destroy = function (user, projectId, machineId, callback) { }); }; +// Destroy user containers when they expire. +events.on('ContainerExpired', ({ email, host, container }) => { + log('[event] container expired', email, host, container); + // FIXME: Actually destroy the container, but only once: + // 1. Container expiration is made sufficiently obvious on the website + // 2. Email reminders are sent some time before actual expiration + // 3. Users have been given ample time to save their work (e.g. by pushing + // commits to a remote repository, or by migrating to a new container). +}); + // Install or overwrite a configuration file in all the user's containers. exports.deployConfigurationInAllContainers = function (user, file) { let count = 0; diff --git a/lib/oauth2.js b/lib/oauth2.js index 7cdf6450..e0b25097 100644 --- a/lib/oauth2.js +++ b/lib/oauth2.js @@ -66,7 +66,7 @@ exports.getAccessToken = async function (parameters) { return; } - resolve({ accessToken, refreshToken }); + resolve({ accessToken, refreshToken, results }); // expires ? }; client.getOAuthAccessToken(code, options, onResults); diff --git a/lib/users.js b/lib/users.js index d83c3c54..e5b1cf96 100644 --- a/lib/users.js +++ b/lib/users.js @@ -1,6 +1,9 @@ // Copyright © 2016 Jan Keromnes. All rights reserved. // The following code is covered by the AGPL-3.0 license. +const nodemailer = require('nodemailer'); +const timeago = require('timeago.js'); + const certificates = require('./certificates'); const configurations = require('./configurations'); const db = require('./db'); @@ -14,6 +17,7 @@ const hostnames = db.get('hostnames', [ 'localhost' ]); const security = db.get('security'); const baseUrl = (security.forceHttp ? 'http' : 'https') + '://' + hostnames[0] + (hostnames[0] === 'localhost' ? ':' + db.get('ports').https : ''); +const transport = nodemailer.createTransport(db.get('mailer')); // Get a user for the current session. exports.get = function (request, callback) { @@ -51,6 +55,25 @@ exports.get = function (request, callback) { }); }; +// Find an existing user with the given primary or alternative email address. +exports.getUserByEmail = function (email) { + const users = db.get('users'); + + // Look for a primary email address. + if (email in users) { + return users[email]; + } + + // Search among alternative email addresses. + for (const user of users) { + if (user.emails.includes(email) || user.keys.github.emails.includes(email)) { + return user; + } + } + + return null; +}; + // Destroy the current session. exports.logout = function (request, callback) { sessions.destroy(request, error => { @@ -158,7 +181,7 @@ exports.sendLoginEmail = function (email, request, callback) { // Login email template. const template = { subject () { - return 'Janitor Sign-in link'; + return 'Janitor sign-in link'; }, htmlMessage (key) { const url = baseUrl + '/?key=' + encodeURIComponent(key); @@ -200,7 +223,7 @@ exports.sendInviteEmail = function (email, callback) { // Invite email template. const template = { subject () { - return 'Janitor Invite'; + return 'Janitor invite'; }, htmlMessage (key) { const url = baseUrl + '/projects/?key=' + encodeURIComponent(key); @@ -241,6 +264,52 @@ exports.sendInviteEmail = function (email, callback) { }); }; +// Remind a user that one of their containers is about to expire. +exports.sendContainerExpiryReminderEmail = async function (user, container, expires) { + const expiresFuzzy = timeago().format(expires); // E.g. "in 3 days". + const expiresExact = (new Date(expires)).toUTCString().replace(/GMT/g, 'UTC'); + const subject = 'Janitor expiration notice for container "' + container + '"'; + const html = '

Hello,

\n' + + '

Your Janitor container "' + container + '" will expire ' + expiresFuzzy + + ' (on ' + expiresExact + '):

\n' + + '

View your containers

\n' + + '

Please back up your work before then, e.g. by moving it into a new container, ' + + 'or you will lose any unexported changes.

\n' + + '

For any questions or support, please visit ' + + 'our Discourse forum.

\n' + + '

Thanks!

\n'; + const text = 'Hello,\n\n' + + 'Your Janitor container "' + container + '" will expire ' + expiresFuzzy + + ' (on ' + expiresExact + '):\n\n' + + 'https://janitor.technology/containers/\n\n' + + 'Please back up your work before then, e.g. by moving it into a new container, ' + + 'or you will lose any unexported changes.\n\n' + + 'For any questions or support, please visit https://discourse.janitor.technology/.\n\n' + + 'Thanks!\n'; + return sendEmail(user, subject, html, text); +}; + +// Send a custom email to a user. +function sendEmail (user, subject, html, text) { + const options = { + from: db.get('mailer').from, + to: user._primaryEmail, + subject, + html, + text + }; + + return new Promise((resolve, reject) => { + transport.sendMail(options, (error, info) => { + if (error) { + reject(error); + return; + } + resolve(info); + }); + }); +} + // Find an existing user, or create a new one. function getOrCreateUser (email) { const users = db.get('users'); diff --git a/package.json b/package.json index 32194845..263061c9 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "le-acme-core": "2.1.4", "ms-rest-azure": "2.5.9", "node-forge": "0.7.6", + "nodemailer": "4.4.2", "oauth": "0.9.15", "oauth2provider": "0.0.2", "selfapi": "1.0.0", diff --git a/static/js/janitor.js b/static/js/janitor.js index 435ee2f3..270227a0 100644 --- a/static/js/janitor.js +++ b/static/js/janitor.js @@ -240,7 +240,7 @@ function getFormData (form) { }, {}); } -// Setup editable labels. +// Setup editable container labels. Array.forEach(document.querySelectorAll('.editable-label'), function (label) { const toggle = label.querySelector('.editable-toggle'); if (!toggle) { diff --git a/static/js/projects.js b/static/js/projects.js index 37f1086b..95bbd1ad 100644 --- a/static/js/projects.js +++ b/static/js/projects.js @@ -1,8 +1,27 @@ // Copyright © 2016 Jan Keromnes. All rights reserved. // The following code is covered by the AGPL-3.0 license. -// Add status badges to elements with a 'data-status' attribute. +// Spawn a project-specific machine when one of its links is clicked. +Array.forEach(document.querySelectorAll('.new-container'), function (form) { + window.setupAsyncForm(form); + form.addEventListener('submit', function (event) { + var url = '/api/hosts/' + form.dataset.host + + '/containers?project=' + form.dataset.project; + window.fetchAPI('PUT', url, null, function(error, data) { + if (error) { + window.updateFormStatus(form, 'error', String(error)); + return; + } + window.updateFormStatus(form, 'success', null); + setTimeout(function () { + document.location.href = + '/containers/#' + data.container.slice(0, 16); + }, 400); + }); + }); +}); +// Add status badges to elements with a 'data-status' attribute. Array.map(document.querySelectorAll('*[data-status]'), function (element) { var status = element.dataset.status; @@ -23,9 +42,8 @@ Array.map(document.querySelectorAll('*[data-status]'), function (element) { }); // Add fuzzy timestamps to elements with a 'data-timestamp' attribute. -var timestampElements = document.querySelectorAll('[data-timestamp]'); -Array.forEach(timestampElements, function (element) { - var date = new Date(parseInt(element.dataset.timestamp)); +Array.forEach(document.querySelectorAll('*[data-timestamp]'), function (element) { + var date = new Date(parseInt(element.dataset.timestamp, 10)); // GMT is deprecated (see https://en.wikipedia.org/wiki/UTC). element.title = date.toUTCString().replace('GMT', 'UTC');