Implemented FxA

This commit is contained in:
Danny Coates 2018-08-07 15:40:17 -07:00
parent 70bc2b7656
commit 718d74fa50
No known key found for this signature in database
GPG key ID: 4C442633C62E00CB
40 changed files with 1306 additions and 651 deletions

View file

@ -21,7 +21,7 @@ const conf = convict({
},
expire_times_seconds: {
format: Array,
default: [300, 3600, 86400, 604800, 1209600],
default: [300, 3600, 86400, 604800],
env: 'EXPIRE_TIMES_SECONDS'
},
default_expire_seconds: {
@ -31,9 +31,34 @@ const conf = convict({
},
max_expire_seconds: {
format: Number,
default: 1209600,
default: 86400 * 7,
env: 'MAX_EXPIRE_SECONDS'
},
anon_max_expire_seconds: {
format: Number,
default: 86400,
env: 'ANON_MAX_EXPIRE_SECONDS'
},
max_downloads: {
format: Number,
default: 200,
env: 'MAX_DOWNLOADS'
},
anon_max_downloads: {
format: Number,
default: 20,
env: 'ANON_MAX_DOWNLOADS'
},
max_files_per_archive: {
format: Number,
default: 64,
env: 'MAX_FILES_PER_ARCHIVE'
},
max_archives_per_user: {
format: Number,
default: 16,
env: 'MAX_ARCHIVES_PER_USER'
},
redis_host: {
format: String,
default: 'localhost',
@ -77,9 +102,14 @@ const conf = convict({
},
max_file_size: {
format: Number,
default: 1024 * 1024 * 1024 * 3,
default: 1024 * 1024 * 1024 * 4,
env: 'MAX_FILE_SIZE'
},
anon_max_file_size: {
format: Number,
default: 1024 * 1024 * 500,
env: 'ANON_MAX_FILE_SIZE'
},
l10n_dev: {
format: Boolean,
default: false,
@ -94,6 +124,21 @@ const conf = convict({
format: 'String',
default: `${tmpdir()}${path.sep}send-${randomBytes(4).toString('hex')}`,
env: 'FILE_DIR'
},
fxa_url: {
format: 'url',
default: 'https://stable.dev.lcip.org',
env: 'FXA_URL'
},
fxa_client_id: {
format: String,
default: 'b50ec33d3c9beb6d', // localhost
env: 'FXA_CLIENT_ID'
},
fxa_client_secret: {
format: String,
default: '05ac76fbe3e739c9effbaea439bc07d265c613c5e0da9070590a2378377c09d8', // localhost
env: 'FXA_CLIENT_SECRET'
}
});

17
server/initScript.js Normal file
View file

@ -0,0 +1,17 @@
const html = require('choo/html');
const raw = require('choo/html/raw');
module.exports = function(state) {
// return '';
return state.cspNonce
? html`
<script nonce="${state.cspNonce}">
const userInfo = ${
state.user.loggedIn ? raw(JSON.stringify(state.user)) : 'null'
};
const downloadMetadata = ${
state.downloadMetadata ? raw(JSON.stringify(state.downloadMetadata)) : '{}'
};
</script>`
: '';
};

View file

@ -1,6 +1,7 @@
const html = require('choo/html');
const assets = require('../common/assets');
const locales = require('../common/locales');
const initScript = require('./initScript');
module.exports = function(state, body = '') {
const firaTag = state.fira
@ -73,6 +74,7 @@ module.exports = function(state, body = '') {
<script defer src="${assets.get('app.js')}"></script>
</head>
${body}
${initScript(state)}
</html>
`;
};

View file

@ -1,38 +1,70 @@
const crypto = require('crypto');
const storage = require('../storage');
const fxa = require('../routes/fxa');
module.exports = async function(req, res, next) {
const id = req.params.id;
if (id && req.header('Authorization')) {
try {
const auth = req.header('Authorization').split(' ')[1];
const meta = await storage.metadata(id);
if (!meta) {
return res.sendStatus(404);
}
const hmac = crypto.createHmac(
'sha256',
Buffer.from(meta.auth, 'base64')
);
hmac.update(Buffer.from(meta.nonce, 'base64'));
const verifyHash = hmac.digest();
if (verifyHash.equals(Buffer.from(auth, 'base64'))) {
req.nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', req.nonce);
res.set('WWW-Authenticate', `send-v1 ${req.nonce}`);
req.authorized = true;
req.meta = meta;
} else {
res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`);
module.exports = {
hmac: async function(req, res, next) {
const id = req.params.id;
const authHeader = req.header('Authorization');
if (id && authHeader) {
try {
const auth = req.header('Authorization').split(' ')[1];
const meta = await storage.metadata(id);
if (!meta) {
return res.sendStatus(404);
}
const hmac = crypto.createHmac(
'sha256',
Buffer.from(meta.auth, 'base64')
);
hmac.update(Buffer.from(meta.nonce, 'base64'));
const verifyHash = hmac.digest();
if (verifyHash.equals(Buffer.from(auth, 'base64'))) {
req.nonce = crypto.randomBytes(16).toString('base64');
storage.setField(id, 'nonce', req.nonce);
res.set('WWW-Authenticate', `send-v1 ${req.nonce}`);
req.authorized = true;
req.meta = meta;
} else {
res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`);
req.authorized = false;
}
} catch (e) {
req.authorized = false;
}
} catch (e) {
req.authorized = false;
}
}
if (req.authorized) {
next();
} else {
res.sendStatus(401);
if (req.authorized) {
next();
} else {
res.sendStatus(401);
}
},
owner: async function(req, res, next) {
const id = req.params.id;
const ownerToken = req.body.owner_token;
if (id && ownerToken) {
try {
req.meta = await storage.metadata(id);
if (!req.meta) {
return res.sendStatus(404);
}
req.authorized = req.meta.owner === ownerToken;
} catch (e) {
req.authorized = false;
}
}
if (req.authorized) {
next();
} else {
res.sendStatus(401);
}
},
fxa: async function(req, res, next) {
const authHeader = req.header('Authorization');
if (authHeader && /^Bearer\s/i.test(authHeader)) {
const token = authHeader.split(' ')[1];
req.user = await fxa.verify(token);
}
return next();
}
};

View file

@ -1,22 +0,0 @@
const storage = require('../storage');
module.exports = async function(req, res, next) {
const id = req.params.id;
const ownerToken = req.body.owner_token;
if (id && ownerToken) {
try {
req.meta = await storage.metadata(id);
if (!req.meta) {
return res.sendStatus(404);
}
req.authorized = req.meta.owner === ownerToken;
} catch (e) {
req.authorized = false;
}
}
if (req.authorized) {
next();
} else {
res.sendStatus(401);
}
};

View file

@ -14,15 +14,15 @@ module.exports = async function(req, res) {
'WWW-Authenticate': `send-v1 ${req.nonce}`
});
const file_stream = await storage.get(id);
const fileStream = await storage.get(id);
let cancelled = false;
req.on('close', () => {
cancelled = true;
file_stream.destroy();
fileStream.destroy();
});
file_stream.on('end', async () => {
fileStream.on('end', async () => {
if (cancelled) {
return;
}
@ -40,7 +40,7 @@ module.exports = async function(req, res) {
}
});
file_stream.pipe(res);
fileStream.pipe(res);
} catch (e) {
res.sendStatus(404);
}

49
server/routes/filelist.js Normal file
View file

@ -0,0 +1,49 @@
const config = require('../config');
const storage = require('../storage');
const Limiter = require('../limiter');
function id(user) {
return `filelist-${user}`;
}
module.exports = {
async get(req, res) {
if (!req.user) {
return res.sendStatus(401);
}
try {
const fileId = id(req.user);
const contentLength = await storage.length(fileId);
const fileStream = await storage.get(fileId);
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': contentLength
});
fileStream.pipe(res);
} catch (e) {
res.sendStatus(404);
}
},
async post(req, res) {
if (!req.user) {
return res.sendStatus(401);
}
try {
const limiter = new Limiter(1024 * 1024 * 10);
const fileStream = req.pipe(limiter);
await storage.set(
id(req.user),
fileStream,
{ n: 'a' }, //TODO
config.max_expire_seconds
);
res.sendStatus(200);
} catch (e) {
if (e.message === 'limit') {
return res.sendStatus(413);
}
res.sendStatus(500);
}
}
};

96
server/routes/fxa.js Normal file
View file

@ -0,0 +1,96 @@
const { URLSearchParams } = require('url');
const fetch = require('node-fetch');
const config = require('../config');
const pages = require('./pages');
const KEY_SCOPE = 'https://identity.mozilla.com/apps/send';
let fxaConfig = null;
let lastConfigRefresh = 0;
async function getFxaConfig() {
if (fxaConfig && Date.now() - lastConfigRefresh < 1000 * 60 * 5) {
return fxaConfig;
}
const res = await fetch(`${config.fxa_url}/.well-known/openid-configuration`);
fxaConfig = await res.json();
lastConfigRefresh = Date.now();
return fxaConfig;
}
module.exports = {
login: async function(req, res) {
const query = req.query;
if (!query || !query.keys_jwk) {
return res.sendStatus(400);
}
const c = await getFxaConfig();
const params = new URLSearchParams({
client_id: config.fxa_client_id,
redirect_uri: `${config.base_url}/api/fxa/oauth`,
state: 'todo',
scope: `profile ${KEY_SCOPE}`,
action: 'email',
keys_jwk: query.keys_jwk
});
res.redirect(`${c.authorization_endpoint}?${params.toString()}`);
},
oauth: async function(req, res) {
const query = req.query;
if (!query || !query.code || !query.state || !query.action) {
return res.sendStatus(400);
}
const c = await getFxaConfig();
const x = await fetch(c.token_endpoint, {
method: 'POST',
body: JSON.stringify({
code: query.code,
client_id: config.fxa_client_id,
client_secret: config.fxa_client_secret
}),
headers: {
'content-type': 'application/json'
}
});
const zzz = await x.json();
console.error(zzz);
const p = await fetch(c.userinfo_endpoint, {
method: 'GET',
headers: {
authorization: `Bearer ${zzz.access_token}`
}
});
const userInfo = await p.json();
userInfo.keys_jwe = zzz.keys_jwe;
userInfo.access_token = zzz.access_token;
req.userInfo = userInfo;
pages.index(req, res);
},
verify: async function(token) {
if (!token) {
return null;
}
const c = await getFxaConfig();
try {
const verifyUrl = c.jwks_uri.replace('jwks', 'verify');
const result = await fetch(verifyUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
const info = await result.json();
if (
info.scope &&
Array.isArray(info.scope) &&
info.scope.includes(KEY_SCOPE)
) {
return info.user;
}
} catch (e) {
// gulp
}
return null;
}
};

View file

@ -1,11 +1,13 @@
const crypto = require('crypto');
const express = require('express');
const helmet = require('helmet');
const storage = require('../storage');
const config = require('../config');
const auth = require('../middleware/auth');
const owner = require('../middleware/owner');
const language = require('../middleware/language');
const pages = require('./pages');
const fxa = require('./fxa');
const filelist = require('./filelist');
const IS_DEV = config.env === 'development';
const ID_REGEX = '([0-9a-fA-F]{10})';
@ -18,6 +20,10 @@ module.exports = function(app) {
force: !IS_DEV
})
);
app.use(function(req, res, next) {
req.cspNonce = crypto.randomBytes(16).toString('hex');
next();
});
if (!IS_DEV) {
app.use(
helmet.contentSecurityPolicy({
@ -31,8 +37,18 @@ module.exports = function(app) {
'https://sentry.prod.mozaws.net',
'https://www.google-analytics.com'
],
imgSrc: ["'self'", 'https://www.google-analytics.com'],
scriptSrc: ["'self'"],
imgSrc: [
"'self'",
'https://www.google-analytics.com',
'https://*.dev.lcip.org',
'https://firefoxusercontent.com'
],
scriptSrc: [
"'self'",
function(req) {
return `'nonce-${req.cspNonce}'`;
}
],
styleSrc: ["'self'", 'https://code.cdn.mozilla.net'],
fontSrc: ["'self'", 'https://code.cdn.mozilla.net'],
formAction: ["'none'"],
@ -49,22 +65,30 @@ module.exports = function(app) {
next();
});
app.use(express.json());
app.get('/', language, pages.blank);
app.get('/', language, pages.index);
app.get('/legal', language, pages.legal);
app.get('/jsconfig.js', require('./jsconfig'));
app.get(`/share/:id${ID_REGEX}`, language, pages.blank);
app.get(`/download/:id${ID_REGEX}`, language, pages.download);
app.get('/completed', language, pages.blank);
app.get('/unsupported/:reason', language, pages.unsupported);
app.get(`/api/download/:id${ID_REGEX}`, auth, require('./download'));
app.get(`/api/download/blob/:id${ID_REGEX}`, auth, require('./download'));
app.get(`/api/download/:id${ID_REGEX}`, auth.hmac, require('./download'));
app.get(
`/api/download/blob/:id${ID_REGEX}`,
auth.hmac,
require('./download')
);
app.get(`/api/exists/:id${ID_REGEX}`, require('./exists'));
app.get(`/api/metadata/:id${ID_REGEX}`, auth, require('./metadata'));
app.post('/api/upload', require('./upload'));
app.post(`/api/delete/:id${ID_REGEX}`, owner, require('./delete'));
app.post(`/api/password/:id${ID_REGEX}`, owner, require('./password'));
app.post(`/api/params/:id${ID_REGEX}`, owner, require('./params'));
app.post(`/api/info/:id${ID_REGEX}`, owner, require('./info'));
app.get(`/api/metadata/:id${ID_REGEX}`, auth.hmac, require('./metadata'));
app.get('/api/fxa/login', fxa.login);
app.get('/api/fxa/oauth', fxa.oauth);
app.get('/api/filelist', auth.fxa, filelist.get);
app.post('/api/filelist', auth.fxa, filelist.post);
app.post('/api/upload', auth.fxa, require('./upload'));
app.post(`/api/delete/:id${ID_REGEX}`, auth.owner, require('./delete'));
app.post(`/api/password/:id${ID_REGEX}`, auth.owner, require('./password'));
app.post(`/api/params/:id${ID_REGEX}`, auth.owner, require('./params'));
app.post(`/api/info/:id${ID_REGEX}`, auth.owner, require('./info'));
app.get('/__version__', function(req, res) {
res.sendFile(require.resolve('../../dist/version.json'));

View file

@ -34,8 +34,21 @@ var isUnsupportedPage = /\\\/unsupported/.test(location.pathname);
if (isIE && !isUnsupportedPage) {
window.location.replace('/unsupported/ie');
}
var MAXFILESIZE = ${config.max_file_size};
var DEFAULT_EXPIRE_SECONDS = ${config.default_expire_seconds};
var LIMITS = {
ANON: {
MAX_FILE_SIZE: ${config.anon_max_file_size},
MAX_DOWNLOADS: ${config.anon_max_downloads},
MAX_EXPIRE_SECONDS: ${config.anon_max_expire_seconds},
},
MAX_FILE_SIZE: ${config.max_file_size},
MAX_DOWNLOADS: ${config.max_downloads},
MAX_EXPIRE_SECONDS: ${config.max_expire_seconds},
MAX_FILES_PER_ARCHIVE: ${config.max_files_per_archive},
MAX_ARCHIVES_PER_USER: ${config.max_archives_per_user}
};
var DEFAULTS = {
EXPIRE_SECONDS: ${config.default_expire_seconds}
};
${ga}
${sentry}
`;

View file

@ -27,7 +27,7 @@ module.exports = {
routes.toString(
`/download/${id}`,
Object.assign(state(req), {
fileInfo: { nonce, requiresPassword: pwd }
downloadMetadata: { nonce, pwd }
})
)
)

View file

@ -1,8 +1,10 @@
const config = require('../config');
const storage = require('../storage');
module.exports = function(req, res) {
const dlimit = req.body.dlimit;
if (!dlimit || dlimit > 20) {
// TODO: fxa auth
if (!dlimit || dlimit > config.max_downloads) {
return res.sendStatus(400);
}

View file

@ -5,10 +5,11 @@ const mozlog = require('../log');
const Limiter = require('../limiter');
const Parser = require('../streamparser');
const wsStream = require('websocket-stream/stream');
// const fxa = require('./fxa');
const log = mozlog('send.upload');
module.exports = async function(ws, req) {
module.exports = function(ws, req) {
let fileStream;
ws.on('close', e => {
@ -26,12 +27,19 @@ module.exports = async function(ws, req) {
const timeLimit = fileInfo.timeLimit;
const metadata = fileInfo.fileMetadata;
const auth = fileInfo.authorization;
const user = '1'; //await fxa.verify(fileInfo.bearer); // TODO
const maxFileSize = user
? config.max_file_size
: config.anon_max_file_size;
const maxExpireSeconds = user
? config.max_expire_seconds
: config.anon_max_expire_seconds;
if (
!metadata ||
!auth ||
timeLimit <= 0 ||
timeLimit > config.max_expire_seconds
timeLimit > maxExpireSeconds
) {
ws.send(
JSON.stringify({
@ -51,7 +59,7 @@ module.exports = async function(ws, req) {
const protocol = config.env === 'production' ? 'https' : req.protocol;
const url = `${protocol}://${req.get('host')}/download/${newId}/`;
const limiter = new Limiter(config.max_file_size);
const limiter = new Limiter(maxFileSize);
const parser = new Parser();
fileStream = wsStream(ws, { binary: true })
.pipe(limiter)

View file

@ -1,9 +1,12 @@
const config = require('./config');
const layout = require('./layout');
const locales = require('../common/locales');
const assets = require('../common/assets');
module.exports = function(req) {
const locale = req.language || 'en-US';
const userInfo = req.userInfo || { avatar: assets.get('user.svg') };
userInfo.loggedIn = !!userInfo.access_token;
return {
locale,
translate: locales.getTranslator(locale),
@ -17,6 +20,8 @@ module.exports = function(req) {
},
fira: false,
fileInfo: {},
cspNonce: req.cspNonce,
user: userInfo,
layout
};
};