1
0
Fork 0

wip(backend): More access control guarding

This commit is contained in:
joostdecock 2022-11-14 17:50:34 +01:00
parent bc5a605c9b
commit e37548fcf7
9 changed files with 325 additions and 130 deletions

View file

@ -4,7 +4,6 @@ export function PersonController() {}
/* /*
* Create a person for the authenticated user * Create a person for the authenticated user
*
* See: https://freesewing.dev/reference/backend/api * See: https://freesewing.dev/reference/backend/api
*/ */
PersonController.prototype.create = async (req, res, tools) => { PersonController.prototype.create = async (req, res, tools) => {
@ -16,34 +15,44 @@ PersonController.prototype.create = async (req, res, tools) => {
/* /*
* Read a person * Read a person
*
* See: https://freesewing.dev/reference/backend/api * See: https://freesewing.dev/reference/backend/api
*/ */
PersonController.prototype.read = async (req, res, tools) => { PersonController.prototype.read = async (req, res, tools) => {
//const Person = new PersonModel(tools) const Person = new PersonModel(tools)
//await Person.read({ id: req.params.id }) await Person.readForReturn({ id: parseInt(req.params.id) }, req.user)
//return Person.sendResponse(res)
return Person.sendResponse(res)
} }
/* /*
* Update a person * Update a person
*
* See: https://freesewing.dev/reference/backend/api * See: https://freesewing.dev/reference/backend/api
*/ */
PersonController.prototype.update = async (req, res, tools) => { PersonController.prototype.update = async (req, res, tools) => {
const Person = new PersonModel(tools) const Person = new PersonModel(tools)
await Person.unsafeUpdate(req) await Person.guardedUpdate(req)
return Person.sendResponse(res) return Person.sendResponse(res)
} }
/* /*
* Remove a person * Remove a person
*
* See: https://freesewing.dev/reference/backend/api * See: https://freesewing.dev/reference/backend/api
*/ */
PersonController.prototype.delete = async (req, res, tools) => { PersonController.prototype.delete = async (req, res, tools) => {
//const Person = new PersonModel(tools) const Person = new PersonModel(tools)
//await Person.remove(req) await Person.guardedDelete(req)
//return Person.sendResponse(res)
return Person.sendResponse(res)
} }
/*
* Clone a person
* See: https://freesewing.dev/reference/backend/api
*/
//PersonController.prototype.clone = async (req, res, tools) => {
// const Person = new PersonModel(tools)
// await Person.unsafeUpdate(req)
//
// return Person.sendResponse(res)
//}

View file

@ -48,7 +48,7 @@ UserController.prototype.login = async function (req, res, tools) {
*/ */
UserController.prototype.whoami = async (req, res, tools) => { UserController.prototype.whoami = async (req, res, tools) => {
const User = new UserModel(tools) const User = new UserModel(tools)
await User.readAsAccount({ id: req.user.uid }) await User.readForReturn({ id: req.user.uid })
return User.sendResponse(res) return User.sendResponse(res)
} }
@ -61,7 +61,7 @@ UserController.prototype.whoami = async (req, res, tools) => {
UserController.prototype.update = async (req, res, tools) => { UserController.prototype.update = async (req, res, tools) => {
const User = new UserModel(tools) const User = new UserModel(tools)
await User.read({ id: req.user.uid }) await User.read({ id: req.user.uid })
await User.unsafeUpdate(req.body) await User.guardedUpdate(req.body, req.user)
return User.sendResponse(res) return User.sendResponse(res)
} }

View file

@ -25,7 +25,7 @@ PersonModel.prototype.create = async function ({ body, user }) {
data.imperial = body.imperial === true ? true : false data.imperial = body.imperial === true ? true : false
data.userId = user.uid data.userId = user.uid
// Set this one initially as we need the ID to create a custom img via Sanity // Set this one initially as we need the ID to create a custom img via Sanity
data.img = this.encrypt(this.config.avatars.person) data.img = this.config.avatars.person
// Create record // Create record
try { try {
@ -43,7 +43,7 @@ PersonModel.prototype.create = async function ({ body, user }) {
? await setPersonAvatar(this.record.id, body.img) ? await setPersonAvatar(this.record.id, body.img)
: false : false
if (img) await this.safeUpdate(this.cloak({ img: img.url })) if (img) await this.unguardedUpdate(this.cloak({ img: img.url }))
else await this.read({ id: this.record.id }) else await this.read({ id: this.record.id })
return this.setResponse(201, 'created', { person: this.asPerson() }) return this.setResponse(201, 'created', { person: this.asPerson() })
@ -66,6 +66,27 @@ PersonModel.prototype.read = async function (where) {
return this.setExists() return this.setExists()
} }
/*
* Loads a person from the database based on the where clause you pass it
* In addition prepares it for returning the person data
*
* Stores result in this.record
*/
PersonModel.prototype.readForReturn = async function (where, user) {
if (user.level < 1) return this.setResponse(403, 'insufficientAccessLevel')
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
await this.read(where)
if (this.record.userId !== user.uid && user.level < 5) {
return this.setResponse(403, 'insufficientAccessLevel')
}
return this.setResponse(200, false, {
result: 'success',
person: this.asPerson(),
})
}
/* /*
* Helper method to decrypt at-rest data * Helper method to decrypt at-rest data
*/ */
@ -85,55 +106,14 @@ PersonModel.prototype.reveal = async function () {
*/ */
PersonModel.prototype.cloak = function (data) { PersonModel.prototype.cloak = function (data) {
for (const field of this.encryptedFields) { for (const field of this.encryptedFields) {
if (typeof data[field] !== 'undefined') data[field] = this.encrypt(data[field]) if (typeof data[field] !== 'undefined') {
data[field] = this.encrypt(data[field])
}
} }
return data return data
} }
/*
* Loads a user from the database based on the where clause you pass it
* In addition prepares it for returning the account data
*
* Stores result in this.record
*/
//UserModel.prototype.readAsAccount = async function (where) {
// await this.read(where)
//
// return this.setResponse(200, false, {
// result: 'success',
// account: this.asAccount(),
// })
//}
/*
* Finds a user based on one of the accepted unique fields which are:
* - lusername (lowercase username)
* - ehash
* - id
*
* Stores result in this.record
*/
//UserModel.prototype.find = async function (body) {
// try {
// this.record = await this.prisma.user.findFirst({
// where: {
// OR: [
// { lusername: { equals: clean(body.username) } },
// { ehash: { equals: hash(clean(body.username)) } },
// { id: { equals: parseInt(body.username) || -1 } },
// ],
// },
// })
// } catch (err) {
// log.warn({ err, body }, `Error while trying to find user: ${body.username}`)
// }
//
// this.reveal()
//
// return this.setExists()
//}
/* /*
* Checks this.record and sets a boolean to indicate whether * Checks this.record and sets a boolean to indicate whether
* the user exists or not * the user exists or not
@ -150,7 +130,7 @@ PersonModel.prototype.setExists = function () {
* Updates the person data - Used when we create the data ourselves * Updates the person data - Used when we create the data ourselves
* so we know it's safe * so we know it's safe
*/ */
PersonModel.prototype.safeUpdate = async function (data) { PersonModel.prototype.unguardedUpdate = async function (data) {
try { try {
this.record = await this.prisma.person.update({ this.record = await this.prisma.person.update({
where: { id: this.record.id }, where: { id: this.record.id },
@ -170,14 +150,14 @@ PersonModel.prototype.safeUpdate = async function (data) {
* Updates the person data - Used when we pass through user-provided data * Updates the person data - Used when we pass through user-provided data
* so we can't be certain it's safe * so we can't be certain it's safe
*/ */
PersonModel.prototype.unsafeUpdate = async function ({ params, body, user }) { PersonModel.prototype.guardedUpdate = async function ({ params, body, user }) {
if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel') if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel')
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
await this.read({ id: parseInt(params.id) }) await this.read({ id: parseInt(params.id) })
if (user.uid !== this.record.userId) return this.setResponse(403, 'accessDenied') if (this.record.userId !== user.uid && user.level < 8) {
return this.setResponse(403, 'insufficientAccessLevel')
}
const data = {} const data = {}
/*
img String?
*/
// Imperial // Imperial
if (body.imperial === true || body.imperial === false) data.imperial = body.imperial if (body.imperial === true || body.imperial === false) data.imperial = body.imperial
// Name // Name
@ -189,11 +169,15 @@ PersonModel.prototype.unsafeUpdate = async function ({ params, body, user }) {
// Measurements // Measurements
const measies = {} const measies = {}
if (typeof body.measies === 'object') { if (typeof body.measies === 'object') {
const remove = []
for (const [key, val] of Object.entries(body.measies)) { for (const [key, val] of Object.entries(body.measies)) {
if (this.config.measies.includes(key) && typeof val === 'number' && val > 0) if (this.config.measies.includes(key)) {
measies[key] = val if (val === null) remove.push(key)
else if (typeof val == 'number' && val > 0) measies[key] = val
}
} }
data.measies = { ...this.clear.measies, ...measies } data.measies = { ...this.clear.measies, ...measies }
for (const key of remove) delete data.measies[key]
} }
// Image (img) // Image (img)
@ -203,11 +187,39 @@ PersonModel.prototype.unsafeUpdate = async function ({ params, body, user }) {
} }
// Now update the record // Now update the record
await this.safeUpdate(this.cloak(data)) await this.unguardedUpdate(this.cloak(data))
return this.setResponse(200, false, { person: this.asPerson() }) return this.setResponse(200, false, { person: this.asPerson() })
} }
/*
* Removes the person - No questions asked
*/
PersonModel.prototype.unguardedDelete = async function () {
await this.prisma.person.delete({ here: { id: this.record.id } })
this.record = null
this.clear = null
return this.setExists()
}
/*
* Removes the person - Checks permissions
*/
PersonModel.prototype.guardedDelete = async function ({ params, body, user }) {
if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel')
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
await this.read({ id: parseInt(params.id) })
if (this.record.userId !== user.uid && user.level < 8) {
return this.setResponse(403, 'insufficientAccessLevel')
}
await this.unguardedDelete()
return this.setResponse(204, false)
}
/* /*
* Returns record data * Returns record data
*/ */

View file

@ -67,7 +67,7 @@ UserModel.prototype.cloak = function (data) {
* *
* Stores result in this.record * Stores result in this.record
*/ */
UserModel.prototype.readAsAccount = async function (where) { UserModel.prototype.readForReturn = async function (where) {
await this.read(where) await this.read(where)
return this.setResponse(200, false, { return this.setResponse(200, false, {
@ -175,7 +175,7 @@ UserModel.prototype.create = async function ({ body }) {
// Update username // Update username
try { try {
await this.safeUpdate({ await this.unguardedUpdate({
username: `user-${this.record.id}`, username: `user-${this.record.id}`,
lusername: `user-${this.record.id}`, lusername: `user-${this.record.id}`,
}) })
@ -243,7 +243,7 @@ UserModel.prototype.passwordLogin = async function (req) {
// Login success // Login success
if (updatedPasswordField) { if (updatedPasswordField) {
// Update the password field with a v3 hash // Update the password field with a v3 hash
await this.safeUpdate({ password: updatedPasswordField }) await this.unguardedUpdate({ password: updatedPasswordField })
} }
return this.isOk() ? this.loginOk() : this.setResponse(401, 'loginFailed') return this.isOk() ? this.loginOk() : this.setResponse(401, 'loginFailed')
@ -283,7 +283,7 @@ UserModel.prototype.confirm = async function ({ body, params }) {
if (this.error) return this if (this.error) return this
// Update user status, consent, and last login // Update user status, consent, and last login
await this.safeUpdate({ await this.unguardedUpdate({
status: 1, status: 1,
consent: body.consent, consent: body.consent,
lastLogin: new Date(), lastLogin: new Date(),
@ -298,7 +298,7 @@ UserModel.prototype.confirm = async function ({ body, params }) {
* Updates the user data - Used when we create the data ourselves * Updates the user data - Used when we create the data ourselves
* so we know it's safe * so we know it's safe
*/ */
UserModel.prototype.safeUpdate = async function (data) { UserModel.prototype.unguardedUpdate = async function (data) {
try { try {
this.record = await this.prisma.user.update({ this.record = await this.prisma.user.update({
where: { id: this.record.id }, where: { id: this.record.id },
@ -318,7 +318,7 @@ UserModel.prototype.safeUpdate = async function (data) {
* Updates the user data - Used when we pass through user-provided data * Updates the user data - Used when we pass through user-provided data
* so we can't be certain it's safe * so we can't be certain it's safe
*/ */
UserModel.prototype.unsafeUpdate = async function (body) { UserModel.prototype.guardedUpdate = async function (body, user) {
if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel') if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel')
const data = {} const data = {}
// Bio // Bio
@ -353,7 +353,7 @@ UserModel.prototype.unsafeUpdate = async function (body) {
} }
// Now update the record // Now update the record
await this.safeUpdate(this.cloak(data)) await this.unguardedUpdate(this.cloak(data))
const isUnitTest = this.isUnitTest(body) const isUnitTest = this.isUnitTest(body)
if (typeof body.email === 'string' && this.clear.email !== clean(body.email)) { if (typeof body.email === 'string' && this.clear.email !== clean(body.email)) {
@ -399,7 +399,7 @@ UserModel.prototype.unsafeUpdate = async function (body) {
const data = this.Confirmation.clear.data const data = this.Confirmation.clear.data
if (data.email.current === this.clear.email && typeof data.email.new === 'string') { if (data.email.current === this.clear.email && typeof data.email.new === 'string') {
await this.safeUpdate({ await this.unguardedUpdate({
email: this.encrypt(data.email.new), email: this.encrypt(data.email.new),
ehash: hash(clean(data.email.new)), ehash: hash(clean(data.email.new)),
}) })

View file

@ -31,6 +31,14 @@ export function personRoutes(tools) {
Person.update(req, res, tools) Person.update(req, res, tools)
) )
// Clone person
app.put('/people/:id/clone/jwt', passport.authenticate(...jwt), (req, res) =>
Person.clone(req, res, tools)
)
app.put('/people/:id/clone/key', passport.authenticate(...bsc), (req, res) =>
Person.clone(req, res, tools)
)
// Delete person // Delete person
app.delete('/people/:id/jwt', passport.authenticate(...jwt), (req, res) => app.delete('/people/:id/jwt', passport.authenticate(...jwt), (req, res) =>
Person.delete(req, res, tools) Person.delete(req, res, tools)

View file

@ -85,7 +85,6 @@ export const encryption = (stringKey, salt = 'FreeSewing') => {
if (!data.iv || typeof data.ct === 'undefined') { if (!data.iv || typeof data.ct === 'undefined') {
throw 'Encrypted data passed to decrypt() was malformed' throw 'Encrypted data passed to decrypt() was malformed'
} }
/* /*
* The thing that does the decrypting * The thing that does the decrypting
*/ */

View file

@ -5,9 +5,9 @@ import { personTests } from './person.mjs'
import { setup } from './shared.mjs' import { setup } from './shared.mjs'
const runTests = async (...params) => { const runTests = async (...params) => {
//await userTests(...params) await userTests(...params)
//await apikeyTests(...params) await apikeyTests(...params)
//await accountTests(...params) await accountTests(...params)
await personTests(...params) await personTests(...params)
} }

View file

@ -30,6 +30,10 @@ export const personTests = async (chai, config, expect, store) => {
jwt: {}, jwt: {},
key: {}, key: {},
} }
store.altperson = {
jwt: {},
key: {},
}
for (const auth of ['jwt', 'key']) { for (const auth of ['jwt', 'key']) {
describe(`${store.icon('person', auth)} Person tests (${auth})`, () => { describe(`${store.icon('person', auth)} Person tests (${auth})`, () => {
@ -146,13 +150,169 @@ export const personTests = async (chai, config, expect, store) => {
}) })
}) })
} }
it(`${store.icon(
'person',
auth
)} Should not set an non-existing measurement (${auth})`, (done) => {
chai
.request(config.api)
.put(`/people/${store.person[auth].id}/${auth}`)
.set(
'Authorization',
auth === 'jwt'
? 'Bearer ' + store.account.token
: 'Basic ' +
new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString(
'base64'
)
)
.send({
measies: {
ankle: 320,
potatoe: 12,
},
})
.end((err, res) => {
expect(err === null).to.equal(true)
expect(res.status).to.equal(200)
expect(res.body.result).to.equal(`success`)
expect(res.body.person.measies.ankle).to.equal(320)
expect(typeof res.body.person.measies.potatoe).to.equal('undefined')
done()
})
})
it(`${store.icon('person', auth)} Should clear a measurement (${auth})`, (done) => {
chai
.request(config.api)
.put(`/people/${store.person[auth].id}/${auth}`)
.set(
'Authorization',
auth === 'jwt'
? 'Bearer ' + store.account.token
: 'Basic ' +
new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString(
'base64'
)
)
.send({
measies: {
chest: null,
},
})
.end((err, res) => {
expect(err === null).to.equal(true)
expect(res.status).to.equal(200)
expect(res.body.result).to.equal(`success`)
expect(typeof res.body.person.measies.chest).to.equal('undefined')
done()
})
})
it(`${store.icon('person', auth)} Should read a person (${auth})`, (done) => {
chai
.request(config.api)
.get(`/people/${store.person[auth].id}/${auth}`)
.set(
'Authorization',
auth === 'jwt'
? 'Bearer ' + store.account.token
: 'Basic ' +
new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString(
'base64'
)
)
.end((err, res) => {
expect(err === null).to.equal(true)
expect(res.status).to.equal(200)
expect(res.body.result).to.equal(`success`)
expect(typeof res.body.person.measies).to.equal('object')
done()
})
})
it(`${store.icon(
'person',
auth
)} Should not allow reading another user's person (${auth})`, (done) => {
chai
.request(config.api)
.get(`/people/${store.person[auth].id}/${auth}`)
.set(
'Authorization',
auth === 'jwt'
? 'Bearer ' + store.altaccount.token
: 'Basic ' +
new Buffer(
`${store.altaccount.apikey.key}:${store.altaccount.apikey.secret}`
).toString('base64')
)
.end((err, res) => {
expect(err === null).to.equal(true)
expect(res.status).to.equal(403)
expect(res.body.result).to.equal(`error`)
expect(res.body.error).to.equal(`insufficientAccessLevel`)
done()
})
})
it(`${store.icon(
'person',
auth
)} Should not allow updating another user's person (${auth})`, (done) => {
chai
.request(config.api)
.put(`/people/${store.person[auth].id}/${auth}`)
.set(
'Authorization',
auth === 'jwt'
? 'Bearer ' + store.altaccount.token
: 'Basic ' +
new Buffer(
`${store.altaccount.apikey.key}:${store.altaccount.apikey.secret}`
).toString('base64')
)
.send({
bio: 'I have been taken over',
})
.end((err, res) => {
expect(err === null).to.equal(true)
expect(res.status).to.equal(403)
expect(res.body.result).to.equal(`error`)
expect(res.body.error).to.equal(`insufficientAccessLevel`)
done()
})
})
it(`${store.icon(
'person',
auth
)} Should not allow removing another user's person (${auth})`, (done) => {
chai
.request(config.api)
.delete(`/people/${store.person[auth].id}/${auth}`)
.set(
'Authorization',
auth === 'jwt'
? 'Bearer ' + store.altaccount.token
: 'Basic ' +
new Buffer(
`${store.altaccount.apikey.key}:${store.altaccount.apikey.secret}`
).toString('base64')
)
.end((err, res) => {
expect(err === null).to.equal(true)
expect(res.status).to.equal(403)
expect(res.body.result).to.equal(`error`)
expect(res.body.error).to.equal(`insufficientAccessLevel`)
done()
})
}) })
// TODO: // TODO:
// - Add non-existing measurement
// - Clear measurement
// - List/get person
// - Clone person // - Clone person
// - Clone person accross accounts of they are public // - Clone person accross accounts of they are public
})
} }
} }

View file

@ -22,6 +22,11 @@ export const setup = async () => {
language: 'en', language: 'en',
password: randomString(), password: randomString(),
}, },
altaccount: {
email: `test_${randomString()}@${config.tests.domain}`,
language: 'en',
password: randomString(),
},
icons: { icons: {
user: '🧑 ', user: '🧑 ',
jwt: '🎫 ', jwt: '🎫 ',
@ -32,32 +37,33 @@ export const setup = async () => {
} }
store.icon = (icon1, icon2 = false) => store.icons[icon1] + (icon2 ? store.icons[icon2] : '') store.icon = (icon1, icon2 = false) => store.icons[icon1] + (icon2 ? store.icons[icon2] : '')
for (const acc of ['account', 'altaccount']) {
// Get confirmation ID // Get confirmation ID
let result let result
try { try {
result = await axios.post(`${store.config.api}/signup`, { result = await axios.post(`${store.config.api}/signup`, {
email: store.account.email, email: store[acc].email,
language: store.account.language, language: store[acc].language,
unittest: true, unittest: true,
}) })
} catch (err) { } catch (err) {
console.log('Failed at first setup request', err) console.log('Failed at first setup request', err)
process.exit() process.exit()
} }
store.account.confirmation = result.data.confirmation store[acc].confirmation = result.data.confirmation
// Confirm account // Confirm account
try { try {
result = await axios.post(`${store.config.api}/confirm/signup/${store.account.confirmation}`, { result = await axios.post(`${store.config.api}/confirm/signup/${store[acc].confirmation}`, {
consent: 1, consent: 1,
}) })
} catch (err) { } catch (err) {
console.log('Failed at account confirmation request', err) console.log('Failed at account confirmation request', err)
process.exit() process.exit()
} }
store.account.token = result.data.token store[acc].token = result.data.token
store.account.username = result.data.account.username store[acc].username = result.data.account.username
store.account.userid = result.data.account.id store[acc].userid = result.data.account.id
// Create API key // Create API key
try { try {
@ -70,7 +76,7 @@ export const setup = async () => {
}, },
{ {
headers: { headers: {
authorization: `Bearer ${store.account.token}`, authorization: `Bearer ${store[acc].token}`,
}, },
} }
) )
@ -78,7 +84,8 @@ export const setup = async () => {
console.log('Failed at API key creation request', err) console.log('Failed at API key creation request', err)
process.exit() process.exit()
} }
store.account.apikey = result.data.apikey store[acc].apikey = result.data.apikey
}
return { chai, config, expect, store } return { chai, config, expect, store }
} }