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
*
* See: https://freesewing.dev/reference/backend/api
*/
PersonController.prototype.create = async (req, res, tools) => {
@ -16,34 +15,44 @@ PersonController.prototype.create = async (req, res, tools) => {
/*
* Read a person
*
* See: https://freesewing.dev/reference/backend/api
*/
PersonController.prototype.read = async (req, res, tools) => {
//const Person = new PersonModel(tools)
//await Person.read({ id: req.params.id })
//return Person.sendResponse(res)
const Person = new PersonModel(tools)
await Person.readForReturn({ id: parseInt(req.params.id) }, req.user)
return Person.sendResponse(res)
}
/*
* Update a person
*
* See: https://freesewing.dev/reference/backend/api
*/
PersonController.prototype.update = async (req, res, tools) => {
const Person = new PersonModel(tools)
await Person.unsafeUpdate(req)
await Person.guardedUpdate(req)
return Person.sendResponse(res)
}
/*
* Remove a person
*
* See: https://freesewing.dev/reference/backend/api
*/
PersonController.prototype.delete = async (req, res, tools) => {
//const Person = new PersonModel(tools)
//await Person.remove(req)
//return Person.sendResponse(res)
const Person = new PersonModel(tools)
await Person.guardedDelete(req)
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) => {
const User = new UserModel(tools)
await User.readAsAccount({ id: req.user.uid })
await User.readForReturn({ id: req.user.uid })
return User.sendResponse(res)
}
@ -61,7 +61,7 @@ UserController.prototype.whoami = async (req, res, tools) => {
UserController.prototype.update = async (req, res, tools) => {
const User = new UserModel(tools)
await User.read({ id: req.user.uid })
await User.unsafeUpdate(req.body)
await User.guardedUpdate(req.body, req.user)
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.userId = user.uid
// 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
try {
@ -43,7 +43,7 @@ PersonModel.prototype.create = async function ({ body, user }) {
? await setPersonAvatar(this.record.id, body.img)
: 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 })
return this.setResponse(201, 'created', { person: this.asPerson() })
@ -66,6 +66,27 @@ PersonModel.prototype.read = async function (where) {
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
*/
@ -85,55 +106,14 @@ PersonModel.prototype.reveal = async function () {
*/
PersonModel.prototype.cloak = function (data) {
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
}
/*
* 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
* the user exists or not
@ -150,7 +130,7 @@ PersonModel.prototype.setExists = function () {
* Updates the person data - Used when we create the data ourselves
* so we know it's safe
*/
PersonModel.prototype.safeUpdate = async function (data) {
PersonModel.prototype.unguardedUpdate = async function (data) {
try {
this.record = await this.prisma.person.update({
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
* 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.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
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 = {}
/*
img String?
*/
// Imperial
if (body.imperial === true || body.imperial === false) data.imperial = body.imperial
// Name
@ -189,11 +169,15 @@ PersonModel.prototype.unsafeUpdate = async function ({ params, body, user }) {
// Measurements
const measies = {}
if (typeof body.measies === 'object') {
const remove = []
for (const [key, val] of Object.entries(body.measies)) {
if (this.config.measies.includes(key) && typeof val === 'number' && val > 0)
measies[key] = val
if (this.config.measies.includes(key)) {
if (val === null) remove.push(key)
else if (typeof val == 'number' && val > 0) measies[key] = val
}
}
data.measies = { ...this.clear.measies, ...measies }
for (const key of remove) delete data.measies[key]
}
// Image (img)
@ -203,11 +187,39 @@ PersonModel.prototype.unsafeUpdate = async function ({ params, body, user }) {
}
// Now update the record
await this.safeUpdate(this.cloak(data))
await this.unguardedUpdate(this.cloak(data))
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
*/

View file

@ -67,7 +67,7 @@ UserModel.prototype.cloak = function (data) {
*
* Stores result in this.record
*/
UserModel.prototype.readAsAccount = async function (where) {
UserModel.prototype.readForReturn = async function (where) {
await this.read(where)
return this.setResponse(200, false, {
@ -175,7 +175,7 @@ UserModel.prototype.create = async function ({ body }) {
// Update username
try {
await this.safeUpdate({
await this.unguardedUpdate({
username: `user-${this.record.id}`,
lusername: `user-${this.record.id}`,
})
@ -243,7 +243,7 @@ UserModel.prototype.passwordLogin = async function (req) {
// Login success
if (updatedPasswordField) {
// 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')
@ -283,7 +283,7 @@ UserModel.prototype.confirm = async function ({ body, params }) {
if (this.error) return this
// Update user status, consent, and last login
await this.safeUpdate({
await this.unguardedUpdate({
status: 1,
consent: body.consent,
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
* so we know it's safe
*/
UserModel.prototype.safeUpdate = async function (data) {
UserModel.prototype.unguardedUpdate = async function (data) {
try {
this.record = await this.prisma.user.update({
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
* 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')
const data = {}
// Bio
@ -353,7 +353,7 @@ UserModel.prototype.unsafeUpdate = async function (body) {
}
// Now update the record
await this.safeUpdate(this.cloak(data))
await this.unguardedUpdate(this.cloak(data))
const isUnitTest = this.isUnitTest(body)
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
if (data.email.current === this.clear.email && typeof data.email.new === 'string') {
await this.safeUpdate({
await this.unguardedUpdate({
email: this.encrypt(data.email.new),
ehash: hash(clean(data.email.new)),
})

View file

@ -31,6 +31,14 @@ export function personRoutes(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
app.delete('/people/:id/jwt', passport.authenticate(...jwt), (req, res) =>
Person.delete(req, res, tools)

View file

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

View file

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

View file

@ -30,6 +30,10 @@ export const personTests = async (chai, config, expect, store) => {
jwt: {},
key: {},
}
store.altperson = {
jwt: {},
key: {},
}
for (const auth of ['jwt', 'key']) {
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:
// - Add non-existing measurement
// - Clear measurement
// - List/get person
// - Clone person
// - Clone person accross accounts of they are public
})
}
}

View file

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