From 61248119c5756b68bae8888f1580c47d64cc3a59 Mon Sep 17 00:00:00 2001 From: Evert Date: Thu, 7 Dec 2017 17:37:36 +0200 Subject: [PATCH] A lot of user actions, add CSRF tokens to all POSTs --- server/api/admin.js | 64 ++++++++++++++--- server/api/emailer.js | 4 ++ server/api/index.js | 37 ++++++---- server/routes/admin.js | 100 +++++++++++++++++++------- src/script/component/BanList.vue | 8 ++- src/script/component/ClientModal.vue | 10 ++- src/script/component/OAuthClients.vue | 8 ++- src/script/component/User.vue | 21 +++++- src/script/component/UserList.vue | 53 +++++++++++++- src/script/component/UserModal.vue | 93 ++++++++++++++++++++++++ src/style/admin.styl | 5 +- src/style/main.styl | 4 ++ templates/reset_password/html.pug | 1 - templates/reset_password/subject.pug | 2 +- 14 files changed, 347 insertions(+), 63 deletions(-) create mode 100644 src/script/component/UserModal.vue diff --git a/server/api/admin.js b/server/api/admin.js index c2aa95f..1ced840 100644 --- a/server/api/admin.js +++ b/server/api/admin.js @@ -3,7 +3,9 @@ import Models from './models' const perPage = 6 -function cleanUserObject (dbe, admin) { +async function cleanUserObject (dbe, admin) { + let totp = await Users.User.Login.totpTokenRequired(dbe) + return { id: dbe.id, username: dbe.username, @@ -17,7 +19,8 @@ function cleanUserObject (dbe, admin) { password: dbe.password !== null, nw_privilege: dbe.nw_privilege, created_at: dbe.created_at, - bannable: dbe.nw_privilege < admin.nw_privilege && dbe.id !== admin.id + totp_enabled: totp, + bannable: admin ? (dbe.nw_privilege < admin.nw_privilege && dbe.id !== admin.id) : false } } @@ -96,7 +99,7 @@ const API = { for (let i in raw) { let entry = raw[i] - users.push(cleanUserObject(entry, admin)) + users.push(await cleanUserObject(entry, admin)) } return { @@ -104,6 +107,53 @@ const API = { users: users } }, + getUser: async function (id) { + let user = await Users.User.get(id) + if (!user) throw new Error('No such user') + + return cleanUserObject(user, null) + }, + editUser: async function (id, data) { + let user = await Users.User.get(id) + if (!user) throw new Error('No such user') + + let fields = [ + 'username', 'display_name', 'email', 'nw_privilege', 'activated' + ] + + data = dataFilter(data, fields, ['nw_privilege', 'activated']) + if (!data) throw new Error('Missing fields') + + await Users.User.update(user, data) + + return {} + }, + resendActivationEmail: async function (id) { + let user = await Users.User.get(id) + if (!user) throw new Error('No such user') + + if (user.activated === 1) return {} + + await Users.User.Register.activationEmail(user) + + return {} + }, + revokeTotpToken: async function (id) { + let user = await Users.User.get(id) + if (!user) throw new Error('No such user') + + await Models.TotpToken.query().delete().where('user_id', user.id) + + return {} + }, + sendPasswordEmail: async function (id) { + let user = await Users.User.get(id) + if (!user) throw new Error('No such user') + + let token = await Users.User.Reset.reset(user.email, false, true) + + return {token} + }, // List all clients (paginated) getAllClients: async function (page) { let count = await Models.OAuth2Client.query().count('id as ids') @@ -123,14 +173,13 @@ const API = { } return { - page: paginated, - clients: clients + page: paginated, clients } }, // Get information about a client via id getClient: async function (id) { let raw = await Models.OAuth2Client.query().where('id', id) - if (!raw.length) return null + if (!raw.length) throw new Error('No such client') return cleanClientObject(raw[0]) }, @@ -211,8 +260,7 @@ const API = { } return { - page: paginated, - bans: bans + page: paginated, bans } }, // Remove a ban diff --git a/server/api/emailer.js b/server/api/emailer.js index 0135742..89b9109 100644 --- a/server/api/emailer.js +++ b/server/api/emailer.js @@ -4,6 +4,10 @@ import nodemailer from 'nodemailer' import config from '../../scripts/load-config' +// TEMPORARY FIX FOR NODE v9.2.0 +import tls from 'tls' +tls.DEFAULT_ECDH_CURVE = 'auto' + const templateDir = path.join(__dirname, '../../', 'templates') let templateCache = {} diff --git a/server/api/index.js b/server/api/index.js index d7df691..3dbd4da 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -372,8 +372,16 @@ const API = { // Create user let user = await models.User.query().insert(data) + if (email) { + await API.User.Register.activationEmail(user, true) + } + + return user + }, + activationEmail: async function (user, deleteOnFail = false) { // Activation token let activationToken = API.Hash(16) + await models.Token.query().insert({ expires_at: new Date(Date.now() + 86400000), // 1 day token: activationToken, @@ -381,25 +389,28 @@ const API = { type: 1 }) - // Send Activation Email console.debug('Activation token:', activationToken) - if (email) { - try { - let em = await emailer.pushMail('activate', user.email, { - domain: config.server.domain, - display_name: user.display_name, - activation_token: activationToken - }) - console.debug(em) - } catch (e) { - console.error(e) + // Send Activation Email + try { + let em = await emailer.pushMail('activate', user.email, { + domain: config.server.domain, + display_name: user.display_name, + activation_token: activationToken + }) + + console.debug(em) + } catch (e) { + console.error(e) + + if (deleteOnFail) { await models.User.query().delete().where('id', user.id) - throw new Error('Invalid email address!') } + + throw new Error('Invalid email address!') } - return user + return true } }, Reset: { diff --git a/server/routes/admin.js b/server/routes/admin.js index 4ab3a46..f8c6eb5 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -88,6 +88,19 @@ router.get('/oauth2', wrap(async (req, res) => { * ======= */ +function csrfVerify (req, res, next) { + if (req.body.csrf !== req.session.csrf) { + return next(new Error('Invalid session')) + } + + next() +} + +/* ============= + * User Data + * ============= + */ + apiRouter.get('/users', wrap(async (req, res) => { let page = parseInt(req.query.page) if (isNaN(page) || page < 1) { @@ -98,6 +111,51 @@ apiRouter.get('/users', wrap(async (req, res) => { res.jsonp(users) })) +apiRouter.get('/user/:id', wrap(async (req, res) => { + let id = parseInt(req.params.id) + if (isNaN(id)) { + throw new Error('Invalid number') + } + + res.jsonp(await API.getUser(id)) +})) + +apiRouter.post('/user', csrfVerify, wrap(async (req, res) => { + let id = parseInt(req.body.user_id) + if (isNaN(id)) { + throw new Error('Invalid or missing user ID') + } + + res.jsonp(await API.editUser(id, req.body)) +})) + +apiRouter.post('/user/resend_activation', csrfVerify, wrap(async (req, res) => { + let id = parseInt(req.body.user_id) + if (isNaN(id)) { + throw new Error('Invalid or missing user ID') + } + + res.jsonp(await API.resendActivationEmail(id)) +})) + +apiRouter.post('/user/revoke_totp', csrfVerify, wrap(async (req, res) => { + let id = parseInt(req.body.user_id) + if (isNaN(id)) { + throw new Error('Invalid or missing user ID') + } + + res.jsonp(await API.revokeTotpToken(id)) +})) + +apiRouter.post('/user/reset_password', csrfVerify, wrap(async (req, res) => { + let id = parseInt(req.body.user_id) + if (isNaN(id)) { + throw new Error('Invalid or missing user ID') + } + + res.jsonp(await API.sendPasswordEmail(id)) +})) + /* =============== * OAuth2 Data * =============== @@ -115,43 +173,34 @@ apiRouter.get('/clients', wrap(async (req, res) => { apiRouter.get('/client/:id', wrap(async (req, res) => { let id = parseInt(req.params.id) if (isNaN(id)) { - return res.status(400).jsonp({error: 'Invalid number'}) + throw new Error('Invalid number') } let client = await API.getClient(id) - if (!client) return res.status(400).jsonp({error: 'Invalid client'}) res.jsonp(client) })) -apiRouter.post('/client/new', wrap(async (req, res) => { - if (req.body.csrf !== req.session.csrf) { - return res.status(400).jsonp({error: 'Invalid session'}) - } - +apiRouter.post('/client/new', csrfVerify, wrap(async (req, res) => { await API.createClient(req.body, req.session.user) res.status(204).end() })) -apiRouter.post('/client/update', wrap(async (req, res) => { +apiRouter.post('/client/update', csrfVerify, wrap(async (req, res) => { let id = parseInt(req.body.id) - if (!id || isNaN(id)) return res.status(400).jsonp({error: 'ID missing'}) - - if (req.body.csrf !== req.session.csrf) { - return res.status(400).jsonp({error: 'Invalid session'}) - } + if (!id || isNaN(id)) throw new Error('ID missing') await API.updateClient(id, req.body) res.status(204).end() })) -apiRouter.post('/client/new_secret/:id', wrap(async (req, res) => { - let id = parseInt(req.params.id) +apiRouter.post('/client/new_secret', csrfVerify, wrap(async (req, res) => { + let id = parseInt(req.body.id) if (isNaN(id)) { - return res.status(400).jsonp({error: 'Invalid number'}) + throw new Error('Invalid client ID') } let client = await API.newSecret(id) @@ -159,10 +208,10 @@ apiRouter.post('/client/new_secret/:id', wrap(async (req, res) => { res.jsonp(client) })) -apiRouter.post('/client/delete/:id', wrap(async (req, res) => { - let id = parseInt(req.params.id) +apiRouter.post('/client/delete', csrfVerify, wrap(async (req, res) => { + let id = parseInt(req.body.id) if (isNaN(id)) { - return res.status(400).jsonp({error: 'Invalid number'}) + throw new Error('Invalid client ID') } let client = await API.removeClient(id) @@ -185,10 +234,10 @@ apiRouter.get('/bans', wrap(async (req, res) => { res.jsonp(bans) })) -apiRouter.post('/ban/pardon/:id', wrap(async (req, res) => { - let id = parseInt(req.params.id) +apiRouter.post('/ban/pardon', csrfVerify, wrap(async (req, res) => { + let id = parseInt(req.body.id) if (isNaN(id)) { - return res.status(400).jsonp({error: 'Invalid number'}) + throw new Error('Invalid number') } let ban = await API.removeBan(id) @@ -196,11 +245,8 @@ apiRouter.post('/ban/pardon/:id', wrap(async (req, res) => { res.jsonp(ban) })) -apiRouter.post('/ban', wrap(async (req, res) => { - if (!req.body.user_id) return res.status(400).jsonp({error: 'ID missing'}) - if (req.body.csrf !== req.session.csrf) { - return res.status(400).jsonp({error: 'Invalid session'}) - } +apiRouter.post('/ban', csrfVerify, wrap(async (req, res) => { + if (!req.body.user_id) throw new Error('ID missing') let result = await API.addBan(req.body, req.session.user.id) diff --git a/src/script/component/BanList.vue b/src/script/component/BanList.vue index da00099..370615a 100644 --- a/src/script/component/BanList.vue +++ b/src/script/component/BanList.vue @@ -11,6 +11,7 @@ diff --git a/src/script/component/UserModal.vue b/src/script/component/UserModal.vue new file mode 100644 index 0000000..1f0903e --- /dev/null +++ b/src/script/component/UserModal.vue @@ -0,0 +1,93 @@ + + + diff --git a/src/style/admin.styl b/src/style/admin.styl index b63f58b..1675ef2 100644 --- a/src/style/admin.styl +++ b/src/style/admin.styl @@ -111,8 +111,9 @@ nav background-color: #fff border: 1px solid #ddd border-radius: 5px - min-width: 180px - font-size: 120% + min-width: 210px + font-size: 100% + z-index: 2999 .title padding: 5px diff --git a/src/style/main.styl b/src/style/main.styl index 75a0b97..92959b7 100644 --- a/src/style/main.styl +++ b/src/style/main.styl @@ -579,6 +579,10 @@ select padding: 8px 0 input[type="checkbox"] margin-top: 10px + span + padding: 10px + display: inline-block + vertical-align: top @media all and (max-width: 800px) .navigator diff --git a/templates/reset_password/html.pug b/templates/reset_password/html.pug index 8a8f29c..509231d 100644 --- a/templates/reset_password/html.pug +++ b/templates/reset_password/html.pug @@ -1,5 +1,4 @@ h1 Hello, #{display_name}! -p You've requested to reset your password on Icy Network. p Click on or copy the following link into your URL bar in order to reset your Icy Network account password: a.activate(href=domain + "/reset/" + reset_token, target="_blank", rel="nofollow")= domain + "/reset/" + reset_token p If you did not request a password reset on Icy Network, please ignore this email. diff --git a/templates/reset_password/subject.pug b/templates/reset_password/subject.pug index 85b8d0b..f266d28 100644 --- a/templates/reset_password/subject.pug +++ b/templates/reset_password/subject.pug @@ -1 +1 @@ -|Icy Network - Password reset request +|Icy Network - Reset Your Password