From b1bcf2fced111043088a65b8b2a3c2b8e0cc660a Mon Sep 17 00:00:00 2001 From: Greg Perkins Date: Thu, 15 Feb 2018 09:00:18 -0700 Subject: [PATCH 1/3] tweak zip construction so it also works on linux and windows --- server/web/vault-api.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/server/web/vault-api.js b/server/web/vault-api.js index f1542b2..61acf16 100644 --- a/server/web/vault-api.js +++ b/server/web/vault-api.js @@ -30,6 +30,7 @@ class VaultAPIv1 extends APIHandler { } async onGetExport(req, res, next) { + const perm644 = parseInt('0644', 8); delete req.query['offset']; delete req.query['limit']; @@ -42,18 +43,18 @@ class VaultAPIv1 extends APIHandler { res.attachment(`${topdir}.zip`); const csvdoc = await exputil.csvDoc({rows, tzoffset}); - zip.addFile(`${topdir}/messages-csv.csv`, new Buffer(csvdoc), "CSV for selected messages"); + zip.addFile(`${topdir}/messages-csv.csv`, new Buffer(csvdoc), 'CSV for selected messages', perm644); const htmldoc = exputil.htmlDoc({query: req.query, exportDate: exputil.offsetTimeStr(moment(), tzoffset), rows, tzoffset}); - zip.addFile(`${topdir}/messages-html.html`, new Buffer(htmldoc), "HTML for selected messages"); + zip.addFile(`${topdir}/messages-html.html`, new Buffer(htmldoc), 'HTML for selected messages', perm644); const jsondoc = exputil.jsonDoc({query: req.query, exportDate: exputil.offsetTimeStr(moment(), tzoffset), rows, tzoffset}); - zip.addFile(`${topdir}/messages-json.json`, new Buffer(jsondoc), "JSON for selected messages"); + zip.addFile(`${topdir}/messages-json.json`, new Buffer(jsondoc), 'JSON for selected messages', perm644); for (const row of rows) { const attachments = await this.fetchAttachments(row); for (const a of attachments) { - zip.addFile(`${topdir}/attachments/${row.messageId}/${a.id}/${a.attachment.name}`, a.attachment.data, "attachment"); + zip.addFile(`${topdir}/attachments/${row.messageId}/${a.id}/${a.attachment.name}`, a.attachment.data, 'message attachment', perm644); } } From 18ab2eb02421e546ffd6b3c02797866668cdf4a2 Mon Sep 17 00:00:00 2001 From: Greg Perkins Date: Wed, 14 Feb 2018 15:46:36 -0700 Subject: [PATCH 2/3] upgrade from password to two-factor authentication --- client/auth/authenticate.vue | 137 --------------------------------- client/auth/loginCode.vue | 111 ++++++++++++++++++++++++++ client/auth/loginTag.vue | 124 +++++++++++++++++++++++++++++ client/auth/setPassword.vue | 114 --------------------------- client/dashboard/dashboard.vue | 4 +- client/globalState.js | 8 ++ client/main.js | 8 +- client/onboard/enterCode.vue | 4 +- client/onboard/enterTag.vue | 2 +- client/root.vue | 2 +- client/util.js | 2 +- client/welcome/welcome.vue | 4 +- package.json | 1 - server/atlas_client.js | 1 + server/forsta_bot.js | 100 ++++++++++++++++++++++++ server/web/api.js | 81 +++++++------------ 16 files changed, 385 insertions(+), 318 deletions(-) delete mode 100644 client/auth/authenticate.vue create mode 100644 client/auth/loginCode.vue create mode 100644 client/auth/loginTag.vue delete mode 100644 client/auth/setPassword.vue diff --git a/client/auth/authenticate.vue b/client/auth/authenticate.vue deleted file mode 100644 index f28b806..0000000 --- a/client/auth/authenticate.vue +++ /dev/null @@ -1,137 +0,0 @@ - - - - \ No newline at end of file diff --git a/client/auth/loginCode.vue b/client/auth/loginCode.vue new file mode 100644 index 0000000..e9897ff --- /dev/null +++ b/client/auth/loginCode.vue @@ -0,0 +1,111 @@ + + + + + \ No newline at end of file diff --git a/client/auth/loginTag.vue b/client/auth/loginTag.vue new file mode 100644 index 0000000..0b07579 --- /dev/null +++ b/client/auth/loginTag.vue @@ -0,0 +1,124 @@ + + + + + \ No newline at end of file diff --git a/client/auth/setPassword.vue b/client/auth/setPassword.vue deleted file mode 100644 index 71d4ab3..0000000 --- a/client/auth/setPassword.vue +++ /dev/null @@ -1,114 +0,0 @@ - - - - \ No newline at end of file diff --git a/client/dashboard/dashboard.vue b/client/dashboard/dashboard.vue index db392da..0da3e0f 100644 --- a/client/dashboard/dashboard.vue +++ b/client/dashboard/dashboard.vue @@ -115,7 +115,7 @@
- + {{threadTitle(m)}}
{{m.distribution.pretty}}
@@ -454,7 +454,7 @@ module.exports = { }); if (!this.global.apiToken) { - this.$router.push({ name: 'authenticate', query: { forwardTo: this.$router.path }}); + this.$router.push({ name: 'loginTag', query: { forwardTo: this.$router.path }}); return; } diff --git a/client/globalState.js b/client/globalState.js index 487ed93..7b449a2 100644 --- a/client/globalState.js +++ b/client/globalState.js @@ -3,6 +3,14 @@ var jwtDecode = require('jwt-decode'); var state = { onboardStatus: undefined, passwordSet: undefined, + userId: undefined, + get loginTag() { + return localStorage.getItem('loginTag') || ''; + }, + set loginTag(value) { + if (value) localStorage.setItem('loginTag', value); + else localStorage.removeItem('loginTag'); + }, get apiToken() { const retval = localStorage.getItem('apiToken') || ''; if (retval != this.prev) autoexpire(retval); diff --git a/client/main.js b/client/main.js index 47a6b40..2e8c51a 100644 --- a/client/main.js +++ b/client/main.js @@ -7,10 +7,10 @@ function main() { const Root = require('./root.vue'); const routes = [ { path: '/welcome', name: 'welcome', component: require('./welcome/welcome.vue') }, - { path: '/auth/login', name: 'authenticate', component: require('./auth/authenticate.vue') }, - { path: '/auth/password', name: 'setPassword', component: require('./auth/setPassword.vue') }, - { path: '/onboard/tag', name: 'enterTag', component: require('./onboard/enterTag.vue') }, - { path: '/onboard/code/:tag', name: 'enterCode', component: require('./onboard/enterCode.vue') }, + { path: '/auth/tag', name: 'loginTag', component: require('./auth/loginTag.vue') }, + { path: '/auth/code', name: 'loginCode', component: require('./auth/loginCode.vue') }, + { path: '/onboard/tag', name: 'onboardTag', component: require('./onboard/enterTag.vue') }, + { path: '/onboard/code/:tag', name: 'onboardCode', component: require('./onboard/enterCode.vue') }, { path: '/dashboard', name: 'dashboard', component: require('./dashboard/dashboard.vue') }, { path: '*', redirect: '/welcome' }, ]; diff --git a/client/onboard/enterCode.vue b/client/onboard/enterCode.vue index 4fd6c7c..78aca11 100644 --- a/client/onboard/enterCode.vue +++ b/client/onboard/enterCode.vue @@ -7,7 +7,7 @@

- Enter Login Code + Enter Forsta Login Code

@@ -21,7 +21,7 @@
- Cancel + Cancel
diff --git a/client/onboard/enterTag.vue b/client/onboard/enterTag.vue index 55224e7..1e30328 100644 --- a/client/onboard/enterTag.vue +++ b/client/onboard/enterTag.vue @@ -85,7 +85,7 @@ function requestAuth() { .then(result => { this.loading = false; if (result.ok) { - this.$router.push({ name: 'enterCode', params: { tag: this.tag }}); + this.$router.push({ name: 'onboardCode', params: { tag: this.tag }}); return false; } else { util.addFormErrors('enter-tag', { tag: util.mergeErrors(result.theJson) }); diff --git a/client/root.vue b/client/root.vue index b3eaec4..91a734a 100644 --- a/client/root.vue +++ b/client/root.vue @@ -30,7 +30,7 @@ module.exports = { globalApiToken: function (next, prev) { if (!next && prev) { console.log('reauthenticating for', this.$route.path) - this.$router.push({ name: 'authenticate', query: { forwardTo: this.$route.path }}); + this.$router.push({ name: 'loginTag', query: { forwardTo: this.$route.path }}); } } }, diff --git a/client/util.js b/client/util.js index 79d0bf5..6f2207d 100644 --- a/client/util.js +++ b/client/util.js @@ -45,7 +45,7 @@ async function _fetch(url, { method='get', headers={}, body={} }={}, noBodyAwait } if (resp.status === 401) { console.log('401 from bot api, so we will visit bot authentication...'); - this.$router.push({ name: 'authenticate', query: { forwardTo: this.$route.fullPath }}); + this.$router.push({ name: 'loginTag', query: { forwardTo: this.$route.fullPath }}); // throw Error('not authenticated with bot server -- looping through authentication'); } return resp; diff --git a/client/welcome/welcome.vue b/client/welcome/welcome.vue index 239830e..c114ca1 100644 --- a/client/welcome/welcome.vue +++ b/client/welcome/welcome.vue @@ -19,7 +19,7 @@
- CONNECT @@ -47,7 +47,7 @@ module.exports = { global: shared.state }), mounted: function() { - const authDash = { name: 'authenticate', query: { forwardTo: '/dashboard' }}; + const authDash = { name: 'loginTag', query: { forwardTo: '/dashboard' }}; if (this.global.onboardStatus === 'complete') { this.$router.push(authDash); return; diff --git a/package.json b/package.json index 39b1097..cd1bc28 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ }, "dependencies": { "adm-zip": "^0.4.7", - "bcrypt": "^1.0.3", "browserify": "^14.5.0", "csv-stringify": "2.0.0", "envify": "^4.1.0", diff --git a/server/atlas_client.js b/server/atlas_client.js index 10c70ce..adce530 100644 --- a/server/atlas_client.js +++ b/server/atlas_client.js @@ -20,6 +20,7 @@ class BotAtlasClient extends relay.AtlasClient { ); const creator = `@${botUser.tag.slug}:${botUser.org.slug}`; console.info(`Bot onboarding performed by: ${creator}`); + await relay.storage.set('authentication', 'adminIds', [botUser.id]); await relay.storage.putState("onboardUser", botUser.id); if (this.onboardingCreatedUser) { try { diff --git a/server/forsta_bot.js b/server/forsta_bot.js index 458c726..00d334d 100644 --- a/server/forsta_bot.js +++ b/server/forsta_bot.js @@ -8,6 +8,10 @@ const relay = require("librelay"); const PGStore = require("./pgstore"); const uuid4 = require("uuid/v4"); +async function sleep(ms) { + return await new Promise(resolve => setTimeout(resolve, ms)); +} + class ForstaBot { async start() { const ourId = await relay.storage.getState('addr'); @@ -15,6 +19,7 @@ class ForstaBot { console.warn('bot is not yet registered'); return; } + await this.migrate(); console.info('Starting message receiver for:', ourId); this.pgStore = new PGStore('message_vault'); await this.pgStore.initialize(); @@ -41,6 +46,17 @@ class ForstaBot { await this.msgReceiver.connect(); } + async migrate() { + const adminIds = await relay.storage.get('authentication', 'adminIds'); + if (!adminIds) { + // need to convert from a password-auth sort of bot to the new auth-code system + const onboardUser = await relay.storage.getState("onboardUser"); + if (!onboardUser) return; // should never happen, but just in case.. + await relay.storage.set('authentication', 'adminIds', [onboardUser]); + await relay.storage.remove('authentication', 'pwhash'); + } + } + async stop() { if (this.msgReceiver) { console.warn('Stopping message receiver'); @@ -127,6 +143,90 @@ class ForstaBot { }); } } + + async getSoloAuthThreadId() { + let id = await relay.storage.get('authentication', 'soloThreadId'); + if (!id) { + id = uuid4(); + relay.storage.set('authentication', 'soloThreadId', id); + } + + return id; + } + + async getGroupAuthThreadId() { + let id = await relay.storage.get('authentication', 'groupThreadId'); + if (!id) { + id = uuid4(); + relay.storage.set('authentication', 'groupThreadId', id); + } + + return id; + } + + genAuthCode(expirationMinutes) { + const code = ('000000' + Math.floor(Math.random() * 1000000)).slice(-6); + const expires = new Date(); + expires.setMinutes(expires.getMinutes() + expirationMinutes); + return { code, expires }; + } + + removeExpiredAuthCodes(pending) { + const now = new Date(); + + Object.keys(pending).forEach(uid => { + pending[uid].expires = new Date(pending[uid].expires); // todo: fix store encoding... + if (pending[uid].expires < now) { + delete pending[uid]; + } + }); + + return pending; + } + + async sendAuthCode(tag) { + tag = (tag && tag[0] === '@') ? tag : '@' + tag; + const resolved = await this.resolveTags(tag); + if (resolved.userids.length === 1 && resolved.warnings.length === 0) { + const uid = resolved.userids[0]; + const adminIds = await relay.storage.get('authentication', 'adminIds'); + if (!adminIds.includes(uid)) { + throw { statusCode: 403, info: { tag: ['not an approved administrator'] } }; + } + + const auth = this.genAuthCode(1); + this.msgSender.send({ + distribution: resolved, + threadId: await this.getSoloAuthThreadId(), + text: `${auth.code} is your authentication code, valid for one minute` + }); + const pending = await relay.storage.get('authentication', 'pending', {}); + pending[uid] = auth; + await relay.storage.set('authentication', 'pending', pending); + + return resolved.userids[0]; + } else { + throw { statusCode: 400, info: { tag: ['not a recognized tag, please try again'] } }; + } + } + + async validateAuthCode(userId, code) { + let pending = await relay.storage.get('authentication', 'pending', {}); + pending = this.removeExpiredAuthCodes(pending); + const auth = pending[userId]; + if (!auth) { + throw { statusCode: 403, info: { code: ['no authentication pending, please start over'] } }; + } + if (auth.code != code) { + await sleep(500); // throttle guessers + throw { statusCode: 403, info: { code: ['incorrect code, please try again'] } }; + } + + delete pending[userId]; + relay.storage.set('authentication', 'pending', pending); + + return true; + } } module.exports = ForstaBot; diff --git a/server/web/api.js b/server/web/api.js index 6eebe66..200546b 100644 --- a/server/web/api.js +++ b/server/web/api.js @@ -3,11 +3,8 @@ const csvStringify = require('csv-stringify'); const express = require('express'); const relay = require('librelay'); const jwt = require('jsonwebtoken'); -const bcrypt = require('bcrypt'); const uuidv4 = require('uuid/v4'); -const bcryptSaltRounds = 12; - class APIHandler { @@ -159,10 +156,10 @@ class AuthenticationAPIV1 extends APIHandler { constructor(options) { super(options); - this.router.get('/status/v1', this.asyncRoute(this.onGetStatus, false)); - this.router.post('/login/v1', this.asyncRoute(this.onLogin, false)); - this.router.post('/password/v1', this.asyncRoute(this.onPost, false)); - this.router.put('/password/v1', this.asyncRoute(this.onPut)); + this.router.get('/login/v1/:tag', this.asyncRoute(this.onRequestLoginCode, false)); + this.router.post('/login/v1', this.asyncRoute(this.onValidateLoginCode, false)); + // this.router.get('/admins/v1', this.asyncRoute(this.onGetAdministrators)); + // this.router.put('/admins/v1', this.asyncRoute(this.onPutAdministrators)); } async genToken() { @@ -174,58 +171,36 @@ class AuthenticationAPIV1 extends APIHandler { return jwt.sign({}, secret, { algorithm: "HS512", expiresIn: 2*60*60 /* later: "2 days" */ }); } - async passwordHash(hash) { - if (hash) { - return await relay.storage.set('authentication', 'pwhash', hash); - } else { - return await relay.storage.get('authentication', 'pwhash'); - } - } - - async onGetStatus(req, res) { - const stashedHash = await this.passwordHash(); - if (stashedHash) { - res.status(204).json({ }); - } else { - res.status(404).json({error: 'not_configured'}); + async onRequestLoginCode(req, res) { + const tag = req.params.tag; + if (!tag) { + res.status(412).json({ + error: 'missing_arg', + message: 'Missing URL param: tag' + }); + return; } - } - - async onLogin(req, res) { - const password = req.body.password; - const stashedHash = await this.passwordHash(); - if (!stashedHash || bcrypt.compareSync(password, stashedHash)) { - // yes, if there is no stashed password hash, we give them a session - const token = await this.genToken(); - res.status(200).json({ token }); - } else { - res.status(401).json({ password: 'incorrect password' }); + try { + const id = await this.server.bot.sendAuthCode(tag); + res.status(200).json({ id }); + } catch (e) { + res.status(e.statusCode).json(e.info); + return; } + return; } - async onPost(req, res) { - const exists = !!await this.passwordHash(); - if (!exists) { - const password = req.body.password; - const hash = await bcrypt.hash(password, bcryptSaltRounds); - this.passwordHash(hash); - const token = await this.genToken(); - res.status(201).json({ token }); - } else { - res.status(405).json({ password: 'already exists' }); - } - } + async onValidateLoginCode(req, res) { + const userId = req.body.id; + const code = req.body.code; - async onPut(req, res) { - const exists = !!this.passwordHash(); - if (exists) { - const password = req.body.password; - const hash = await bcrypt.hash(password, bcryptSaltRounds); - this.passwordHash(hash); + try { + await this.server.bot.validateAuthCode(userId, code); const token = await this.genToken(); - res.status(201).json({ token }); - } else { - res.status(405).json({ password: 'does not exist' }); + res.status(200).json({ token }); + } catch (e) { + res.status(e.statusCode).json(e.info); + return; } } } From 4b6b70c1a2718b23580a109c455d8bee7b20d1a6 Mon Sep 17 00:00:00 2001 From: Greg Perkins Date: Fri, 16 Feb 2018 05:04:17 -0700 Subject: [PATCH 3/3] add settings page and management of administrators --- client/dashboard/dashboard.vue | 25 ++----- client/main.js | 1 + client/menu/top.vue | 37 +++++----- client/settings/settings.vue | 129 +++++++++++++++++++++++++++++++++ client/util.js | 18 ++++- server/forsta_bot.js | 74 ++++++++++++++++++- server/web/api.js | 55 +++++++++++--- 7 files changed, 289 insertions(+), 50 deletions(-) create mode 100644 client/settings/settings.vue diff --git a/client/dashboard/dashboard.vue b/client/dashboard/dashboard.vue index 0da3e0f..5f0742b 100644 --- a/client/dashboard/dashboard.vue +++ b/client/dashboard/dashboard.vue @@ -79,7 +79,7 @@ user-select: none; } div.pager .active-page { - color: #FF8888; + color: #FF6666; } div.zero { @@ -193,7 +193,7 @@

Current Filters

- + {{v.presentation}}
@@ -392,14 +392,14 @@ module.exports = { const q = this.queryString; util.fetch.call(this, '/api/vault/messages/v1?' + q) .then(result => { - this.messages = result.theJson.messages.forEach(m => { + this.messages = result.theJson.messages; + this.messages.forEach(m => { m.receivedMoment = moment(m.received); m.receivedText = m.receivedMoment.format('llll'); if (m.recipientIds.length <= 5 && !(m.messageId in this.showDist)) { this.$set(this.showDist, m.messageId, true); } }); - this.messages = result.theJson.messages; this.fullCount = (this.messages.length && this.messages[0].fullCount) || 0; }); }, @@ -441,22 +441,7 @@ module.exports = { } }, mounted: function() { - if (this.global.onboardStatus !== 'complete') { - this.$router.push({ name: 'welcome' }); - return; - } - util.fetch.call(this, '/api/onboard/status/v1') - .then(result => { - this.global.onboardStatus = result.theJson.status; - if (this.global.onboardStatus !== 'complete') { - this.$router.push({ name: 'welcome' }); - } - }); - - if (!this.global.apiToken) { - this.$router.push({ name: 'loginTag', query: { forwardTo: this.$router.path }}); - return; - } + util.checkPrerequisites.call(this); this.getMessages(); this.interval = setInterval(() => this.getMessages(), REFRESH_POLL_RATE); diff --git a/client/main.js b/client/main.js index 2e8c51a..9dacf58 100644 --- a/client/main.js +++ b/client/main.js @@ -12,6 +12,7 @@ function main() { { path: '/onboard/tag', name: 'onboardTag', component: require('./onboard/enterTag.vue') }, { path: '/onboard/code/:tag', name: 'onboardCode', component: require('./onboard/enterCode.vue') }, { path: '/dashboard', name: 'dashboard', component: require('./dashboard/dashboard.vue') }, + { path: '/settings', name: 'settings', component: require('./settings/settings.vue') }, { path: '*', redirect: '/welcome' }, ]; diff --git a/client/menu/top.vue b/client/menu/top.vue index 0fbda68..fcdbffb 100644 --- a/client/menu/top.vue +++ b/client/menu/top.vue @@ -1,6 +1,3 @@ - - \ No newline at end of file diff --git a/client/settings/settings.vue b/client/settings/settings.vue new file mode 100644 index 0000000..2b83f2b --- /dev/null +++ b/client/settings/settings.vue @@ -0,0 +1,129 @@ + + + + + + \ No newline at end of file diff --git a/client/util.js b/client/util.js index 6f2207d..519df5c 100644 --- a/client/util.js +++ b/client/util.js @@ -51,9 +51,25 @@ async function _fetch(url, { method='get', headers={}, body={} }={}, noBodyAwait return resp; } +function checkPrerequisites() { + _fetch.call(this, '/api/onboard/status/v1') + .then(result => { + this.global.onboardStatus = result.theJson.status; + if (this.global.onboardStatus !== 'complete') { + this.$router.push({ name: 'welcome' }); + } + }); + + if (!this.global.apiToken) { + this.$router.push({ name: 'loginTag', query: { forwardTo: this.$router.path }}); + return; + } +} + module.exports = { addFormErrors, mergeErrors, RequestError, - fetch: _fetch + fetch: _fetch, + checkPrerequisites }; diff --git a/server/forsta_bot.js b/server/forsta_bot.js index 00d334d..32edb01 100644 --- a/server/forsta_bot.js +++ b/server/forsta_bot.js @@ -86,6 +86,7 @@ class ForstaBot { } fqLabel(user) { return `${this.fqTag(user)} (${this.fqName(user)})`; } + async onMessage(ev) { const received = new Date(ev.data.timestamp); const envelope = JSON.parse(ev.data.message.body); @@ -197,7 +198,7 @@ class ForstaBot { const auth = this.genAuthCode(1); this.msgSender.send({ distribution: resolved, - threadId: await this.getSoloAuthThreadId(), + threadId: await this.getGroupAuthThreadId(), text: `${auth.code} is your authentication code, valid for one minute` }); const pending = await relay.storage.get('authentication', 'pending', {}); @@ -225,8 +226,79 @@ class ForstaBot { delete pending[userId]; relay.storage.set('authentication', 'pending', pending); + await this.broadcastNotice('has successfully authenticated as an administrator', userId); return true; } + + async getAdministrators() { + const adminIds = await relay.storage.get('authentication', 'adminIds', []); + const adminUsers = await this.getUsers(adminIds); + const admins = adminUsers.map(u => { + return { + id: u.id, + label: this.fqLabel(u) + }; + }); + return admins; + } + + async broadcastNotice(action, actorUserId) { + const adminIds = await relay.storage.get('authentication', 'adminIds', []); + let added = false; + if (!adminIds.includes(actorUserId)) { + adminIds.push(actorUserId); + added = true; + } + const adminUsers = await this.getUsers(adminIds); + const actor = adminUsers.find(u => u.id === actorUserId); + const actorLabel = actor ? this.fqLabel(actor) : ''; + const expression = adminUsers.map(u => this.fqTag(u)).join(' + '); + const distribution = await this.resolveTags(expression); + + const adminList = adminUsers.filter(u => !(added && u.id === actorUserId)).map(u => this.fqLabel(u)).join('\n'); + + const fullMessage = `Note: ${actorLabel} ${action}.\n\nCurrent administrators are:\n${adminList}`; + const subbedFullMessage = fullMessage.replace(/<<([^>]*)>>/g, (_, id) => { + const user = adminUsers.find(x => x.id === id); + return this.fqLabel(user); + }); + + this.msgSender.send({ + distribution, + threadId: await this.getSoloAuthThreadId(), + text: subbedFullMessage + }); + } + + async addAdministrator({addTag, actorUserId}) { + const tag = (addTag && addTag[0] === '@') ? addTag : '@' + addTag; + const resolved = await this.resolveTags(tag); + if (resolved.userids.length === 1 && resolved.warnings.length === 0) { + const uid = resolved.userids[0]; + const adminIds = await relay.storage.get('authentication', 'adminIds'); + if (!adminIds.includes(uid)) { + adminIds.push(uid); + await relay.storage.set('authentication', 'adminIds', adminIds); + } + await this.broadcastNotice(`has added <<${uid}>> to the administrator list`, actorUserId); + return this.getAdministrators(); + } + throw { statusCode: 400, info: { tag: ['not a recognized tag, please try again'] } }; + } + + async removeAdministrator({removeId, actorUserId}) { + const adminIds = await relay.storage.get('authentication', 'adminIds', []); + const idx = adminIds.indexOf(removeId); + + if (idx < 0) { + throw { statusCode: 400, info: { id: ['administrator id not found'] } }; + } + adminIds.splice(idx, 1); + await this.broadcastNotice(`is removing <<${removeId}>> from the administrator list`, actorUserId); + await relay.storage.set('authentication', 'adminIds', adminIds); + + return this.getAdministrators(); + } } module.exports = ForstaBot; diff --git a/server/web/api.js b/server/web/api.js index 200546b..284488e 100644 --- a/server/web/api.js +++ b/server/web/api.js @@ -27,8 +27,8 @@ class APIHandler { relay.storage.get('authentication', 'jwtsecret') .then((secret) => { try { - jwt.verify(parts[1], secret); - fn.call(this, req, res, next).catch(e => { + const jwtInfo = jwt.verify(parts[1], secret); + fn.call(this, req, res, next, jwtInfo.userId).catch(e => { console.error('Async Route Error:', e); next(); }); @@ -158,17 +158,17 @@ class AuthenticationAPIV1 extends APIHandler { super(options); this.router.get('/login/v1/:tag', this.asyncRoute(this.onRequestLoginCode, false)); this.router.post('/login/v1', this.asyncRoute(this.onValidateLoginCode, false)); - // this.router.get('/admins/v1', this.asyncRoute(this.onGetAdministrators)); - // this.router.put('/admins/v1', this.asyncRoute(this.onPutAdministrators)); + this.router.get('/admins/v1', this.asyncRoute(this.onGetAdministrators)); + this.router.post('/admins/v1', this.asyncRoute(this.onUpdateAdministrators)); } - async genToken() { + async genToken(userId) { let secret = await relay.storage.get('authentication', 'jwtsecret'); if (!secret) { secret = uuidv4(); await relay.storage.set('authentication', 'jwtsecret', secret); } - return jwt.sign({}, secret, { algorithm: "HS512", expiresIn: 2*60*60 /* later: "2 days" */ }); + return jwt.sign({ userId }, secret, { algorithm: "HS512", expiresIn: 2*60*60 /* later: "2 days" */ }); } async onRequestLoginCode(req, res) { @@ -183,11 +183,11 @@ class AuthenticationAPIV1 extends APIHandler { try { const id = await this.server.bot.sendAuthCode(tag); res.status(200).json({ id }); + return; } catch (e) { - res.status(e.statusCode).json(e.info); + res.status(e.statusCode || 500).json(e.info || { message: 'internal error'}); return; } - return; } async onValidateLoginCode(req, res) { @@ -196,10 +196,45 @@ class AuthenticationAPIV1 extends APIHandler { try { await this.server.bot.validateAuthCode(userId, code); - const token = await this.genToken(); + const token = await this.genToken(userId); res.status(200).json({ token }); + return; + } catch (e) { + res.status(e.statusCode || 500).json(e.info || { message: 'internal error'}); + return; + } + } + + async onGetAdministrators(req, res) { + try { + const admins = await this.server.bot.getAdministrators(); + res.status(200).json({ administrators: admins }); + return; + } catch (e) { + console.log('problem in get administrators', e); + res.status(e.statusCode || 500).json(e.info || { message: 'internal error'}); + return; + } + } + + async onUpdateAdministrators(req, res, next, userId) { + const op = req.body.op; + const id = req.body.id; + const tag = req.body.tag; + + if (!(op === 'add' && tag || op === 'remove' && id)) { + res.status(400).json({ non_field_errors: ['must either add tag or remove id'] }); + } + + try { + const admins = (op === 'add') + ? await this.server.bot.addAdministrator({addTag: tag, actorUserId: userId}) + : await this.server.bot.removeAdministrator({removeId: id, actorUserId: userId}); + res.status(200).json({ administrators: admins }); + return; } catch (e) { - res.status(e.statusCode).json(e.info); + console.log('problem in update administrators', e); + res.status(e.statusCode || 500).json(e.info || { message: 'internal error'}); return; } }