A lot of user actions, add CSRF tokens to all POSTs

This commit is contained in:
Evert Prants 2017-12-07 17:37:36 +02:00
parent e938ac5ab9
commit 61248119c5
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
14 changed files with 347 additions and 63 deletions

View File

@ -3,7 +3,9 @@ import Models from './models'
const perPage = 6 const perPage = 6
function cleanUserObject (dbe, admin) { async function cleanUserObject (dbe, admin) {
let totp = await Users.User.Login.totpTokenRequired(dbe)
return { return {
id: dbe.id, id: dbe.id,
username: dbe.username, username: dbe.username,
@ -17,7 +19,8 @@ function cleanUserObject (dbe, admin) {
password: dbe.password !== null, password: dbe.password !== null,
nw_privilege: dbe.nw_privilege, nw_privilege: dbe.nw_privilege,
created_at: dbe.created_at, 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) { for (let i in raw) {
let entry = raw[i] let entry = raw[i]
users.push(cleanUserObject(entry, admin)) users.push(await cleanUserObject(entry, admin))
} }
return { return {
@ -104,6 +107,53 @@ const API = {
users: users 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) // List all clients (paginated)
getAllClients: async function (page) { getAllClients: async function (page) {
let count = await Models.OAuth2Client.query().count('id as ids') let count = await Models.OAuth2Client.query().count('id as ids')
@ -123,14 +173,13 @@ const API = {
} }
return { return {
page: paginated, page: paginated, clients
clients: clients
} }
}, },
// Get information about a client via id // Get information about a client via id
getClient: async function (id) { getClient: async function (id) {
let raw = await Models.OAuth2Client.query().where('id', 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]) return cleanClientObject(raw[0])
}, },
@ -211,8 +260,7 @@ const API = {
} }
return { return {
page: paginated, page: paginated, bans
bans: bans
} }
}, },
// Remove a ban // Remove a ban

View File

@ -4,6 +4,10 @@ import nodemailer from 'nodemailer'
import config from '../../scripts/load-config' 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') const templateDir = path.join(__dirname, '../../', 'templates')
let templateCache = {} let templateCache = {}

View File

@ -372,8 +372,16 @@ const API = {
// Create user // Create user
let user = await models.User.query().insert(data) 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 // Activation token
let activationToken = API.Hash(16) let activationToken = API.Hash(16)
await models.Token.query().insert({ await models.Token.query().insert({
expires_at: new Date(Date.now() + 86400000), // 1 day expires_at: new Date(Date.now() + 86400000), // 1 day
token: activationToken, token: activationToken,
@ -381,25 +389,28 @@ const API = {
type: 1 type: 1
}) })
// Send Activation Email
console.debug('Activation token:', activationToken) 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) // Send Activation Email
} catch (e) { try {
console.error(e) 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) 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: { Reset: {

View File

@ -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) => { apiRouter.get('/users', wrap(async (req, res) => {
let page = parseInt(req.query.page) let page = parseInt(req.query.page)
if (isNaN(page) || page < 1) { if (isNaN(page) || page < 1) {
@ -98,6 +111,51 @@ apiRouter.get('/users', wrap(async (req, res) => {
res.jsonp(users) 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 * OAuth2 Data
* =============== * ===============
@ -115,43 +173,34 @@ apiRouter.get('/clients', wrap(async (req, res) => {
apiRouter.get('/client/:id', wrap(async (req, res) => { apiRouter.get('/client/:id', wrap(async (req, res) => {
let id = parseInt(req.params.id) let id = parseInt(req.params.id)
if (isNaN(id)) { if (isNaN(id)) {
return res.status(400).jsonp({error: 'Invalid number'}) throw new Error('Invalid number')
} }
let client = await API.getClient(id) let client = await API.getClient(id)
if (!client) return res.status(400).jsonp({error: 'Invalid client'})
res.jsonp(client) res.jsonp(client)
})) }))
apiRouter.post('/client/new', wrap(async (req, res) => { apiRouter.post('/client/new', csrfVerify, wrap(async (req, res) => {
if (req.body.csrf !== req.session.csrf) {
return res.status(400).jsonp({error: 'Invalid session'})
}
await API.createClient(req.body, req.session.user) await API.createClient(req.body, req.session.user)
res.status(204).end() 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) let id = parseInt(req.body.id)
if (!id || isNaN(id)) return res.status(400).jsonp({error: 'ID missing'}) if (!id || isNaN(id)) throw new Error('ID missing')
if (req.body.csrf !== req.session.csrf) {
return res.status(400).jsonp({error: 'Invalid session'})
}
await API.updateClient(id, req.body) await API.updateClient(id, req.body)
res.status(204).end() res.status(204).end()
})) }))
apiRouter.post('/client/new_secret/:id', wrap(async (req, res) => { apiRouter.post('/client/new_secret', csrfVerify, wrap(async (req, res) => {
let id = parseInt(req.params.id) let id = parseInt(req.body.id)
if (isNaN(id)) { if (isNaN(id)) {
return res.status(400).jsonp({error: 'Invalid number'}) throw new Error('Invalid client ID')
} }
let client = await API.newSecret(id) let client = await API.newSecret(id)
@ -159,10 +208,10 @@ apiRouter.post('/client/new_secret/:id', wrap(async (req, res) => {
res.jsonp(client) res.jsonp(client)
})) }))
apiRouter.post('/client/delete/:id', wrap(async (req, res) => { apiRouter.post('/client/delete', csrfVerify, wrap(async (req, res) => {
let id = parseInt(req.params.id) let id = parseInt(req.body.id)
if (isNaN(id)) { if (isNaN(id)) {
return res.status(400).jsonp({error: 'Invalid number'}) throw new Error('Invalid client ID')
} }
let client = await API.removeClient(id) let client = await API.removeClient(id)
@ -185,10 +234,10 @@ apiRouter.get('/bans', wrap(async (req, res) => {
res.jsonp(bans) res.jsonp(bans)
})) }))
apiRouter.post('/ban/pardon/:id', wrap(async (req, res) => { apiRouter.post('/ban/pardon', csrfVerify, wrap(async (req, res) => {
let id = parseInt(req.params.id) let id = parseInt(req.body.id)
if (isNaN(id)) { if (isNaN(id)) {
return res.status(400).jsonp({error: 'Invalid number'}) throw new Error('Invalid number')
} }
let ban = await API.removeBan(id) let ban = await API.removeBan(id)
@ -196,11 +245,8 @@ apiRouter.post('/ban/pardon/:id', wrap(async (req, res) => {
res.jsonp(ban) res.jsonp(ban)
})) }))
apiRouter.post('/ban', wrap(async (req, res) => { apiRouter.post('/ban', csrfVerify, wrap(async (req, res) => {
if (!req.body.user_id) return res.status(400).jsonp({error: 'ID missing'}) if (!req.body.user_id) throw new Error('ID missing')
if (req.body.csrf !== req.session.csrf) {
return res.status(400).jsonp({error: 'Invalid session'})
}
let result = await API.addBan(req.body, req.session.user.id) let result = await API.addBan(req.body, req.session.user.id)

View File

@ -11,6 +11,7 @@
<script type="text/javascript"> <script type="text/javascript">
import Pagination from './Pagination.vue' import Pagination from './Pagination.vue'
import Ban from './Ban.vue' import Ban from './Ban.vue'
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
export default { export default {
data: function () { data: function () {
@ -44,7 +45,10 @@
}) })
}, },
pardon: function (id) { pardon: function (id) {
this.$http.post('/admin/api/ban/pardon/' + id).then(data => { this.$http.post('/admin/api/ban/pardon', {
id: id,
csrf: csrfToken
}).then(data => {
this.getBans(1) this.getBans(1)
}) })
} }
@ -53,7 +57,7 @@
this.getBans(1) this.getBans(1)
this.$root.$on('reload_bans', () => { this.$root.$on('reload_bans', () => {
this.getBans(1) this.getBans(this.pagination.page)
}) })
this.$on('pardon', (id) => { this.$on('pardon', (id) => {

View File

@ -61,9 +61,9 @@
this.verified = false this.verified = false
}, },
submit: function () { submit: function () {
let url = this.id === -1 ? 'new' : 'update' let uri = this.id === -1 ? 'new' : 'update'
this.$http.post('/admin/api/client/' + url, { this.$http.post('/admin/api/client/' + uri, {
id: this.id, id: this.id,
title: this.title, title: this.title,
description: this.description, description: this.description,
@ -81,7 +81,10 @@
}) })
}, },
newSecret: function () { newSecret: function () {
this.$http.post('/admin/api/client/new_secret/' + this.id).then(data => { this.$http.post('/admin/api/client/new_secret', {
id: this.id,
csrf: csrfToken
}).then(data => {
alert('New secret generated.') alert('New secret generated.')
this.$root.$emit('reload_clients') this.$root.$emit('reload_clients')
}).catch(err => { }).catch(err => {
@ -92,6 +95,7 @@
watch: { watch: {
id: function () { id: function () {
if (this.id <= 0) return if (this.id <= 0) return
this.$http.get('/admin/api/client/' + this.id).then(data => { this.$http.get('/admin/api/client/' + this.id).then(data => {
let dr = data.body let dr = data.body

View File

@ -13,6 +13,7 @@
import Pagination from './Pagination.vue' import Pagination from './Pagination.vue'
import OAuthClient from './OAuthClient.vue' import OAuthClient from './OAuthClient.vue'
import ClientModal from './ClientModal.vue' import ClientModal from './ClientModal.vue'
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
export default { export default {
data: function () { data: function () {
@ -48,7 +49,10 @@
}) })
}, },
deleteClient: function (id) { deleteClient: function (id) {
this.$http.post('/admin/api/client/delete/' + id).then(data => { this.$http.post('/admin/api/client/delete', {
id: id,
csrf: csrfToken
}).then(data => {
this.getClients(1) this.getClients(1)
}) })
} }
@ -57,7 +61,7 @@
this.getClients(1) this.getClients(1)
this.$root.$on('reload_clients', () => { this.$root.$on('reload_clients', () => {
this.getClients(1) this.getClients(this.pagination.page)
}) })
this.$on('edit', function (id) { this.$on('edit', function (id) {

View File

@ -8,17 +8,34 @@
.stamp(title="Used an external login" v-if="!password") .stamp(title="Used an external login" v-if="!password")
i.fa.fa-fw.fa-sign-out i.fa.fa-fw.fa-sign-out
.noactive.stamp(v-if='activated == false', title='Not activated.') .noactive.stamp(v-if='activated == false' title='Not activated.')
i.fa.fa-fw.fa-envelope i.fa.fa-fw.fa-envelope
.totp.stamp(v-if='totp_enabled' title="Two-Factor Authentication Enabled")
i.fa.fa-fw.fa-shield
.dropdown-wrapper.stamp(@click="dropdown = !dropdown" v-on-clickaway='away') .dropdown-wrapper.stamp(@click="dropdown = !dropdown" v-on-clickaway='away')
i.fa.fa-fw.fa-ellipsis-v i.fa.fa-fw.fa-ellipsis-v
transition(name="pop") transition(name="pop")
.dropdown(v-show="dropdown") .dropdown(v-show="dropdown")
.title Actions .title Actions
.action(v-on:click='$parent.$emit("edit", id)')
i.fa.fa-fw.fa-pencil
|&nbsp;Edit User
.action(v-if='bannable' v-on:click='$parent.$emit("ban", id)') .action(v-if='bannable' v-on:click='$parent.$emit("ban", id)')
i.fa.fa-fw.fa-ban i.fa.fa-fw.fa-ban
|&nbsp;Ban User |&nbsp;Ban User
.separator
.action(v-if='!activated' v-on:click='$parent.$emit("activation", id)')
i.fa.fa-fw.fa-envelope
|&nbsp;Activation Email
.action(v-if="totp_enabled" v-on:click='$parent.$emit("totp-revoke", id)')
i.fa.fa-fw.fa-shield
|&nbsp;Revoke 2FA
.action(v-on:click='$parent.$emit("reset-password", id)')
i.fa.fa-fw.fa-envelope
|&nbsp;Password Email
.display_name {{ display_name }} .display_name {{ display_name }}
.name {{ id }} - {{ username }} ({{ uuid }}) .name {{ id }} - {{ username }} ({{ uuid }})
@ -40,7 +57,7 @@
import { directive as onClickaway } from 'vue-clickaway' import { directive as onClickaway } from 'vue-clickaway'
export default { export default {
props: ['avatar_file', 'activated', 'display_name', 'id', 'username', 'uuid', 'email', 'nw_privilege', 'created_at', 'password', 'bannable', 'ip_address'], props: ['avatar_file', 'activated', 'display_name', 'id', 'username', 'uuid', 'email', 'nw_privilege', 'created_at', 'password', 'bannable', 'ip_address', 'totp_enabled'],
directives: { directives: {
onClickaway: onClickaway, onClickaway: onClickaway,
}, },

View File

@ -5,12 +5,15 @@
.list.users .list.users
user(v-for='user in users' v-bind="user" :key="user.id") user(v-for='user in users' v-bind="user" :key="user.id")
ban-modal(:show='banning' @close='banning = 0' :id='banning') ban-modal(:show='banning' @close='banning = 0' :id='banning')
user-modal(:show='editing' @close='editing = 0' :id='editing')
</template> </template>
<script> <script>
import Pagination from './Pagination.vue' import Pagination from './Pagination.vue'
import User from './User.vue' import User from './User.vue'
import BanModal from './BanModal.vue' import BanModal from './BanModal.vue'
import UserModal from './UserModal.vue'
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
export default { export default {
data: function () { data: function () {
@ -23,11 +26,12 @@
total: 0 total: 0
}, },
users: [], users: [],
banning: 0 banning: 0,
editing: 0
} }
}, },
components: { components: {
Pagination, User, BanModal Pagination, User, BanModal, UserModal
}, },
methods: { methods: {
getUsers: function (page) { getUsers: function (page) {
@ -44,6 +48,51 @@
this.$on('ban', function (id) { this.$on('ban', function (id) {
this.banning = id this.banning = id
}) })
this.$on('edit', function (id) {
this.editing = id
})
this.$on('activation', function (id) {
this.$http.post('/admin/api/user/resend_activation', {
user_id: id,
csrf: csrfToken
}).then(data => {
alert('Email sent!')
}).catch(err => {
console.error(err)
alert('Failed to send activation email to this user.')
})
})
this.$on('totp-revoke', function (id) {
this.$http.post('/admin/api/user/revoke_totp', {
user_id: id,
csrf: csrfToken
}).then(data => {
alert('Success!')
this.getUsers(this.pagination.page)
}).catch(err => {
console.error(err)
alert('An error occured.')
})
})
this.$on('reset-password', function (id) {
this.$http.post('/admin/api/user/reset_password', {
user_id: id,
csrf: csrfToken
}).then(data => {
alert('Email sent!')
}).catch(err => {
console.error(err)
alert('Failed to send activation email to this user.')
})
})
this.$root.$on('reload_users', () => {
this.getUsers(this.pagination.page)
})
} }
} }
</script> </script>

View File

@ -0,0 +1,93 @@
<template lang="pug">
modal(:show='show', @close='close')
.modal-header
h3 Edit User
.modal-body.aligned-form
.message.error(v-if='error') {{ error }}
.cell
label(for="username") Username
input(type="text" id="username" name="username" v-model="username")
.cell
label(for="display_name") Display Name
input(type="text" id="display_name" name="display_name" v-model="display_name")
.cell
label(for="email") Email
input(type="email" id="email" name="email" v-model="email")
.cell
label(for="privilege") Privilege
input(type="range" min="0" max="5" step="1" id="privilege" name="privilege" v-model="nw_privilege")
span {{ nw_privilege }}
.cell
label(for="activated") Activated
input(type="checkbox" id="activated" name="activated" v-model="activated")
.modal-footer.text-align
button(@click='submit') Done
button(@click='close') Cancel
</template>
<script type="text/javascript">
import Modal from './Modal.vue'
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
export default {
props: ['show', 'id'],
data: function () {
return {
error: '',
username: '',
display_name: '',
email: '',
nw_privilege: 0,
activated: false
}
},
components: {
Modal
},
methods: {
close: function () {
this.$emit('close')
this.error = ''
this.username = ''
this.display_name = ''
this.email = ''
this.nw_privilege = 0
this.activated = true
},
submit: function () {
this.$http.post('/admin/api/user', {
user_id: this.id,
username: this.username,
display_name: this.display_name,
email: this.email,
nw_privilege: this.nw_privilege,
activated: this.activated,
csrf: csrfToken
}).then(data => {
this.close()
this.$root.$emit('reload_users')
}).catch(err => {
console.error(err)
if (err.body && err.body.error) this.error = err.body.error
})
}
},
watch: {
id: function () {
if (this.id <= 0) return
this.$http.get('/admin/api/user/' + this.id).then(data => {
let dr = data.body
this.username = dr.username
this.display_name = dr.display_name
this.email = dr.email
this.nw_privilege = dr.nw_privilege
this.activated = dr.activated
}).catch(err => {
alert('Failed to fetch user information')
this.close()
})
}
}
}
</script>

View File

@ -111,8 +111,9 @@ nav
background-color: #fff background-color: #fff
border: 1px solid #ddd border: 1px solid #ddd
border-radius: 5px border-radius: 5px
min-width: 180px min-width: 210px
font-size: 120% font-size: 100%
z-index: 2999
.title .title
padding: 5px padding: 5px

View File

@ -579,6 +579,10 @@ select
padding: 8px 0 padding: 8px 0
input[type="checkbox"] input[type="checkbox"]
margin-top: 10px margin-top: 10px
span
padding: 10px
display: inline-block
vertical-align: top
@media all and (max-width: 800px) @media all and (max-width: 800px)
.navigator .navigator

View File

@ -1,5 +1,4 @@
h1 Hello, #{display_name}! 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: 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 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. p If you did not request a password reset on Icy Network, please ignore this email.

View File

@ -1 +1 @@
|Icy Network - Password reset request |Icy Network - Reset Your Password