diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a5e40dc5..08361eaf 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -3,14 +3,17 @@ ## Setup 1. Run `git clone https://github.com/remotestorage/armadietto.git` to pull the code. -2. Run `yarn install` or `npm install`. to install the dependencies. +2. Run `npm install` to install the dependencies. +3. Register for S3-compatible storage with your hosting provider, install [a self-hosted implementation](notes/S3-store-router.md), or use the public account on `play.min.io` (which is slow, and to which anyone in the world can read and write!). ## Development -* Run `npm test` to run the automated tests. -* Run `npm run dev` to start a server on your local machine, and have it automatically restart when you update a source code file in `/lib`. +* Run `npm test` to run the automated tests for both monolithic and modular servers (except the tests for S3 store router). +* Set the S3 environment variables and run Mocha with `./node_modules/mocha/bin/mocha.js -u bdd-lazy-var/getter spec/armadietto/storage_spec.js` to test the S3 store router using your configured S3 server. (If the S3 environment variables aren't set, the S3 router uses the shared public account on play.min.io.) If any tests fail, add a note to [the S3 notes](notes/S3-store-router.md) +* Run `npm run modular` to start a modular server on your local machine, and have it automatically restart when you update a source code file in `/lib`. +* Run `npm run dev` to start a monolithic server on your local machine, and have it automatically restart when you update a source code file in `/lib`. -Set the environment `DEBUG` to enable verbose logging of HTTP requests. +Set the environment `DEBUG` to enable verbose logging of HTTP requests. For the modular server, these are the requests to the S3 server. For the monolithic server, these are the requests to Armadietto. Add automated tests for any new functionality. For bug fixes, start by writing an automated test that fails under the buggy code, but will pass when you've written a fix. Using TDD is not required, but will help you write better code. diff --git a/README.md b/README.md index dbdcf6c3..1235c5ae 100644 --- a/README.md +++ b/README.md @@ -24,46 +24,24 @@ This is a complete rewrite of [reStore](https://github.com/jcoglan/restore). See the `notes` directory for configuring a reverse proxy and other recipes. -1. Run `armadietto -e` to see a sample configuration file. -2. Create a configuration file at `/etc/armadietto/conf.json` (or elsewhere). See below for values and their meanings. -3. Run `armadietto -c /etc/armadietto/conf.json` +### Modular (new) Server -To see all options, run `armadietto -h`. Set the environment `DEBUG` to log the headers of every request. +* Streaming storage (documents don't have to fit in server memory) +* S3-compatible storage (requires separate S3 server; AWS S3 allows documents up to 5 TB) +* Can run multiple application servers to increase capacity to enterprise-scale +* Bug Fix: correctly handles If-None-Match with ETag +* Bug Fix: returns empty listing for nonexistent folder +* Implements current spec: draft-dejong-remotestorage-22 -## Use as a library +See [the modular-server-specific documentation](./modular-server.md) for usage. -The following Node script will run a basic server: +### Monolithic (old) Server -```js -process.umask(077); - -const Armadietto = require('armadietto'); -store = new Armadietto.FileTree({path: 'path/to/storage'}), - -server = new Armadietto({ - store: store, - http: {host: '127.0.0.1', port: 8000} -}); - -server.boot(); -``` - -The `host` option is optional and specifies the hostname the server will listen -on. Its default value is `0.0.0.0`, meaning it will listen on all interfaces. +* Stores user documents in server file system +* More thoroughly tested +* Implements older spec: draft-dejong-remotestorage-01 -The server does not allow users to sign up, out of the box. If you need to allow -that, use the `allow.signup` option: - -```js -var server = new Armadietto({ - store: store, - http: { host: '127.0.0.1', port: 8000 }, - allow: { signup: true } -}); -``` - -If you navigate to `http://localhost:8000/` you should then see a sign-up link -in the navigation. +See [the monolithic-server-specific documentation](./monolithic-server.md) for usage. ## Storage security @@ -159,45 +137,6 @@ setup, you can set `https.force = true` but omit `https.port`; this means armadietto itself will not accept encrypted connections but will apply the above behaviour to enforce secure connections. -## Storage backends - -armadietto supports pluggable storage backends, and comes with a file system -implementation out of the box (redis storage backend is on the way in -`feature/redis` branch): - -* `Armadietto.FileTree` - Uses the filesystem hierarchy and stores each item in its - own individual file. Content and metadata are stored in separate files so the - content does not need base64-encoding and can be hand-edited. Must only be run - using a single server process. - -All the backends support the same set of features, including the ability to -store arbitrary binary data with content types and modification times. - -They are configured as follows: - -```js -// To use the file tree store: -const store = new Armadietto.FileTree({path: 'path/to/storage'}); - -// Then create the server with your store: -const server = new Armadietto({ - store: store, - http: {port: process.argv[2]} -}); - -server.boot(); -``` - -## Lock file contention - -The data-access locking mechanism is lock-file based. - -You may need to tune the lock-file timeouts in your configuration: - -- *lock_timeout_ms* - millis to wait for lock file to be available -- *lock_stale_after_ms* - millis to wait to deem lockfile stale - -To tune run the [hosted RS load test](https://overhide.github.io/armadietto/example/load.html) or follow instructions in [example/README.md](example/README.md) for local setup and subsequently run [example/load.html](example/load.html) off of `npm run serve` therein. ## Debugging an installation @@ -211,8 +150,8 @@ See `DEVELOPMENT.md` (The MIT License) -Copyright (c) 2012-2015 James Coglan -Copyright (c) 2018 remoteStorage contributors +Copyright © 2012–2015 James Coglan +Copyright © 2018–2024 remoteStorage contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in diff --git a/bin/www b/bin/www index b36740ad..856b7e4b 100755 --- a/bin/www +++ b/bin/www @@ -4,11 +4,13 @@ const http = require('http'); const fs = require("fs"); const path = require("path"); const {ArgumentParser} = require("argparse"); +const { stat } = require('node:fs/promises'); const appFactory = require('../lib/appFactory'); const {configureLogger, getLogger} = require("../lib/logger"); -const S3Handler = require("../lib/routes/S3_store_router"); +const S3StoreRouter = require("../lib/routes/S3_store_router"); const process = require("process"); const https = require("https"); +const errToMessages = require("../lib/util/errToMessages"); const SSL_CIPHERS = 'ECDHE-RSA-AES256-SHA384:AES256-SHA256:RC4-SHA:RC4:HIGH:!MD5:!aNULL:!EDH:!AESGCM'; const SSL_OPTIONS = require('crypto').constants.SSL_OP_CIPHER_SERVER_PREFERENCE; @@ -51,23 +53,23 @@ if (!hostIdentity) { const userNameSuffix = conf.user_name_suffix ?? '-' + hostIdentity; if (conf.http?.port) { - start( Object.assign({}, conf.http, process.env.PORT && {port: process.env.PORT})); + start( Object.assign({}, conf.http, process.env.PORT && {port: process.env.PORT})).catch(getLogger.error); } if (conf.https?.port) { - start(conf.https); + start(conf.https).catch(getLogger.error); } -function start(network) { - // If the environment variables aren't set, s3handler uses a shared public account on play.min.io, +async function start(network) { + // If the environment variables aren't set, s3storeRouter uses a shared public account on play.min.io, // to which anyone in the world can read and write! - // It is not entirely compatible with S3Handler. - const s3handler = new S3Handler({endPoint: process.env.S3_ENDPOINT, + // It is not entirely compatible with S3StoreRouter. + const s3storeRouter = new S3StoreRouter({endPoint: process.env.S3_ENDPOINT, accessKey: process.env.S3_ACCESS_KEY, secretKey: process.env.S3_SECRET_KEY, region: process.env.S3_REGION || 'us-east-1', userNameSuffix}); - const app = appFactory({hostIdentity, jwtSecret, account: s3handler, storeRouter: s3handler, basePath}); + const app = await appFactory({hostIdentity, jwtSecret, accountMgr: s3storeRouter, storeRouter: s3storeRouter, basePath}); const port = normalizePort( network?.port || '8000'); app.set('port', port); @@ -106,7 +108,7 @@ function start(network) { server.listen(port); server.on('error', onError); - server.on('clientError', clientError); + server.on('clientError', clientError); // a client connection emitted an 'error' event server.on('listening', onListening); /** Event listener for HTTP server "error" event. */ @@ -212,10 +214,11 @@ function clientError (err, socket) { status = 400; message = 'Bad Request'; } - getLogger().warning(`${socket.address().address} n/a n/a ${status} ${message} ${err.toString()}`); + const logNotes = errToMessages(err, new Set([message])); + getLogger().warning(`- - - - - ${status} - “${Array.from(logNotes).join(' ')}”`); if (err.code !== 'ECONNRESET' && socket.writable) { socket.end(`HTTP/1.1 ${status} ${message}\r\n\r\n`); } - socket.destroy(err); + socket.destroySoon(); } diff --git a/contrib/systemd/armadietto.service b/contrib/systemd/armadietto.service index 66fcffab..f53500b3 100644 --- a/contrib/systemd/armadietto.service +++ b/contrib/systemd/armadietto.service @@ -11,7 +11,7 @@ Restart=always RestartSec=1 User=armadietto Group=armadietto -Environment= +Environment=NODE_ENV=production ExecStartPre= ExecStart=/usr/bin/armadietto -c /etc/armadietto/conf.json ExecStartPost= diff --git a/lib/appFactory.js b/lib/appFactory.js index dca45804..3aaff0f0 100644 --- a/lib/appFactory.js +++ b/lib/appFactory.js @@ -1,20 +1,28 @@ const express = require('express'); const path = require('path'); -const { loggingMiddleware } = require('./logger'); +const { loggingMiddleware, getLogger } = require('./logger'); const indexRouter = require('./routes/index'); -const signupRouter = require('./routes/signup'); +const robots = require('robots.txt'); +const requestInviteRouter = require('./routes/request-invite'); const webFingerRouter = require('./routes/webfinger'); const oAuthRouter = require('./routes/oauth'); const storageCommonRouter = require('./routes/storage_common'); const errorPage = require('./util/errorPage'); const helmet = require('helmet'); const shorten = require('./util/shorten'); +const loginFactory = require('./routes/login'); +const accountRouterFactory = require('./routes/account'); +const adminFactory = require('./routes/admin'); +const session = require('express-session'); +const MemoryStore = require('memorystore')(session); +const { rateLimiterPenalty, rateLimiterBlock, rateLimiterMiddleware } = require('./middleware/rateLimiterMiddleware'); -module.exports = function ({ hostIdentity, jwtSecret, account, storeRouter, basePath = '' }) { +module.exports = async function ({ hostIdentity, jwtSecret, accountMgr, storeRouter, basePath = '' }) { if (basePath && !basePath.startsWith('/')) { basePath = '/' + basePath; } const app = express(); app.locals.basePath = basePath; + app.disable('x-powered-by'); // view engine setup app.engine('.html', require('ejs').__express); @@ -23,14 +31,52 @@ module.exports = function ({ hostIdentity, jwtSecret, account, storeRouter, base app.set('views', path.join(__dirname, 'views')); express.static.mime.define({ 'text/javascript': ['js'] }); + express.static.mime.define({ 'text/javascript': ['mjs'] }); - app.set('account', account); + app.set('accountMgr', accountMgr); + + // web browsers ask for this way too often, so doesn't log this + app.get(['/favicon.ico', '/apple-touch-icon*'], (req, res, _next) => { + res.set('Cache-Control', 'public, max-age=31536000'); + res.status(404).end(); + }); app.use(loggingMiddleware); - app.use(helmet({ + + if (process.env.NODE_ENV === 'production') { + app.use(rateLimiterMiddleware); + } + + app.use(robots(path.join(__dirname, 'robots.txt'))); + + const helmetStorage = helmet({ contentSecurityPolicy: { directives: { - sandbox: ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-same-origin'], + sandbox: ['allow-orientation-lock'], + defaultSrc: ['\'none\''], + scriptSrc: ['\'none\''], + scriptSrcAttr: ['\'none\''], + styleSrc: ['\'self\''], + imgSrc: ['\'self\''], + fontSrc: ['\'self\''], + // styleSrc: ['\'self\'', req => getOriginator(req)], + // imgSrc: ['\'self\'', req => getOriginator(req)], + // fontSrc: ['\'self\'', req => getOriginator(req)], + objectSrc: ['\'none\''], + childSrc: ['\'none\''], + connectSrc: ['\'none\''], + baseUri: ['\'self\''], + frameAncestors: ['\'none\''], + formAction: '\'none\'', + upgradeInsecureRequests: [] + } + }, + crossOriginResourcePolicy: false + }); + const helmetWebapp = helmet({ + contentSecurityPolicy: { + directives: { + sandbox: ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-same-origin', 'allow-orientation-lock'], defaultSrc: ['\'self\''], scriptSrc: ['\'self\''], scriptSrcAttr: ['\'none\''], @@ -39,47 +85,114 @@ module.exports = function ({ hostIdentity, jwtSecret, account, storeRouter, base fontSrc: ['\'self\''], objectSrc: ['\'none\''], childSrc: ['\'none\''], - connectSrc: ['\'none\''], + connectSrc: ['\'self\''], baseUri: ['\'self\''], frameAncestors: ['\'none\''], formAction: (process.env.NODE_ENV === 'production' ? ['https:'] : ['https:', 'http:']), // allows redirect to any RS app upgradeInsecureRequests: [] } } - })); - app.use(express.urlencoded({ extended: true })); - app.use(`${basePath}/assets`, express.static(path.join(__dirname, 'assets'))); + }); + app.use((req, res, next) => { + if (/^\/storage\//.test(req.url)) { + helmetStorage(req, res, next); + } else { + helmetWebapp(req, res, next); + } + }); - app.use(`${basePath}/`, indexRouter); + app.use(express.urlencoded({ extended: true })); + app.use(`${basePath}/assets`, express.static(path.join(__dirname, 'assets'), { + fallthrough: true, index: false, maxAge: '25m' + })); + app.use(`${basePath}/assets`, async (req, res, _next) => { + res.set('Cache-Control', 'public, max-age=1500'); + res.status(404).end(); + await rateLimiterPenalty(req.ip); + }); - app.use(`${basePath}/signup`, signupRouter); + app.use(`${basePath}/signup`, requestInviteRouter(storeRouter)); app.use([`${basePath}/.well-known`, `${basePath}/webfinger`], webFingerRouter); - app.use(`${basePath}/oauth`, oAuthRouter(hostIdentity, jwtSecret)); app.use(`${basePath}/storage`, storageCommonRouter(hostIdentity, jwtSecret)); app.use(`${basePath}/storage`, storeRouter); + // Only some routes require a session + const memorySession = session({ + cookie: { + path: `${basePath}/`, + maxAge: 20 * 60 * 1000, + sameSite: 'strict', + secure: process.env.NODE_ENV === 'production' + }, + store: new MemoryStore({ checkPeriod: 20 * 60 * 1000 }), // prune expired entries every 20 min + rolling: false, // maxAge is absolute timeout + resave: false, + secret: jwtSecret, + saveUninitialized: false, + name: 'id' + }); + if (process.env.NODE_ENV === 'production') { + app.set('trust proxy', 'loopback'); // required for secure cookies + } + + app.use([`${basePath}/`, `${basePath}/oauth`, `${basePath}/account`, `${basePath}/admin`], memorySession); + + app.use(`${basePath}/`, indexRouter); + + app.use(`${basePath}/oauth`, oAuthRouter(hostIdentity, jwtSecret, accountMgr)); + + const loginUserRouter = await loginFactory(hostIdentity, jwtSecret, accountMgr, false); + app.use(`${basePath}/account`, loginUserRouter); + const accountRouter = await accountRouterFactory(hostIdentity, jwtSecret, accountMgr); + app.use(`${basePath}/account`, accountRouter); + + const loginAdminRouter = await loginFactory(hostIdentity, jwtSecret, accountMgr, true); + app.use(`${basePath}/admin`, loginAdminRouter); + const admin = await adminFactory(hostIdentity, jwtSecret, accountMgr, storeRouter); + app.use(`${basePath}/admin`, admin); + // catches paths not handled and returns Not Found - app.use(basePath, function (req, res, next) { + app.use(basePath, async function (req, res) { + if (!res.get('Cache-Control')) { + res.set('Cache-Control', 'max-age=1500'); + } + const subpath = req.path.slice(basePath.length).split('/')?.[1]; const name = req.path.slice(1); - errorPage(req, res, 404, { title: 'Not Found', message: `“${name}” doesn't exist` }); + if (['.well-known', 'storage', 'oauth', 'account', 'admin', 'crossdomain.xml', 'sitemap.xml'].includes(subpath)) { + errorPage(req, res, 404, { title: 'Not Found', message: `“${name}” doesn't exist` }); + } else { // probably hostile + res.logNotes.add(`“${name}” shouldn't and doesn't exist`); + res.status(404).end(); + if (Object.keys(req.session?.privileges || {}).length > 0) { + await rateLimiterPenalty(req.ip, 2); + } else { + await rateLimiterBlock(req.ip, 61); + } + } }); // redirect for paths outside the app - app.use(function (req, res, next) { + app.use(async function (req, res) { res.status(308).set('Location', basePath).end(); + await rateLimiterPenalty(req.ip); }); // error handler - app.use(function (err, req, res, _next) { + app.use(async function (err, req, res, _next) { const message = err?.message || err?.errors?.find(e => e.message).message || err?.cause?.message || 'indescribable error'; errorPage(req, res, err.status || 500, { title: shorten(message, 30), message, error: req.app.get('env') === 'development' ? err : {} }); + await rateLimiterPenalty(req.ip); }); + setTimeout(() => { + admin.bootstrap().catch(getLogger().error); + }, 0); + return app; }; diff --git a/lib/assets/admin-users.mjs b/lib/assets/admin-users.mjs new file mode 100644 index 00000000..0288a949 --- /dev/null +++ b/lib/assets/admin-users.mjs @@ -0,0 +1,171 @@ +/* eslint-env browser es2022 */ + +document.getElementById('reinviteSelf')?.addEventListener('click', resendInvite); + +document.querySelector('table#users')?.addEventListener('click', resendInvite); +document.querySelector('form')?.addEventListener('submit', sendInvite); +document.getElementById('share')?.addEventListener('click', share); + +document.querySelector('table#inviteRequests')?.addEventListener('click', resendInvite); + +let invite; // Global variables are are simpler when there is little code. + +async function resendInvite(evt) { + if (evt.target.dataset.contacturl) { + if (evt.target.dataset.privilegegrant) { + await submit(new URLSearchParams(evt.target.dataset)); + } else { + await deleteInviteRequest(new URLSearchParams(evt.target.dataset)); + } + } +} + +async function sendInvite(evt) { + evt.preventDefault(); + await submit(new URLSearchParams(new FormData(document.querySelector('form')))); +} + +async function submit(data) { + try { + document.getElementById('progress').hidden = false; + const resp = await fetch('/admin/sendInvite', {method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded'}, + body: data + }); + document.getElementById('progress').hidden = true; + if (resp.ok) { + invite = await resp.json(); + console.info(`Invite created for “${data.get('username') || ''}” ${invite.contactURL}`); + + contactURLToLink(invite); + + if (typeof navigator.share === 'function') { + document.getElementById('shareContainer').hidden = false; + } + + displayOutput(invite.text + '\n' + invite.url, 'Or, copy and paste this to a secure channel:'); + } else { + await displayNonsuccess(resp); + if (resp.status === 401) { + window.location = '/account/login' + } + } + } catch (err) { + console.error(`while sending invite:`, err); + displayOutput('Check your connection', err.message, true); + } +} + +async function deleteInviteRequest(data) { + try { + document.getElementById('progress').hidden = false; + const resp = await fetch('/admin/deleteInviteRequest', {method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded'}, + body: data + }); + document.getElementById('progress').hidden = true; + if (resp.ok) { + displayOutput('Request deleted', "Success", false); + window.location.reload(true); + } else { + const text = await resp.text(); + console.error(`while deleting invite request:`, text); + displayOutput(text, 'Unable to delete', true); + } + } catch (err) { + console.error(`while deleting invite request:`, err); + displayOutput('Check your connection', err.message, true); + } +} + +async function displayNonsuccess(resp) { + let msg; + switch (resp.headers.get('Content-Type')?.split(';')[0]) { + case 'text/plain': + const t = await resp.text(); + console.error(`Sending invite was rejected.`, t); + msg = t; + break; + case 'application/json': + const r = await resp.json(); + console.error(`Sending invite was rejected.`, r); + msg = r?.message + break; + default: + const t2 = await resp.text(); + console.error(`Sending invite was rejected.`, resp.headers.get('Content-Type'), t2); + msg = 'Sending invite was rejected.'; + } + displayOutput(msg, 'Something went wrong', true); +} + +function displayOutput(msg, label = '', isError = false) { + document.getElementById('outputDiv').hidden = false; + + const output = document.getElementById('output') + output.innerText = msg; + output.scrollIntoView({ behavior: "smooth", block: "end"}); + + document.getElementById('outputLabel').innerText = label; + + if (isError) { + output.classList.add('error'); + } else { + output.classList.remove('error'); + } +} + +function contactURLToLink(invite) { + try { + let mode = null; + const contactURL = new URL(invite.contactURL) + switch(contactURL.protocol) { + case 'sgnl:': // sgnl://signal.me/#p/+15555555555 + mode = 'from my Signal Private Messenger account'; + break; + case 'threema:': // threema://compose?id=ABCDEFGH&text=Test%20Text + contactURL.searchParams.set('text', invite.text); + mode = 'from my Threema account'; + break; + case 'facetime:': // facetime:14085551234 or facetime:user@example.com + mode = 'from my FaceTime account'; + break; + case 'xmpp:': // xmpp:username@domain.tld + mode = 'from my Jabber account'; + break; + case 'skype:': // skype:?[add|call|chat|sendfile|userinfo][&topic=foo] + contactURL.search = `?chat&topic=${encodeURIComponent(invite.title)}`; + mode = 'from my Skype account'; + break; + case 'mailto:': + contactURL.search = `?subject=${encodeURIComponent(invite.title)}&body=${encodeURIComponent(invite.text + '\n' + invite.url)}`; + mode = 'from my email account'; + break; + case 'sms:': + case 'mms:': + contactURL.search = `?body=${encodeURIComponent(invite.text + '\n' + invite.url)}`; + mode = 'from my messaging account'; + break; + case 'whatsapp:': // whatsapp://send/?phone=447700900123 + contactURL.search += `&text=${encodeURIComponent(invite.text + '\n' + invite.url)}`; + mode = 'from my WhatsApp account'; + // TODO: support https://wa.me/15551234567 + break; + case 'tg:': // tg://msg?to=+1555999 + contactURL.search += `&text=${encodeURIComponent(invite.text + '\n' + invite.url)}`; + mode = 'from my Telegram account'; + break; + } + + if (mode) { + document.getElementById('sendFromMe').href = contactURL; + document.getElementById('sendFromMe').innerText = `Send invite ${mode}`; + document.getElementById('sendFromMeContainer').hidden = false; + } + } catch (err) { + console.error(`while assembling link:`, err); + } +} +function share() { + navigator.share(invite).catch(console.error); +} diff --git a/lib/assets/armadietto-utilities.js b/lib/assets/armadietto-utilities.js index 81498faf..9c400ad2 100644 --- a/lib/assets/armadietto-utilities.js +++ b/lib/assets/armadietto-utilities.js @@ -17,7 +17,7 @@ function toggleTheme () { localStorage.getItem('theme') === 'dark' ? setTheme('dark') : setTheme('light'); })(); -document.getElementById('switch').addEventListener('click', toggleTheme); +document.getElementById('switch')?.addEventListener('click', toggleTheme); const togglePassword = document.querySelector('#togglePassword'); const password = document.querySelector('#password'); @@ -32,17 +32,3 @@ if (togglePassword) { this.classList.toggle('icon-slash'); }); } - -const submitBtn = document.querySelector('button[type=submit]'); -if (submitBtn) { - submitBtn.addEventListener('click', function () { - const validForm = document.querySelector('form:valid'); - if (validForm) { - const spinner = document.createElement('progress'); - validForm.appendChild(spinner); - setTimeout(() => { - submitBtn.disabled = true; - }, 0); - } - }); -} diff --git a/lib/assets/contact-url.mjs b/lib/assets/contact-url.mjs new file mode 100644 index 00000000..00f91723 --- /dev/null +++ b/lib/assets/contact-url.mjs @@ -0,0 +1,85 @@ +/* eslint-env browser es2022 */ + +const select = document.querySelector('select'); +select.addEventListener('input', protocolChanged); +select.dispatchEvent(new InputEvent('input')); + +function protocolChanged(evt) { + let type, pattern, label, placeholder; + switch (evt.target.value) { + case 'sgnl:': + type = 'tel'; + pattern = '\\+?[\\d \\)\\(\\*\\-]{4,24}'; + label = 'Phone number' + placeholder = '+1 800 555 1212'; + break; + case 'threema:': // threema://compose?text=Test%20Text + type = 'text'; + pattern = '[a-zA-Z0-9]{8}'; + label = 'Threema ID'; + placeholder = 'ABCDEFGH'; + break; + case 'facetime:': + type = 'text'; + pattern = '.*[\\p{L}\\p{N}]+@[a-zA-Z0-9][a-zA-Z0-9.]{2,}[a-zA-Z0-9]|\\+?[\\d \\)\\(\\*\\-]{4,24}'; + label = 'Apple ID or phone number' + placeholder = 'username@domain.tld or +1 800 555 1212' + break; + case 'xmpp:': // xmpp:username@domain.tld + type = 'email'; + pattern = null; + // pattern = '[\\p{L}\\p{N}]+@[a-zA-Z0-9][a-zA-Z0-9.]{2,}[a-zA-Z0-9]'; + label = "Jabber ID"; + placeholder = 'username@domain.tld' + break; + case 'skype:': // skype:?[add|call|chat|sendfile|userinfo][&topic=foo] + type = 'text'; + pattern = '(live:)?[\\w\\. @\\)\\(\\-]{3,24}'; + label = 'Skype ID'; + placeholder = 'email, phone number or username'; + break; + case 'mailto:': + type = 'email'; + pattern = null; + // pattern = '.*[\\p{L}\\p{N}]+@[a-zA-Z0-9][a-zA-Z0-9.]{2,}[a-zA-Z0-9]'; + label = 'E-mail address'; + placeholder = 'username@domain.tld' + break; + case 'sms:': + case 'mms:': + type = 'tel'; + pattern = '\\+?[\\d \\)\\(\\*\\-]{4,24}'; + label = 'Phone number' + placeholder = '+1 800 555 1212'; + break; + case 'whatsapp:': // whatsapp://send/?phone=447700900123 + type = 'tel'; + pattern = '\\+?[\\d \\)\\(\\*\\-]{4,24}'; + label = 'Phone number' + placeholder = '+1 800 555 1212'; + // TODO: support https://wa.me/15551234567 + break; + case 'tg:': // tg://msg?to=+1555999 + type = 'tel'; + pattern = '\\+?[\\d \\)\\(\\*\\-]{4,24}'; + // pattern = '(@|https?://t.me/|https?://telegram.me/)?\w{3,24}'; + label = 'Phone number' + placeholder = '+1 800 555 1212'; + break; + default: + type = 'text'; + pattern = '[\\p{L}\\p{N}]{2,}'; + label = 'Address' + placeholder = 'At least two letters or digits'; + } + const labelElmt = document.querySelector('[for=address]'); + labelElmt.innerText = label; + const addressInpt = document.getElementById('address'); + addressInpt.setAttribute('type', type); + if (pattern) { + addressInpt.setAttribute('pattern', pattern); + } else { + addressInpt.removeAttribute('pattern'); + } + addressInpt.setAttribute('placeholder', placeholder); +} diff --git a/lib/assets/login.mjs b/lib/assets/login.mjs new file mode 100644 index 00000000..dac72de9 --- /dev/null +++ b/lib/assets/login.mjs @@ -0,0 +1,81 @@ +/* eslint-env browser es2022 */ + +import {startAuthentication} from './simplewebauthn-browser.js'; + +// login().catch(err => { +// err = preprocessError(err); +// console.log(`logging-in on load:`, err, err.code || '', err.cause || '', err.cause?.code || ''); +// +// document.getElementById('login').hidden = false; + document.getElementById('login')?.addEventListener('click', loginCatchingErrors); +// }); + +async function loginCatchingErrors() { + try { + await login(); + } catch (err) { + err = preprocessError(err); + console.error(err, err.code || '', err.cause || '', err.cause?.code || ''); + + if (err.name === 'AbortError') { + displayMessage('Validating passkey aborted') + } else { + displayMessage(err.message || err.toString(), true); + } + } +} + +function preprocessError(err) { + document.getElementById('progress').hidden = true; + if (err.code === 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY') { + return err.cause; + } else { + return err; + } +} + +async function login() { + displayMessage('Logging in'); + + // @simplewebauthn/server -> generateAuthenticationOptions() + const options = JSON.parse(document.getElementById('options').value); + // console.log(`credentials options:`, options) + + // Passes the options to the authenticator and waits for a response + const credential = await startAuthentication(options); + // console.log(`credential:`, credential) + + // POST the response to the endpoint that calls + // @simplewebauthn/server -> verifyAuthenticationResponse() + const verificationResp = await fetch('./verify-authentication', { + method: 'POST', + headers: { 'Content-Type': 'application/json',}, + body: JSON.stringify(credential), + }); + + // Wait for the results of verification + const verificationJSON = await verificationResp.json(); + + // Show UI appropriate for the `verified` status + if (verificationJSON?.verified) { + displayMessage(`Logged in as ${verificationJSON.username}`); + document.getElementById('login').hidden = true; + if (document.location.pathname.startsWith('/admin')) { + document.location = '/admin/users'; + } else { + document.location = '/account/'; + } + } else { + displayMessage(verificationJSON?.msg || JSON.stringify(verificationJSON), true); + } +} + +function displayMessage(msg, isError) { + const elmtMsg = document.getElementById('message'); + elmtMsg.innerText = msg; + if (isError) { + elmtMsg.classList.add('error'); + } else { + elmtMsg.classList.remove('error'); + } +} diff --git a/lib/assets/oauth.mjs b/lib/assets/oauth.mjs new file mode 100644 index 00000000..e0d7e0a2 --- /dev/null +++ b/lib/assets/oauth.mjs @@ -0,0 +1,58 @@ +/* eslint-env browser es2022 */ + +import {startAuthentication} from './simplewebauthn-browser.js'; + +document.querySelector('form')?.addEventListener('submit', submit); + +async function submit(evt) { + try { + evt.preventDefault(); + if (evt.submitter.name !== 'allow') { + const redirect = document.querySelector('input[name=redirect_uri]').value; + console.info(`authorization denied; redirecting to`, redirect); + document.location = redirect; + return; + } + + displayMessage('authorizing'); + + // @simplewebauthn/server -> generateAuthenticationOptions() + const options = JSON.parse(document.getElementById('options').value); + // console.log(`credentials options:`, options); + if (Object.keys(options).length === 0) { + throw new Error("Reload this page"); + } + + // Passes the options to the authenticator and waits for a response + const credential = await startAuthentication(options); + // console.log(`credential:`, credential); + document.getElementById('credential').value = JSON.stringify(credential); + + // POSTs the response to the endpoint that calls + // @simplewebauthn/server -> verifyAuthenticationResponse() + evt.target.submit(); + } catch (err) { + document.getElementById('progress').hidden = true; + if (err.code === 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY') { + err = err.cause; + } + console.error(err, err.code || '', err.cause || '', err.cause?.code || ''); + + if (err.name === 'AbortError') { + displayMessage('Validating passkey aborted') + } else { + displayMessage(err.message || err.toString(), true); + } + } +} + + +function displayMessage(msg, isError) { + const elmtMsg = document.getElementById('message'); + elmtMsg.innerText = msg; + if (isError) { + elmtMsg.classList.add('error'); + } else { + elmtMsg.classList.remove('error'); + } +} diff --git a/lib/assets/passkeymajor-svgrepo-com.svg b/lib/assets/passkeymajor-svgrepo-com.svg new file mode 100644 index 00000000..4d81e146 --- /dev/null +++ b/lib/assets/passkeymajor-svgrepo-com.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/lib/assets/register.mjs b/lib/assets/register.mjs new file mode 100644 index 00000000..6e1281a4 --- /dev/null +++ b/lib/assets/register.mjs @@ -0,0 +1,140 @@ +/* eslint-env browser es2022 */ + +import {startRegistration} from './simplewebauthn-browser.js'; + +const usernameInpt = document.getElementById('username'); +usernameInpt.addEventListener('change', getOptions); +let username = usernameInpt.value; +if (username) { + getOptions().catch(console.error); +} + +let options; // global variables are the least-bad solution for simple pages + +async function getOptions() { + try { + username = usernameInpt.value; + const response = await fetch('/admin/getRegistrationOptions', { + method: 'POST', + headers: {'Content-type': 'application/json'}, + body: JSON.stringify({username}) + }); + if (response.ok) { + options = await response.json() + + usernameInpt.readOnly = true; + const btn = document.querySelector('button#register') + btn.hidden = false; + btn.addEventListener('click', generateCatchingErrors); + displayMessage('Click the button below to create a passkey for this device'); + } else { + const body = await response.json(); + if (body.error) { + displayMessage(body.error, true); + } else { + displayMessage(`Something went wrong. Request another invite using the link above.`, true); + } + } + } catch (err) { + console.error(`while fetching options:`, err); + displayMessage(`Something went wrong. Request another invite using the link above.`, true); + await cancelInvite() + } +} + +async function generateCatchingErrors() { + try { + await generateAndRegisterPasskey(); + } catch (err) { + document.getElementById('progress').hidden = true; + if (err.code === 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY') { err = err.cause; } + console.error(err, err.code || '', err.cause || '', err.cause?.code || ''); + + if (err.code === "ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED") { + document.querySelector('button#register').hidden = true; + displayMessage('You have already created a passkey for this device. Just log in, using the “Log in” link above!'); + await cancelInvite() + } else if (err.name === 'AbortError') { + displayMessage('Creating passkey aborted') + } else if (err.name === 'InvalidStateError') { + displayMessage('You already have a passkey:' + err.message, false); + await cancelInvite() + } else { + displayMessage(err.message || err.toString(), true); + await cancelInvite(); + } + } +} + +function preprocessError(err) { + document.getElementById('progress').hidden = true; + if (err.code === 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY') { + return err.cause; + } else { + return err; + } +} + +async function generateAndRegisterPasskey() { + displayMessage('Creating passkey'); + + // console.log(`credentials options:`, options) + + document.getElementById('progress').hidden = false; + // Passes the options to the authenticator and waits for a response + const attResp = await startRegistration(options); + // console.log(`attResp:`, attResp) + + // POST the response to the endpoint that calls + // @simplewebauthn/server -> verifyRegistrationResponse() + const searchParams = new URLSearchParams(document.location.search) + const verificationUrl = new URL('/admin/verifyRegistration?token=' + searchParams.get('token'), document.location.origin) + const verificationResp = await fetch(verificationUrl, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(attResp), + }); + document.getElementById('progress').hidden = true; + + if (verificationResp.ok) { + // Waits for the results of verification + const verificationJSON = await verificationResp.json(); + // console.log(`verificationJSON:`, verificationJSON) + + if (verificationJSON?.verified) { + displayMessage('Verified and saved on server!', false); + document.location = '/account'; + } else { + displayMessage(`Something went wrong! ${JSON.stringify(verificationJSON)}`, true); + } + } else { + let msg = 'Check your connection'; + try { + const failJSON = await verificationResp.json(); + msg = failJSON.error || msg; + } catch (err) { + console.error(`while parsing fail JSON:`, err); + } + displayMessage(msg, true); + } +} + +async function cancelInvite() { + const searchParams = new URLSearchParams(document.location.search) + const cancelUrl = new URL('/admin/cancelInvite?token=' + searchParams.get('token'), document.location.origin) + const cancelResp = await fetch(cancelUrl, {method: 'POST'}); + if (!cancelResp.ok) { + console.error(`while cancelling invite:`, await cancelResp.text()); + } +} + + +function displayMessage(msg, isError) { + const elmtMsg = document.getElementById('message'); + elmtMsg.innerText = msg; + if (isError) { + elmtMsg.classList.add('error'); + } else { + elmtMsg.classList.remove('error'); + } +} diff --git a/lib/assets/simplewebauthn-browser.js b/lib/assets/simplewebauthn-browser.js new file mode 100644 index 00000000..1fed505d --- /dev/null +++ b/lib/assets/simplewebauthn-browser.js @@ -0,0 +1,370 @@ +/* [@simplewebauthn/browser@10.0.0] */ + +/* globals PublicKeyCredential */ + +function bufferToBase64URLString (buffer) { + const bytes = new Uint8Array(buffer); + let str = ''; + for (const charCode of bytes) { + str += String.fromCharCode(charCode); + } + const base64String = btoa(str); + return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function base64URLStringToBuffer (base64URLString) { + const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/'); + const padLength = (4 - (base64.length % 4)) % 4; + const padded = base64.padEnd(base64.length + padLength, '='); + const binary = atob(padded); + const buffer = new ArrayBuffer(binary.length); + const bytes = new Uint8Array(buffer); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return buffer; +} + +function browserSupportsWebAuthn () { + return (window?.PublicKeyCredential !== undefined && + typeof window.PublicKeyCredential === 'function'); +} + +function toPublicKeyCredentialDescriptor (descriptor) { + const { id } = descriptor; + return { + ...descriptor, + id: base64URLStringToBuffer(id), + transports: descriptor.transports + }; +} + +function isValidDomain (hostname) { + return (hostname === 'localhost' || + /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(hostname)); +} + +class WebAuthnError extends Error { + constructor ({ message, code, cause, name }) { + super(message, { cause }); + this.name = name ?? cause.name; + this.code = code; + } +} + +function identifyRegistrationError ({ error, options }) { + const { publicKey } = options; + if (!publicKey) { + throw Error('options was missing required publicKey property'); + } + if (error.name === 'AbortError') { + if (options.signal instanceof AbortSignal) { + return new WebAuthnError({ + message: 'Registration ceremony was sent an abort signal', + code: 'ERROR_CEREMONY_ABORTED', + cause: error + }); + } + } else if (error.name === 'ConstraintError') { + if (publicKey.authenticatorSelection?.requireResidentKey === true) { + return new WebAuthnError({ + message: 'Discoverable credentials were required but no available authenticator supported it', + code: 'ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT', + cause: error + }); + } else if (publicKey.authenticatorSelection?.userVerification === 'required') { + return new WebAuthnError({ + message: 'User verification was required but no available authenticator supported it', + code: 'ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT', + cause: error + }); + } + } else if (error.name === 'InvalidStateError') { + return new WebAuthnError({ + message: 'The authenticator was previously registered', + code: 'ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', + cause: error + }); + } else if (error.name === 'NotAllowedError') { + return new WebAuthnError({ + message: error.message, + code: 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY', + cause: error + }); + } else if (error.name === 'NotSupportedError') { + const validPubKeyCredParams = publicKey.pubKeyCredParams.filter((param) => param.type === 'public-key'); + if (validPubKeyCredParams.length === 0) { + return new WebAuthnError({ + message: 'No entry in pubKeyCredParams was of type "public-key"', + code: 'ERROR_MALFORMED_PUBKEYCREDPARAMS', + cause: error + }); + } + return new WebAuthnError({ + message: 'No available authenticator supported any of the specified pubKeyCredParams algorithms', + code: 'ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG', + cause: error + }); + } else if (error.name === 'SecurityError') { + const effectiveDomain = window.location.hostname; + if (!isValidDomain(effectiveDomain)) { + return new WebAuthnError({ + message: `${window.location.hostname} is an invalid domain`, + code: 'ERROR_INVALID_DOMAIN', + cause: error + }); + } else if (publicKey.rp.id !== effectiveDomain) { + return new WebAuthnError({ + message: `The RP ID "${publicKey.rp.id}" is invalid for this domain`, + code: 'ERROR_INVALID_RP_ID', + cause: error + }); + } + } else if (error.name === 'TypeError') { + if (publicKey.user.id.byteLength < 1 || publicKey.user.id.byteLength > 64) { + return new WebAuthnError({ + message: 'User ID was not between 1 and 64 characters', + code: 'ERROR_INVALID_USER_ID_LENGTH', + cause: error + }); + } + } else if (error.name === 'UnknownError') { + return new WebAuthnError({ + message: 'The authenticator was unable to process the specified options, or could not create a new credential', + code: 'ERROR_AUTHENTICATOR_GENERAL_ERROR', + cause: error + }); + } + return error; +} + +class BaseWebAuthnAbortService { + createNewAbortSignal () { + if (this.controller) { + const abortError = new Error('Cancelling existing WebAuthn API call for new one'); + abortError.name = 'AbortError'; + this.controller.abort(abortError); + } + const newController = new AbortController(); + this.controller = newController; + return newController.signal; + } + + cancelCeremony () { + if (this.controller) { + const abortError = new Error('Manually cancelling existing WebAuthn API call'); + abortError.name = 'AbortError'; + this.controller.abort(abortError); + this.controller = undefined; + } + } +} +const WebAuthnAbortService = new BaseWebAuthnAbortService(); + +const attachments = ['cross-platform', 'platform']; +function toAuthenticatorAttachment (attachment) { + if (!attachment) { + return; + } + if (attachments.indexOf(attachment) < 0) { + return; + } + return attachment; +} + +async function startRegistration (optionsJSON) { + if (!browserSupportsWebAuthn()) { + throw new Error('WebAuthn is not supported in this browser'); + } + const publicKey = { + ...optionsJSON, + challenge: base64URLStringToBuffer(optionsJSON.challenge), + user: { + ...optionsJSON.user, + id: base64URLStringToBuffer(optionsJSON.user.id) + }, + excludeCredentials: optionsJSON.excludeCredentials?.map(toPublicKeyCredentialDescriptor) + }; + const options = { publicKey }; + options.signal = WebAuthnAbortService.createNewAbortSignal(); + let credential; + try { + credential = (await navigator.credentials.create(options)); + } catch (err) { + throw identifyRegistrationError({ error: err, options }); + } + if (!credential) { + throw new Error('Registration was not completed'); + } + const { id, rawId, response, type } = credential; + let transports; + if (typeof response.getTransports === 'function') { + transports = response.getTransports(); + } + let responsePublicKeyAlgorithm; + if (typeof response.getPublicKeyAlgorithm === 'function') { + try { + responsePublicKeyAlgorithm = response.getPublicKeyAlgorithm(); + } catch (error) { + warnOnBrokenImplementation('getPublicKeyAlgorithm()', error); + } + } + let responsePublicKey; + if (typeof response.getPublicKey === 'function') { + try { + const _publicKey = response.getPublicKey(); + if (_publicKey !== null) { + responsePublicKey = bufferToBase64URLString(_publicKey); + } + } catch (error) { + warnOnBrokenImplementation('getPublicKey()', error); + } + } + let responseAuthenticatorData; + if (typeof response.getAuthenticatorData === 'function') { + try { + responseAuthenticatorData = bufferToBase64URLString(response.getAuthenticatorData()); + } catch (error) { + warnOnBrokenImplementation('getAuthenticatorData()', error); + } + } + return { + id, + rawId: bufferToBase64URLString(rawId), + response: { + attestationObject: bufferToBase64URLString(response.attestationObject), + clientDataJSON: bufferToBase64URLString(response.clientDataJSON), + transports, + publicKeyAlgorithm: responsePublicKeyAlgorithm, + publicKey: responsePublicKey, + authenticatorData: responseAuthenticatorData + }, + type, + clientExtensionResults: credential.getClientExtensionResults(), + authenticatorAttachment: toAuthenticatorAttachment(credential.authenticatorAttachment) + }; +} +function warnOnBrokenImplementation (methodName, cause) { + console.warn(`The browser extension that intercepted this WebAuthn API call incorrectly implemented ${methodName}. You should report this error to them.\n`, cause); +} + +function browserSupportsWebAuthnAutofill () { + if (!browserSupportsWebAuthn()) { + return new Promise((resolve) => resolve(false)); + } + const globalPublicKeyCredential = window + .PublicKeyCredential; + if (globalPublicKeyCredential.isConditionalMediationAvailable === undefined) { + return new Promise((resolve) => resolve(false)); + } + return globalPublicKeyCredential.isConditionalMediationAvailable(); +} + +function identifyAuthenticationError ({ error, options }) { + const { publicKey } = options; + if (!publicKey) { + throw Error('options was missing required publicKey property'); + } + if (error.name === 'AbortError') { + if (options.signal instanceof AbortSignal) { + return new WebAuthnError({ + message: 'Authentication ceremony was sent an abort signal', + code: 'ERROR_CEREMONY_ABORTED', + cause: error + }); + } + } else if (error.name === 'NotAllowedError') { + return new WebAuthnError({ + message: error.message, + code: 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY', + cause: error + }); + } else if (error.name === 'SecurityError') { + const effectiveDomain = window.location.hostname; + if (!isValidDomain(effectiveDomain)) { + return new WebAuthnError({ + message: `${window.location.hostname} is an invalid domain`, + code: 'ERROR_INVALID_DOMAIN', + cause: error + }); + } else if (publicKey.rpId !== effectiveDomain) { + return new WebAuthnError({ + message: `The RP ID "${publicKey.rpId}" is invalid for this domain`, + code: 'ERROR_INVALID_RP_ID', + cause: error + }); + } + } else if (error.name === 'UnknownError') { + return new WebAuthnError({ + message: 'The authenticator was unable to process the specified options, or could not create a new assertion signature', + code: 'ERROR_AUTHENTICATOR_GENERAL_ERROR', + cause: error + }); + } + return error; +} + +async function startAuthentication (optionsJSON, useBrowserAutofill = false) { + if (!browserSupportsWebAuthn()) { + throw new Error('WebAuthn is not supported in this browser'); + } + let allowCredentials; + if (optionsJSON.allowCredentials?.length !== 0) { + allowCredentials = optionsJSON.allowCredentials?.map(toPublicKeyCredentialDescriptor); + } + const publicKey = { + ...optionsJSON, + challenge: base64URLStringToBuffer(optionsJSON.challenge), + allowCredentials + }; + const options = {}; + if (useBrowserAutofill) { + if (!(await browserSupportsWebAuthnAutofill())) { + throw Error('Browser does not support WebAuthn autofill'); + } + const eligibleInputs = document.querySelectorAll("input[autocomplete$='webauthn']"); + if (eligibleInputs.length < 1) { + throw Error('No with "webauthn" as the only or last value in its `autocomplete` attribute was detected'); + } + options.mediation = 'conditional'; + publicKey.allowCredentials = []; + } + options.publicKey = publicKey; + options.signal = WebAuthnAbortService.createNewAbortSignal(); + let credential; + try { + credential = (await navigator.credentials.get(options)); + } catch (err) { + throw identifyAuthenticationError({ error: err, options }); + } + if (!credential) { + throw new Error('Authentication was not completed'); + } + const { id, rawId, response, type } = credential; + let userHandle; + if (response.userHandle) { + userHandle = bufferToBase64URLString(response.userHandle); + } + return { + id, + rawId: bufferToBase64URLString(rawId), + response: { + authenticatorData: bufferToBase64URLString(response.authenticatorData), + clientDataJSON: bufferToBase64URLString(response.clientDataJSON), + signature: bufferToBase64URLString(response.signature), + userHandle + }, + type, + clientExtensionResults: credential.getClientExtensionResults(), + authenticatorAttachment: toAuthenticatorAttachment(credential.authenticatorAttachment) + }; +} + +function platformAuthenticatorIsAvailable () { + if (!browserSupportsWebAuthn()) { + return new Promise((resolve) => resolve(false)); + } + return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); +} + +export { WebAuthnAbortService, WebAuthnError, base64URLStringToBuffer, browserSupportsWebAuthn, browserSupportsWebAuthnAutofill, bufferToBase64URLString, platformAuthenticatorIsAvailable, startAuthentication, startRegistration }; diff --git a/lib/assets/style.css b/lib/assets/style.css index fbec3d72..f27ecf98 100644 --- a/lib/assets/style.css +++ b/lib/assets/style.css @@ -23,7 +23,9 @@ /* Armadietto colors */ --arma-black-color: #171614; + --arma-darkgray-color: #424239; --arma-whitesmoke-color: #e6e2da; + --arma-lightgray-color: #c8c8c8; --arma-wheat-color: #c7b9bd; --arma-orange-color: #f4a903; --arma-orangered-color: #f07b0d; @@ -40,6 +42,7 @@ --rs-bckgrd-color: var(--rs-black-color); --arma-bckgrd-color: var(--arma-whitesmoke-color); + --arma-bckgrd-color-alt: var(--arma-lightgray-color); --arma-title-color: #0d0608; --arma-text-color: var(--arma-black-color); --arma-brand-color: var(--arma-orange-color); @@ -81,6 +84,7 @@ --rs-bckgrd-color: var(--rs-white-color); --arma-bckgrd-color: var(--arma-black-color); + --arma-bckgrd-color-alt: var(--arma-darkgray-color); --arma-title-color: var(--arma-whitesmoke-color); --arma-text-color: var(--arma-wheat-color); --arma-brand-color: var(--arma-orange-color); @@ -115,7 +119,7 @@ body { display: flex; flex-direction: column; align-items: center; - justify-content: space-between; + justify-content: stretch; min-height: 100vh; background: var(--arma-bckgrd-color); background-image: var(--body-bckgrd-linear-gradient, transparent); @@ -163,6 +167,20 @@ li { text-align: center; } +.centering-container { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +.width10em { + width: 10em; +} +.width4em { + width: 4em; +} + /* TOPBAR HEADER */ header.topbar { @@ -173,7 +191,7 @@ header.topbar { width: 100%; max-width: calc(100vw - 2rem); min-height: 3rem; - margin: 1rem 0; + margin: 1rem 0 0 0; } header.topbar h1 { @@ -251,9 +269,10 @@ header.topbar h1 a:focus { header.topbar nav ul { display: flex; - align-items: center; justify-content: space-between; - gap: .5rem; + align-items: center; + flex-wrap: wrap; + column-gap: .5rem; margin: 0; padding: 0; } @@ -268,7 +287,7 @@ header.topbar .navitem { color: var(--arma-text-color); transition: all .2s linear; text-decoration: none; - height: 3rem; + height: 2.5rem; } header.topbar .signup::before { @@ -289,6 +308,8 @@ header.topbar .signup:focus { section.hero { width: 50rem; max-width: calc(100vw - 2rem); + margin-left: auto; + margin-right: auto; text-align: center; } @@ -343,6 +364,119 @@ section.hero header + p { } } +.artwork { + margin-top: 1rem; +} + +.fitName { + font-size: 1.3rem; + font-size: math; + overflow: scroll; +} + +.centeredBoxContent { + display: flex; + justify-content: center; + align-items: center; +} + +.centeredText { + text-align: center; +} + +.marginTop { + margin-top: 1em; +} +.marginSides { + margin-left: 1em; + margin-right: 1em; +} +.padding { + padding: 1em; +} + +.floatRight { + float: right; + margin-left: 1em; +} + +.fullwidth { + width: 100%; +} +th { + background: #333; + color: white; + font-weight: bold; +} +tr:nth-of-type(even) { + background: var(--arma-bckgrd-color-alt); +} +td:first-child { + padding-left: 0.67em; +} +/* +Max width before these PARTICULAR tables gets nasty +This query will take effect for any screen smaller than 500px +*/ +@media +only screen and (max-width: 500px) { + + /* Force table to not be like tables anymore */ + table.adaptive, .adaptive thead, .adaptive tbody, .adaptive th, .adaptive td, .adaptive tr { + display: block; + } + + /* Hide table headers (but not display: none;, for accessibility) */ + .adaptive thead tr { + border: 0; + padding: 0; + margin: 0; + position: absolute !important; + height: 1px; + width: 1px; + overflow: hidden; + clip: rect(1px 1px 1px 1px); /* IE6, IE7 - a 0 height clip, off to the bottom right of the visible 1px box */ + clip: rect(1px, 1px, 1px, 1px); /*maybe deprecated but we need to support legacy browsers */ + clip-path: inset(50%); /*modern browsers, clip-path works inwards from each corner*/ + white-space: nowrap; /* added line to stop words getting smushed together (as they go onto seperate lines and some screen readers do not understand line feeds as a space */ + } + + .adaptive tr { border: 1px solid #ccc; } + + .adaptive td { + /* Behave like a "row" */ + border: none; + border-bottom: 1px solid #eee; + position: relative; + padding-left: 40%; + } + + .adaptive td:before { + /* Now like a table header */ + position: absolute; + /* Top/left values mimic padding */ + left: 0.67em; + width: 35%; + padding-right: 10px; + white-space: nowrap; + } + + /* Labels the data */ + #credentials td:nth-of-type(1):before { content: "Created using"; } + #credentials td:nth-of-type(2):before { content: "Created on"; } + #credentials td:nth-of-type(3):before { content: "Last used"; } + + #users td:nth-of-type(1):before { content: "Name"; } + #users td:nth-of-type(2):before { content: "Contact URL"; } + #users td:nth-of-type(3):before { content: "Last logged-in"; } + #users td:nth-of-type(4):before { content: "Privileges"; } +} + +#output { + border: black 1px solid; + padding: 1em; +} + /* BODY FOOTER */ body > footer > p { @@ -420,6 +554,11 @@ body > footer > p { /* MAIN CONTENT */ +main { + width: 100%; + max-width: 65rem; +} + main.content { margin: auto; } @@ -450,6 +589,13 @@ main .message { text-align: center; } +.notTooWide { + width: 100%; + max-width: 30rem; + margin-left: auto; + margin-right: auto; +} + /* FORMS */ form { @@ -471,10 +617,13 @@ form { margin-bottom: 1rem; } -label { +label.overLabel { color: var(--arma-extra-color); font-size: .875rem; } +label.leftLabel { + +} input { display: flex; @@ -528,7 +677,7 @@ input#password { font-weight: 700; } -button[type="submit"] { +button[type="submit"], button.mainAction { display: flex; place-content: center; width: auto; @@ -542,7 +691,6 @@ button[type="submit"] { color: var(--arma-btn-txt-color); font-family: 'Outfit', var(--font-fallback); font-size: 1.3rem; - margin-left: auto; font-weight: 700; line-height: 1.4; transition: all .3s ease; @@ -595,6 +743,8 @@ button[name="signup"]:focus { } .oauth-form { + margin-left: auto; + margin-right: auto; display: flex; flex-direction: column; align-items: flex-start; @@ -740,6 +890,48 @@ button[name="deny"]:focus { outline: 2pt dotted var(--arma-focus-border-color); } +/* UTILITY */ +.flexRowSpaceBetween { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +.flexRowSpaceAround { + display: flex; + flex-direction: row; + justify-content: space-around; + align-items: center; +} + +.flexRowCenter { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +} + +.flexRowWrapResponsive { + display: flex; + flex-flow: row nowrap; + justify-content: space-around; + align-items: center; +} + +@media +only screen and (max-width: 500px) { + .flexRowWrapResponsive { + display: flex; + flex-flow: row wrap; + justify-content: space-around; + align-items: center; + } +} + +.preWrap { + white-space: pre-wrap; +} /* ICONS */ @@ -758,6 +950,15 @@ button[name="deny"]:focus { margin: auto; } +.passkeyIcon { + height: 1.5em; + margin: 0.5em; +} + +.passkeyIconLg { + height: 6.5em; + margin: 0.5em; +} /* SWITCH */ #switch { diff --git a/lib/logger.js b/lib/logger.js index 5fa87cc7..d9899b9b 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -3,6 +3,9 @@ const { format } = winston; const { printf } = format; const path = require('path'); const getOriginator = require('./util/getOriginator'); +const shorten = require('./util/shorten'); + +const LOG_NOTE_MAX_LEN = 150; let logger; @@ -93,8 +96,13 @@ function getLogger () { function loggingMiddleware (req, res, next) { res.logNotes = new Set(); res.on('finish', () => { - logRequest(req, req.data?.username || req.params?.username || '-', res.statusCode, - res._contentLength ?? res.get('Content-Length') ?? '-', Array.from(res.logNotes).join(' '), res.logLevel); + let names = new Set([req.body?.username, req.query?.username, req.params?.username, req.username, req.session?.oauthParams?.username, req.session?.user?.username].filter(n => Boolean(n))); + if (names.size === 0) { + names = new Set([req.contactURL, req.session?.user?.contactURL].filter(n => Boolean(n))); + } + const username = names.size > 0 ? Array.from(names).join('|') : '-'; + logRequest(req, username, res.statusCode, + res._contentLength ?? res.get('Content-Length') ?? '-', shorten(Array.from(res.logNotes).join(' '), LOG_NOTE_MAX_LEN), res.logLevel); }); next(); } @@ -134,7 +142,7 @@ function logRequest (req, username, status, numBytesWritten, logNote, logLevel) if (logNote instanceof Error) { logNote = logNote.message || logNote.code || logNote.name || logNote.toString(); } - line += ' “' + logNote + '”'; + line += ' «' + logNote + '»'; } logger.log(level, line); } diff --git a/lib/middleware/rateLimiterMiddleware.js b/lib/middleware/rateLimiterMiddleware.js new file mode 100644 index 00000000..c25a493f --- /dev/null +++ b/lib/middleware/rateLimiterMiddleware.js @@ -0,0 +1,42 @@ +const { RateLimiterMemory, BurstyRateLimiter } = require('rate-limiter-flexible'); + +const SUSTAINED_REQ_PER_SEC = 8; +const MAX_BURST = 50; // remotestorage.js appears to keep requesting until 10 failures + +const rateLimiterSustained = new RateLimiterMemory({ + points: SUSTAINED_REQ_PER_SEC, + duration: 1 +}); + +const rateLimiterBurst = new RateLimiterMemory({ + keyPrefix: 'burst', + points: MAX_BURST - SUSTAINED_REQ_PER_SEC, + duration: 10 +}); + +async function rateLimiterPenalty (key, points = 1) { + await rateLimiterSustained.penalty(key, points); + await rateLimiterBurst.penalty(key, points); +} + +async function rateLimiterBlock (key, secDuration) { + await rateLimiterSustained.block(key, secDuration); + await rateLimiterBurst.block(key, secDuration); +} + +const rateLimiter = new BurstyRateLimiter( + rateLimiterSustained, + rateLimiterBurst +); + +const rateLimiterMiddleware = async (req, res, next) => { + try { + await rateLimiter.consume(req.ip); + next(); + } catch (err) { + res.set({ 'Retry-After': Math.ceil(err.msBeforeNext / 1000) }); + res.status(429).end(); + } +}; + +module.exports = { rateLimiterPenalty, rateLimiterBlock, rateLimiterMiddleware }; diff --git a/lib/middleware/sanityCheckUsername.js b/lib/middleware/sanityCheckUsername.js index d9e52ac1..217df031 100644 --- a/lib/middleware/sanityCheckUsername.js +++ b/lib/middleware/sanityCheckUsername.js @@ -1,10 +1,12 @@ /** sanity check of username, to defend against ".." and whatnot */ -module.exports = function sanityCheckUsername (req, res, next) { +const { rateLimiterBlock } = require('./rateLimiterMiddleware'); +module.exports = async function sanityCheckUsername (req, res, next) { const username = req.params.username || req.data.username || ''; - if (username.length > 0 && !/\/|^\.+$/.test(username) && /[\p{Lu}\p{Ll}\p{Lt}\p{Lo}\p{Nd}]{1,63}/u.test(username)) { + if (username.length > 0 && !/\/|^\.+$/.test(username) && /[\p{L}\p{Nd}]{1,63}/u.test(username)) { return next(); } res.logNotes.add('invalid user'); res.status(400).end(); + await rateLimiterBlock(req.ip, 61); }; diff --git a/lib/robots.txt b/lib/robots.txt new file mode 100644 index 00000000..9fcd22e8 --- /dev/null +++ b/lib/robots.txt @@ -0,0 +1,6 @@ +User-agent: * +Disallow: / +Allow: /$ +Allow: /signup$ +Allow: /assets/ +Allow: /storage/*/public/ diff --git a/lib/routes/S3_store_router.js b/lib/routes/S3_store_router.js index 4e56c80c..d5640a05 100644 --- a/lib/routes/S3_store_router.js +++ b/lib/routes/S3_store_router.js @@ -3,7 +3,7 @@ /* eslint-env node */ /* eslint-disable camelcase */ const errToMessages = require('../util/errToMessages'); -const { createHash } = require('node:crypto'); +const { createHash, randomBytes } = require('node:crypto'); const express = require('express'); const { posix } = require('node:path'); const { @@ -14,7 +14,6 @@ const { DeleteBucketCommand, GetBucketVersioningCommand, ListBucketsCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3'); const { NodeHttpHandler } = require('@smithy/node-http-handler'); -const { EMAIL_PATTERN, EMAIL_ERROR, PASSWORD_ERROR, PASSWORD_PATTERN } = require('../util/validations'); const normalizeETag = require('../util/normalizeETag'); const ParameterError = require('../util/ParameterError'); const EndResponseError = require('../util/EndResponseError'); @@ -23,12 +22,17 @@ const YAML = require('yaml'); const TimeoutError = require('../util/timeoutError'); const { Upload } = require('@aws-sdk/lib-storage'); const { pipeline } = require('node:stream/promises'); -const core = require('../stores/core'); const { getLogger } = require('../logger'); +const proquint = require('proquint'); +const { calcContactURL } = require('../../lib/util/protocols'); +const NoSuchUserError = require('../util/NoSuchUserError'); +const NoSuchBlobError = require('../util/NoSuchBlobError'); +const shorten = require('../util/shorten'); +const ID_NUM_BITS = 64; const MAX_KEY_LENGTH = 910; // MinIO in /var/minio; OpenIO: 1023; AWS & play.min.io: 1024 +const ADMIN_NAME = 'admin'; // this plus the userNameSuffix is the admin bucket name const AUTH_PREFIX = 'remoteStorageAuth'; -const AUTHENTICATION_LOCAL_PASSWORD = 'authenticationLocalPassword'; const USER_METADATA = 'userMetadata'; const BLOB_PREFIX = 'remoteStorageBlob'; const FOLDER_MIME_TYPE = 'application/ld+json'; // external type (Linked Data) @@ -63,18 +67,31 @@ module.exports = function ({ endPoint = 'play.min.io', accessKey = 'Q3AM3UQ867SP } // logger: getLogger(), }); + s3client.send(new CreateBucketCommand({ Bucket: calcBucketName(ADMIN_NAME) })).then(resp => { + getLogger().notice(`created (or re-created) admin bucket: ${resp.Location}`); + }).catch(err => { + if (err.name === 'BucketAlreadyOwnedByYou') { + getLogger().notice(`admin bucket already exists: ${calcBucketName(ADMIN_NAME)}`); + } else { + getLogger().alert(Array.from(errToMessages(err, new Set(['while creating admin bucket:']))).join(' ')); + } + }); s3client.send(new ListBucketsCommand({})).then(({ Buckets }) => { const users = []; - let numOtherBuckets = 0; + let numAdminBuckets = 0; let numOtherBuckets = 0; for (const bucket of Buckets) { if (bucket.Name.endsWith(userNameSuffix)) { - users.push(bucket.Name.slice(0, -userNameSuffix.length)); + if (bucket.Name === ADMIN_NAME + userNameSuffix) { + ++numAdminBuckets; + } else { + users.push(bucket.Name.slice(0, -userNameSuffix.length)); + } } else { ++numOtherBuckets; } } - getLogger().notice(`${endPoint} ${accessKey} has ${users.length} users: ` + users.join(', ')); - getLogger().notice(`${endPoint} ${accessKey} has ${numOtherBuckets} buckets that are not accounts (don't end with “${userNameSuffix}”)`); + getLogger().notice(`${endPoint} ${accessKey} has ${users.length} users: ` + shorten(users.join(', '), 200)); + getLogger().notice(`${endPoint} ${accessKey} has ${numAdminBuckets} admin bucket & ${numOtherBuckets} buckets that are not accounts (don't end with “${userNameSuffix}”)`); }).catch(err => { // it's bad if storage can't be reached getLogger().alert(Array.from(errToMessages(err, new Set(['while listing buckets:']))).join(' ')); }); @@ -103,6 +120,7 @@ module.exports = function ({ endPoint = 'play.min.io', accessKey = 'Q3AM3UQ867SP const isFolder = ContentType === FOLDER_FLAG; const contentType = isFolder ? FOLDER_MIME_TYPE : ContentType; if (isFolderRequest ^ isFolder) { + res.logNotes.add(`isFolderRequest: ${isFolderRequest} isFolder: ${isFolder}`); return res.status(409).end(); // Conflict } else { res.status(200).set('Content-Length', ContentLength).set('Content-Type', contentType).set('ETag', normalizeETag(ETag)); @@ -117,6 +135,7 @@ module.exports = function ({ endPoint = 'play.min.io', accessKey = 'Q3AM3UQ867SP res.set('ETag', normalizeETag(`"${digest}"`)); res.type(FOLDER_MIME_TYPE).send(folderJson); } else { + errToMessages(err, res.logNotes); return res.status(404).end(); // Not Found } } else if (err.name === 'PreconditionFailed') { @@ -150,6 +169,7 @@ module.exports = function ({ endPoint = 'play.min.io', accessKey = 'Q3AM3UQ867SP try { const headResponse = await s3client.send(new HeadObjectCommand({ Bucket: bucketName, Key: s3Path })); if (headResponse.ContentType === FOLDER_FLAG) { + res.logNotes.add('blocked attempt to overwrite folder'); res.status(409).type('text/plain').send('can\'t overwrite folder: ' + s3Path); return; } currentETag = normalizeETag(headResponse.ETag); @@ -211,6 +231,7 @@ module.exports = function ({ endPoint = 'play.min.io', accessKey = 'Q3AM3UQ867SP const s3Path = calcS3Path(BLOB_PREFIX, req.params[0]); const headResponse = await s3client.send(new HeadObjectCommand({ Bucket: bucketName, Key: s3Path })); if (headResponse.ContentType === FOLDER_FLAG) { + res.logNotes.add('blocked attempt to delete folder directly'); res.status(409).type('text/plain').send('can\'t delete folder directly: ' + s3Path); return; } const currentETag = normalizeETag(headResponse.ETag); @@ -251,44 +272,37 @@ module.exports = function ({ endPoint = 'play.min.io', accessKey = 'Q3AM3UQ867SP ); const USER_NAME_PATTERN = new RegExp(`^[a-zA-Z0-9][a-zA-Z0-9-]{1,${61 - userNameSuffix.length}}[a-zA-Z0-9]$`); - const USER_NAME_ERROR = `User name must start and end with a letter or digit, contain only letters, digits & hyphens, and be 3–${63 - userNameSuffix.length} characters long.`; + const USER_NAME_ERROR = `Username must contain only letters, digits & hyphens, be 3–${63 - userNameSuffix.length} characters long, and start and end with a letter or digit.`; /** - * Creates a versioned bucket with authentication & version data for the new user. + * Creates a bucket (versioned if supported) and user record. * @param {Object} params * @param {string} params.username - * @param {string} params.email - * @param {string} params.password + * @param {string} params.contactURL typically a mailto: or sms: URL * @param {Set} logNotes: strings for the notes field in the log - * @returns {Promise} name of bucket + * @returns {Promise} user record */ router.createUser = async function createUser (params, logNotes) { - let { username, email, password } = params; - username = username.trim(); - + const username = params.username || proquint.encode(randomBytes(Math.ceil(ID_NUM_BITS / 16) * 2)); if (!USER_NAME_PATTERN.test(username)) { - throw new Error(USER_NAME_ERROR); - } - if (!EMAIL_PATTERN.test(email)) { - throw new Error(EMAIL_ERROR); - } - if (!PASSWORD_PATTERN.test(password)) { - throw new Error(PASSWORD_ERROR); + throw new ParameterError(USER_NAME_ERROR); } + const contactURL = calcContactURL(params.contactURL).href; // validates & normalizes + + const normalizedParams = { ...params, username, contactURL }; + + // TODO: move check for existing contactURL to account module and call here + const bucketName = await this.allocateUserStorage(username, logNotes); try { - const config = await core.hashPassword(password, null); - - const hashedPasswordBlobPath = calcS3Path(AUTH_PREFIX, AUTHENTICATION_LOCAL_PASSWORD); - await s3client.send(new PutObjectCommand({ Bucket: bucketName, Key: hashedPasswordBlobPath, Body: YAML.stringify(config), ContentType: 'application/yaml' })); + const metadata = { privileges: {}, ...normalizedParams, storeId: bucketName, credentials: {} }; - const metadata = { username, email, bucketName }; const metadataPath = calcS3Path(AUTH_PREFIX, USER_METADATA); await s3client.send(new PutObjectCommand({ Bucket: bucketName, Key: metadataPath, Body: YAML.stringify(metadata), ContentType: 'application/yaml' })); - return bucketName; + return metadata; } catch (err) { logNotes.add('while creating user auth or metadata:'); throw err; @@ -301,18 +315,19 @@ module.exports = function ({ endPoint = 'play.min.io', accessKey = 'Q3AM3UQ867SP try { await s3client.send(new GetBucketVersioningCommand({ Bucket: bucketName })); - throw new Error(`Username “${username}” is already taken`); + throw new ParameterError(`Username “${username}” is already taken`); } catch (err) { if (err.name !== 'NoSuchBucket') { throw err; - } // else bucket doesn't exist, thus the name is available for the new user + } // else bucket doesn't exist, thus the username is available for the new user } try { await s3client.send(new CreateBucketCommand({ Bucket: bucketName })); + logNotes.add(`allocated storage for user “${username}”`); } catch (err) { if (err.name === 'BucketAlreadyOwnedByYou') { - throw new Error(`Username “${username}” is already taken`, { cause: err }); + throw new ParameterError(`Username “${username}” is already taken`, { cause: err }); } else { logNotes.add('while creating or configuring bucket:'); throw err; @@ -354,6 +369,177 @@ module.exports = function ({ endPoint = 'play.min.io', accessKey = 'Q3AM3UQ867SP return bucketName; }; + /** + * store router method + * @param {string} path the relative path for the blob + * @param {string} contentType content type (MIME type) + * @param {string} content + * @return {Promise} number of bytes written + * */ + router.upsertAdminBlob = async function (path, contentType, content) { + const bucketName = calcBucketName(ADMIN_NAME); + const s3Path = calcS3Path(BLOB_PREFIX, path); + + await s3client.send(new PutObjectCommand( + { Bucket: bucketName, Key: s3Path, Body: content, ContentType: contentType, ContentLength: content.length })); + + return content.length; + }; + + /** + * store router method + * @param {string} path the relative path of the blob + * @return {Promise} the blob text + */ + router.readAdminBlob = async function (path) { + const bucketName = calcBucketName(ADMIN_NAME); + const s3Path = calcS3Path(BLOB_PREFIX, path); + + const { Body } = await s3client.send(new GetObjectCommand({ Bucket: bucketName, Key: s3Path })); + const chunks = []; + for await (const chunk of Body) { + chunks.push(chunk); + } + const string = Buffer.concat(chunks).toString('utf-8'); + + return string; + }; + + /** + * store router method + * @param {string} path the relative path of the blob + * @return {Promise<{contentLength: *, etag, lastModified: *, acceptRanges: string, contentType: *}>} + */ + router.metadataAdminBlob = async function (path) { + try { + const bucketName = calcBucketName(ADMIN_NAME); + const s3Path = calcS3Path(BLOB_PREFIX, path); + + const headResponse = await s3client.send(new HeadObjectCommand({ Bucket: bucketName, Key: s3Path })); + return { + contentType: headResponse.ContentType, + contentLength: headResponse.ContentLength, + etag: headResponse.ETag, + lastModified: headResponse.LastModified, + acceptRanges: headResponse.AcceptRanges + }; + } catch (err) { + if (err.name === 'NotFound' || err.$metadata.httpStatusCode === 404) { + throw new NoSuchBlobError(`${path} does not exist`); + } else { + throw err; + } + } + }; + + /** + * store router method + * @param {string} path the relative path of the blob + * @return {Promise} + */ + router.deleteAdminBlob = async function (path) { + const bucketName = calcBucketName(ADMIN_NAME); + const s3Path = calcS3Path(BLOB_PREFIX, path); + + await s3client.send(new DeleteObjectCommand({ Bucket: bucketName, Key: s3Path })); + }; + + /** + * store router method to list all admin blobs w/ the path as prefix + * @param {string} path the prefix of all blobs to be listed + * @return {Promise} metadata of the blobs: relative path, contentLength, ETag, lastModified + */ + router.listAdminBlobs = async function (path) { + const bucketName = calcBucketName(ADMIN_NAME); + const s3Path = calcS3Path(BLOB_PREFIX, path); + const blobsMeta = []; + let ContinuationToken; + do { + const { Contents, IsTruncated, NextContinuationToken } = await s3client.send(new ListObjectsV2Command({ Bucket: bucketName, Prefix: s3Path, ...(ContinuationToken ? { ContinuationToken } : null) })); + + if (!Contents?.length) { break; } + + // Fetching content-type would require a HEAD call for each blob. + blobsMeta.push(...Contents.map(m => ({ + path: m.Key.slice(s3Path.length + 1), + contentLength: m.Size, + ETag: m.ETag, + lastModified: m.LastModified + }))); + + ContinuationToken = IsTruncated ? NextContinuationToken : undefined; + } while (ContinuationToken); + + return blobsMeta; + }; + + /** + * account method + * @param logNotes + * @returns {Promise<*[]>} users, in the order returned by S3 + */ + router.listUsers = async function (logNotes) { + const { Buckets } = await s3client.send(new ListBucketsCommand({})); + const userBuckets = Buckets.filter( + bucket => bucket.Name?.endsWith(userNameSuffix) && bucket.Name !== ADMIN_NAME + userNameSuffix + ); + const metadataPath = calcS3Path(AUTH_PREFIX, USER_METADATA); + const outcomes = await Promise.allSettled(userBuckets.map(bucket => readYaml(bucket.Name, metadataPath))); + const users = []; + for (let i = 0; i < outcomes.length; ++i) { + if (outcomes[i].status === 'fulfilled') { + users.push(outcomes[i].value); + } else { + errToMessages(outcomes[i].reason, logNotes); + const username = userBuckets[i].Name?.endsWith(userNameSuffix) + ? userBuckets[i].Name.slice(0, -userNameSuffix.length) + : userBuckets[i].Name; + users.push({ username, contactURL: '‽', privileges: {}, lastUsed: '' }); + } + } + return users; + }; + + /** + * account method + * @param username + * @param _logNotes + * @returns {Promise<*>} + */ + router.getUser = async function (username, _logNotes) { + const bucketName = calcBucketName(username); + const metadataPath = calcS3Path(AUTH_PREFIX, USER_METADATA); + try { + return await readYaml(bucketName, metadataPath); + } catch (err) { + if (['NoSuchBucket', 'InvalidBucketName', 'PermanentRedirect'].includes(err.Code)) { + throw new NoSuchUserError(`No user "${username}"`, { cause: err }); + } else { + throw err; + } + } + }; + + /** + * account method + * @param {Object} user + * @param {Set} _logNotes + * @returns {Promise<>} + */ + router.updateUser = async function (user, _logNotes) { + const bucketName = calcBucketName(user.username); + const metadataPath = calcS3Path(AUTH_PREFIX, USER_METADATA); + try { + await s3client.send(new PutObjectCommand({ Bucket: bucketName, Key: metadataPath, Body: YAML.stringify(user), ContentType: 'application/yaml' })); + } catch (err) { + if (['NoSuchBucket', 'InvalidBucketName', 'PermanentRedirect'].includes(err.Code)) { + throw new NoSuchUserError(`No user "${user.username}"`, { cause: err }); + } else { + throw err; + } + } + }; + /** * Deletes all of user's documents & folders and the bucket. NOT REVERSIBLE. * @param {string} username @@ -416,9 +602,11 @@ module.exports = function ({ endPoint = 'play.min.io', accessKey = 'Q3AM3UQ867SP } await s3client.send(new DeleteBucketCommand({ Bucket: bucketName })); + logNotes.add(`deleted bucket & ${numDeletions} blobs w/ ${numErrors} errors in ${numPasses} passes`); return [numDeletions, numErrors, numPasses]; } catch (err) { if (err.Code === 'NoSuchBucket') { + logNotes.add(`bucket already deleted; deleted ${numDeletions} blobs w/ ${numErrors} errors in ${numPasses} passes`); return [numDeletions, numErrors, numPasses]; } else { throw err; @@ -426,40 +614,6 @@ module.exports = function ({ endPoint = 'play.min.io', accessKey = 'Q3AM3UQ867SP } }; - /** - * Checks password against stored credentials - * @param {String} username - * @param {String} email - * @param {String} password - * @param {Set} logNotes: strings for the notes field in the log - * @returns {Promise} true if correct - * @throws if user doesn't exist, password doesn't match, or any error - */ - router.authenticate = async function authenticate ({ username, password }, logNotes) { - let storedKey, presentedKey; - try { - const bucketName = calcBucketName(username); - const configPath = calcS3Path(AUTH_PREFIX, AUTHENTICATION_LOCAL_PASSWORD); - const storedConfig = await readYaml(bucketName, configPath); - storedKey = storedConfig.key; - presentedKey = (await core.hashPassword(password, storedConfig)).key; - } catch (err) { - if (err.name === 'NoSuchBucket') { - errToMessages(err, logNotes.add(`attempt to log in with nonexistent user “${username}”`)); - // can't set log level to 'info' - } else { - errToMessages(err, logNotes.add(`while validating password for “${username}”:`)); - // can't set log level to 'error' - } - throw new Error('Password and username do not match'); - } - if (presentedKey === storedKey) { - return true; - } else { - throw new Error('Password and username do not match'); - } - }; - async function checkAncestorsAreFolders (bucketName, s3Path) { // validates that each ancestor folder is a folder or doesn't exist let ancestorPath = dirname(s3Path); @@ -643,7 +797,11 @@ module.exports = function ({ endPoint = 'play.min.io', accessKey = 'Q3AM3UQ867SP } function calcBucketName (username) { - return (username.toLowerCase() + userNameSuffix).slice(0, 63); + const bucketName = username.toLowerCase() + userNameSuffix; + if (bucketName.length > 63) { + throw new Error('Username too long'); + } + return bucketName; } function setPauseUntil (retryAfter) { diff --git a/lib/routes/account.js b/lib/routes/account.js new file mode 100644 index 00000000..d29bd895 --- /dev/null +++ b/lib/routes/account.js @@ -0,0 +1,49 @@ +const express = require('express'); +const { getHost } = require('../util/getHost'); +const errToMessages = require('../util/errToMessages'); +const removeUserDataFromSession = require('../util/removeUserDataFromSession'); + +module.exports = async function (hostIdentity, jwtSecret, accountMgr) { + const router = express.Router(); + + // ----------------------- user account ------------------------------------------- + + router.get('/', + // csrfCheck, + async (req, res) => { + try { + if (!req.session.user?.username) { + res.logNotes.add('-> /account/login'); + removeUserDataFromSession(req.session); + res.redirect(307, '/account/login'); + return; + } + req.session.user = await accountMgr.getUser(req.session.user.username, res.logNotes); // get latest values + + res.set('Cache-Control', 'private, no-cache'); + res.render('account/account.html', { + title: 'Your Account', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {}, + username: req.session.user?.username, + contactURL: req.session.user?.contactURL, + credentials: Object.values(req.session.user?.credentials || {}) + }); + res.logNotes.add(`${req.session.user?.username} ${req.session.user?.contactURL} ${Object.keys(req.session.user?.privileges).join('&')} ${Object.keys(req.session.user?.credentials)?.length} credential(s)`); + } catch (err) { + errToMessages(err, res.logNotes); + res.status(401).render('login/error.html', { + title: 'Your Account', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {}, + subtitle: '', + message: 'There was an error displaying your info' + }); + } + } + ); + + return router; +}; diff --git a/lib/routes/admin.js b/lib/routes/admin.js new file mode 100644 index 00000000..378b6cd1 --- /dev/null +++ b/lib/routes/admin.js @@ -0,0 +1,455 @@ +const process = require('process'); +const { getLogger } = require('../logger'); +const errToMessages = require('../util/errToMessages'); +const crypto = require('crypto'); +const express = require('express'); +const { getHost } = require('../util/getHost'); +const path = require('path'); +const YAML = require('yaml'); +const { generateRegistrationOptions, verifyRegistrationResponse } = require('@simplewebauthn/server'); +const { initProtocols, assembleContactURL, calcContactURL, protocolOptions } = require('../util/protocols'); +const nameFromUseragent = require('../util/nameFromUseragent'); +const removeUserDataFromSession = require('../util/removeUserDataFromSession'); +const ParameterError = require('../util/ParameterError'); +const { rateLimiterBlock, rateLimiterPenalty } = require('../middleware/rateLimiterMiddleware'); + +/* eslint no-unused-vars: ["warn", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }] */ +/* eslint-disable no-case-declarations */ + +const INVITE_REQUEST_DIR = 'inviteRequests'; +const INVITE_DIR_NAME = 'invites'; +const CONTACT_URL_DIR = 'contactUrls'; +const INVITE_DURATION = 42 * 60 * 60 * 1000; // The user may not be expecting the invite. + +module.exports = async function (hostIdentity, jwtSecret, accountMgr, storeRouter) { + const rpName = hostIdentity; + const rpID = hostIdentity; + const sslEnabled = !/\blocalhost\b|\b127.0.0.1\b|\b10.0.0.2\b/.test(hostIdentity); + const origin = sslEnabled ? `https://${rpID}` : `https://${rpID}`; + + const router = express.Router(); + + // ----------------------- invitations ---------------------------------- + + router.get('/acceptInvite', + // csrfCheck, + loadAdmin, + async (req, res) => { + res.set('Cache-Control', 'private, no-store'); // set this because this request can't be a POST + try { + res.logNotes.add(`user ${req.session.user?.username || '«new»'} redeeming ${req.query?.token} "${req.session.user?.contactURL}"`); + + res.status(200).render('admin/invite-valid.html', { + title: req.session.user?.username ? `Welcome, ${req.session.user.username}!` : 'Welcome!', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {}, + username: req.session.user?.username, + contactURL: req.session.user?.contactURL, + message: req.session.user?.username ? 'Create a passkey' : 'Pick a username', + options: '' + }); + } catch (err) { + removeUserDataFromSession(req.session); + errToMessages(err, res.logNotes); + res.status(401).render('login/error.html', { + title: 'Your invitation has expired or is not valid', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {}, + subtitle: '(or there was an error)', + message: 'Log in from your old device and invite yourself to create a new passkey.' + }); + } + } + ); + + router.post('/getRegistrationOptions', + // csrfCheck, + express.json(), + async (req, res) => { + try { + let isCreate = false; + let user = req.session.user; + if (!user) { throw new Error('Reload this page or request another invite'); } + + const newUsername = req.body.username?.trim(); + if (newUsername) { user.username = newUsername; } + if (!user.username) { throw new ParameterError('username is required'); } + await storeRouter.upsertAdminBlob(path.join(CONTACT_URL_DIR, encodeURIComponent(user.contactURL) + '.yaml'), 'application/yaml', user.username); + + if (req.session.isUserSynthetic) { + user = await accountMgr.createUser(user, res.logNotes); + delete req.session.isUserSynthetic; + res.logNotes.add(`created user ${user.username} "${user.contactURL}" with privileges ${Object.keys(user.privileges).join('&')}`); + isCreate = true; + } + + const excludeCredentials = Object.values(user?.credentials || {}).map(credential => ({ + id: Buffer.from(credential.credentialID, 'base64url'), + type: 'public-key', + transports: credential.transports // Optional + })); + const options = await generateRegistrationOptions({ + rpName, + rpID, + userID: Buffer.from(user.username, 'utf8').toString('base64url'), + userName: user.username, + userDisplayName: user.username, + attestationType: 'none', // Don't prompt users for additional information (Recommended for smoother UX) + excludeCredentials, // Prevents users from re-registering existing credentials + authenticatorSelection: { // See "Guiding use of authenticators via authenticatorSelection" below + residentKey: 'preferred', // resident keys are discoverable + userVerification: 'preferred' // Typically requires biometric, but not password. + // no value for authenticatorAttachment to allow both platform & cross-platform + } + }); + + req.session.regChallenge = options.challenge; + + res.status(isCreate ? 201 : 200).json(options); + } catch (err) { + if (!['ParameterError'].includes(err.name)) { + removeUserDataFromSession(req.session); + } + errToMessages(err, res.logNotes); + res.status(400).type('application/json').json({ error: err.message }); + } + } + ); + + router.post('/verifyRegistration', + // csrfCheck, + express.json(), + async (req, res) => { + try { + if (!req.session.regChallenge) { + res.logNotes.add('session doesn\'t contain registration challenge'); + return res.status(401).json({ error: 'Reload this page — your session expired' }); + } + + await storeRouter.deleteAdminBlob(path.join(INVITE_DIR_NAME, req.query?.token + '.yaml')); + + const { verified, registrationInfo } = await verifyRegistrationResponse({ + response: req.body, + expectedChallenge: req.session.regChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: false + }); + + if (!verified) { + await rateLimiterPenalty(req.ip); + throw new Error('Verification failed.'); + } + + if (!req.session.user.credentials) { req.session.user.credentials = {}; } + const name = nameFromUseragent(req.body.authenticatorAttachment, req.headers['user-agent'], req.body.response?.transports); + const credential = Object.assign({}, registrationInfo, + { transports: req.body?.response?.transports, name, createdAt: new Date() }); + req.session.user.credentials[Buffer.from(registrationInfo.credentialID.buffer).toString('base64url')] = credential; + await accountMgr.updateUser(req.session.user, res.logNotes); + res.logNotes.add(`Passkey “${credential.name}” saved for “${req.session.user.username}”`); + + req.session.privileges = { ...req.session.user.privileges }; // includes admin privileges + return res.status(201).json({ verified }); + } catch (err) { + removeUserDataFromSession(req.session); + errToMessages(err, res.logNotes); + return res.status(400).json({ error: err.message }); + } finally { + delete req.session.regChallenge; + } + }); + + router.post('/cancelInvite', + // csrfCheck, + async (req, res) => { + try { + removeUserDataFromSession(req.session); + await storeRouter.deleteAdminBlob(path.join(INVITE_DIR_NAME, req.query?.token + '.yaml')); + res.json({}); + } catch (err) { + errToMessages(err, res.logNotes); + res.status(400).json({ error: 'Something went wrong' }); + } + } + ); + + async function loadAdmin (req, res, next) { + try { + removeUserDataFromSession(req.session); + const inviteFile = await storeRouter.readAdminBlob(path.join(INVITE_DIR_NAME, req.query?.token + '.yaml')); + const userInvite = YAML.parse(inviteFile); + const contactURL = new URL(userInvite.contactURL); // validates & normalizes + req.contactURL = contactURL.href; + if (!userInvite.expiresAt || !(Date.now() <= new Date(userInvite.expiresAt))) { + await storeRouter.deleteAdminBlob(path.join(INVITE_DIR_NAME, req.query?.token + '.yaml')); + throw new Error('invite expired'); + } + + let username = req.username = userInvite.username?.trim(); + if (username) { + await storeRouter.upsertAdminBlob(path.join(CONTACT_URL_DIR, encodeURIComponent(contactURL.href) + '.yaml'), 'application/yaml', username); + } else { + try { + username = await storeRouter.readAdminBlob(path.join(CONTACT_URL_DIR, encodeURIComponent(contactURL.href) + '.yaml')); + } catch (err) { + if (!['NoSuchBlobError', 'NoSuchKey'].includes(err.name)) { + throw err; + } + } + } + username ||= req.session.user?.username; + + let user; + if (username) { + try { + user = await accountMgr.getUser(username, res.logNotes); + user.privileges ||= userInvite.privileges; // do we really need this backstop? + } catch (err) { + if (err.name !== 'NoSuchUserError') { + throw err; + } + } + } + + if (!user) { + delete userInvite.expiresAt; + user = { credentials: {}, ...userInvite, ...(username && { username }) }; + req.session.isUserSynthetic = true; + } + req.session.user = user; + + next(); + } catch (err) { + removeUserDataFromSession(req.session); + errToMessages(err, res.logNotes); + res.status(401).render('login/error.html', { + title: 'Your invitation has expired or is not valid', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {}, + subtitle: '(or there was an error)', + message: 'Log in from your old device and invite yourself to create a new passkey.' + }); + } + } + + router.bootstrap = async function bootstrap () { + await initProtocols(storeRouter); + + const bootstrapOwner = process.env.BOOTSTRAP_OWNER ? process.env.BOOTSTRAP_OWNER.trim() : ''; + let idx = bootstrapOwner.indexOf(' '); + if (idx < 0) { idx = bootstrapOwner.length; } + const contactStr = bootstrapOwner.slice(0, idx); + if (!contactStr) { return; } + try { + const contactURL = calcContactURL(contactStr); + const filePath = path.join(INVITE_DIR_NAME, `${encodeURIComponent(contactURL)}.uri`); + try { + await storeRouter.metadataAdminBlob(filePath); + getLogger().info(`owner invite already created for ${contactURL}`); + return; + } catch (err) { + if (!['NoSuchBlobError', 'NoSuchKey'].includes(err.name)) { + throw err; + } + } + const username = bootstrapOwner.slice(idx).trim(); + const [_, inviteURL] = await router.generateInviteURL(contactURL.href, username, { + OWNER: true, + ADMIN: true, + STORE: true + }); + await storeRouter.upsertAdminBlob(filePath, 'application/yaml', inviteURL.href + '\n'); + getLogger().notice(`wrote owner invite to “${filePath}”`); // must log during boot (no res obj) + // inviteUser(contactStr, username, inviteURL) + } catch (err) { + getLogger().warning(Array.from(errToMessages(err, new Set([`while sending owner invite to “${contactStr}”`]))).join(' ')); + } + }; + + router.generateInviteURL = async function (contactStr, username, privileges = { STORE: true }) { + username = username?.trim() || ''; + contactStr = contactStr?.trim(); + if (!contactStr) { + throw new Error('contactStr can\'t be empty'); + } + + const contactURL = calcContactURL(contactStr); + const token = crypto.randomBytes(256 / 8).toString('base64url'); + await storeRouter.upsertAdminBlob(path.join(INVITE_DIR_NAME, token + '.yaml'), 'application/yaml', + YAML.stringify({ // createAccount will be called with these values (except expiresAt) + username, + contactURL, + privileges, + expiresAt: new Date(Date.now() + INVITE_DURATION) + })); + await storeRouter.deleteAdminBlob(path.join(INVITE_REQUEST_DIR, encodeURIComponent(contactURL.href) + '.yaml')); + return [contactURL, new URL('/admin/acceptInvite?token=' + token, 'https://' + hostIdentity)]; + }; + + // ----------------------- invite request list ---------------------------------------------- + + router.get('/inviteRequests', + hasAdminPrivilege, + async (req, res, next) => { + try { + const items = (await storeRouter.listAdminBlobs(INVITE_REQUEST_DIR)).map( + m => ({ contacturl: decodeURIComponent(m.path.slice(0, -5)), privilegeGrant: { STORE: true } }) + ).sort( + (a, b) => a.contacturl.toLowerCase() - b.contacturl.toLowerCase() + ); + + res.logNotes.add(`${items.length} invite requests`); + res.set('Cache-Control', 'private, no-cache'); + res.render('admin/invite-requests.html', { + title: 'Requests for Invitation', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {}, + items, + protocolOptions: protocolOptions(), + privilegeGrant: { STORE: true }, + params: { submitName: 'Create User Invitation' }, + error: null + }); + } catch (err) { + next(err); + } + } + ); + + router.post('/deleteInviteRequest', + // csrfCheck, + async (req, res) => { + try { + await storeRouter.deleteAdminBlob(path.join(INVITE_REQUEST_DIR, encodeURIComponent(req.body.contacturl) + '.yaml')); + res.json({}); + res.logNotes.add(`deleted invite request for “${req.body.contacturl}”`); + } catch (err) { + errToMessages(err, res.logNotes); + res.status(400).json({ error: err.message }); + } + } + ); + + // ----------------------- admin lists ---------------------------------------------- + + router.get('/admins', + // csrfCheck, + hasAdminPrivilege, + async (req, res, next) => { + try { + const admins = (await accountMgr.listUsers(res.logNotes)).filter(u => u.privileges?.ADMIN); + admins.sort((a, b) => a.username?.localeCompare(b.username)); + + res.logNotes.add(`${admins.length} admins`); + res.set('Cache-Control', 'private, no-cache'); + res.render('admin/users.html', { + title: 'Admins', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {}, + users: admins, + protocolOptions: protocolOptions(), + privilegeGrant: { ADMIN: true, STORE: true }, + params: { submitName: req.session.privileges?.OWNER ? 'Create Admin Invitation' : 'Create User Invitation' }, + error: null + }); + } catch (err) { + next(err); + } + } + ); + + router.get('/users', + // csrfCheck, + hasAdminPrivilege, + async (req, res, next) => { + try { + const users = await accountMgr.listUsers(res.logNotes); + users.sort((a, b) => a.username?.localeCompare(b.username)); + + res.logNotes.add(`${users.length} users`); + res.set('Cache-Control', 'private, no-cache'); + res.render('admin/users.html', { + title: 'Users', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {}, + users, + protocolOptions: protocolOptions(), + privilegeGrant: { STORE: true }, + params: { submitName: 'Create User Invitation' }, + error: null + }); + } catch (err) { + next(err); + } + } + ); + + router.post('/sendInvite', + // csrfCheck, + async (req, res, _next) => { + try { + if (!(req.session.privileges?.ADMIN || (req.session.user && req.body.contacturl === req.session.user?.contactURL))) { + res.logNotes.add('session does not have ADMIN privilege'); + res.status(401).type('text/plain').send('Ask an admin to send the invite'); + await rateLimiterBlock(req.ip, 61); + return; + } + + const privilegeGrant = JSON.parse(req.body.privilegegrant || '{}'); + if (privilegeGrant.OWNER) { + privilegeGrant.ADMIN = true; + } + if (!req.session.privileges.OWNER) { + delete privilegeGrant.OWNER; + delete privilegeGrant.ADMIN; + } + const contactURL = req.body.contacturl || assembleContactURL(req.body.protocol, req.body.address).href; + res.logNotes.add(`inviting ${contactURL} w/ privileges ${Object.keys(privilegeGrant).join(', ')}`); + const [_, inviteURL] = await router.generateInviteURL(contactURL, req.body.username, privilegeGrant); + let title, text; + if (privilegeGrant.ADMIN) { + title = `${hostIdentity} Admin Invite`; + text = req.body.username + ? `${req.body.username}, to create a passkey for ${hostIdentity} for a new device or browser, copy and paste this URL into the browser on that device:` + : `You're invited to be an admin on ${hostIdentity}, a remoteStorage server! To accept, copy and paste this URL into your browser:`; + } else { + title = `${hostIdentity} User Invite`; + text = req.body.username + ? `${req.body.username}, to create a passkey for ${hostIdentity} for a new device or browser, copy and paste this URL into the browser on that device:` + : `You're invited to use remoteStorage to store data on ${hostIdentity}! To accept, copy and paste this URL into your browser:`; + } + res.status(201).json({ url: inviteURL, title, text, contactURL }); + } catch (err) { + errToMessages(err, res.logNotes); + res.status(400).type('text/plain').send(err.message); + } + } + ); + + // ----------------------- util ---------------------------------------------- + + function hasAdminPrivilege (req, res, next) { + if (req.session.privileges?.ADMIN) { + next(); + } else { + res.logNotes.add('session does not have ADMIN privilege'); + if (['GET', 'HEAD'].includes(req.method)) { + res.redirect(307, '/admin/login'); + } else { // TODO consider deleting this unused code + res.logNotes.add('session lacks ADMIN privilege'); + res.status(401).end(); + // await rateLimiterBlock(req.ip, 61); + } + } + } + + /** Should be called whenever there's an error relating to user credentials */ + return router; +}; diff --git a/lib/routes/index.js b/lib/routes/index.js index 1f914759..c7a96119 100644 --- a/lib/routes/index.js +++ b/lib/routes/index.js @@ -4,9 +4,12 @@ const { getHost } = require('../util/getHost'); /* GET home page. */ router.get('/', function (req, res) { - res.render('index.html', { + res.set('Cache-Control', 'public, max-age=1500'); + res.render('index2.html', { title: 'Welcome', - host: getHost(req) + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {} }); }); diff --git a/lib/routes/login.js b/lib/routes/login.js new file mode 100644 index 00000000..8522af3d --- /dev/null +++ b/lib/routes/login.js @@ -0,0 +1,125 @@ +const express = require('express'); +const { getHost } = require('../util/getHost'); +const errToMessages = require('../util/errToMessages'); +const loginOptsWCreds = require('../util/loginOptsWCreds'); +const removeUserDataFromSession = require('../util/removeUserDataFromSession'); +const verifyCredential = require('../util/verifyCredential'); +const updateSessionPrivileges = require('../util/updateSessionPrivileges'); +const { rateLimiterPenalty } = require('../middleware/rateLimiterMiddleware'); + +module.exports = async function (hostIdentity, jwtSecret, account, isAdminLogin) { + const rpID = hostIdentity; + const sslEnabled = !/\blocalhost\b|\b127.0.0.1\b|\b10.0.0.2\b/.test(hostIdentity); + const origin = sslEnabled ? `https://${rpID}` : `https://${rpID}`; + + const router = express.Router(); + + router.get('/login', + // csrfCheck, + async (req, res) => { + try { + if (!req.session.user) { + removeUserDataFromSession(req.session); + } + + const options = await loginOptsWCreds(req.session.user?.username, req.session.user, account, rpID, res.logNotes); + req.session.loginChallenge = options.challenge; // Saves the challenge in the user session + + res.set('Cache-Control', 'private, no-store'); + res.render('login/login.html', { + title: isAdminLogin ? 'Start Admin Session' : 'Login', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {}, + message: isAdminLogin ? 'Click the button below to authenticate with a passkey.' : 'Click the button below to log in with a passkey.\n\nIf you need to create a passkey for this device or browser, log in from your old device and invite yourself to create a new passkey.', + options: JSON.stringify(options), + actionLabel: isAdminLogin ? 'Authenticate' : 'Log in' + }); + } catch (err) { + removeUserDataFromSession(req.session); + errToMessages(err, res.logNotes); + res.status(401).render('login/error.html', { + title: 'Login Error', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {}, + subtitle: '', + message: 'If you need to create a passkey for this device or browser, log in from your old device and invite yourself to create a new passkey..' + }); + } + }); + + router.post('/verify-authentication', + // csrfCheck, + express.json(), + async (req, res) => { + try { + if (!req.session.loginChallenge) { + res.logNotes.add('no loginChallenge in session'); + res.status(401).json({ msg: 'Reload this page — your session expired' }); + return; + } + + const presentedCredential = req.body; + const username = presentedCredential.response.userHandle + ? Buffer.from(presentedCredential.response.userHandle, 'base64url').toString('utf8') + : req.session.user?.username; + const user = await account.getUser(username, res.logNotes); // always loads latest values + + await verifyCredential(user, req.session.loginChallenge, origin, rpID, presentedCredential); + delete req.session.loginChallenge; // Kills the challenge for this session. + + await account.updateUser(user, res.logNotes); + + await updateSessionPrivileges(req, user, isAdminLogin); + res.logNotes.add(`${user?.username} ${user?.contactURL} logged in with ${Object.keys(req.session.privileges || {}).join('&')}`); + + req.session.user = user; + return res.json({ verified: true, username: user.username }); + } catch (err) { + delete req.session.loginChallenge; + removeUserDataFromSession(req.session); + + errToMessages(err, res.logNotes); + if (['Error', 'NoSuchUserError'].includes(err.name)) { + res.status(401).json({ msg: 'Your passkey could not be validated' }); + } else { + res.status(500).json({ msg: 'If this problem persists, contact an administrator.' }); + } + await rateLimiterPenalty(req.ip); + } + }); + + /** TODO: make this a POST, and change link to form */ + router.get('/logout', + // csrfCheck, + async (req, res) => { + try { + res.logNotes.add(`${req.session.user?.username} ${req.session.user?.contactURL} logging out`); + + await new Promise((resolve, reject) => { + req.session.destroy(err => { if (err) { reject(err); } else { resolve(); } }); + }); + + res.set('Cache-Control', 'private, no-store'); + res.render('login/logout.html', { + title: 'Logged Out', + host: getHost(req), + privileges: {}, + accountPrivileges: {} + }); + } catch (err) { + errToMessages(err, res.logNotes); + res.status(401).render('login/error.html', { + title: 'Logout Error', + host: getHost(req), + privileges: {}, + accountPrivileges: {}, + subtitle: '', + message: 'If you need to create a passkey for this device or browser, log in from your old device and invite yourself to create a new passkey..' + }); + } + }); + + return router; +}; diff --git a/lib/routes/oauth.js b/lib/routes/oauth.js index 1e1d6405..24ef605c 100644 --- a/lib/routes/oauth.js +++ b/lib/routes/oauth.js @@ -8,54 +8,95 @@ const sanityCheckUsername = require('../middleware/sanityCheckUsername'); const secureRequest = require('../middleware/secureRequest'); const jwt = require('jsonwebtoken'); const qs = require('querystring'); +const { getHost } = require('../util/getHost'); +const loginOptsWCreds = require('../util/loginOptsWCreds'); +const verifyCredential = require('../util/verifyCredential'); +const removeUserDataFromSession = require('../util/removeUserDataFromSession'); +const updateSessionPrivileges = require('../util/updateSessionPrivileges'); +const errorPage = require('../util/errorPage'); +const { rateLimiterPenalty, rateLimiterBlock } = require('../middleware/rateLimiterMiddleware'); const accessStrings = { r: 'Read', rw: 'Read/write' }; -module.exports = function (hostIdentity, jwtSecret) { +module.exports = function (hostIdentity, jwtSecret, account) { + const rpID = hostIdentity; + const sslEnabled = !/\blocalhost\b|\b127.0.0.1\b|\b10.0.0.2\b/.test(hostIdentity); + const origin = sslEnabled ? `https://${rpID}` : `http://${rpID}`; + const router = express.Router(); router.get('/:username', redirectToSSL, formOrQueryData, sanityCheckUsername, validOAuthRequest, - function (req, res) { - res.render('auth.html', { - title: 'Authorize', - client_host: new URL(req.query.redirect_uri).host, - client_id: req.query.client_id, - redirect_uri: req.query.redirect_uri, - response_type: req.query.response_type, - scope: req.query.scope || '', - state: req.query.state || '', - permissions: parseScope(req.query.scope || ''), - username: req.params.username, - access_strings: accessStrings - }); + async function (req, res) { + try { + res.set('Cache-Control', 'private, no-store'); + + const options = await loginOptsWCreds(req.params.username, req.session.user, account, rpID, res.logNotes); + + req.session.oauthParams = Object.assign(req.query, { username: req.params.username, challenge: options.challenge }); + res.render('auth-passkey.html', { + title: 'Authorize', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {}, + client_host: req.query.redirect_uri ? new URL(req.query.redirect_uri).host : '[missing origin]', + client_id: req.query.client_id, + redirect_uri: req.query.redirect_uri, + permissions: parseScope(req.query.scope || ''), + username: req.params.username, + options: JSON.stringify(options), + access_strings: accessStrings + }); + } catch (err) { + delete req.session.oauthParams; + removeUserDataFromSession(req.session); + errToMessages(err, res.logNotes); + res.status(401).render('login/error.html', { + title: 'Authorization Error', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {}, + subtitle: '', + message: 'If you need to create a passkey for this device or browser, log in from your old device and invite yourself to create a new passkey, or contact an administrator.' + }); + } }); router.post('/', secureRequest, formOrQueryData, - sanityCheckUsername, - validOAuthRequest, async function (req, res) { - const locals = req.data; - const username = locals.username.split('@')[0]; + try { + if (!req.session.oauthParams) { + return errorPage(req, res, 401, + { title: 'Authorization Failure', message: 'Go back to the app then try again — your session expired' }, + 'no oauthParams — session expired?' + ); + } - if (locals.deny) { - return error(req, res, 'access_denied', 'The user did not grant permission to'); - } + const presentedCredential = JSON.parse(req.body.credential); + const user = await account.getUser(req.session.oauthParams.username, res.logNotes); + + await verifyCredential(user, req.session.oauthParams.challenge, origin, rpID, presentedCredential); + await account.updateUser(user, res.logNotes); + + await updateSessionPrivileges(req, user, false); + if (!req.session.privileges.STORE) { + await rateLimiterPenalty(req.ip); + throw new Error('STORE privilege required to grant access'); + } - try { - await req.app.get('account').authenticate({ username, password: locals.password }, res.logNotes); let redirectOrigin; try { - redirectOrigin = new URL(req.data.redirect_uri).origin; + redirectOrigin = new URL(req.session.oauthParams.redirect_uri).origin; } catch (err) { + await rateLimiterBlock(req.ip, 61); throw new Error('Application origin is bad', { cause: err }); } - const scopes = req.data.scope.split(/\s+/).map(scope => { + const scopes = req.session.oauthParams.scope.split(/\s+/).map(scope => { scope = scope.replace(/[^\w*:]/g, '').toLowerCase(); if (scope.endsWith(':r') || scope.endsWith(':rw')) { return scope; @@ -63,28 +104,38 @@ module.exports = function (hostIdentity, jwtSecret) { return scope + ':rw'; } }).join(' '); + const grantDuration = (req.body.grantDuration?.trim() || '7') + 'd'; const token = jwt.sign( { scopes }, jwtSecret, - { algorithm: 'HS512', issuer: hostIdentity, audience: redirectOrigin, subject: username, expiresIn: '30d' } + { algorithm: 'HS512', issuer: hostIdentity, audience: redirectOrigin, subject: req.session.oauthParams.username, expiresIn: grantDuration } ); - res.logNotes.add(`created JWT for ${username} on ${redirectOrigin} w/ scope ${locals.scope}`); + res.logNotes.add(`created JWT for ${req.session.oauthParams.username} on ${redirectOrigin} w/ scope ${scopes} for ${grantDuration}`); const args = { access_token: token, token_type: 'bearer', - ...(locals.state && { state: locals.state }) + ...(req.session.oauthParams.state && { state: req.session.oauthParams.state }) }; - redirect(req, res, args); + redirect(req, res, req.session.oauthParams.redirect_uri, args); } catch (error) { - locals.title = 'Authorization Failure'; - locals.client_host = locals.redirect_uri ? new URL(locals.redirect_uri).host : '[missing origin]'; - locals.error = error.message; - locals.permissions = parseScope(locals.scope); - locals.access_strings = accessStrings; - locals.state = locals.state || ''; - errToMessages(error, res.logNotes); - res.status(401).render('auth.html', locals); + res.status(401).render('auth-passkey.html', { + title: 'Authorization Failure', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: req.session.user?.privileges || {}, + error: error.message, + client_host: req.session.oauthParams?.redirect_uri ? new URL(req.session.oauthParams?.redirect_uri).host : '[missing origin]', + client_id: req.session.oauthParams?.client_id, + redirect_uri: req.session.oauthParams?.redirect_uri, + permissions: parseScope(req.session.oauthParams?.scope || ''), + username: req.session.oauthParams?.username, + options: JSON.stringify({}), + access_strings: accessStrings + }); + removeUserDataFromSession(req.session); + } finally { + delete req.session.oauthParams; // Kills the challenge for this session. } } ); @@ -115,20 +166,20 @@ module.exports = function (hostIdentity, jwtSecret) { } function error (req, res, error, error_description) { - redirect(req, res, { error, error_description }, + redirect(req, res, req.data.redirect_uri, { error, error_description }, `${error_description} ${req.data.client_id}`); } - function redirect (req, res, args, logNote) { + function redirect (req, res, redirect_uri, args, logNote) { const hash = qs.stringify(args); - if (req.data.redirect_uri) { - const location = req.data.redirect_uri + '#' + hash; + if (redirect_uri) { + const location = redirect_uri + '#' + hash; if (logNote) { res.logNotes.add(logNote); res.logLevel = 'warning'; } else { - res.logNotes.add('-> ' + req.data.redirect_uri); + res.logNotes.add('-> ' + redirect_uri); res.logLevel = 'notice'; } res.redirect(location); diff --git a/lib/routes/request-invite.js b/lib/routes/request-invite.js new file mode 100644 index 00000000..87272bad --- /dev/null +++ b/lib/routes/request-invite.js @@ -0,0 +1,87 @@ +const express = require('express'); +const { assembleContactURL } = require('../../lib/util/protocols'); +const { getHost } = require('../util/getHost'); +const errToMessages = require('../util/errToMessages'); +const errorPage = require('../util/errorPage'); +const path = require('path'); +const { protocolOptions } = require('../util/protocols'); + +const DISABLED_LOCALS = { title: 'Forbidden', message: 'Requesting invite is not allowed currently' }; +const DISABLED_LOG_NOTE = 'requesting invites disabled'; +const INVITE_REQUEST_DIR = 'inviteRequests'; + +module.exports = function (storeRouter) { + const router = express.Router(); + + /* initial entry */ + router.get('/', function (req, res) { + try { + if (req.app?.locals?.signup) { + res.set('Cache-Control', 'public, max-age=1500'); + res.render('login/request-invite.html', { + title: 'Request an Invitation', + host: getHost(req), + privileges: {}, + accountPrivileges: {}, + protocolOptions: protocolOptions(), + params: { submitName: 'Request invite' }, + privilegeGrant: {}, + error: null + }); + } else { + errorPage(req, res, 403, DISABLED_LOCALS, DISABLED_LOG_NOTE); + } + } catch (err) { + errToMessages(err, res.logNotes); + res.status(401).render('login/error.html', { + title: 'Error requesting invite', + host: getHost(req), + privileges: req.session.privileges || {}, + accountPrivileges: {}, + subtitle: '', + message: 'There was an error displaying your info' + }); + } + }); + + /* submission or re-submission */ + router.post('/', + async function (req, res) { + if (!req.app?.locals?.signup) { + errorPage(req, res, 403, DISABLED_LOCALS, DISABLED_LOG_NOTE); + return; + } + try { + req.body.address = req.body.address.trim(); + + const contactURL = req.contactURL = assembleContactURL(req.body.protocol, req.body.address).href; + + await storeRouter.upsertAdminBlob(path.join(INVITE_REQUEST_DIR, encodeURIComponent(contactURL) + '.yaml'), 'application/yaml', contactURL); + + res.status(201).render('login/request-invite-success.html', { + title: 'Invitation Requested', + host: getHost(req), + privileges: {}, + accountPrivileges: {}, + params: { contactURL } + }); + } catch (err) { + errToMessages(err, res.logNotes.add(`while requesting invite “${req.body}”:`)); + + const statusCode = err.name === 'ParameterError' ? 400 : 409; + const errChain = [err, ...(err.errors || []), ...(err.cause ? [err.cause] : []), new Error('indescribable error')]; + res.status(statusCode).render('login/request-invite.html', { + title: 'Request Failure', + host: getHost(req), + privileges: {}, + accountPrivileges: {}, + protocolOptions: protocolOptions(), + params: Object.assign(req.body, { submitName: 'Request invite' }), + privilegeGrant: {}, + error: errChain.find(e => e.message) + }); + } + }); + + return router; +}; diff --git a/lib/routes/signup.js b/lib/routes/signup.js deleted file mode 100644 index e5f1b179..00000000 --- a/lib/routes/signup.js +++ /dev/null @@ -1,51 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const { getHost } = require('../util/getHost'); -const errToMessages = require('../util/errToMessages'); -const errorPage = require('../util/errorPage'); - -const DISABLED_LOCALS = { title: 'Forbidden', message: 'Signing up is not allowed currently' }; -const DISABLED_LOG_NOTE = 'signups disabled'; - -/* initial entry */ -router.get('/', function (req, res) { - if (req.app?.locals?.signup) { - res.render('signup.html', { - title: 'Signup', - params: {}, - error: null, - host: getHost(req) - }); - } else { - errorPage(req, res, 403, DISABLED_LOCALS, DISABLED_LOG_NOTE); - } -}); - -/* submission or re-submission */ -router.post('/', - async function (req, res) { - if (req.app?.locals?.signup) { - try { - const storageName = await req.app?.get('account').createUser(req.body, res.logNotes); - res.logNotes.add(`created storage “${storageName}” for user “${req.body.username}”`); - res.status(201).render('signup-success.html', { - title: 'Signup Success', - params: req.body, - host: getHost(req) - }); - } catch (err) { - errToMessages(err, res.logNotes.add(`while creating user “${req.body?.username}”:`)); - const errChain = [err, ...(err.errors || []), ...(err.cause ? [err.cause] : []), new Error('indescribable error')]; - res.status(409).render('signup.html', { - title: 'Signup Failure', - params: req.body, - error: errChain.find(e => e.message), - host: getHost(req) - }); - } - } else { - errorPage(req, res, 403, DISABLED_LOCALS, DISABLED_LOG_NOTE); - } - }); - -module.exports = router; diff --git a/lib/routes/storage_common.js b/lib/routes/storage_common.js index c13bcdc0..9f272c9e 100644 --- a/lib/routes/storage_common.js +++ b/lib/routes/storage_common.js @@ -14,30 +14,29 @@ module.exports = function (hostIdentity, jwtSecret) { algorithms: ['HS512'], issuer: hostIdentity, // audience: req.get('Origin'), - // subject: username, + // subject: user.username, maxAge: '30d', credentialsRequired: false // public documents & permission checking }); router.options('/:username/*', (_req, res, next) => { res.logLevel = 'debug'; next(); }, - cacheControl, corsAllowPrivate, corsRS ); /** Express uses GET to generate HEAD */ router.get('/:username/*', - cacheControl, corsAllowPrivate, corsRS, sanityCheckUsername, validPathParam, (req, res, next) => { if (req.blobPath?.startsWith('public/') && !req.blobPath.endsWith('/')) { - res.set('Cache-Control', 'no-cache, public'); + res.set('Cache-Control', 'public, no-cache'); next('route'); // allows access without checking JWT } else { + res.set('Cache-Control', 'private, no-cache'); next(); } }, @@ -78,11 +77,6 @@ module.exports = function (hostIdentity, jwtSecret) { } ); - function cacheControl (req, res, next) { - res.set('Cache-Control', 'no-cache'); - next(); - } - function validPathParam (req, res, next) { req.blobPath = req.params[0]; const path = req.blobPath.endsWith('/') ? req.blobPath.slice(0, -1) : req.blobPath; @@ -135,13 +129,15 @@ module.exports = function (hostIdentity, jwtSecret) { return unauthorized(req, res, 401, '', requiredScope, 'no authentication'); } - if (req.auth.sub !== req.params.username || req.auth.aud !== req.headers.origin) { - return unauthorized(req, res, 401, 'invalid_token', requiredScope); + if (req.auth.sub !== req.params.username) { + return unauthorized(req, res, 401, 'invalid_token', requiredScope, `granted for user ${req.auth.sub}, but asking for req.params.username`); + } + if (req.auth.aud !== req.headers.origin) { + return unauthorized(req, res, 401, 'invalid_token', requiredScope, `granted for ${req.auth.aud}, but origin is ${req.headers.origin}`); } - const grantedScopes = req.auth?.scopes; if (!grantedScopes) { - return unauthorized(req, res, 401, 'invalid_token', requiredScope); + return unauthorized(req, res, 401, 'invalid_token', requiredScope, 'no scopes granted'); } if (grantedScopes?.split(/\s+/).some(scope => { diff --git a/lib/routes/webfinger.js b/lib/routes/webfinger.js index 4d71daa1..f34bf81a 100644 --- a/lib/routes/webfinger.js +++ b/lib/routes/webfinger.js @@ -58,6 +58,12 @@ function wellKnown (req, res) { } } +router.get('/change-password', cors(), changePassword); + +function changePassword (_req, res, _next) { + res.redirect(303, '/signup'); +} + // /webfinger router.get(['/jrd', '/xrd'], corsAllowPrivate, cors(), webfinger); diff --git a/lib/util/NoSuchBlobError.js b/lib/util/NoSuchBlobError.js new file mode 100644 index 00000000..2deb5182 --- /dev/null +++ b/lib/util/NoSuchBlobError.js @@ -0,0 +1,6 @@ +module.exports = class NoSuchBlobError extends Error { + constructor (message, options) { + super(message, options); + this.name = 'NoSuchBlobError'; + } +}; diff --git a/lib/util/NoSuchUserError.js b/lib/util/NoSuchUserError.js new file mode 100644 index 00000000..519330d8 --- /dev/null +++ b/lib/util/NoSuchUserError.js @@ -0,0 +1,6 @@ +module.exports = class NoSuchUserError extends Error { + constructor () { + super(...arguments); + this.name = 'NoSuchUserError'; + } +}; diff --git a/lib/util/corsMiddleware.js b/lib/util/corsMiddleware.js index a05ac7dd..c87b09f1 100644 --- a/lib/util/corsMiddleware.js +++ b/lib/util/corsMiddleware.js @@ -5,5 +5,5 @@ module.exports = { res.set('Access-Control-Allow-Private-Network', 'true'); next(); }, - corsRS: cors({ origin: true, allowedHeaders: 'Content-Type, Authorization, Content-Length, If-Match, If-None-Match, Origin, X-Requested-With', methods: 'GET, HEAD, PUT, DELETE', exposedHeaders: 'ETag', maxAge: 7200 }) + corsRS: cors({ origin: true, allowedHeaders: 'Content-Type, Authorization, Content-Length, If-Match, If-None-Match, Origin, X-Requested-With', methods: 'GET, HEAD, PUT, DELETE', exposedHeaders: 'ETag', credentials: true, maxAge: 7200 }) }; diff --git a/lib/util/errToMessages.js b/lib/util/errToMessages.js index eb6e8c09..2a802fd9 100644 --- a/lib/util/errToMessages.js +++ b/lib/util/errToMessages.js @@ -14,7 +14,7 @@ module.exports = function errToMessages (err, messages) { messages.add(err.name); } if (err.message) { - messages.add(err.message); + messages.add(err.message?.replace(/\r\n|\n|\r/, ' ')); } if (err.code && !Array.from(messages).some(msg => typeof msg === 'string' && msg?.includes(err.code))) { messages.add(err.code); diff --git a/lib/util/loginOptsWCreds.js b/lib/util/loginOptsWCreds.js new file mode 100644 index 00000000..ba2785cb --- /dev/null +++ b/lib/util/loginOptsWCreds.js @@ -0,0 +1,23 @@ +const { generateAuthenticationOptions } = require('@simplewebauthn/server'); + +module.exports = async function loginOptsWCreds (username = undefined, user = undefined, accountMgr, rpID, logNotes) { + let allowCredentials = []; // user selects from browser-generated list + if (username && !user) { + user = await accountMgr.getUser(username, logNotes); + } + if (user) { + allowCredentials = Object.values(user.credentials || {}).map( + cred => ({ + id: Buffer.from(cred.credentialID, 'base64url'), + transports: cred.transports, + type: cred.credentialType + }) + ); + } + return await generateAuthenticationOptions({ + rpID, + allowCredentials, + userVerification: 'preferred', // Typically will ask for biometric, but not password. + timeout: 5 * 60 * 1000 + }); +}; diff --git a/lib/util/nameFromUseragent.js b/lib/util/nameFromUseragent.js new file mode 100644 index 00000000..ac5bee57 --- /dev/null +++ b/lib/util/nameFromUseragent.js @@ -0,0 +1,18 @@ +const parser = require('ua-parser-js'); + +module.exports = function nameFromUseragent (authenticatorAttachment = '', useragent = '', transports = []) { + let ua, nameParts; + switch (authenticatorAttachment) { + case 'platform': + ua = parser(useragent); + nameParts = [...(ua?.device?.vendor ? [ua?.device?.vendor] : []), + ...(ua?.device?.model ? [ua?.device?.model] : []), + ...(ua?.os?.name ? [ua?.os?.name] : []), + ...(ua?.browser?.name ? [ua?.browser?.name] : [])]; + return nameParts.length > 0 ? nameParts.join(' ') : new Date().toLocaleString(); + case 'cross-platform': + return `security key (${transports.join(', ')})`; + default: + return 'unknown'; + } +}; diff --git a/lib/util/protocols.js b/lib/util/protocols.js new file mode 100644 index 00000000..403fd18a --- /dev/null +++ b/lib/util/protocols.js @@ -0,0 +1,154 @@ +const { format } = require('node:util'); +const ParameterError = require('./ParameterError'); + +const protocols = { + 'sgnl:': { + name: 'Signal', + contactTemplate: '%s//signal.me/#p/%s', + contactHasHash: true + }, + 'threema:': { + name: 'Threema', + contactTemplate: '%s//compose?id=%s', + contactAllowedSearchKeys: ['id'] + }, + 'facetime:': { + name: 'FaceTime', + contactTemplate: '%s%s' + }, + 'xmpp:': { + name: 'Jabber', + contactTemplate: '%s%s' + }, + 'skype:': { + name: 'Skype', + contactTemplate: '%s%s?chat', + contactRequiredSearchKeys: ['chat'] + }, + 'mailto:': { + name: 'e-mail', + contactTemplate: '%s%s' + }, + 'sms:': { + name: 'SMS', + contactTemplate: '%s%s' + }, + 'mms:': { + name: 'MMS', + actualProtocol: 'sms:', + contactTemplate: '%s%s' + }, + 'whatsapp:': { + name: 'WhatsApp', + contactTemplate: '%s//send/?phone=%s', + contactAllowedSearchKeys: ['phone'] + }, + 'tg:': { + name: 'Telegram', + contactTemplate: '%s//resolve?domain=%s', + contactTemplatePhone: '%s//resolve?phone=%s', + contactAllowedSearchKeys: ['phone', 'domain'] + } +}; + +async function initProtocols (storeRouter) { + // TODO: load configured set of protocols +} + +function assembleContactURL (protocol, address) { + address = address?.trim(); + if (!address) { + throw new ParameterError('Missing address'); + } + + const protocolAttr = protocols[protocol]; + if (!protocolAttr) { + throw new ParameterError(`Protocol “${protocol}” not supported`); + } + let { actualProtocol, contactTemplate, contactTemplatePhone, addressIsNeverPhone } = protocolAttr; + if (!contactTemplate) { + throw new Error(`No contactTemplate for protocol “${protocol}”`); + } + if (actualProtocol) { + protocol = actualProtocol; + } + + if (/^\+?[\d\s)(*x-]{4,20}$/.test(address) && !addressIsNeverPhone) { + address = normalizePhoneNumber(address); + if (contactTemplatePhone) { + contactTemplate = contactTemplatePhone; + } + } + + const str = format(contactTemplate, protocol, address); + + return new URL(str); +} + +function normalizePhoneNumber (raw) { + const number = (raw.split('x')[0]).replace(/\D/g, ''); + + if (raw.startsWith('+')) { + return '+' + number; + } else { + if (number.length === 10 && !['0', '1'].includes(number[0])) { // matches North American pattern + return '+1' + number; + } else { + return number; + } + } +} + +function calcContactURL (contactStr) { + contactStr = contactStr?.trim(); + + let contactURL; + try { + contactURL = new URL(contactStr); + } catch (err) { + if (/[\p{L}\p{N}]@(?:[A-Z0-9-]+\.)+[A-Z]{2,6}\b/iu.test(contactStr)) { + contactURL = new URL('mailto:' + contactStr); + } else { + throw new ParameterError(`“${contactStr}” is neither a URL nor email address`); + } + } + + const protocolAttr = protocols[contactURL.protocol]; + if (!protocolAttr) { + throw new ParameterError(`Protocol “${contactURL.protocol}” not supported`); + } + const { actualProtocol, contactHasHash, contactAllowedSearchKeys = [], contactRequiredSearchKeys = [] } = protocolAttr; + + if (actualProtocol) { + contactURL.protocol = actualProtocol; + } + + const newSearchParams = new URLSearchParams(); + for (const key of contactRequiredSearchKeys) { + newSearchParams.set(key, contactURL.searchParams.get(key) || ''); + } + for (const [key, value] of contactURL.searchParams.entries()) { + if (contactAllowedSearchKeys.includes(key)) { + newSearchParams.set(key, value); + } + } + newSearchParams.sort(); + contactURL.search = Array.from(newSearchParams.entries()).map( + ([key, value]) => value ? key + '=' + value : key) + .join('&'); + + if (!contactHasHash) { + contactURL.hash = ''; + } + + return contactURL; +} + +function protocolOptions () { + return Object.entries(protocols).map( + entry => + ({ protocol: entry[0], name: entry[1].name }) + ); +} + +module.exports = { initProtocols, assembleContactURL, calcContactURL, protocolOptions }; diff --git a/lib/util/removeUserDataFromSession.js b/lib/util/removeUserDataFromSession.js new file mode 100644 index 00000000..c04b548f --- /dev/null +++ b/lib/util/removeUserDataFromSession.js @@ -0,0 +1,8 @@ +module.exports = function removeUserDataFromSession (session) { + delete session?.privileges; + delete session?.user; + delete session?.regChallenge; + delete session?.loginChallenge; + delete session?.oauthParams; + delete session?.isUserSynthetic; +}; diff --git a/lib/util/replaceUint8.js b/lib/util/replaceUint8.js new file mode 100644 index 00000000..d5d0dbcd --- /dev/null +++ b/lib/util/replaceUint8.js @@ -0,0 +1,9 @@ +/* eslint-env node */ + +module.exports = function replaceUint8 (key, value) { + if (value instanceof Uint8Array) { + return Buffer.from(value.buffer).toString('base64url'); + } else { + return value; + } +}; diff --git a/lib/util/updateSessionPrivileges.js b/lib/util/updateSessionPrivileges.js new file mode 100644 index 00000000..0aa8c372 --- /dev/null +++ b/lib/util/updateSessionPrivileges.js @@ -0,0 +1,24 @@ +module.exports = async function updateSessionPrivileges (req, user, isAdminLogin) { + const oauthParams = req.session.oauthParams; + // removes any privileges the user no longer has + const oldPrivileges = {}; + for (const name of Object.keys(user.privileges)) { + if (req.session.privileges?.[name]) { + oldPrivileges[name] = true; + } + } + + // Privilege level has changed, so the session must be regenerated. + await new Promise((resolve, reject) => { + req.session.regenerate(err => { if (err) { reject(err); } else { resolve(); } }); + }); + + const newPrivileges = { ...user.privileges }; + if (!isAdminLogin) { + delete newPrivileges.ADMIN; + delete newPrivileges.OWNER; + } + + req.session.privileges = { ...oldPrivileges, ...newPrivileges }; + req.session.oauthParams = oauthParams; +}; diff --git a/lib/util/verifyCredential.js b/lib/util/verifyCredential.js new file mode 100644 index 00000000..b63c5a2d --- /dev/null +++ b/lib/util/verifyCredential.js @@ -0,0 +1,47 @@ +const { verifyAuthenticationResponse } = require('@simplewebauthn/server'); + +/** + * Returns authenticationInfo or throws error if not valid + * @param user + * @param expectedChallenge + * @param expectedOrigin + * @param expectedRPID + * @param presentedCredential + * @returns {Promise} authenticationInfo + */ +module.exports = async function verifyCredential (user, expectedChallenge, expectedOrigin, expectedRPID, presentedCredential) { + const storedCredential = user.credentials[presentedCredential.id]; + if (!storedCredential) { + throw new Error('Presented credential does not belong to user.'); + } + + // Base64URL decodes some values + const memoryCredential = { + credentialPublicKey: Buffer.from(storedCredential.credentialPublicKey, 'base64url'), + credentialID: Buffer.from(storedCredential.credentialID), + transports: storedCredential.transports + }; + + // Verifies the credential + const { verified, authenticationInfo } = await verifyAuthenticationResponse({ + response: presentedCredential, + expectedChallenge, + expectedOrigin, + expectedRPID, + authenticator: memoryCredential, + requireUserVerification: false + }); + if (!verified) { + throw new Error('Credential verification failed.'); + } + + user.credentials[presentedCredential.id].counter = authenticationInfo.newCounter; + user.credentials[presentedCredential.id].lastUsed = user.lastUsed = new Date(); + if (presentedCredential.authenticatorAttachment === 'cross-platform' && + storedCredential.transports.length === 1 && storedCredential.transports[0] === 'internal') { + // we didn't get this credential via internal transport; hybrid is most likely + user.credentials[presentedCredential.id].transports.push('hybrid'); + } + + return authenticationInfo; +}; diff --git a/lib/util/widelyCompatibleId.js b/lib/util/widelyCompatibleId.js new file mode 100644 index 00000000..0a969802 --- /dev/null +++ b/lib/util/widelyCompatibleId.js @@ -0,0 +1,16 @@ +const LETTERS = 'abcdefghijklmnopqrstuvwxyz'; +const DIGITS = '0123456789' + LETTERS; + +/** + * Generates a random identifier starting with a letter and using only lowercase letters and digits. + * @param { number } numBits desired number of bits of randomness + * @returns { string } + */ +module.exports = function widelyCompatibleId (numBits) { + const len = Math.ceil(numBits / Math.log2(36)); + let s = LETTERS[Math.floor(Math.random() * 26)]; // This reduces the number of random bits slightly. + while (s.length < len) { + s += DIGITS[Math.floor(Math.random() * 36)]; + } + return s; +}; diff --git a/lib/views/account.html b/lib/views/account.html index f78fe8cc..d8ccf742 100644 --- a/lib/views/account.html +++ b/lib/views/account.html @@ -1,9 +1,9 @@ <%- include('header.html'); %>
-
+

Account Info

-

Your storage account: <%= params.username %>@<%= host %>

+

Your storage account: <%= params.displayName %>@<%= host %>

diff --git a/lib/views/account/account.html b/lib/views/account/account.html new file mode 100644 index 00000000..a147e306 --- /dev/null +++ b/lib/views/account/account.html @@ -0,0 +1,63 @@ +<%- include('../begin.html'); %> + + + +
+
+

<%= title %>

+

<%= username + '@' + host %>

+
+
+ +
+
+ + + + +
Account Privileges<%= Object.keys(accountPrivileges).join(', ') || '«none»' %>
Session Privileges<%= Object.keys(privileges).join(', ') || '«none»' %>
+ +
+

Passkeys  

+ +
+ + + + + + + <% for (const cred of credentials) { %> + + + + + + <% } %> + +
Created usingCreated onLast used
<%= cred.name %><%= new Date(cred.createdAt).toLocaleDateString() %><%= cred.lastUsed ? new Date(cred.lastUsed).toLocaleString().replace(/:\d\d(?!:)/, '') : 'never' %>
+ +
+ +
To create a passkey on a new device, invite yourself to create another passkey:
+
+ +
+ + + + + + + + +
+ +<%- include('../end.html'); %> diff --git a/lib/views/admin/invite-requests.html b/lib/views/admin/invite-requests.html new file mode 100644 index 00000000..0bd45891 --- /dev/null +++ b/lib/views/admin/invite-requests.html @@ -0,0 +1,54 @@ +<%- include('../begin.html'); %> + + + + +
+

<%= title %>

+
+ +<% if (items.length) { %> + + + <% for (item of items) { %> + + + + <% } %> + +
<%= item.contacturl %> + + +
+<% } else { %> +
+
No invite requests currently
+
+<% } %> + +  +
+  + +<%- include('../contact-url.html'); %> + +  +
+  + + + + + + + + + +<%- include('../end.html'); %> diff --git a/lib/views/admin/invite-valid.html b/lib/views/admin/invite-valid.html new file mode 100644 index 00000000..28aa1b80 --- /dev/null +++ b/lib/views/admin/invite-valid.html @@ -0,0 +1,29 @@ +<%- include('../begin.html'); %> + + + +
+
+

<%= title %>

+

Contact URL: <%= contactURL %>

+

Privileges: <%= Object.keys(accountPrivileges).join(', ') || '«none»' %>

+
+

You will always use a passkey to log into your account.  

+ +
+

<%= message %>

+
+
+   + +
+ +
+ +
+
+ +
+
+ +<%- include('../end.html'); %> diff --git a/lib/views/admin/users.html b/lib/views/admin/users.html new file mode 100644 index 00000000..ba0bce39 --- /dev/null +++ b/lib/views/admin/users.html @@ -0,0 +1,62 @@ +<%- include('../begin.html'); %> + + + + +
+

<%= title %>

+
+ +
+ + + + + + <% for (const user of users) { %> + + + + + + + <% } %> + <% if (!users.length) { %> + + <% } %> + +
UsernameContact URLLast logged-inPrivileges
<%= user.username || " " %> + <% if (user.privileges) { %> + + <% } %> + <%= user.contactURL?.split(':').join(':​') || " " %><%= user.lastUsed ? new Date(user.lastUsed).toLocaleString().replace(/:\d\d(?!:)/, '') : 'never' %><%= Object.keys(user.privileges || {}).join(', ') || '«none»' %>
No users
+ +   +
+   + + <%- include('../contact-url.html'); %> + +   +
+   + + + + + + + + +
+ +<%- include('../end.html'); %> diff --git a/lib/views/auth-passkey.html b/lib/views/auth-passkey.html new file mode 100644 index 00000000..a83b4699 --- /dev/null +++ b/lib/views/auth-passkey.html @@ -0,0 +1,56 @@ +<%- include('begin.html'); %> + + + +

<%= title %>

+ +
+

The application <%= client_id %> hosted at + <%= client_host %> wants to access these resources for user + <%= username %>: +

+ +
    + <% for (var path in permissions) { %> + <% var flags = permissions[path].join('') %> +
  • <%= access_strings[flags] %> access to <%= path.replace(/^\/*/, '/') %>
  • + <% } %> +
+ +
+
+

Use your passkey to authorize.  

+ +
+ + +
+ + <% if (typeof error !== 'undefined') { %> +

<%= error %>

+ <% } %> + +
+ + + + +
+ + +
+
+ + +
+ +<%- include('end.html'); %> diff --git a/lib/views/begin.html b/lib/views/begin.html new file mode 100644 index 00000000..6b8771da --- /dev/null +++ b/lib/views/begin.html @@ -0,0 +1,62 @@ + + + + + + + + + <%= title %> — Armadietto + + + + + + + + + +
diff --git a/lib/views/contact-url.html b/lib/views/contact-url.html new file mode 100644 index 00000000..bcf0fced --- /dev/null +++ b/lib/views/contact-url.html @@ -0,0 +1,27 @@ + +
+ <% if (error) { %> +

<%= error.message %>

+ <% } %> + +
+   + +
+ +
+ + +
+ + <% if (privilegeGrant) { %> + + <% } %> + + +
diff --git a/lib/views/end.html b/lib/views/end.html new file mode 100644 index 00000000..24ff0412 --- /dev/null +++ b/lib/views/end.html @@ -0,0 +1,5 @@ + +
+ + + diff --git a/lib/views/index.html b/lib/views/index.html index 73e037f1..49c4ca0b 100644 --- a/lib/views/index.html +++ b/lib/views/index.html @@ -2,7 +2,7 @@
-
+
armadietto logo diff --git a/lib/views/index2.html b/lib/views/index2.html new file mode 100644 index 00000000..beba6d75 --- /dev/null +++ b/lib/views/index2.html @@ -0,0 +1,27 @@ +<%- include('./begin.html'); %> + +
+ +
+
+ + armadietto logo + + +
+

Welcome to Armadietto!

+

+ This is an open-source server for syncing your data between different devices and applications. It is built on + remoteStorage, + an open protocol for user data storage. +

+
+ +

+ If you would like to host your own server,
+ visit armadietto on GitHub. +

+ +
+ +<%- include('./end.html'); %> diff --git a/lib/views/login/error.html b/lib/views/login/error.html new file mode 100644 index 00000000..e9bcb64f --- /dev/null +++ b/lib/views/login/error.html @@ -0,0 +1,26 @@ +<%- include('../begin.html'); %> + +
+ +
+
+ + armadietto logo + + +
+

<%= title %>

+

<%= subtitle %>

+

+ <%= message %> +

+
+ +

+ If you would like to host your own server,
+ visit armadietto on GitHub. +

+ +
+ +<%- include('../end.html'); %> diff --git a/lib/views/login/login.html b/lib/views/login/login.html new file mode 100644 index 00000000..5d29825b --- /dev/null +++ b/lib/views/login/login.html @@ -0,0 +1,27 @@ +<%- include('../begin.html'); %> + + + +
+ +
+ <% if (title === 'Login') { %> +
+ + armadietto logo + + +
+ <% } %> +

<%= title %>

+
+
+

<%= message %>

+ +
+ +
+ +
+ +<%- include('../end.html'); %> diff --git a/lib/views/login/logout.html b/lib/views/login/logout.html new file mode 100644 index 00000000..89664061 --- /dev/null +++ b/lib/views/login/logout.html @@ -0,0 +1,16 @@ +<%- include('../begin.html'); %> + +
+ +
+
+ + armadietto logo + + +
+

<%= title %>

+
+
+ +<%- include('../end.html'); %> diff --git a/lib/views/login/request-invite-success.html b/lib/views/login/request-invite-success.html new file mode 100644 index 00000000..b72097b7 --- /dev/null +++ b/lib/views/login/request-invite-success.html @@ -0,0 +1,11 @@ +<%- include('../begin.html'); %> + +
+

Invitation Requested

+
+ +

Check that clicking this link starts a message to you:

+ +

<%= params.contactURL %>

+ +<%- include('../end.html'); %> diff --git a/lib/views/login/request-invite.html b/lib/views/login/request-invite.html new file mode 100644 index 00000000..74afa7db --- /dev/null +++ b/lib/views/login/request-invite.html @@ -0,0 +1,11 @@ +<%- include('../begin.html'); %> + + + +

Request an Invitation

+ +

How should we contact you?

+ +<%- include('../contact-url.html'); %> + +<%- include('../end.html'); %> diff --git a/lib/views/signup.html b/lib/views/signup.html index bd2b752e..bf780d69 100644 --- a/lib/views/signup.html +++ b/lib/views/signup.html @@ -8,17 +8,17 @@

Sign up

<% } %>
- +
- +
- +
diff --git a/notes/S3-store-router.md b/notes/S3-store-router.md index 065656ad..f8b46699 100644 --- a/notes/S3-store-router.md +++ b/notes/S3-store-router.md @@ -14,7 +14,7 @@ Tested services include: ### Garage -* doesn't implement versioning +* doesn't implement versioning (which would be nice for recovery) * doesn't implement If-Match for GET, which is not yet used but will be required to support Range requests ### min.io (both self-hosted and cloud) @@ -25,7 +25,7 @@ Tested services include: ### OpenIO Disrecommended — bugs can't be worked around -* fails simultaneous delete test +* fails simultaneous delete test * doesn't implement DeleteObjectsCommand @@ -48,13 +48,13 @@ const s3handler = new S3Handler({ accessKey: process.env.S3_ACCESS_KEY, secretKey: process.env.S3_SECRET_KEY, userNameSuffix}); -const app = require('../../lib/appFactory')({account: s3handler, storeRouter: s3handler, ...}); +const app = require('../../lib/appFactory')({accountMgr: s3handler, storeRouter: s3handler, ...}); ``` HTTPS is used if the endpoint is not localhost. If you must use http, you can include the scheme in the endpoint: `http://myhost.example.org`. This one access key is used to create a bucket for each user. -The bucket name is the username plus the suffix, if any. +The bucket name is the id plus the suffix, if any. If other non-remoteStorage buckets are created at that endpoint, those bucket names will be unavailable as usernames. Buckets can be administered using the service's tools, such as a webapp console or command-line tools. The bucket **SHOULD NOT** contain non-RS blobs with these prefixes: diff --git a/notes/modular-server.md b/notes/modular-server.md index 10835aea..639382e7 100644 --- a/notes/modular-server.md +++ b/notes/modular-server.md @@ -1,25 +1,78 @@ -# Modular Server +# Modular (New) Server It's built using Express, so bespoke versions can be implemented by copying appFactory.js and adding new middleware. There's an NPM module for almost anything worth doing in a Node.js server (albeit, not everything is production-quality). +## Installation + +In addition to installing Armadietto, you **MUST** have a server with an S3-compatible interface. +If your hosting provider doesn't offer S3-compatible storage as a service, you can self-host using any of several open-source servers, on the same machine as Armadietto if you like. + +See [S3-compatible Streaming Store](S3-store-router.md) for compatability of various implementations. +[Garage](https://garagehq.deuxfleurs.fr/) is used while developing Armadietto. + +## Use + +After copying and editing the configuration file (see below), set the S3 environment variables (see [S3-compatible Streaming Store](S3-store-router.md)) to tell +Armadietto how to access storage. +(If you don't set the S3 environment variables, the S3 router will use the public account on `play.min.io`, where the documents & folders can be **read, altered and deleted** by anyone in the world! Also, the Min.IO browser can't list your documents or folders.) + +Then run the modular server with the command +```shell +npm run modular +``` +or directly with +```shell +node ./bin/www -c ./bin/dev-conf.json +``` + +The following environment variables are read: +* S3_ENDPOINT +* S3_ACCESS_KEY +* S3_SECRET_KEY +* S3_REGION [defaults to `us-east-1`] +* JWT_SECRET [defaults to `S3_SECRET_KEY`] +* BOOTSTRAP_OWNER [used to create OWNER accounts] +* PORT [overrides `http.port` configuration file value] +* DEBUG [set to log all calls to the S3 server] +* NODE_ENV + +For production, you should set the environment variable `NODE_ENV` to `production` and configure systemd (or your OS's equivalent) to start and re-start Armadietto. +See [the systemd docs](../contrib/systemd/README.md). + +To add Express modules (such as [express-rate-limit](https://www.npmjs.com/package/express-rate-limit)), edit `bin/www` and `lib/appFactory.js`, or write your own scripts. + ## Configuration -Your configuration file *MUST* set `host_identity`. -It's normally the usual domain name of the host. +Your configuration file *MUST* set `host_identity` to a domain name that points to your server. It doesn't have +to be the canonical name. Changing `host_identity` will invalidate all grants of access, and make unavailable accounts stored in S3-compatible storage (unless you set `s3.user_name_suffix` to the old value). -### Modular Server Factory +The following values in the configuration file must be set: +* `host_identity` +* `basePath` [usually ""] +* `allow_signup` +* `http` [set `http.port` to serve using HTTP] +* `https` [set `https.port` to serve using HTTPS] +* `logging` + +The following values in the configuration file are optional: +* `s3.user_name_suffix` + +Other values in the configuration files are ignored. + +### Customizing `bin/www` The secret used to generate JWTs must have at least 64 cryptographically random ASCII characters. The streaming storage handler is an Express Router and does the actual storage. -The `account` object has methods to create user accounts somewhere, and check passwords. -When it is created, it should be passed the streaming storage router. +The `accountMgr` object has methods to create, retrieve, update, list and delete user accounts. +When it is created, it should be passed the streaming storage router, so the accountMgr +can call `storeRouter.allocateUserStorage()`. -They may be the same object (S3-compatible storage can use itself for an account object, or a different type of account object). +They may be the same object (S3-compatible storage can use itself for an accountMgr object, or a different type of accountMgr object). S3-compatible storage is typically configured using environment variables; see the note [S3-store-router.md](`./S3-store-router.md`) for details. @@ -33,6 +86,9 @@ If you call `app.set('forceSSL', ...)` you must also call `app.set('httpsPort')` You *MUST* set `app.locals.title` and `app.locals.signup` or the web pages won't render. +## Multiple instances of the modular server + +Sessions are stored in memory, so your load balancer must use sticky sessions (session affinity), for `/admin`, `/account` and `/oauth` paths. ## Proxies @@ -40,8 +96,42 @@ Production servers typically outsource TLS to a proxy server — nginx and Apach See the note [reverse-proxy-configuration.md](`./reverse-proxy-configuration.md`) for details. A proxy server can also cache static content. Armadietto sets caching headers to tell caches what they can and can't cache. -If the modular server is behind a proxy, you **MUST** set -`app.set('trust proxy', 1)` +If the modular server is behind a proxy, you **MUST** enable trusting the proxy by +`app.set('trust proxy', 1)`, `app.set('trust proxy', 'loopback')` or another value that enables it. + +## Operations + +### Invitations + +Contact URLs are used to identify users, when issuing invitations. Ideally, it should be a secure channel, such as Signal Private Messenger: `sgnl://signal.me/#p/+yourphonenumber`. Often, contact URLs will use the less-secure `mailto:` or `sms:` scheme. + +Armadietto can't send invitations itself yet; an admin must send the invitation manually, using their own account. The Armadietto user interface allows you to send the invite via the system share functionality, or copy and paste from the user interface. +You can send the invite by any means; it's not required that you use the contact info in the Contact URL. +For example, you could send an invite to a person in the same room, using AirDrop or Nearby Share, or save the invite in a file drop. + +If `allow_signup` is set in the configuration file, anyone can *request* an invite. +Admins can list these requests and grant them. +There is no notification of new requests for invites yet. + +Changing the Contact URL of a user (not implemented yet) will *invalidate all of their passkeys*. + +In place of password reset functionality, you re-invite the user. An invite for an existing account will allow a user to create a passkey for a device or browser where no passkey exists. +A logged-in user can re-invite themselves. +Issuing an invite **does not invalidate** any existing passkeys. +Typically, a user will need one passkey (and thus one invite) for each ecosystem (Apple, Google, Microsoft, FIDO hardware key, password manager, etc.) they use. +If the ecosystem backs up passkeys to the cloud, a user may not need to generate a new passkey for a new device. + +If a user has valid passkeys for more than one account, +the selected passkey will determine which account the user is logged in to. + +### Administrators + +To create an account with `OWNER` privilege, set the `BOOTSTRAP_OWNER` environment variable to the Contact URL followed by a space and the username. (If a Contact URL doesn't parse as a URL, the system will attempt to parse it as an email address.) Then start or re-start the server. The invite will be written using the store router and the log will contain the path to the blob. (It's in the `invites` directory.) +To re-send the invite, delete the blob in the `invites` directory named with the contactURL. + +An account with `OWNER` privilege can invite others to be administrators. An account with `ADMIN` privilege can invite regular users. At present, there is no way to promote a regular user to an administrator, nor an administrator to an owner. :-( + +Admins can also see the list of users, list of other admins and list of requests for an invite. ## Development @@ -49,33 +139,43 @@ If the modular server is behind a proxy, you **MUST** set A streaming store router is an instance of `Router` that implements `get`, `put` and `delete` for the path -`'/:username/*'`. It is mounted after `storageCommonRouter`: +`'/:id/*'`. It is mounted after `storageCommonRouter`: ```javascript app.use(`${basePath}/storage`, storageCommonRouter(hostIdentity, jwtSecret)); app.use(`${basePath}/storage`, storeRouter); ``` -It also has a method +It also must implement a method ``` -router.allocateUserStorage = async function (username, logNotes) +router.allocateUserStorage = async function (id, logNotes) ``` -which is called by the account object. +which is called by the accountMgr object. + +It also must implement methods +```javascript +upsertAdminBlob(path, contentType, content) +readAdminBlob(path) +metadataAdminBlob(path) +deleteAdminBlob(path) +``` + +which are called by the admin module. -### Account Object +### Account Manager Accounts are managed by an object with methods -`createUser({ username, email, password }, logNotes)`, +`createUser({ username, contactURL }, logNotes)`, `deleteUser(username, logNotes)` and `authenticate ({ username, password }, logNotes)` -`createUser` MUST call `allocateUserStorage(username, logNotes)` +`createUser` MUST call `allocateUserStorage(id, logNotes)` on the streaming store router. -`logNotes` is a set of strings which methods can append to. The logging middleware will +`logNotes` is a Set of strings which methods can append to. The logging middleware will append everything in `logNotes` to the log entry for the current request. -The account object may be the same object as the streaming +The accountMgr object may be the same object as the streaming store router. ### Logging @@ -86,12 +186,12 @@ An unsuccessful request should log enough detail to recreate why it failed. Log info is much more helpful when it's clear what request -it's associated with. Code does not call the logger directly. Instead, messages are added to the set `res.logNotes`. +it's associated with. Code does not call the logger directly. Instead, messages are added to the Set `res.logNotes`. Multiple pieces of information don't need to be concatenated before adding to `logNotes`. The logging middleware will concatenate them when logging the request. When an error has been thrown, the function `errToMessages` is useful to extract fields from the error and its cause -(if any), eliminated duplicated messages. +(if any), eliminating duplicate messages. A few activities not associated with requests do call the logger directly. diff --git a/notes/monolithic-server.md b/notes/monolithic-server.md new file mode 100644 index 00000000..083aec0b --- /dev/null +++ b/notes/monolithic-server.md @@ -0,0 +1,76 @@ +# Monolithic (old) Server + +## Use + +1. Run `armadietto -e` to see a sample configuration file. +2. Create a configuration file at `/etc/armadietto/conf.json` (or elsewhere). See below for values and their meanings. +3. Run `armadietto -c /etc/armadietto/conf.json` + +To see all options, run `armadietto -h`. Set the environment `DEBUG` to log the headers of every request. + +## Use as a library + +The following Node script will run a basic server: +```js +process.umask(077); + +const Armadietto = require('armadietto'); +store = new Armadietto.FileTree({path: 'path/to/storage'}), +server = new Armadietto({ + store: store, + http: {host: '127.0.0.1', port: 8000} +}); +server.boot(); +``` + +The `host` option is optional and specifies the hostname the server will listen on. Its default value is `0.0.0.0`, meaning it will listen on all interfaces. + +The server does not allow users to sign up, out of the box. If you need to allow that, use the `allow_signup` option: +```js + +var server = new Armadietto({ + store: store, + http: { host: '127.0.0.1', port: 8000 }, + allow_signup: true +}); +``` + +If you navigate to `http://localhost:8000/` you should then see a sign-up link in the navigation. + +## Storage backends + +Armadietto supports pluggable storage backends, and comes with a file system +implementation out of the box (redis storage backend is on the way in +`feature/redis` branch): + +* `Armadietto.FileTree` - Uses the filesystem hierarchy and stores each item in its + own individual file. Content and metadata are stored in separate files so the + content does not need base64-encoding and can be hand-edited. Must only be run + using a single server process. + +All the backends support the same set of features, including the ability to +store arbitrary binary data with content types and modification times. + +They are configured as follows: + +```js +// To use the file tree store: +const store = new Armadietto.FileTree({path: 'path/to/storage'}); + +// Then create the server with your store: +const server = new Armadietto({ + store: store, + http: {port: process.argv[2]} +}); + +server.boot(); +``` + +## Lock file contention + +The data-access locking mechanism is lock-file based. +You may need to tune the lock-file timeouts in your configuration: +- *lock_timeout_ms* - millis to wait for lock file to be available +- *lock_stale_after_ms* - millis to wait to deem lockfile stale + +To tune, run the [hosted RS load test](https://overhide.github.io/armadietto/example/load.html) or follow instructions in [example/README.md](example/README.md) for local setup and subsequently run [example/load.html](example/load.html) off of `npm run serve` therein. diff --git a/package-lock.json b/package-lock.json index 397c870a..e4946b91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,19 +11,25 @@ "dependencies": { "@aws-sdk/client-s3": "^3.523.0", "@aws-sdk/lib-storage": "^3.525.1", + "@simplewebauthn/server": "^9.0.3", "@smithy/node-http-handler": "^2.5.0", "argparse": "^2.0.1", "cors": "^2.8.5", "ejs": "^3.1.9", "express": "^4.18.2", "express-jwt": "^8.4.1", + "express-session": "^1.18.0", "helmet": "^7.1.0", "http-errors": "^2.0.0", "jsonwebtoken": "^9.0.2", "lockfile": "^1.0.4", + "memorystore": "^1.6.7", "mkdirp": "^1.0.4", "node-mocks-http": "^1.14.1", - "pug": "^3.0.2", + "proquint": "^0.0.1", + "rate-limiter-flexible": "^5.0.3", + "robots.txt": "^1.1.0", + "ua-parser-js": "^1.0.38", "winston": "^3.11.0", "yaml": "^2.4.0" }, @@ -47,7 +53,7 @@ "rimraf": "^3.0.2" }, "engines": { - "node": ">=18.0" + "node": ">=20.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -921,46 +927,6 @@ "node": ">=14.0.0" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", - "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", - "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", - "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -1035,6 +1001,11 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1068,6 +1039,11 @@ "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.2.tgz", + "integrity": "sha512-f5CnPw997Y2GQ8FAvtuVVC19FX8mwNNC+1XJcIi16n/LTJifKO6QBgGLgN3YEmqtGMk17SKSuoWES3imJVxAVw==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1103,6 +1079,92 @@ "node": ">= 8" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.10.tgz", + "integrity": "sha512-z9Rx9cFJv7UUablZISe7uksNbFJCq13hO0yEAOoIpAymALTLlvUOSLnGiQS7okPaM5dP42oTLhezH6XDXRXjGw==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.8.tgz", + "integrity": "sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.8.tgz", + "integrity": "sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz", + "integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==", + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.8.tgz", + "integrity": "sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "asn1js": "^3.0.5", + "ipaddr.js": "^2.1.0", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509/node_modules/ipaddr.js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@simplewebauthn/server": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-9.0.3.tgz", + "integrity": "sha512-FMZieoBosrVLFxCnxPFD9Enhd1U7D8nidVDT4MsHc6l4fdVcjoeHjDueeXCloO1k5O/fZg1fsSXXPKbY2XTzDA==", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@simplewebauthn/types": "^9.0.1", + "cross-fetch": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz", + "integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==" + }, "node_modules/@smithy/abort-controller": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.2.0.tgz", @@ -2133,12 +2195,21 @@ "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true }, - "node_modules/assert-never": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.2.1.tgz", - "integrity": "sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==" + "node_modules/asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dependencies": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12.0.0" + } }, "node_modules/assertion-error": { "version": "1.1.0", @@ -2175,17 +2246,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/babel-walk": { - "version": "3.0.0-canary-5", - "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", - "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", - "dependencies": { - "@babel/types": "^7.9.6" - }, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -2246,12 +2306,12 @@ } }, "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -2259,7 +2319,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -2310,12 +2370,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -2473,14 +2533,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/character-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", - "integrity": "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==", - "dependencies": { - "is-regex": "^1.0.3" - } - }, "node_modules/charset": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/charset/-/charset-1.0.1.tgz", @@ -2606,15 +2658,6 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, - "node_modules/constantinople": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", - "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", - "dependencies": { - "@babel/parser": "^7.6.0", - "@babel/types": "^7.6.1" - } - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2654,9 +2697,9 @@ } }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "engines": { "node": ">= 0.6" } @@ -2684,6 +2727,14 @@ "node": ">= 0.10" } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2702,7 +2753,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -2718,8 +2768,7 @@ "node_modules/debug/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/deep-eql": { "version": "4.1.3", @@ -2829,11 +2878,6 @@ "node": ">=6.0.0" } }, - "node_modules/doctypes": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", - "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==" - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2848,9 +2892,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dependencies": { "jake": "^10.8.5" }, @@ -3547,16 +3591,16 @@ } }, "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -3600,6 +3644,61 @@ "node": ">= 8.0.0" } }, + "node_modules/express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==", + "dependencies": { + "cookie": "0.6.0", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/express-session/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/express-unless": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/express-unless/-/express-unless-2.1.3.tgz", @@ -3729,9 +3828,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -4111,6 +4210,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -4376,6 +4476,7 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, "dependencies": { "hasown": "^2.0.0" }, @@ -4398,26 +4499,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-expression": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", - "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", - "dependencies": { - "acorn": "^7.1.1", - "object-assign": "^4.1.1" - } - }, - "node_modules/is-expression/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4514,15 +4595,11 @@ "node": ">=8" } }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" - }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -4647,11 +4724,6 @@ "node": ">=10" } }, - "node_modules/js-stringify": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", - "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==" - }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -4709,15 +4781,6 @@ "npm": ">=6" } }, - "node_modules/jstransformer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", - "integrity": "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==", - "dependencies": { - "is-promise": "^2.0.0", - "promise": "^7.0.1" - } - }, "node_modules/jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -4864,6 +4927,32 @@ "node": ">= 0.6" } }, + "node_modules/memorystore": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/memorystore/-/memorystore-1.6.7.tgz", + "integrity": "sha512-OZnmNY/NDrKohPQ+hxp0muBcBKrzKNtHr55DbqSx9hLsYVNnomSAMRAtI7R64t3gf3ID7tHQA7mG4oL3Hu9hdw==", + "dependencies": { + "debug": "^4.3.0", + "lru-cache": "^4.0.3" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/memorystore/node_modules/lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dependencies": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "node_modules/memorystore/node_modules/yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==" + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -5148,6 +5237,25 @@ "node": ">= 0.6" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-mocks-http": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/node-mocks-http/-/node-mocks-http-1.14.1.tgz", @@ -5354,6 +5462,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5429,7 +5545,8 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true }, "node_modules/path-to-regexp": { "version": "0.1.7", @@ -5475,13 +5592,10 @@ "node": ">= 0.8.0" } }, - "node_modules/promise": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", - "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", - "dependencies": { - "asap": "~2.0.3" - } + "node_modules/proquint": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/proquint/-/proquint-0.0.1.tgz", + "integrity": "sha512-6ZQaEo+Ts+Dr7wUu4+/VlBICOV+fxxg/sFNelr22H+0GnnrnzWdMSbcu+c2X1b3YnvS8scyojjOWPbejLD1cnQ==" }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -5495,124 +5609,17 @@ "node": ">= 0.10" } }, + "node_modules/pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, - "node_modules/pug": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.2.tgz", - "integrity": "sha512-bp0I/hiK1D1vChHh6EfDxtndHji55XP/ZJKwsRqrz6lRia6ZC2OZbdAymlxdVFwd1L70ebrVJw4/eZ79skrIaw==", - "dependencies": { - "pug-code-gen": "^3.0.2", - "pug-filters": "^4.0.0", - "pug-lexer": "^5.0.1", - "pug-linker": "^4.0.0", - "pug-load": "^3.0.0", - "pug-parser": "^6.0.0", - "pug-runtime": "^3.0.1", - "pug-strip-comments": "^2.0.0" - } - }, - "node_modules/pug-attrs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", - "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", - "dependencies": { - "constantinople": "^4.0.1", - "js-stringify": "^1.0.2", - "pug-runtime": "^3.0.0" - } - }, - "node_modules/pug-code-gen": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.2.tgz", - "integrity": "sha512-nJMhW16MbiGRiyR4miDTQMRWDgKplnHyeLvioEJYbk1RsPI3FuA3saEP8uwnTb2nTJEKBU90NFVWJBk4OU5qyg==", - "dependencies": { - "constantinople": "^4.0.1", - "doctypes": "^1.1.0", - "js-stringify": "^1.0.2", - "pug-attrs": "^3.0.0", - "pug-error": "^2.0.0", - "pug-runtime": "^3.0.0", - "void-elements": "^3.1.0", - "with": "^7.0.0" - } - }, - "node_modules/pug-error": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.0.0.tgz", - "integrity": "sha512-sjiUsi9M4RAGHktC1drQfCr5C5eriu24Lfbt4s+7SykztEOwVZtbFk1RRq0tzLxcMxMYTBR+zMQaG07J/btayQ==" - }, - "node_modules/pug-filters": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-4.0.0.tgz", - "integrity": "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==", - "dependencies": { - "constantinople": "^4.0.1", - "jstransformer": "1.0.0", - "pug-error": "^2.0.0", - "pug-walk": "^2.0.0", - "resolve": "^1.15.1" - } - }, - "node_modules/pug-lexer": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz", - "integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==", - "dependencies": { - "character-parser": "^2.2.0", - "is-expression": "^4.0.0", - "pug-error": "^2.0.0" - } - }, - "node_modules/pug-linker": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", - "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", - "dependencies": { - "pug-error": "^2.0.0", - "pug-walk": "^2.0.0" - } - }, - "node_modules/pug-load": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", - "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", - "dependencies": { - "object-assign": "^4.1.1", - "pug-walk": "^2.0.0" - } - }, - "node_modules/pug-parser": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", - "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", - "dependencies": { - "pug-error": "^2.0.0", - "token-stream": "1.0.0" - } - }, - "node_modules/pug-runtime": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", - "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==" - }, - "node_modules/pug-strip-comments": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", - "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", - "dependencies": { - "pug-error": "^2.0.0" - } - }, - "node_modules/pug-walk": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", - "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5622,6 +5629,22 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", + "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", + "dependencies": { + "tslib": "^2.6.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/qs": { "version": "6.11.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", @@ -5657,6 +5680,14 @@ } ] }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -5674,10 +5705,15 @@ "node": ">= 0.6" } }, + "node_modules/rate-limiter-flexible": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-5.0.3.tgz", + "integrity": "sha512-lWx2y8NBVlTOLPyqs+6y7dxfEpT6YFqKy3MzWbCy95sTTOhOuxufP2QvRyOHpfXpB9OUJPbVLybw3z3AVAS5fA==" + }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -5756,6 +5792,7 @@ "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -5811,6 +5848,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robots.txt": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/robots.txt/-/robots.txt-1.1.0.tgz", + "integrity": "sha512-Nq8dDAdgVmmQ56qqQGXx3YSYeURe9Sj65KnVUNB5jZ9pXtC/MdsZZmm2Kj1Cez0I3P8v0bMu8yyeIY1REjz3Gg==" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6245,6 +6287,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -6263,14 +6306,6 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6291,11 +6326,6 @@ "node": ">=0.6" } }, - "node_modules/token-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", - "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==" - }, "node_modules/touch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", @@ -6308,6 +6338,11 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -6451,6 +6486,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ua-parser-js": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", + "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "engines": { + "node": "*" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -6523,12 +6591,18 @@ "node": ">= 0.8" } }, - "node_modules/void-elements": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", - "engines": { - "node": ">=0.10.0" + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" } }, "node_modules/which": { @@ -6623,20 +6697,6 @@ "node": ">=8" } }, - "node_modules/with": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", - "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", - "dependencies": { - "@babel/parser": "^7.9.6", - "@babel/types": "^7.9.6", - "assert-never": "^1.2.1", - "babel-walk": "3.0.0-canary-5" - }, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/workerpool": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", @@ -7517,31 +7577,6 @@ "tslib": "^2.5.0" } }, - "@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==" - }, - "@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==" - }, - "@babel/parser": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", - "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==" - }, - "@babel/types": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", - "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", - "requires": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - } - }, "@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -7595,6 +7630,11 @@ "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", "dev": true }, + "@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==" + }, "@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -7618,6 +7658,11 @@ "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, + "@levischuck/tiny-cbor": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.2.tgz", + "integrity": "sha512-f5CnPw997Y2GQ8FAvtuVVC19FX8mwNNC+1XJcIi16n/LTJifKO6QBgGLgN3YEmqtGMk17SKSuoWES3imJVxAVw==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -7644,6 +7689,88 @@ "fastq": "^1.6.0" } }, + "@peculiar/asn1-android": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.10.tgz", + "integrity": "sha512-z9Rx9cFJv7UUablZISe7uksNbFJCq13hO0yEAOoIpAymALTLlvUOSLnGiQS7okPaM5dP42oTLhezH6XDXRXjGw==", + "requires": { + "@peculiar/asn1-schema": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "@peculiar/asn1-ecc": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.8.tgz", + "integrity": "sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==", + "requires": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "@peculiar/asn1-rsa": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.8.tgz", + "integrity": "sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==", + "requires": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "@peculiar/asn1-schema": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz", + "integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==", + "requires": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "@peculiar/asn1-x509": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.8.tgz", + "integrity": "sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==", + "requires": { + "@peculiar/asn1-schema": "^2.3.8", + "asn1js": "^3.0.5", + "ipaddr.js": "^2.1.0", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + }, + "dependencies": { + "ipaddr.js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==" + } + } + }, + "@simplewebauthn/server": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-9.0.3.tgz", + "integrity": "sha512-FMZieoBosrVLFxCnxPFD9Enhd1U7D8nidVDT4MsHc6l4fdVcjoeHjDueeXCloO1k5O/fZg1fsSXXPKbY2XTzDA==", + "requires": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@simplewebauthn/types": "^9.0.1", + "cross-fetch": "^4.0.0" + } + }, + "@simplewebauthn/types": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz", + "integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==" + }, "@smithy/abort-controller": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-2.2.0.tgz", @@ -8478,12 +8605,18 @@ "asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true }, - "assert-never": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.2.1.tgz", - "integrity": "sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==" + "asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "requires": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + } }, "assertion-error": { "version": "1.1.0", @@ -8511,14 +8644,6 @@ "possible-typed-array-names": "^1.0.0" } }, - "babel-walk": { - "version": "3.0.0-canary-5", - "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", - "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", - "requires": { - "@babel/types": "^7.9.6" - } - }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -8543,12 +8668,12 @@ "dev": true }, "body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "requires": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -8556,7 +8681,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -8599,12 +8724,12 @@ } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" } }, "browser-stdout": { @@ -8721,14 +8846,6 @@ "supports-color": "^7.1.0" } }, - "character-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", - "integrity": "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==", - "requires": { - "is-regex": "^1.0.3" - } - }, "charset": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/charset/-/charset-1.0.1.tgz", @@ -8831,15 +8948,6 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, - "constantinople": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", - "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", - "requires": { - "@babel/parser": "^7.6.0", - "@babel/types": "^7.6.1" - } - }, "content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -8861,9 +8969,9 @@ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" }, "cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" }, "cookie-signature": { "version": "1.0.6", @@ -8885,6 +8993,14 @@ "vary": "^1" } }, + "cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "requires": { + "node-fetch": "^2.6.12" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -8900,7 +9016,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "requires": { "ms": "2.1.2" }, @@ -8908,8 +9023,7 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" } } }, @@ -8990,11 +9104,6 @@ "esutils": "^2.0.2" } }, - "doctypes": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", - "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==" - }, "ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -9009,9 +9118,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "requires": { "jake": "^10.8.5" } @@ -9507,16 +9616,16 @@ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" }, "express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -9582,6 +9691,46 @@ "jsonwebtoken": "^9.0.0" } }, + "express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==", + "requires": { + "cookie": "0.6.0", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "dependencies": { + "cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } + } + }, "express-unless": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/express-unless/-/express-unless-2.1.3.tgz", @@ -9661,9 +9810,9 @@ } }, "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "requires": { "to-regex-range": "^5.0.1" @@ -9936,6 +10085,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "requires": { "has-symbols": "^1.0.3" } @@ -10112,6 +10262,7 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, "requires": { "hasown": "^2.0.0" } @@ -10125,22 +10276,6 @@ "has-tostringtag": "^1.0.0" } }, - "is-expression": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", - "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", - "requires": { - "acorn": "^7.1.1", - "object-assign": "^4.1.1" - }, - "dependencies": { - "acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" - } - } - }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -10204,15 +10339,11 @@ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "dev": true }, - "is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" - }, "is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, "requires": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -10292,11 +10423,6 @@ "minimatch": "^3.0.4" } }, - "js-stringify": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", - "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==" - }, "js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -10344,15 +10470,6 @@ "semver": "^7.5.4" } }, - "jstransformer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", - "integrity": "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==", - "requires": { - "is-promise": "^2.0.0", - "promise": "^7.0.1" - } - }, "jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -10481,6 +10598,31 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" }, + "memorystore": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/memorystore/-/memorystore-1.6.7.tgz", + "integrity": "sha512-OZnmNY/NDrKohPQ+hxp0muBcBKrzKNtHr55DbqSx9hLsYVNnomSAMRAtI7R64t3gf3ID7tHQA7mG4oL3Hu9hdw==", + "requires": { + "debug": "^4.3.0", + "lru-cache": "^4.0.3" + }, + "dependencies": { + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==" + } + } + }, "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -10676,6 +10818,14 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" }, + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "requires": { + "whatwg-url": "^5.0.0" + } + }, "node-mocks-http": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/node-mocks-http/-/node-mocks-http-1.14.1.tgz", @@ -10822,6 +10972,11 @@ "ee-first": "1.1.1" } }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -10882,7 +11037,8 @@ "path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true }, "path-to-regexp": { "version": "0.1.7", @@ -10913,13 +11069,10 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true }, - "promise": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", - "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", - "requires": { - "asap": "~2.0.3" - } + "proquint": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/proquint/-/proquint-0.0.1.tgz", + "integrity": "sha512-6ZQaEo+Ts+Dr7wUu4+/VlBICOV+fxxg/sFNelr22H+0GnnrnzWdMSbcu+c2X1b3YnvS8scyojjOWPbejLD1cnQ==" }, "proxy-addr": { "version": "2.0.7", @@ -10930,130 +11083,36 @@ "ipaddr.js": "1.9.1" } }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==" + }, "pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, - "pug": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.2.tgz", - "integrity": "sha512-bp0I/hiK1D1vChHh6EfDxtndHji55XP/ZJKwsRqrz6lRia6ZC2OZbdAymlxdVFwd1L70ebrVJw4/eZ79skrIaw==", - "requires": { - "pug-code-gen": "^3.0.2", - "pug-filters": "^4.0.0", - "pug-lexer": "^5.0.1", - "pug-linker": "^4.0.0", - "pug-load": "^3.0.0", - "pug-parser": "^6.0.0", - "pug-runtime": "^3.0.1", - "pug-strip-comments": "^2.0.0" - } - }, - "pug-attrs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", - "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", - "requires": { - "constantinople": "^4.0.1", - "js-stringify": "^1.0.2", - "pug-runtime": "^3.0.0" - } - }, - "pug-code-gen": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.2.tgz", - "integrity": "sha512-nJMhW16MbiGRiyR4miDTQMRWDgKplnHyeLvioEJYbk1RsPI3FuA3saEP8uwnTb2nTJEKBU90NFVWJBk4OU5qyg==", - "requires": { - "constantinople": "^4.0.1", - "doctypes": "^1.1.0", - "js-stringify": "^1.0.2", - "pug-attrs": "^3.0.0", - "pug-error": "^2.0.0", - "pug-runtime": "^3.0.0", - "void-elements": "^3.1.0", - "with": "^7.0.0" - } - }, - "pug-error": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.0.0.tgz", - "integrity": "sha512-sjiUsi9M4RAGHktC1drQfCr5C5eriu24Lfbt4s+7SykztEOwVZtbFk1RRq0tzLxcMxMYTBR+zMQaG07J/btayQ==" - }, - "pug-filters": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-4.0.0.tgz", - "integrity": "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==", - "requires": { - "constantinople": "^4.0.1", - "jstransformer": "1.0.0", - "pug-error": "^2.0.0", - "pug-walk": "^2.0.0", - "resolve": "^1.15.1" - } - }, - "pug-lexer": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz", - "integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==", - "requires": { - "character-parser": "^2.2.0", - "is-expression": "^4.0.0", - "pug-error": "^2.0.0" - } - }, - "pug-linker": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", - "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", - "requires": { - "pug-error": "^2.0.0", - "pug-walk": "^2.0.0" - } - }, - "pug-load": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", - "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", - "requires": { - "object-assign": "^4.1.1", - "pug-walk": "^2.0.0" - } - }, - "pug-parser": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", - "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", - "requires": { - "pug-error": "^2.0.0", - "token-stream": "1.0.0" - } - }, - "pug-runtime": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", - "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==" - }, - "pug-strip-comments": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", - "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", - "requires": { - "pug-error": "^2.0.0" - } - }, - "pug-walk": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", - "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==" - }, "punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true }, + "pvtsutils": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", + "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", + "requires": { + "tslib": "^2.6.1" + } + }, + "pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==" + }, "qs": { "version": "6.11.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", @@ -11069,6 +11128,11 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, + "random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==" + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -11083,10 +11147,15 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" }, + "rate-limiter-flexible": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-5.0.3.tgz", + "integrity": "sha512-lWx2y8NBVlTOLPyqs+6y7dxfEpT6YFqKy3MzWbCy95sTTOhOuxufP2QvRyOHpfXpB9OUJPbVLybw3z3AVAS5fA==" + }, "raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "requires": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -11141,6 +11210,7 @@ "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, "requires": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -11174,6 +11244,11 @@ "glob": "^7.1.3" } }, + "robots.txt": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/robots.txt/-/robots.txt-1.1.0.tgz", + "integrity": "sha512-Nq8dDAdgVmmQ56qqQGXx3YSYeURe9Sj65KnVUNB5jZ9pXtC/MdsZZmm2Kj1Cez0I3P8v0bMu8yyeIY1REjz3Gg==" + }, "run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -11508,7 +11583,8 @@ "supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true }, "text-hex": { "version": "1.0.0", @@ -11521,11 +11597,6 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, - "to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==" - }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11540,11 +11611,6 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, - "token-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", - "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==" - }, "touch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", @@ -11554,6 +11620,11 @@ "nopt": "~1.0.10" } }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -11658,6 +11729,19 @@ "possible-typed-array-names": "^1.0.0" } }, + "ua-parser-js": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", + "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==" + }, + "uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "requires": { + "random-bytes": "~1.0.0" + } + }, "unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -11715,10 +11799,19 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, - "void-elements": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } }, "which": { "version": "2.0.2", @@ -11790,17 +11883,6 @@ "triple-beam": "^1.3.0" } }, - "with": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", - "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", - "requires": { - "@babel/parser": "^7.9.6", - "@babel/types": "^7.9.6", - "assert-never": "^1.2.1", - "babel-walk": "3.0.0-canary-5" - } - }, "workerpool": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", diff --git a/package.json b/package.json index f9ce2527..008bf494 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "license": "MIT", "version": "0.5.0", "engines": { - "node": ">=18.0" + "node": ">=20.0" }, "bin": { "armadietto": "./bin/armadietto.js" @@ -24,19 +24,25 @@ "dependencies": { "@aws-sdk/client-s3": "^3.523.0", "@aws-sdk/lib-storage": "^3.525.1", + "@simplewebauthn/server": "^9.0.3", "@smithy/node-http-handler": "^2.5.0", "argparse": "^2.0.1", "cors": "^2.8.5", "ejs": "^3.1.9", "express": "^4.18.2", "express-jwt": "^8.4.1", + "express-session": "^1.18.0", "helmet": "^7.1.0", "http-errors": "^2.0.0", "jsonwebtoken": "^9.0.2", "lockfile": "^1.0.4", + "memorystore": "^1.6.7", "mkdirp": "^1.0.4", "node-mocks-http": "^1.14.1", - "pug": "^3.0.2", + "proquint": "^0.0.1", + "rate-limiter-flexible": "^5.0.3", + "robots.txt": "^1.1.0", + "ua-parser-js": "^1.0.38", "winston": "^3.11.0", "yaml": "^2.4.0" }, diff --git a/spec/account.spec.js b/spec/account.spec.js index 824bd535..259277d2 100644 --- a/spec/account.spec.js +++ b/spec/account.spec.js @@ -2,134 +2,151 @@ /* eslint-disable no-unused-expressions */ const chai = require('chai'); +const widelyCompatibleId = require('../lib/util/widelyCompatibleId'); +const NoSuchUserError = require('../lib/util/NoSuchUserError'); const expect = chai.expect; chai.use(require('chai-as-promised')); module.exports.shouldCreateDeleteAndReadAccounts = function () { describe('createUser', function () { before(function () { - this.username = 'automated-test-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + this.usernameAccount = 'automated-test-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER); }); after(async function () { - this.timeout(10_000); - await this.store.deleteUser(this.username, new Set()); + if (this.userIdAccount) { + this.timeout(10_000); + await this.accountMgr.deleteUser(this.userIdAccount, new Set()); + } }); - it('rejects a user with too short a name', async function () { - const params = { username: 'aa', email: 'a@b.c', password: 'swordfish' }; + it('rejects a user with control characters in username', async function () { + const params = { username: 'a\n1', contactURL: 'a@b.c' }; const logNotes = new Set(); - await expect(this.store.createUser(params, logNotes)).to.be.rejectedWith(Error, /user\s?name/i); + await expect(this.accountMgr.createUser(params, logNotes)).to.be.rejectedWith(Error, /\busername\b.*\bcharacter/i); }); - it('rejects creating a user with illegal characters in username', async function () { - const params = { username: this.username + '\\q', email: 'a@b.c', password: 'swordfish' }; + it('rejects creating a user with bad contactURL', async function () { + const params = { username: this.usernameAccount + 'j', contactURL: 'a@b' }; const logNotes = new Set(); - await expect(this.store.createUser(params, logNotes)).to.be.rejectedWith(Error, /user\s?name/i); + await expect(this.accountMgr.createUser(params, logNotes)).to.be.rejectedWith(Error, /URL\b/i); }); - it('rejects creating a user with bad email', async function () { - const params = { username: this.username + 'j', email: 'a@b', password: 'swordfish' }; - const logNotes = new Set(); - await expect(this.store.createUser(params, logNotes)).to.be.rejectedWith(Error, /email/i); + it('creates a user & rejects creating a new user with an existing username', async function () { + const params1 = { username: this.usernameAccount, contactURL: 'a@b.cc' }; + const logNotes1 = new Set(); + const user = await this.accountMgr.createUser(params1, logNotes1); + this.userIdAccount = user.username; + expect(user.username).to.match(/^[0-9a-z.-]{10,63}$/); + expect(user).to.have.property('username', this.usernameAccount); + expect(user).to.have.property('contactURL', 'mailto:' + params1.contactURL); + + const params2 = { username: this.usernameAccount, contactURL: '2' + params1.contactURL }; + const logNotes2 = new Set(); + await expect(this.accountMgr.createUser(params2, logNotes2)).to.be.rejectedWith(Error, 'is already taken'); }); + }); - it('rejects creating a user without password', async function () { - const params = { username: this.username + 'h', email: 'a@b.c', password: '' }; - const logNotes = new Set(); - await expect(this.store.createUser(params, logNotes)).to.be.rejectedWith(Error, /password/i); + describe('getUser', function () { + it('should throw NoSuchUserError if user doesn\'t exist', async function () { + const novelUsername = 'automated-test-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + + await expect(this.accountMgr.getUser(novelUsername, new Set())).to.be.rejectedWith(NoSuchUserError); }); + }); - it('creates a user & rejects creating a new user with an existing name', async function () { - const params1 = { username: this.username, email: 'a@b.c', password: 'swordfish' }; - const logNotes1 = new Set(); - await expect(this.store.createUser(params1, logNotes1)).to.eventually.eql(this.username + this.USER_NAME_SUFFIX); - const params2 = { username: this.username, email: 'd@e.f', password: 'iloveyou' }; - const logNotes2 = new Set(); - await expect(this.store.createUser(params2, logNotes2)).to.be.rejectedWith(Error, 'is already taken'); + describe('updateUser', function () { + it('should throw NoSuchUserError if user doesn\'t exist', async function () { + const novelUsername = 'automated-test-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + const novelUser = { username: novelUsername, contactURL: 'j@kk.ll' }; + + await expect(this.accountMgr.updateUser(novelUser, new Set())).to.be.rejectedWith(NoSuchUserError); + }); + }); + + describe('listUsers', function () { + before(function () { + this.usernameAccountList1 = 'automated-test-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + this.usernameAccountList2 = 'automated-test-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + }); + + after(async function () { + await this.accountMgr.deleteUser(this.user1?.username, new Set()); + await this.accountMgr.deleteUser(this.user2?.username, new Set()); + }); + + it('should list users', async function () { + this.timeout(10_000); + const params1 = { username: this.usernameAccountList1, contactURL: 'mailto:d@ef.gh' }; + this.user1 = await this.accountMgr.createUser(params1, new Set()); + const params2 = { username: this.usernameAccountList2, contactURL: 'mailto:i@jk.lm' }; + this.user2 = await this.accountMgr.createUser(params2, new Set()); + + const logNotes = new Set(); + const users = await this.accountMgr.listUsers(logNotes); + expect(users).to.have.length.greaterThanOrEqual(2); + const first = users.find(user => user.username === params1.username); + expect(first).to.have.property('contactURL', params1.contactURL); + const second = users.find(user => user.username === params2.username); + expect(second).to.have.property('contactURL', params2.contactURL); }); }); describe('deleteUser', function () { before(function () { - this.username2 = 'automated-test-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER); - this.username3 = 'automated-test-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER); - this.username4 = 'automated-test-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + this.usernameAccount2 = 'automated-test-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + this.usernameAccount3 = 'automated-test-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + }); + + after(async function () { + this.timeout(10_000); + if (this.user2?.username) { + await this.accountMgr.deleteUser(this.user2.username, new Set()); + } + if (this.user3?.username) { + await this.accountMgr.deleteUser(this.user3.username, new Set()); + } }); it('deletes a user', async function () { this.timeout(10_000); - const params = { username: this.username2, email: 'a@b.c', password: 'swordfish' }; - await expect(this.store.createUser(params, new Set())).to.eventually.eql(this.username2 + this.USER_NAME_SUFFIX); + const params = { username: this.usernameAccount2, contactURL: 'a@b.cc' }; + this.user2 = await this.accountMgr.createUser(params, new Set()); const logNotes = new Set(); - const result = await this.store.deleteUser(this.username2, logNotes); - expect(result?.[0]).to.be.greaterThanOrEqual(2); + const result = await this.accountMgr.deleteUser(this.user2.username, logNotes); + expect(result?.[0]).to.be.greaterThanOrEqual(1); expect(result?.[1]).to.equal(0); expect(result?.[2]).to.equal(1); - expect(logNotes.size).to.equal(0); + expect(logNotes.size).to.equal(1); }); it('returns normally when user deleted twice at the same time', async function () { this.timeout(10_000); - const params = { username: this.username3, email: 'a@b.c', password: 'swordfish' }; - await expect(this.store.createUser(params, new Set())).to.eventually.eql(this.username3 + this.USER_NAME_SUFFIX); + const params = { username: this.usernameAccount3, contactURL: 'b@c.dd' }; + this.user3 = await this.accountMgr.createUser(params, new Set()); const logNotes = new Set(); const results = await Promise.all( - [this.store.deleteUser(this.username3, logNotes), this.store.deleteUser(this.username3, logNotes)]); - expect(results[0]?.[0]).to.be.greaterThanOrEqual(0); - expect(results[0]?.[1]).to.equal(0); - expect(results[0]?.[2]).to.be.within(0, 1); - expect(results[1]?.[0]).to.be.greaterThanOrEqual(0); - expect(results[1]?.[1]).to.equal(0); - expect(results[1]?.[2]).to.be.within(0, 1); - expect(logNotes.size).to.equal(0); + [this.accountMgr.deleteUser(this.user3.username, logNotes), this.accountMgr.deleteUser(this.user3.username, logNotes)]); + expect(results[0]?.[0]).to.be.within(0, 1); // at most 1 blob + expect(results[0]?.[1]).to.equal(0); // no errors + expect(results[0]?.[2]).to.be.within(0, 1); // at most 1 pass + expect(results[1]?.[0]).to.be.within(0, 1); // at most 1 blob + expect(results[1]?.[1]).to.equal(0); // no errors + expect(results[1]?.[2]).to.be.within(0, 1); // at most 1 pass + expect(logNotes.size).to.equal(2); // success note from each call }); it('returns normally when user doesn\'t exist', async function () { this.timeout(10_000); const logNotes = new Set(); - const result = await this.store.deleteUser(this.username4, logNotes); + const result = await this.accountMgr.deleteUser(widelyCompatibleId(64), logNotes); expect(result?.[0]).to.equal(0); expect(result?.[1]).to.equal(0); expect(result?.[2]).to.equal(0); - expect(logNotes.size).to.equal(0); - }); - }); - - describe('authenticate', function () { - let goodParams; - - before(async function () { - goodParams = { - username: 'automated-test-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER), - email: 'g@h.i', - password: 'dictionary' - }; - return this.store.createUser(goodParams, new Set()); - }); - - after(async function () { - this.timeout(10_000); - return this.store.deleteUser(goodParams.username, new Set()); - }); - - it('throws a cagey error if the user does not exist', async function () { - const badParams = { username: 'nonexisting', email: 'g@h.i', password: 'dictionary' }; - const logNotes = new Set(); - await expect(this.store.authenticate(badParams, logNotes)).to.be.rejectedWith('Password and username do not match'); - expect(Array.from(logNotes)).to.include('attempt to log in with nonexistent user “nonexisting”'); - }); - - it('throws a cagey error for a wrong password for an existing user', async function () { - const badPassword = Object.assign({}, goodParams, { password: 'wrong' }); - await expect(this.store.authenticate(badPassword)).to.be.rejectedWith('Password and username do not match'); - }); - - it('resolves for a good user', async function () { - await expect(this.store.authenticate(goodParams)).to.eventually.equal(true); + expect(logNotes.size).to.equal(1); // success note }); }); }; diff --git a/spec/modular/account.spec.js b/spec/modular/account.spec.js new file mode 100644 index 00000000..0d151c7c --- /dev/null +++ b/spec/modular/account.spec.js @@ -0,0 +1,105 @@ +/* eslint-env mocha, chai, node */ +/* eslint no-unused-vars: ["warn", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }] */ +/* eslint-disable no-unused-expressions */ + +const chai = require('chai'); +const expect = chai.expect; +const chaiAsPromised = require('chai-as-promised'); +chai.use(chaiAsPromised); +const chaiHttp = require('chai-http'); +chai.use(chaiHttp); +const { configureLogger } = require('../../lib/logger'); +const { mockAccountFactory, USER } = require('../util/mockAccount'); +const path = require('path'); +const loginFactory = require('../../lib/routes/login'); +const accountRouterFactory = require('../../lib/routes/account'); +const express = require('express'); +const session = require('express-session'); +const crypto = require('crypto'); + +const HOST_IDENTITY = 'psteniusubi.github.io'; + +describe('account router', function () { + before(async function () { + configureLogger({ log_dir: './test-log', stdout: ['notice'], log_files: ['error'] }); + this.hostIdentity = HOST_IDENTITY; + + this.accountMgr = mockAccountFactory(HOST_IDENTITY); + + this.jwtSecret = 'scrimshaw'; + this.loginRouter = await loginFactory(this.hostIdentity, this.jwtSecret, this.accountMgr, false); + this.accountRouter = await accountRouterFactory(this.hostIdentity, this.jwtSecret, this.accountMgr); + + this.app = express(); + this.app.locals.basePath = ''; + this.app.set('views', path.join(__dirname, '../../lib/views')); + this.app.set('view engine', 'html'); + this.app.engine('.html', require('ejs').__express); + + const developSession = session({ + name: 'id', + secret: crypto.randomBytes(32 / 8).toString('base64') + }); + this.app.use(developSession); + this.sessionValues = {}; + this.app.use((req, res, next) => { // shim for testing + Object.assign(req.session, this.sessionValues); + res.logNotes = new Set(); + next(); + }); + this.app.use('/account', this.loginRouter); + this.app.use('/account', this.accountRouter); + + this.app.locals.title = 'Test Armadietto'; + this.app.locals.host = HOST_IDENTITY; + this.app.locals.signup = false; + }); + + beforeEach(function () { + this.sessionValues = { privileges: {} }; + }); + + it('account page displays account data', async function () { + this.sessionValues = { user: USER }; + const res = await chai.request(this.app).get('/account'); + expect(res).to.have.status(200); + expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); + expect(res).to.have.header('Cache-Control', /\bprivate\b/); + expect(res).to.have.header('Cache-Control', /\bno-cache\b/); + const resText = res.text.replace(/"/g, '"'); + expect(resText).to.contain('

Your Account

'); + expect(resText).to.match(new RegExp('

' + USER.username + '@')); + expect(resText).to.contain('STORE'); + expect(resText).to.contain('Apple Mac Firefox'); + expect(resText).to.match(/5\/\d\/2024<\/td>/); + expect(resText).to.contain('never'); + + expect(resText).to.contain('To create a passkey on a new device, invite yourself to create another passkey'); + expect(resText).to.contain('data-username="nisar-dazan-dafig-kanih"'); + expect(resText).to.contain('data-contacturl="skype:skye"'); + expect(resText).to.contain('>Invite yourself to create another passkey'); + }); + + it('account page, when not logged in, redirect to login page', async function () { + const res = await chai.request(this.app).get('/account'); + expect(res).to.redirectTo(/http:\/\/127.0.0.1:\d{1,5}\/account\/login/); + expect(res).to.have.status(200); + expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); + const resText = res.text.replace(/"/g, '"'); + expect(resText).to.contain('

Login

'); + }); + + it('login page displays messages & contains options', async function () { + const res = await chai.request(this.app).get('/account/login'); + expect(res).to.have.status(200); + expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); + expect(res).to.have.header('Cache-Control', /\bprivate\b/); + expect(res).to.have.header('Cache-Control', /\bno-store\b/); + const resText = res.text.replace(/"/g, '"'); + expect(resText).to.contain('

Login

'); + expect(resText).to.contain('

Click the button below to log in with a passkey.\n\nIf you need to create a passkey for this device or browser, log in from your old device and invite yourself to create a new passkey.

'); + expect(resText).to.contain('"challenge":"'); + expect(resText).to.contain('"userVerification":"preferred"'); + expect(resText).to.contain('"rpId":"psteniusubi.github.io"'); + }); +}); diff --git a/spec/modular/admin.spec.js b/spec/modular/admin.spec.js new file mode 100644 index 00000000..a41f1381 --- /dev/null +++ b/spec/modular/admin.spec.js @@ -0,0 +1,658 @@ +/* eslint-env mocha, chai, node */ +/* eslint no-unused-vars: ["warn", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }] */ +/* eslint-disable no-unused-expressions */ + +const chai = require('chai'); +const expect = chai.expect; +const chaiAsPromised = require('chai-as-promised'); +chai.use(chaiAsPromised); +const chaiHttp = require('chai-http'); +chai.use(chaiHttp); +const { configureLogger } = require('../../lib/logger'); +const adminFactory = require('../../lib/routes/admin'); +const process = require('process'); +const path = require('path'); +const YAML = require('yaml'); +const express = require('express'); +const session = require('express-session'); + +const INVITE_REQUEST_DIR = 'inviteRequests'; +const ADMIN_INVITE_DIR_NAME = 'invites'; +const CONTACT_URL_DIR = 'contactUrls'; +const HOST_IDENTITY = 'psteniusubi.github.io'; +const { + mockAccountFactory, CREDENTIAL_PRESENTED_RIGHT, CREDENTIAL_PRESENTED_WRONG, USER, + CREDENTIAL_PRESENTED_RIGHT_NO_USERHANDLE +} = require('../util/mockAccount'); +const crypto = require('crypto'); +const loginFactory = require('../../lib/routes/login'); +const NoSuchBlobError = require('../../lib/util/NoSuchBlobError'); +const requestInviteRouter = require('../../lib/routes/request-invite'); +const ParameterError = require('../../lib/util/ParameterError'); +const LOGIN_CHALLENGE = 'mJXERSBetL-NRL7AMozeWfnobXk'; + +const mockStoreRouter = { + blobs: {}, + contentTypes: {}, + + upsertAdminBlob: async function (path, contentType, content) { + this.blobs[path] = content; + this.contentTypes[path] = contentType; + }, + + readAdminBlob: async function (path) { + if (this.blobs[path]) { + return this.blobs[path]; + } else { + throw new NoSuchBlobError(`${path} does not exist`); + } + }, + + metadataAdminBlob: async function (path) { + if (this.blobs[path]) { + return { contentType: this.contentTypes[path], contentLength: this.blobs[path]?.length }; + } else { + throw new NoSuchBlobError(`${path} does not exist`); + } + }, + + deleteAdminBlob: async function (path) { + delete this.blobs[path]; + delete this.contentTypes[path]; + }, + + listAdminBlobs: async function (prefix) { + const metadata = []; + for (const [path, content] of Object.entries(this.blobs || {})) { + if (path.startsWith(prefix)) { + metadata.push({ path: path.slice(prefix.length + 1), contentLength: content.length }); + } + } + return metadata; + } +}; + +describe('admin module', function () { + before(async function () { + configureLogger({ log_dir: './test-log', stdout: ['notice'], log_files: ['error'] }); + this.hostIdentity = HOST_IDENTITY; + + this.accountMgr = mockAccountFactory(HOST_IDENTITY); + this.storeRouter = mockStoreRouter; + + this.jwtSecret = 'fubar'; + this.loginRouter = await loginFactory(this.hostIdentity, this.jwtSecret, this.accountMgr, true); + this.admin = await adminFactory(this.hostIdentity, this.jwtSecret, this.accountMgr, this.storeRouter); + + this.app = express(); + this.app.locals.basePath = ''; + this.app.set('views', path.join(__dirname, '../../lib/views')); + this.app.set('view engine', 'html'); + this.app.engine('.html', require('ejs').__express); + + this.app.use(express.urlencoded({ extended: true })); + const developSession = session({ + name: 'id', + secret: crypto.randomBytes(32 / 8).toString('base64') + }); + this.app.use(developSession); + this.sessionValues = {}; + this.app.use((req, res, next) => { // shim for testing + Object.assign(req.session, this.sessionValues); + res.logNotes = new Set(); + next(); + }); + this.app.use('/signup', requestInviteRouter(this.storeRouter)); + this.app.use('/admin', this.loginRouter); + this.app.use('/admin', this.admin); + + this.app.locals.title = 'Test Armadietto'; + this.app.locals.host = 'localhost:xxxx'; + this.app.locals.signup = true; + }); + + beforeEach(function () { + this.sessionValues = { privileges: {} }; + }); + + describe('generateInviteURL', function () { + it('throws an error for undefined username & contact string', async function () { + await expect(this.admin.generateInviteURL(undefined, undefined)).to.eventually.be.rejectedWith(Error, 'contactStr'); + }); + + it('throws an error for username w/o contact string', async function () { + await expect(this.admin.generateInviteURL(undefined, 'robert')).to.eventually.be.rejectedWith(Error, 'contact'); + }); + + it('throws an error for tel: URL', async function () { + await expect(this.admin.generateInviteURL('tel:+1-800-555-1212', undefined)).to.eventually.be.rejectedWith(ParameterError, 'not supported'); + }); + + it('returns inviteURL for email address', async function () { + const [contactURL, inviteURL] = await this.admin.generateInviteURL('foo@bar.com', undefined, { STORE: true }); + + expect(contactURL.href).to.equal('mailto:foo@bar.com'); + expect(inviteURL).to.have.property('protocol', 'https:'); + expect(inviteURL).to.have.property('host', this.hostIdentity); + expect(inviteURL).to.have.property('pathname', '/admin/acceptInvite'); + expect(inviteURL.searchParams.has('token')).to.equal(true); + + const token = inviteURL.searchParams.get('token'); + expect(token).to.match(/^[a-zA-Z0-9_-]{10,64}/); + const invite = YAML.parse(await this.storeRouter.readAdminBlob(path.join(ADMIN_INVITE_DIR_NAME, token + '.yaml'))); + expect(invite).to.have.property('username', ''); + expect(invite).to.have.property('contactURL', contactURL.href); + expect(invite).to.have.property('expiresAt'); + expect(new Date(invite.expiresAt) - Date.now()).to.be.greaterThan(60 * 60 * 1000); + expect(new Date(invite.expiresAt) - Date.now()).to.be.lessThan(48 * 60 * 60 * 1000); + expect(invite.privileges).to.deep.equal({ STORE: true }); + }); + + it('returns inviteURL for sms: URL', async function () { + const [contactURL, inviteURL] = await this.admin.generateInviteURL('sms:+18664504185?body=Hi%2520there', undefined); + + expect(contactURL.href).to.equal('sms:+18664504185'); + + expect(inviteURL).to.have.property('protocol', 'https:'); + expect(inviteURL).to.have.property('host', this.hostIdentity); + expect(inviteURL).to.have.property('pathname', '/admin/acceptInvite'); + expect(inviteURL.searchParams.has('token')).to.equal(true); + + const token = inviteURL.searchParams.get('token'); + expect(token).to.match(/^[a-zA-Z0-9_-]{10,64}/); + const invite = YAML.parse(await this.storeRouter.readAdminBlob(path.join(ADMIN_INVITE_DIR_NAME, token + '.yaml'))); + expect(invite).to.have.property('username', ''); + expect(invite).to.have.property('contactURL', contactURL.href); + expect(new Date(invite.expiresAt) - Date.now()).to.be.greaterThan(60 * 60 * 1000); + expect(new Date(invite.expiresAt) - Date.now()).to.be.lessThan(48 * 60 * 60 * 1000); + }); + }); + + describe('bootstrap', function () { + it('doesn\'t throw exception when BOOTSTRAP_OWNER is undefined', async function () { + delete process.env.BOOTSTRAP_OWNER; + + await expect(this.admin.bootstrap()).to.eventually.equal(undefined); + }); + + it('doesn\'t throw exception when BOOTSTRAP_OWNER doesn\'t contain URL', async function () { + process.env.BOOTSTRAP_OWNER = ' fu bar '; + + await expect(this.admin.bootstrap()).to.eventually.equal(undefined); + }); + + it('writes invite URL to file based on BOOTSTRAP_OWNER environment variable', async function () { + const username = 'RedKumari'; + const contactStr = 'red@kumari.org'; + await this.storeRouter.deleteAdminBlob(path.join(ADMIN_INVITE_DIR_NAME, `${encodeURIComponent('mailto:' + contactStr)}.uri`)); + process.env.BOOTSTRAP_OWNER = ` ${contactStr} ${username} `; + + await this.admin.bootstrap(); + + const inviteURLFile = await this.storeRouter.readAdminBlob(path.join(ADMIN_INVITE_DIR_NAME, `${encodeURIComponent('mailto:' + contactStr)}.uri`)); + const inviteURL = new URL(inviteURLFile); + expect(inviteURL.protocol).to.equal('https:'); + expect(inviteURL.host).to.equal(this.hostIdentity); + expect(inviteURL.pathname).to.equal('/admin/acceptInvite'); + + const token = inviteURL.searchParams.get('token'); + expect(token).to.match(/^[a-zA-Z0-9_-]{10,64}/); + const invite1 = YAML.parse(await this.storeRouter.readAdminBlob(path.join(ADMIN_INVITE_DIR_NAME, token + '.yaml'))); + expect(invite1).to.have.property('username', username); + expect(invite1).to.have.property('contactURL', 'mailto:' + contactStr); + expect(invite1.privileges).to.deep.equal({ OWNER: true, ADMIN: true, STORE: true }); + + delete process.env.BOOTSTRAP_OWNER; + }); + }); + + describe('sending invitation', function () { + it('rejects when neither admin privilege nor for self', async function () { + const res = await chai.request(this.app).post('/admin/sendInvite').type('form').send({ + protocol: 'mailto:', + address: 'me@myplace.net', + privilegegrant: JSON.stringify({ STORE: true }) + }); + expect(res).to.have.status(401); + }); + + it('rejects missing protocol', async function () { + this.sessionValues.privileges.ADMIN = true; + + const res = await chai.request(this.app).post('/admin/sendInvite').type('form').send({ + protocol: '', + address: 'me@myplace.net', + privilegegrant: JSON.stringify({ STORE: true }) + }); + expect(res).to.have.status(400); + expect(res).to.have.header('Content-Type', 'text/plain; charset=utf-8'); + expect(res.text).to.match(/\bprotocol\b/i); + }); + + it('rejects missing address', async function () { + this.sessionValues.privileges.ADMIN = true; + + const res = await chai.request(this.app).post('/admin/sendInvite').type('form').send({ + protocol: 'sgnl:', + address: '', + privilegegrant: JSON.stringify({ STORE: true }) + }); + expect(res).to.have.status(400); + expect(res).to.have.header('Content-Type', 'text/plain; charset=utf-8'); + expect(res.text).to.contain('Missing address'); + }); + + it('Generates invite for valid Signal address', async function () { + this.sessionValues.privileges.ADMIN = true; + + const res = await chai.request(this.app).post('/admin/sendInvite').type('form').send({ + protocol: 'sgnl:', + address: '206 555 1212', + privilegegrant: JSON.stringify({ STORE: true }) + }); + expect(res).to.have.status(201); + expect(res).to.have.header('Content-Type', 'application/json; charset=utf-8'); + expect(res.body.title).to.match(new RegExp(`${HOST_IDENTITY} User Invite`)); + expect(res.body.text).to.match(new RegExp(`You're invited to use remoteStorage to store data on ${HOST_IDENTITY}! To accept, copy and paste this URL into your browser:`)); + expect(res.body.url).to.match(new RegExp(`https://${HOST_IDENTITY}/admin/acceptInvite\\?token=`)); + }); + + it('Generates re-invite w/ contacturl', async function () { + this.sessionValues.privileges.ADMIN = true; + const USERNAME = 'Bubba'; + + const res = await chai.request(this.app).post('/admin/sendInvite').type('form').send({ + username: USERNAME, + contacturl: 'bubs@bluecollar.com', + privilegegrant: JSON.stringify({ STORE: true, ADMIN: true }) + }); + expect(res).to.have.status(201); + expect(res).to.have.header('Content-Type', 'application/json; charset=utf-8'); + expect(res.body.title).to.match(new RegExp(`${HOST_IDENTITY} User Invite`)); + expect(res.body.text).to.match(new RegExp(`${USERNAME}, to create a passkey for ${HOST_IDENTITY} for a new device or browser, copy and paste this URL into the browser on that device:`)); + expect(res.body.url).to.match(new RegExp(`https://${HOST_IDENTITY}/admin/acceptInvite\\?token=`)); + }); + + it('Generates re-invite for self without ADMIN privilege', async function () { + const USERNAME = 'Heather'; + const CONTACT_URL = 'skype:heath@hometown.hs'; + this.sessionValues.user = { username: USERNAME, contactURL: CONTACT_URL }; + + const res = await chai.request(this.app).post('/admin/sendInvite').type('form').send({ + username: USERNAME, + contacturl: CONTACT_URL, + privilegegrant: JSON.stringify({ STORE: true }) + }); + expect(res).to.have.status(201); + expect(res).to.have.header('Content-Type', 'application/json; charset=utf-8'); + expect(res.body.title).to.match(new RegExp(`${HOST_IDENTITY} User Invite`)); + expect(res.body.text).to.match(new RegExp(`${USERNAME}, to create a passkey for ${HOST_IDENTITY} for a new device or browser, copy and paste this URL into the browser on that device:`)); + expect(res.body.url).to.match(new RegExp(`https://${HOST_IDENTITY}/admin/acceptInvite\\?token=`)); + }); + }); + + describe('redeeming invitation', function () { + it('rejects invalid token', async function () { + const invalidToken = 'nOtAcUrReNtInViTe'; + const inviteURL = new URL('/admin/acceptInvite?token=' + invalidToken, 'https://' + this.hostIdentity); + + const res = await chai.request(this.app).get(inviteURL.pathname + inviteURL.search); + expect(res).to.have.status(401); + expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); + expect(res.text).to.contain('not valid'); + }); + + it('validates token & re-issues to same account', async function () { + const number = Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + const contactStr = ` theking${number}@graceland.com `; + const privileges = { ADMIN: true, STORE: true }; + const [contactURL, inviteURL] = await this.admin.generateInviteURL(contactStr, undefined, + privileges); + + expect(contactURL.href).to.equal('mailto:' + contactStr.trim()); + + const agent = chai.request.agent(this.app); + const res = await agent.get(inviteURL.pathname + inviteURL.search); + + expect(res).to.have.status(200); + expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); + expect(res.text).to.contain('Welcome!'); + expect(res.text).to.match(new RegExp(`\\b${contactStr.trim()}\\b`)); + expect(res.text).to.match(/Pick a username/i); + + try { + await this.storeRouter.metadataAdminBlob(path.join(CONTACT_URL_DIR, encodeURIComponent(contactURL) + '.yaml')); + expect.fail(path.join(CONTACT_URL_DIR, encodeURIComponent(contactURL) + '.yaml') + ' should not exist'); + } catch (err) { + expect(err.name).to.equal('NoSuchBlobError'); + } + + // re-invite + const [contactURL2, inviteURL2] = await this.admin.generateInviteURL(contactURL.href, undefined); + expect(contactURL2).to.deep.equal(contactURL); + expect(inviteURL2).not.to.deep.equal(inviteURL); + + const token2 = inviteURL2.searchParams.get('token'); + expect(token2).to.match(/^[a-zA-Z0-9_-]{10,64}/); + const invite2 = YAML.parse(await this.storeRouter.readAdminBlob(path.join(ADMIN_INVITE_DIR_NAME, token2 + '.yaml'))); + expect(invite2).to.have.property('username', ''); + expect(invite2).to.have.property('contactURL', contactURL.href); + + // selects username & creates user + const username = 'Elvis-' + number; + const optRes = await agent.post('/admin/getRegistrationOptions').type('application/json').send({ username }); + expect(optRes).to.have.status(201); + expect(optRes).to.have.header('Content-Type', /^application\/json/); + const body = JSON.parse(optRes.text); + expect(body).to.have.nested.property('rp.name', 'psteniusubi.github.io'); + expect(body).to.have.nested.property('user.name', username); + expect(body).to.have.deep.property('excludeCredentials', []); + expect(body).to.have.deep.property('authenticatorSelection', { + residentKey: 'preferred', + userVerification: 'preferred', + requireResidentKey: false + }); + expect(body).to.have.deep.property('extensions', { credProps: true }); + expect(body).to.have.property('attestation', 'none'); + + const usernameRet = await this.storeRouter.readAdminBlob(path.join(CONTACT_URL_DIR, encodeURIComponent(contactURL) + '.yaml')); + const admin = await this.accountMgr.getUser(usernameRet, new Set()); + expect(admin).to.have.property('username', username); + expect(admin).to.have.property('contactURL', contactURL.href); + expect(admin).to.have.deep.property('privileges', privileges); + expect(admin).to.have.property('credentials'); + + await agent.close(); + }); + + it('matches invite to existing account', async function () { + const number = Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + const username1 = 'Leo'; + const contactStr = ` graf${number}@galactech.com `; + const privileges = { ADMIN: true, STORE: true }; + + const [contactURL1, inviteURL1] = await this.admin.generateInviteURL(contactStr, username1, privileges); + const agent = chai.request.agent(this.app); + const res1 = await agent.get(inviteURL1.pathname + inviteURL1.search); + expect(res1).to.have.status(200); + + const optRes1 = await agent.post('/admin/getRegistrationOptions').type('application/json').send({ }); + expect(optRes1).to.have.status(201); + const body1 = JSON.parse(optRes1.text); + expect(body1).to.have.nested.property('user.name', username1); + const admin = await this.accountMgr.getUser(username1, new Set()); + expect(admin).has.property('username', username1); + expect(admin).has.property('contactURL', contactURL1.href); + expect(admin).to.have.deep.property('privileges', { ADMIN: true, STORE: true }); + + // re-invites w/ same contact URL but no username + const [contactURL2, inviteURL2] = await this.admin.generateInviteURL(contactURL1.href, undefined); + expect(contactURL2.href).to.deep.equal('mailto:' + contactStr.trim()); + expect(inviteURL2).not.to.deep.equal(inviteURL1); + + const token2 = inviteURL2.searchParams.get('token'); + expect(token2).to.match(/^[a-zA-Z0-9_-]{10,64}$/); + const invite2 = YAML.parse(await this.storeRouter.readAdminBlob(path.join(ADMIN_INVITE_DIR_NAME, token2 + '.yaml'))); + expect(invite2).to.have.property('contactURL', contactURL1.href); + + const res2 = await chai.request(this.app).get(inviteURL2.pathname + inviteURL2.search); + expect(res2).to.have.status(200); + expect(res2.text).to.match(new RegExp(`\\bWelcome, ${username1}!`)); + expect(res2.text).to.match(/Create a passkey/i); + expect(res2.text).not.to.match(/Pick a username/i); + expect(res2.text).to.match(new RegExp(`value="${username1}"`, 'i')); + + // gets options for existing user + const optRes2 = await agent.post('/admin/getRegistrationOptions').type('application/json').send({}); + expect(optRes2).to.have.status(200); + expect(optRes2).to.have.header('Content-Type', /^application\/json/); + const body2 = JSON.parse(optRes2.text); + expect(body2).to.have.nested.property('rp.name', 'psteniusubi.github.io'); + expect(body2).to.have.nested.property('user.name', username1); + expect(body2).to.have.deep.property('excludeCredentials', []); + expect(body2).to.have.deep.property('authenticatorSelection', { + residentKey: 'preferred', + userVerification: 'preferred', + requireResidentKey: false + }); + expect(body2).to.have.deep.property('extensions', { credProps: true }); + expect(body2).to.have.property('attestation', 'none'); + + const usernameContact = await this.storeRouter.readAdminBlob(path.join(CONTACT_URL_DIR, encodeURIComponent(contactURL1) + '.yaml')); + expect(usernameContact).to.equal(username1); + await agent.close(); + }); + + it('rejects expired token', async function () { + const number = Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + const username = 'Bob-' + number; + const contactStr = ` bob${number}@robert.com `; + const [_, inviteURL] = await this.admin.generateInviteURL(contactStr, username); + + const token = inviteURL.searchParams.get('token'); + const invite = YAML.parse(await this.storeRouter.readAdminBlob(path.join(ADMIN_INVITE_DIR_NAME, token + '.yaml'))); + invite.expiresAt = new Date(Date.now() - 1); + await this.storeRouter.upsertAdminBlob(path.join(ADMIN_INVITE_DIR_NAME, token + '.yaml'), YAML.stringify(invite)); + + const res = await chai.request(this.app).get(inviteURL.pathname + inviteURL.search); + expect(res).to.have.status(401); + }); + }); + + describe('cancelling invitation', function () { + it('succeeds for invalid token', async function () { + const invalidToken = 'nOtAcUrReNtInViTe'; + const inviteURL = new URL('/admin/cancelInvite?token=' + invalidToken, 'https://' + this.hostIdentity); + + const res = await chai.request(this.app).post(inviteURL.pathname + inviteURL.search); + expect(res).to.have.status(200); + expect(res).to.have.header('Content-Type', 'application/json; charset=utf-8'); + }); + + it('removes invitation file for valid token', async function () { + const number = Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + const username = 'Jackie-' + number; + const contactStr = ` theduke${number}@nationalgallery.org `; + const [contactURL, inviteURL] = await this.admin.generateInviteURL(contactStr, username); + expect(contactURL.href).to.equal('mailto:' + contactStr.trim()); + + const res = await chai.request(this.app).get(inviteURL.pathname + inviteURL.search); + expect(res).to.have.status(200); + + const token = inviteURL.searchParams.get('token'); + await expect(this.storeRouter.readAdminBlob(path.join(ADMIN_INVITE_DIR_NAME, token + '.yaml'))).to.eventually.match(new RegExp(`username: ${username}`)); + + const cancelRes = await chai.request(this.app).post('/admin/cancelInvite?token=' + token); + expect(cancelRes).to.have.status(200); + + await expect(this.storeRouter.readAdminBlob(path.join(ADMIN_INVITE_DIR_NAME, token + '.yaml'))).to.eventually.be.rejectedWith(NoSuchBlobError); + }); + }); + + // ----------------------- implemented by login router ------------------------------------------- + + describe('login page', function () { + it('displays messages & contains options', async function () { + const res = await chai.request(this.app).get('/admin/login'); + expect(res).to.have.status(200); + expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); + expect(res).to.have.header('Cache-Control', /\bprivate\b/); + expect(res).to.have.header('Cache-Control', /\bno-store\b/); + const resText = res.text.replace(/"/g, '"'); + expect(resText).to.contain('

Start Admin Session

'); + expect(resText).to.contain('

Click the button below to authenticate with a passkey.

'); + expect(resText).to.contain('"challenge":"'); + expect(resText).to.contain('"userVerification":"preferred"'); + expect(resText).to.contain('"rpId":"psteniusubi.github.io"'); + }); + }); + + describe('verification of passkeys', function () { + it('rejects an unknown user as a bad request', async function () { + this.sessionValues.loginChallenge = LOGIN_CHALLENGE; + const unknownUserCredential = structuredClone(CREDENTIAL_PRESENTED_RIGHT); + unknownUserCredential.response.userHandle = Buffer.from('anotherguy', 'utf8').toString('base64url'); + + const verifyRes = await chai.request(this.app).post('/admin/verify-authentication') + .type('application/json').send(JSON.stringify(unknownUserCredential)); + + expect(verifyRes).to.have.status(401); + expect(verifyRes).to.be.json; + expect(verifyRes.body.msg).to.match(/could not be validated/); + }); + + it('rejects an unregistered passkey as a bad request', async function () { + this.sessionValues.loginChallenge = LOGIN_CHALLENGE; + const verifyRes = await chai.request(this.app).post('/admin/verify-authentication') + .type('application/json').send(JSON.stringify(CREDENTIAL_PRESENTED_WRONG)); + expect(verifyRes).to.have.status(401); + expect(verifyRes).to.be.json; + expect(verifyRes.body.msg).to.match(/could not be validated/); + }); + + it('accepts a registered passkey', async function () { + this.sessionValues.loginChallenge = LOGIN_CHALLENGE; + const verifyRes = await chai.request(this.app).post('/admin/verify-authentication') + .type('application/json').send(JSON.stringify(CREDENTIAL_PRESENTED_RIGHT)); + expect(verifyRes).to.have.status(200); + expect(verifyRes).to.be.json; + expect(verifyRes.body.verified).to.equal(true); + expect(verifyRes.body.username).to.equal(USER.username); + }); + + it('accepts a registered passkey w/o userHandle when already logged in', async function () { + this.sessionValues.loginChallenge = LOGIN_CHALLENGE; + this.sessionValues.user = structuredClone(USER); + const verifyRes = await chai.request(this.app).post('/admin/verify-authentication') + .type('application/json').send(JSON.stringify(CREDENTIAL_PRESENTED_RIGHT_NO_USERHANDLE)); + expect(verifyRes).to.have.status(200); + expect(verifyRes).to.be.json; + expect(verifyRes.body.verified).to.equal(true); + expect(verifyRes.body.username).to.equal(USER.username); + }); + }); + + describe('users list', function () { + it('when user is not admin, should redirect to admin login page', async function () { + const res = await chai.request(this.app).get('/admin/users'); + expect(res).to.redirectTo(/http:\/\/127.0.0.1:\d{1,5}\/admin\/login/); + expect(res).to.have.status(200); + expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); + + const resText = res.text.replace(/"/g, '"'); + expect(resText).to.contain('

Start Admin Session

'); + }); + + it('when user is admin, should display users', async function () { + this.sessionValues.privileges.ADMIN = true; + + const res = await chai.request(this.app).get('/admin/users'); + expect(res).to.have.status(200); + expect(res).to.have.header('Content-Type', /^text\/html/); + expect(parseInt(res.get('Content-Length'))).to.be.greaterThan(0); + expect(res).to.have.header('Cache-Control', /\bno-cache\b/); + expect(res).to.have.header('Cache-Control', /\bprivate\b/); + expect(res).to.have.header('ETag'); + + expect(res.text).to.contain('

Users

'); + expect(res.text).to.contain('FirstUser'); + expect(res.text).to.contain('mailto:​foo@bar.co'); + expect(res.text).to.contain('SecondUser'); + + expect(res.text).to.contain(''); + expect(res.text).to.contain(']*type="submit"[^>]*>Create User Invitation<\/button>/); + }); + }); + + describe('admin list', function () { + it('when not admin, should redirect to admin login page', async function () { + const res = await chai.request(this.app).get('/admin/admins'); + expect(res).to.redirectTo(/http:\/\/127.0.0.1:\d{1,5}\/admin\/login/); + expect(res).to.have.status(200); + expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); + + const resText = res.text.replace(/"/g, '"'); + expect(resText).to.contain('

Start Admin Session

'); + }); + + it('when admin, should display users', async function () { + this.sessionValues.privileges.ADMIN = true; + + const res = await chai.request(this.app).get('/admin/admins'); + expect(res).to.have.status(200); + expect(res).to.have.header('Content-Type', /^text\/html/); + expect(parseInt(res.get('Content-Length'))).to.be.greaterThan(0); + expect(res).to.have.header('Cache-Control', /\bno-cache\b/); + expect(res).to.have.header('Cache-Control', /\bprivate\b/); + expect(res).to.have.header('ETag'); + + expect(res.text).to.contain('

Admins

'); + expect(res.text).to.contain('FirstUser'); + expect(res.text).to.contain('mailto:​foo@bar.co'); + expect(res.text).not.to.contain('SecondUser'); + + expect(res.text).to.contain(''); + expect(res.text).to.contain(']*type="submit"[^>]*>Create User Invitation<\/button>/); + }); + }); + + describe('invite request list', function () { + beforeEach(function () { + this.sessionValues.privileges.ADMIN = true; + }); + + it('should display invite request contact URL and invite & delete buttons', async function () { + let res = await chai.request(this.app).post('/signup').type('form').send({ + protocol: 'xmpp:', + address: 'mine@jabber.org' + }); + expect(res).to.have.status(201); + res = await chai.request(this.app).post('/signup').type('form').send({ + protocol: 'sgnl:', + address: '(509) 555-1212)' + }); + expect(res).to.have.status(201); + + res = await chai.request(this.app).get('/admin/inviteRequests'); + expect(res).to.have.status(200); + expect(res).to.have.header('Cache-Control', /\bprivate\b/); + expect(res).to.have.header('Cache-Control', /\bno-cache\b/); + const resText = res.text.replace(/"/g, '"'); + expect(resText).to.contain('

Requests for Invitation

'); + expect(resText).to.contain('xmpp:mine@jabber.org'); + expect(resText).to.match(/]*>Invite<\/button>/); + expect(resText).to.contain('data-contacturl="xmpp:mine@jabber.org"'); + expect(resText).to.contain('data-privilegegrant="{"STORE":true}"'); + expect(resText).to.match(/]*>Delete<\/button>/); + expect(resText).to.contain('sgnl://signal.me/#p/+15095551212'); + expect(resText).to.contain('data-contacturl="sgnl://signal.me/#p/+15095551212"'); + }); + + it('removes request file, when button clicked', async function () { + const number = Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + const email = `j${number}@industry.com`; + let res = await chai.request(this.app).post('/signup').type('form').send({ + protocol: 'mailto:', + address: email + }); + expect(res).to.have.status(201); + + const contacturl = 'mailto:' + email; + const filePath = path.join(INVITE_REQUEST_DIR, encodeURIComponent(contacturl) + '.yaml'); + await expect(this.storeRouter.readAdminBlob(filePath)).to.eventually.equal(contacturl); + + res = await chai.request(this.app).post('/admin/deleteInviteRequest').type('form').send({ + contacturl + }); + expect(res).to.have.status(200); + + await expect(this.storeRouter.readAdminBlob(filePath)).to.eventually.be.rejectedWith(NoSuchBlobError); + }); + }); +}); diff --git a/spec/modular/m_not_found.spec.js b/spec/modular/m_not_found.spec.js index f0697a35..26f96eb7 100644 --- a/spec/modular/m_not_found.spec.js +++ b/spec/modular/m_not_found.spec.js @@ -1,3 +1,7 @@ +const chai = require('chai'); +const expect = chai.expect; +chai.use(require('chai-http')); +const { mockAccountFactory } = require('../util/mockAccount'); const appFactory = require('../../lib/appFactory'); const { configureLogger } = require('../../lib/logger'); const { shouldHandleNonexistingResource } = require('../not_found.spec'); @@ -9,7 +13,13 @@ describe('Nonexistant resource (modular)', function () { before(async function () { configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] }); - const app = appFactory({ hostIdentity: 'autotest', jwtSecret: 'swordfish', account: {}, storeRouter: (_req, _res, next) => next() }); + this.hostIdentity = 'autotest.us'; + const app = await appFactory({ + hostIdentity: this.hostIdentity, + jwtSecret: 'swordfish', + accountMgr: mockAccountFactory(this.hostIdentity), + storeRouter: (_req, _res, next) => next() + }); app.locals.title = 'Test Armadietto'; app.locals.host = 'localhost:xxxx'; app.locals.signup = true; @@ -17,4 +27,80 @@ describe('Nonexistant resource (modular)', function () { }); shouldHandleNonexistingResource(); + + /** This extends the test in shouldHandleNonexistingResource */ + it('should say cacheable for 25 minutes', async function () { + const res = await chai.request(this.app).get('/account/wildebeest/'); + expect(res).to.have.status(404); + expect(res).to.have.header('Cache-Control', /max-age=\d{4}/); + expect(res).to.have.header('Strict-Transport-Security', /^max-age=/); + expect(res).to.have.header('ETag'); + + expect(res.text).to.contain('Not Found — Armadietto'); + expect(res.text).to.contain('>“account/wildebeest/” doesn't exist<'); + }); + + it('should curtly & cache-ably refuse to serve unlikely paths', async function () { + const res = await chai.request(this.app).get('/_profiler/phpinfo'); + expect(res).to.have.status(404); + expect(res).to.have.header('Cache-Control', /max-age=\d{4}/); + expect(res.text).to.equal(''); + }); + + /** This tests that 404 for nonexistent assets is cache-able */ + it('should return cache headers for asset', async function () { + const res = await chai.request(this.app).get('/assets/not-there').set('Origin', this.hostIdentity); + expect(res).to.have.status(404); + expect(res).to.have.header('Cache-Control', /max-age=\d{4}/); + expect(res).to.have.header('Cache-Control', /public/); + expect(res.text).to.equal(''); + }); + + /** This tests that /storage paths have tighter security (except allow cross-origin) than other paths */ + it('should return security headers', async function () { + const res = await chai.request(this.app).get('/storage/zebcoe/public/nonexistant') + .set('Origin', this.hostIdentity); + expect(res).to.have.status(404); + expect(res.get('Content-Security-Policy')).to.contain('sandbox allow-orientation-lock;'); + expect(res.get('Content-Security-Policy')).to.contain('default-src \'none\';'); + expect(res.get('Content-Security-Policy')).to.contain('script-src \'none\';'); + expect(res.get('Content-Security-Policy')).to.contain('script-src-attr \'none\';'); + expect(res.get('Content-Security-Policy')).to.contain('style-src \'self\';'); + expect(res.get('Content-Security-Policy')).to.contain('img-src \'self\';'); + expect(res.get('Content-Security-Policy')).to.contain('font-src \'self\';'); + // expect(res.get('Content-Security-Policy')).to.contain(`style-src 'self' ${this.hostIdentity};`); + // expect(res.get('Content-Security-Policy')).to.contain(`img-src 'self' ${this.hostIdentity};`); + // expect(res.get('Content-Security-Policy')).to.contain(`font-src 'self' ${this.hostIdentity};`); + expect(res.get('Content-Security-Policy')).to.contain('object-src \'none\';'); + expect(res.get('Content-Security-Policy')).to.contain('child-src \'none\';'); + expect(res.get('Content-Security-Policy')).to.contain('connect-src \'none\';'); + expect(res.get('Content-Security-Policy')).to.contain('base-uri \'self\';'); + expect(res.get('Content-Security-Policy')).to.contain('frame-ancestors \'none\';'); + expect(res.get('Content-Security-Policy')).to.contain('form-action \'none\''); + expect(res.get('Content-Security-Policy')).to.contain('upgrade-insecure-requests'); + expect(res).not.to.have.header('Cross-Origin-Resource-Policy'); + expect(res).to.have.header('Cross-Origin-Opener-Policy', 'same-origin'); + expect(res).to.have.header('Origin-Agent-Cluster'); + expect(res).to.have.header('Referrer-Policy', 'no-referrer'); + expect(res).to.have.header('X-Content-Type-Options', 'nosniff'); + expect(res).to.have.header('Strict-Transport-Security', /^max-age=/); + expect(res).not.to.have.header('X-Powered-By'); + expect(res).to.have.header('X-XSS-Protection', '0'); // disabled because counterproductive + + expect(res).to.have.header('Content-Type', /^text\/html/); + expect(parseInt(res.get('Content-Length'))).to.be.greaterThan(0); + + expect(res).to.have.header('ETag'); + expect(res).to.have.header('Cache-Control', /\bno-cache\b/); + expect(res).to.have.header('Cache-Control', /\bpublic\b/); + }); + + /** This tests that the 404 for /favicon.ico is cacheable */ + it('should curtly, finally & cache-ably refuse to serve /favicon.ico', async function () { + const res = await chai.request(this.app).get('/favicon.ico'); + expect(res).to.have.status(404); + expect(res).to.have.header('Cache-Control', /max-age=\d{8}/); + expect(res).to.have.header('Cache-Control', /public/); + expect(res.text).to.equal(''); + }); }); diff --git a/spec/modular/m_oauth.spec.js b/spec/modular/m_oauth.spec.js index 4c59e73f..d3a21804 100644 --- a/spec/modular/m_oauth.spec.js +++ b/spec/modular/m_oauth.spec.js @@ -8,12 +8,14 @@ chai.use(chaiHttp); const spies = require('chai-spies'); chai.use(spies); const { configureLogger } = require('../../lib/logger'); -const { shouldImplementOAuth } = require('../oauth.spec'); const express = require('express'); const oAuthRouter = require('../../lib/routes/oauth'); const path = require('path'); const helmet = require('helmet'); const jwt = require('jsonwebtoken'); +const session = require('express-session'); +const crypto = require('crypto'); +const { mockAccountFactory, USER, CREDENTIAL_PRESENTED_WRONG, CREDENTIAL_PRESENTED_RIGHT_NO_USERHANDLE } = require('../util/mockAccount'); async function post (app, url, params) { return chai.request(app).post(url).type('form').send(params).redirects(0); @@ -23,23 +25,15 @@ describe('OAuth (modular)', function () { before(async function () { configureLogger({ log_dir: './test-log', stdout: [], log_files: ['debug'] }); - const mockAccount = { - authenticate ({ username, email, password }) { - if (username === 'zebcoe' && password === 'locog') { return; } - throw new Error('Password and username do not match'); - } - }; + const mockAccount = mockAccountFactory('autotest'); + this.user = USER; - this.hostIdentity = 'automated test'; + this.hostIdentity = 'psteniusubi.github.io'; this.app = express(); this.app.engine('.html', require('ejs').__express); this.app.set('view engine', 'html'); this.app.set('views', path.join(__dirname, '../../lib/views')); - this.app.use((_req, res, next) => { - res.logNotes = new Set(); - next(); - }); this.app.use(helmet({ contentSecurityPolicy: { directives: { @@ -61,29 +55,151 @@ describe('OAuth (modular)', function () { } })); this.app.use(express.urlencoded({ extended: true })); - this.app.use('/oauth', oAuthRouter(this.hostIdentity, 'swordfish')); - this.app.set('account', mockAccount); + + const developSession = session({ + secret: crypto.randomBytes(32 / 8).toString('base64') + }); + this.app.use(developSession); + this.sessionValues = {}; + this.app.use((req, res, next) => { // shim for testing + for (const [key, value] of Object.entries(this.sessionValues)) { + if (value instanceof Object) { + req.session[key] = Object.assign(req.session[key] || {}, value); + } else { + req.session[key] = value; + } + } + res.logNotes = new Set(); + next(); + }); + + this.app.use('/oauth', oAuthRouter(this.hostIdentity, 'swordfish', mockAccount)); + this.app.set('accountMgr', mockAccount); this.app.locals.title = 'Test Armadietto'; this.app.locals.basePath = ''; this.app.locals.host = 'localhost:xxxx'; this.app.locals.signup = false; }); - shouldImplementOAuth(); + beforeEach(function () { + this.sessionValues = { }; + }); - describe('with valid login credentials (new account module)', async function () { + describe('authorization form', function () { beforeEach(function () { this.auth_params = { - username: 'zebcoe', - password: 'locog', client_id: 'the_client_id', redirect_uri: 'http://example.com/cb', response_type: 'token', - scope: 'the_scope', + scope: 'data:rw', state: 'the_state' }; }); + it('should ask for passkey, not password', async function () { + const res = await chai.request(this.app).get('/oauth/' + this.user.username).query(this.auth_params); + expect(res).to.have.status(200); + expect(res).to.have.header('Cache-Control', /\bprivate\b/); + expect(res).to.have.header('Cache-Control', /\bno-store\b/); + expect(res.text).to.contain('>Authorize<'); + expect(res.text).to.contain('>the_client_id<'); + expect(res.text).to.contain('>example.com<'); + expect(res.text).to.match(/Read\/write.*access to.*\/data/); + expect(res.text).not.to.contain('password'); + expect(res.text).to.contain('Use your passkey to authorize'); + }); + }); + + describe('GETing with invalid client input', function () { + beforeEach(function () { + this.auth_params = { + // username: this.user.username, + client_id: 'the_client_id', + redirect_uri: 'http://example.com/cb', + response_type: 'token', + scope: 'the_scope' + // no state + }; + }); + + it('returns an error if redirect_uri is missing', async function () { + delete this.auth_params.redirect_uri; + const res = await chai.request(this.app).get('/oauth/me').query(this.auth_params); + expect(res).to.have.status(400); + expect(res.text).to.equal('error=invalid_request&error_description=Required%20parameter%20%22redirect_uri%22%20is%20missing'); + }); + + it('returns an error if client_id is missing', async function () { + delete this.auth_params.client_id; + const res = await chai.request(this.app).get('/oauth/me').query(this.auth_params); + expect(res).to.redirectTo('http://example.com/cb#error=invalid_request&error_description=Required%20parameter%20%22client_id%22%20is%20missing'); + }); + + it('returns an error if response_type is missing', async function () { + delete this.auth_params.response_type; + const res = await chai.request(this.app).get('/oauth/me').query(this.auth_params); + expect(res).to.redirectTo('http://example.com/cb#error=invalid_request&error_description=Required%20parameter%20%22response_type%22%20is%20missing'); + }); + + it('returns an error if response_type is not recognized', async function () { + this.auth_params.response_type = 'wrong'; + const res = await chai.request(this.app).get('/oauth/me').query(this.auth_params); + expect(res).to.redirectTo('http://example.com/cb#error=unsupported_response_type&error_description=Response%20type%20%22wrong%22%20is%20not%20supported'); + }); + + it('returns an error if scope is missing', async function () { + delete this.auth_params.scope; + const res = await chai.request(this.app).get('/oauth/me').query(this.auth_params); + expect(res).to.redirectTo('http://example.com/cb#error=invalid_scope&error_description=Parameter%20%22scope%22%20is%20invalid'); + }); + }); + + describe('POSTing with invalid login credentials', async function () { + beforeEach(function () { + this.auth_params = { + credential: JSON.stringify(CREDENTIAL_PRESENTED_WRONG) + }; + this.sessionValues.oauthParams = { + username: this.user.username, + challenge: '2LRuM9KrEZ-EkZHxOwu1w0TJEKQ', + client_id: 'the_client_id', + redirect_uri: 'http://example.com/cb', + response_type: 'token', + scope: 'the_scope', + state: 'the_state', + credential: JSON.stringify(CREDENTIAL_PRESENTED_WRONG) + }; + }); + + it('returns a 401 response with the login form', async function () { + const res = await post(this.app, '/oauth', this.auth_params); + expect(res).to.have.status(401); + expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); + expect(res).to.have.header('Content-Security-Policy', /sandbox.*default-src 'self'/); + expect(res).to.have.header('Referrer-Policy', 'no-referrer'); + expect(res).to.have.header('X-Content-Type-Options', 'nosniff'); + expect(res.text).to.contain('application the_client_id hosted'); + expect(res.text).to.contain('Presented credential does not belong to user.'); + }); + }); + + describe('POSTing with valid login credentials (new accountMgr module)', async function () { + beforeEach(function () { + this.auth_params = { + credential: JSON.stringify(CREDENTIAL_PRESENTED_RIGHT_NO_USERHANDLE) + }; + this.sessionValues.oauthParams = { + username: this.user.username, + challenge: 'mJXERSBetL-NRL7AMozeWfnobXk', + client_id: 'the_client_id', + redirect_uri: 'http://example.com/cb', + response_type: 'token', + scope: 'the_scope', + state: 'the_state', + credential: JSON.stringify(CREDENTIAL_PRESENTED_RIGHT_NO_USERHANDLE) + }; + }); + describe('without explicit read/write permissions', async function () { it('authorizes the client to read and write', async function () { const res = await post(this.app, '/oauth', this.auth_params); @@ -95,61 +211,114 @@ describe('OAuth (modular)', function () { expect(params.get('token_type')).to.equal('bearer'); expect(params.get('state')).to.equal('the_state'); const token = params.get('access_token'); - const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: 'zebcoe' }); + const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: this.user.username }); expect(scopes).to.equal('the_scope:rw'); }); }); describe('with explicit read permission', async function () { it('authorizes the client to read', async function () { - this.auth_params.scope = 'the_scope:r'; + this.sessionValues.oauthParams.scope = 'the_scope:r'; const res = await post(this.app, '/oauth', this.auth_params); expect(res).to.redirect; const redirect = new URL(res.get('location')); const params = new URLSearchParams(redirect.hash.slice(1)); const token = params.get('access_token'); - const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: 'zebcoe' }); + const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: this.user.username }); expect(scopes).to.equal('the_scope:r'); }); }); describe('with explicit read/write permission', async function () { it('authorizes the client to read and write', async function () { - this.auth_params.scope = 'the_scope:rw'; + this.sessionValues.oauthParams.scope = 'the_scope:rw'; const res = await post(this.app, '/oauth', this.auth_params); expect(res).to.redirect; const redirect = new URL(res.get('location')); const params = new URLSearchParams(redirect.hash.slice(1)); const token = params.get('access_token'); - const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: 'zebcoe' }); + const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: this.user.username }); expect(scopes).to.equal('the_scope:rw'); }); }); describe('with implicit root permission', async function () { it('authorizes the client to read and write', async function () { - this.auth_params.scope = '*'; + this.sessionValues.oauthParams.scope = '*'; const res = await post(this.app, '/oauth', this.auth_params); expect(res).to.redirect; const redirect = new URL(res.get('location')); const params = new URLSearchParams(redirect.hash.slice(1)); const token = params.get('access_token'); - const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: 'zebcoe' }); + const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: this.user.username }); expect(scopes).to.equal('*:rw'); }); }); describe('with multiple read/write permissions', async function () { it('authorizes the client to read and write nonexplicit scopes', async function () { - this.auth_params.scope = 'first_scope second_scope:r third_scope:rw fourth_scope *:r'; + this.sessionValues.oauthParams.scope = 'first_scope second_scope:r third_scope:rw fourth_scope *:r'; const res = await post(this.app, '/oauth', this.auth_params); expect(res).to.redirect; const redirect = new URL(res.get('location')); const params = new URLSearchParams(redirect.hash.slice(1)); const token = params.get('access_token'); - const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: 'zebcoe' }); + const { scopes } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: this.user.username }); expect(scopes).to.equal('first_scope:rw second_scope:r third_scope:rw fourth_scope:rw *:r'); }); }); }); + + describe('POSTing after session expired', async function () { + it('tells the user to reload the page', async function () { + this.auth_params = { credential: JSON.stringify(CREDENTIAL_PRESENTED_RIGHT_NO_USERHANDLE) }; + + const res = await post(this.app, '/oauth', this.auth_params); + + expect(res).to.have.status(401); + expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); + expect(res).to.have.header('Content-Security-Policy', /sandbox.*default-src 'self'/); + + expect(res.text).to.contain('Authorization Failure — Armadietto'); + expect(res.text).to.contain('

Go back to the app then try again — your session expired

'); + }); + }); + + describe('GET then POST with valid login credentials', function () { + it('should save OAuth params & read expiration from form input', async function () { + const agent = chai.request.agent(this.app); + + const getAuthParams = { + client_id: 'https://someclient.net', + redirect_uri: 'http://example.com/cb', + response_type: 'token', + scope: 'data:rw', + state: 'some_state' + }; + const getRes = await agent.get('/oauth/' + this.user.username).query(getAuthParams); + + expect(getRes).to.have.status(200); + expect(getRes).to.have.header('Cache-Control', /\bprivate\b/); + expect(getRes).to.have.header('Cache-Control', /\bno-store\b/); + + this.sessionValues.oauthParams = { challenge: 'mJXERSBetL-NRL7AMozeWfnobXk' }; + const GRANT_DURATION_DAYS = 13; + const postAuthParams = { + credential: JSON.stringify(CREDENTIAL_PRESENTED_RIGHT_NO_USERHANDLE), + grantDuration: String(GRANT_DURATION_DAYS) + }; + const postRes = await agent.post('/oauth').type('form').send(postAuthParams).redirects(0); + + expect(postRes).to.redirect; + const redirect = new URL(postRes.get('location')); + const params = new URLSearchParams(redirect.hash.slice(1)); + const token = params.get('access_token'); + const { scopes, exp } = jwt.verify(token, 'swordfish', { issuer: this.hostIdentity, audience: 'http://example.com', subject: this.user.username }); + expect(scopes).to.equal(getAuthParams.scope); + expect(exp * 1000 - Date.now()).to.be.greaterThan(0.99 * GRANT_DURATION_DAYS * 24 * 60 * 60 * 1000); + expect(exp * 1000 - Date.now()).to.be.lessThan(1.01 * GRANT_DURATION_DAYS * 24 * 60 * 60 * 1000); + + await agent.close(); + }); + }); }); diff --git a/spec/modular/m_root.spec.js b/spec/modular/m_root.spec.js index 8a6ba796..cca3e7c7 100644 --- a/spec/modular/m_root.spec.js +++ b/spec/modular/m_root.spec.js @@ -1,6 +1,7 @@ const chai = require('chai'); const chaiHttp = require('chai-http'); const expect = chai.expect; +const { mockAccountFactory } = require('../util/mockAccount'); const appFactory = require('../../lib/appFactory'); const { configureLogger } = require('../../lib/logger'); const { shouldBeWelcomeWithoutSignup, shouldBeWelcomeWithSignup } = require('../root.spec'); @@ -11,10 +12,15 @@ chai.use(chaiHttp); describe('root page (modular)', function () { describe('w/o signup', function () { - beforeEach(function () { + beforeEach(async function () { configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] }); - this.app = appFactory({ hostIdentity: 'autotest', jwtSecret: 'swordfish', account: {}, storeRouter: (_req, _res, next) => next() }); + this.app = await appFactory({ + hostIdentity: 'autotest', + jwtSecret: 'swordfish', + accountMgr: mockAccountFactory('autotest'), + storeRouter: (_req, _res, next) => next() + }); this.app.locals.title = 'Armadietto without Signup'; this.app.locals.host = 'localhost:xxxx'; this.app.locals.signup = false; @@ -24,10 +30,15 @@ describe('root page (modular)', function () { }); describe('with signup', function () { - beforeEach(function () { + beforeEach(async function () { configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] }); - this.app = appFactory({ hostIdentity: 'autotest', jwtSecret: 'swordfish', account: {}, storeRouter: (_req, _res, next) => next() }); + this.app = await appFactory({ + hostIdentity: 'autotest', + jwtSecret: 'swordfish', + accountMgr: mockAccountFactory('autotest'), + storeRouter: (_req, _res, next) => next() + }); this.app.locals.title = 'Armadietto with Signup'; this.app.locals.host = 'localhost:xxxx'; this.app.locals.signup = true; @@ -41,7 +52,12 @@ describe('root page (modular)', function () { before(async () => { configureLogger({}); - this.app = appFactory({ hostIdentity: 'autotest', jwtSecret: 'swordfish', account: {}, storeRouter: (_req, _res, next) => next() }); + this.app = await appFactory({ + hostIdentity: 'autotest', + jwtSecret: 'swordfish', + accountMgr: mockAccountFactory('autotest'), + storeRouter: (_req, _res, next) => next() + }); this.app.locals.title = 'Armadietto with Signup'; this.app.locals.host = 'localhost:xxxx'; this.app.locals.signup = true; @@ -50,7 +66,7 @@ describe('root page (modular)', function () { it('should return Welcome page w/ security headers', async () => { const res = await chai.request(this.app).get('/'); expect(res).to.have.status(200); - expect(res.get('Content-Security-Policy')).to.contain('sandbox allow-scripts allow-forms allow-popups allow-same-origin;'); + expect(res.get('Content-Security-Policy')).to.contain('sandbox allow-scripts allow-forms allow-popups allow-same-origin allow-orientation-lock;'); expect(res.get('Content-Security-Policy')).to.contain('default-src \'self\';'); expect(res.get('Content-Security-Policy')).to.contain('script-src \'self\';'); expect(res.get('Content-Security-Policy')).to.contain('script-src-attr \'none\';'); @@ -59,7 +75,7 @@ describe('root page (modular)', function () { expect(res.get('Content-Security-Policy')).to.contain('font-src \'self\';'); expect(res.get('Content-Security-Policy')).to.contain('object-src \'none\';'); expect(res.get('Content-Security-Policy')).to.contain('child-src \'none\';'); - expect(res.get('Content-Security-Policy')).to.contain('connect-src \'none\';'); + expect(res.get('Content-Security-Policy')).to.contain('connect-src \'self\';'); expect(res.get('Content-Security-Policy')).to.contain('base-uri \'self\';'); expect(res.get('Content-Security-Policy')).to.contain('frame-ancestors \'none\';'); expect(res.get('Content-Security-Policy')).to.contain('form-action https:'); // in dev may also allow http: @@ -75,6 +91,8 @@ describe('root page (modular)', function () { expect(res).to.have.header('Content-Type', /^text\/html/); expect(parseInt(res.get('Content-Length'))).to.be.greaterThan(2500); expect(res).to.have.header('ETag'); + expect(res).to.have.header('Cache-Control', /max-age=\d{4}/); + expect(res).to.have.header('Cache-Control', /public/); }); }); }); diff --git a/spec/modular/m_signup.spec.js b/spec/modular/m_signup.spec.js deleted file mode 100644 index 0f91fc6a..00000000 --- a/spec/modular/m_signup.spec.js +++ /dev/null @@ -1,59 +0,0 @@ -/* eslint-env mocha, chai, node */ - -const { configureLogger } = require('../../lib/logger'); -const { shouldBlockSignups, shouldAllowSignupsBasePath } = require('../signup.spec'); -const core = require('../../lib/stores/core'); -const appFactory = require('../../lib/appFactory'); - -const mockAccount = { - -}; - -describe('Signup (modular)', function () { - describe('w/ signup disabled', function () { - before(async function () { - configureLogger({ log_dir: './test-log', stdout: [], log_files: ['debug'] }); - - const app = appFactory({ hostIdentity: 'autotest', jwtSecret: 'swordfish', account: mockAccount, storeRouter: (_req, _res, next) => next() }); - app.locals.title = 'Test Armadietto'; - app.locals.host = 'localhost:xxxx'; - app.locals.signup = false; - this.app = app; - }); - - shouldBlockSignups(); - }); - - describe('w/ base path & signup enabled', function () { - before(async function () { - configureLogger({ log_dir: './test-log', stdout: [], log_files: ['debug'] }); - - this.storeRouter = { - async createUser (params) { - const errors = core.validateUser(params); - if (errors.length > 0) throw new Error(errors[0]); - } - }; - - delete require.cache[require.resolve('../../lib/appFactory')]; - const app = require('../../lib/appFactory')({ - jwtSecret: 'swordfish', - account: mockAccount, - storeRouter: (_req, _res, next) => next(), - basePath: '/basic' - }); - app.set('account', this.storeRouter); - app.locals.title = 'Test Armadietto'; - app.locals.host = 'localhost:xxxx'; - app.locals.signup = true; - this.app = app; - this.username = 'john-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER); - }); - - after(function () { - delete require.cache[require.resolve('../../lib/appFactory')]; - }); - - shouldAllowSignupsBasePath(); - }); -}); diff --git a/spec/modular/m_static.spec.js b/spec/modular/m_static.spec.js index 839212f3..e63039c9 100644 --- a/spec/modular/m_static.spec.js +++ b/spec/modular/m_static.spec.js @@ -1,15 +1,24 @@ /* eslint-env mocha */ +const chai = require('chai'); +const expect = chai.expect; +chai.use(require('chai-http')); +const { mockAccountFactory } = require('../util/mockAccount'); const appFactory = require('../../lib/appFactory'); const { configureLogger } = require('../../lib/logger'); const { shouldServeStaticFiles } = require('../static_files.spec'); /** This suite starts a server on an open port on each test */ describe('Static asset handler (modular)', function () { - before(function () { + before(async function () { configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] }); - const app = appFactory({ hostIdentity: 'autotest', jwtSecret: 'swordfish', account: {}, storeRouter: (_req, _res, next) => next() }); + const app = await appFactory({ + hostIdentity: 'autotest', + jwtSecret: 'swordfish', + accountMgr: mockAccountFactory('autotest'), + storeRouter: (_req, _res, next) => next() + }); app.locals.title = 'Test Armadietto'; app.locals.host = 'localhost:xxxx'; app.locals.signup = false; @@ -17,4 +26,32 @@ describe('Static asset handler (modular)', function () { }); shouldServeStaticFiles(); + + it('should return security & caching headers', async function () { + const res = await chai.request(this.app).get('/assets/outfit-variablefont_wght.woff2'); + expect(res).to.have.status(200); + expect(res).to.have.header('Content-Security-Policy', /\bsandbox\b/); + expect(res).to.have.header('Content-Security-Policy', /\bdefault-src 'self';/); + expect(res).to.have.header('Content-Security-Policy', /\bscript-src 'self';.*\bscript-src-attr 'none';/); + expect(res).to.have.header('Content-Security-Policy', /\bstyle-src 'self';/); + expect(res).to.have.header('Content-Security-Policy', /\bimg-src 'self';/); + expect(res).to.have.header('Content-Security-Policy', /\bfont-src 'self';/); + expect(res).to.have.header('Content-Security-Policy', /\bobject-src 'none';/); + expect(res).to.have.header('Content-Security-Policy', /\bchild-src 'none';/); + expect(res).to.have.header('Content-Security-Policy', /\bconnect-src 'self';/); + expect(res).to.have.header('Content-Security-Policy', /\bbase-uri 'self';/); + expect(res).to.have.header('Content-Security-Policy', /\bframe-ancestors 'none';/); + expect(res).to.have.header('Content-Security-Policy', /\bform-action https: http:;/); + expect(res).to.have.header('Content-Security-Policy', /\bupgrade-insecure-requests/); + expect(res).to.have.header('Referrer-Policy', 'no-referrer'); + expect(res).to.have.header('Cross-Origin-Opener-Policy', 'same-origin'); + expect(res).to.have.header('Cross-Origin-Resource-Policy', 'same-origin'); + expect(res).to.have.header('X-Content-Type-Options', 'nosniff'); + expect(res).to.have.header('Strict-Transport-Security', /^max-age=/); + expect(res).not.to.have.header('X-Powered-By'); + expect(res).to.have.header('ETag'); + expect(res).to.have.header('Cache-Control', /max-age=\d{4}/); + expect(res).to.have.header('Content-Type', /^font\/woff2/); + expect(parseInt(res.get('Content-Length'))).to.be.greaterThan(20_000); + }); }); diff --git a/spec/modular/m_storage_common.spec.js b/spec/modular/m_storage_common.spec.js index 688683d5..5209ddfc 100644 --- a/spec/modular/m_storage_common.spec.js +++ b/spec/modular/m_storage_common.spec.js @@ -111,6 +111,7 @@ describe('Storage (modular)', function () { this.hostIdentity = 'testhost'; this.app = express(); + this.app.disable('x-powered-by'); this.app.use((_req, res, next) => { res.logNotes = new Set(); next(); @@ -265,9 +266,8 @@ describe('Storage (modular)', function () { it('returns Unauthorized w/ OAuth realm & scope but no error', async function () { const res = await chai.request(this.app).get('/storage/zebcoe/statuses/') .set('Origin', 'https://rs-app.com:2112').buffer(true); - expect(res).to.have.status(401); + expect(res).to.have.status(401); // never cacheable expect(res).to.have.header('Access-Control-Allow-Origin', 'https://rs-app.com:2112'); - expect(res.get('Cache-Control')).to.contain('no-cache'); expect(res).to.have.header('WWW-Authenticate', /^Bearer\b/); expect(res).to.have.header('WWW-Authenticate', /\srealm="127\.0\.0\.1:\d{1,5}"/); expect(res).to.have.header('WWW-Authenticate', /\sscope="statuses:r"/); diff --git a/spec/modular/m_web_finger.spec.js b/spec/modular/m_web_finger.spec.js index 3b145126..953debb4 100644 --- a/spec/modular/m_web_finger.spec.js +++ b/spec/modular/m_web_finger.spec.js @@ -1,23 +1,35 @@ /* eslint-env mocha, chai, node */ +const { mockAccountFactory } = require('../util/mockAccount'); const http = require('http'); const { configureLogger } = require('../../lib/logger'); const { shouldImplementWebFinger } = require('../web_finger.spec'); +const chai = require('chai'); +const expect = chai.expect; +chai.use(require('chai-http')); describe('Web Finger (modular)', function () { - before(function (done) { + before(async function () { configureLogger({ log_dir: './test-log', stdout: [], log_files: ['debug'] }); - this.app = require('../../lib/appFactory')({ jwtSecret: 'swordfish', account: {}, storeRouter: (_req, _res, next) => next() }); + this.app = await require('../../lib/appFactory')({ + hostIdentity: 'autotest.org', + jwtSecret: 'swordfish', + accountMgr: mockAccountFactory('autotest.org'), + storeRouter: (_req, _res, next) => next() + }); this.app.locals.title = 'Test Armadietto'; this.app.locals.host = 'localhost:xxxx'; this.app.locals.signup = false; this.server = http.createServer(this.app); this.server.listen(); - this.server.on('listening', () => { - this.port = this.server.address().port; - this.host = this.server.address().address + ':' + this.server.address().port; - done(); + + await new Promise(resolve => { + this.server.on('listening', () => { + this.port = this.server.address().port; + this.host = this.server.address().address + ':' + this.server.address().port; + resolve(); + }); }); }); @@ -26,4 +38,9 @@ describe('Web Finger (modular)', function () { }); shouldImplementWebFinger(); + + it('redirects change-password to /signup', async function () { + const res = await chai.request(this.app).get('/.well-known/change-password'); + expect(res).to.redirectTo(/^http:\/\/127.0.0.1:\d{1,5}\/signup$/); + }); }); diff --git a/spec/modular/protocols.spec.js b/spec/modular/protocols.spec.js new file mode 100644 index 00000000..d4082f34 --- /dev/null +++ b/spec/modular/protocols.spec.js @@ -0,0 +1,82 @@ +/* eslint-env mocha, chai, node */ +/* eslint no-unused-vars: ["warn", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }] */ +/* eslint-disable no-unused-expressions */ + +const chai = require('chai'); +const expect = chai.expect; +chai.use(require('chai-as-promised')); +const { assembleContactURL, calcContactURL } = require('../../lib/util/protocols'); +const ParameterError = require('../../lib/util/ParameterError'); + +describe('calcContactURL', function () { + it('should strip query but not strip hash from Signal URL', function () { + expect(calcContactURL('sgnl://signal.me/?foo=bar#p/+18005551212').href) + .to.equal('sgnl://signal.me/#p/+18005551212'); + }); + it('should strip hash but not query param "id" from Threema: URL', function () { + expect(calcContactURL('threema://compose?id=ABCDEFGH&text=Test%20Text#anotherHash').href) + .to.equal('threema://compose?id=ABCDEFGH'); + }); + it('should strip query and hash from FaceTime: URL', function () { + expect(calcContactURL('facetime:denise@place.us?subject=Something%20random#someHash').href) + .to.equal('facetime:denise@place.us'); + expect(calcContactURL('facetime:14085551234?subject=Something%20random#someHash').href) + .to.equal('facetime:14085551234'); + }); + it('should strip query and hash from Jabber URL', function () { + expect(calcContactURL('xmpp:username@domain.tld?subject=Something%20random#someHash').href) + .to.equal('xmpp:username@domain.tld'); + }); + it('should strip hash but not query param "chat" from Skype: URL', function () { + expect(calcContactURL('skype:username@domain.tld?add&topic=foo').href) + .to.equal('skype:username@domain.tld?chat'); + expect(calcContactURL('skype:+18885551212?topic=foo&chat').href) + .to.equal('skype:+18885551212?chat'); + }); + it('should strip query and hash from e-mail URL', function () { + expect(calcContactURL('mailto:denise@place.us?subject=Something%20random#someHash').href) + .to.equal('mailto:denise@place.us'); + }); + it('should change MMS URL to SMS and strip query and hash', function () { + expect(calcContactURL('mms:+15153755550?body=Hi%20there#someHash').href) + .to.equal('sms:+15153755550'); + }); + it('should strip hash but not query param "phone" from Whatsapp URL', function () { + expect(calcContactURL('whatsapp://send/?foo=bar&phone=447700900123#yetAnotherHash').href) + .to.equal('whatsapp://send/?phone=447700900123'); + }); + it('should strip hash but not query param "phone" or username from Telegram URL', function () { + expect(calcContactURL('tg://resolve?foo=bar&phone=19995551212#andAnotherHash').href) + .to.equal('tg://resolve?phone=19995551212'); + expect(calcContactURL('tg://resolve?foo=bar&domain=bobroberts#andAnotherHash').href) + .to.equal('tg://resolve?domain=bobroberts'); + }); +}); + +describe('assembleContactURL', function () { + it('should throw error when protocol missing', function () { + expect(() => assembleContactURL(undefined, '8885551212')).to.throw(ParameterError, /not supported/); + }); + it('should throw error when address missing', function () { + expect(() => assembleContactURL('sgnl:', undefined)).to.throw(ParameterError, /Missing address/); + }); + for (const protocol of [ + ['sgnl:', '(800) 555-1212', 'sgnl://signal.me/#p/+18005551212'], + ['threema:', 'ABCDEFGH', 'threema://compose?id=ABCDEFGH'], + ['facetime:', '+1 408 555-1234', 'facetime:+14085551234'], + ['facetime:', 'user@example.com', 'facetime:user@example.com'], + ['xmpp:', 'username@domain.tld', 'xmpp:username@domain.tld'], + ['skype:', 'username', 'skype:username?chat'], + ['skype:', '+1-888-999-7777', 'skype:+18889997777?chat'], + ['mailto:', 'me@myschool.edu', 'mailto:me@myschool.edu'], + ['sms:', '(888) 555 6666', 'sms:+18885556666'], + ['mms:', '(800) 555 6666', 'sms:+18005556666'], + ['whatsapp:', '+44 7700 900123', 'whatsapp://send/?phone=+447700900123'], + ['tg:', '1 (999) 555-1212', 'tg://resolve?phone=19995551212'], + ['tg:', 'bobroberts', 'tg://resolve?domain=bobroberts'] + ]) { + it(`should assemble ${protocol[0]} URL`, function () { + expect(assembleContactURL(protocol[0], protocol[1]).href).to.equal(protocol[2]); + }); + } +}); diff --git a/spec/modular/request_invite.spec.js b/spec/modular/request_invite.spec.js new file mode 100644 index 00000000..4b1c4677 --- /dev/null +++ b/spec/modular/request_invite.spec.js @@ -0,0 +1,213 @@ +/* eslint-env mocha, chai, node */ +/* eslint no-unused-vars: ["warn", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }] */ +/* eslint-disable no-unused-expressions */ + +const chai = require('chai'); +const expect = chai.expect; +chai.use(require('chai-spies')); +chai.use(require('chai-http')); +const { configureLogger } = require('../../lib/logger'); +const { shouldBlockSignups } = require('../signup.spec'); +const { mockAccountFactory } = require('../util/mockAccount'); +const appFactory = require('../../lib/appFactory'); +const path = require('path'); + +const INVITE_REQUEST_DIR = 'inviteRequests'; + +const mockAccount = mockAccountFactory('autotest'); + +describe('Request invite', function () { + describe('w/ request disabled', function () { + before(async function () { + configureLogger({ log_dir: './test-log', stdout: [], log_files: ['debug'] }); + + const storeRouter = (_req, _res, next) => next(); + storeRouter.upsertAdminBlob = chai.spy(); + const app = await appFactory({ + hostIdentity: 'autotest', + jwtSecret: 'swordfish', + accountMgr: mockAccount, + storeRouter, + adminDir: '/tmp/admin' + }); + app.locals.title = 'Test Armadietto'; + app.locals.host = 'localhost:xxxx'; + app.locals.signup = false; + this.app = app; + }); + + shouldBlockSignups(); + }); + + describe('w/ base path & request enabled', function () { + before(async function () { + configureLogger({ log_dir: './test-log', stdout: [], log_files: ['debug'] }); + + this.storeRouter = (_req, _res, next) => next(); + this.storeRouter.upsertAdminBlob = chai.spy(); + delete require.cache[require.resolve('../../lib/appFactory')]; + const app = await require('../../lib/appFactory')({ + hostIdentity: 'autotest.org', + jwtSecret: 'swordfish', + accountMgr: mockAccount, + storeRouter: this.storeRouter, + adminDir: '/tmp/admin', + basePath: '/basic' + }); + app.set('accountMgr', this.storeRouter); + app.locals.title = 'Test Armadietto'; + app.locals.host = 'localhost:xxxx'; + app.locals.signup = true; + this.app = app; + + this.username = 'john-' + Math.round(Math.random() * Number.MAX_SAFE_INTEGER); + }); + + after(function () { + delete require.cache[require.resolve('../../lib/appFactory')]; + }); + + it('redirects to the home page', async function () { + const res = await chai.request(this.app).get('/'); + expect(res).to.redirect; + expect(res).to.redirectTo(/http:\/\/127.0.0.1:\d{1,5}\/basic/); + expect(res).to.have.header('Cache-Control', /max-age=\d{4}/); + expect(res).to.have.header('Cache-Control', /public/); + }); + + it('returns a home page w/ invite request link', async function () { + const res = await chai.request(this.app).get('/basic/'); + expect(res).to.have.status(200); + expect(res).to.have.header('Content-Security-Policy', /sandbox.*default-src 'self'/); + expect(res).to.have.header('Referrer-Policy', 'no-referrer'); + expect(res).to.have.header('X-Content-Type-Options', 'nosniff'); + expect(res).to.be.html; + expect(res.text).to.match(/]*href="\/basic\/"[^>]*>Home<\/a>/); + expect(res.text).to.match(/]*href="\/basic\/account\/login"[^>]*>Log in<\/a>/); + expect(res.text).to.match(/]*href="\/basic\/signup"[^>]*>Request invite<\/a>/i); + expect(res.text).to.match(/Request an Invitation — Armadietto'); + expect(res.text).to.match(/Request an Invitation<\/h\d>/i); + expect(res.text).to.match(/
]*method="post"[^>]*action="\/basic\/signup"/); + expect(res.text).not.to.match(/]*type="text"[^>]*name="username"[^>]*value=""/); + expect(res.text).to.match(/>Protocol]*type="text"[^>]*name="address"[^>]*value=""/); + expect(res.text).not.to.match(/]*type="password"[^>]*name="password"[^>]*value=""/); + expect(res.text).to.match(/