diff --git a/sites/backend/src/controllers/user.mjs b/sites/backend/src/controllers/user.mjs index e7c81c14fad..cd2bf037e6b 100644 --- a/sites/backend/src/controllers/user.mjs +++ b/sites/backend/src/controllers/user.mjs @@ -7,42 +7,6 @@ import { log } from '../utils/log.mjs' import set from 'lodash.set' import { UserModel } from '../models/user.mjs' -/* - * Prisma is not an ORM and we can't attach methods to the model - * So here's a bunch of helper methods that expect a user object - * as input - */ -const asAccount = (user, decrypt) => ({ - id: user.id, - consent: user.consent, - createdAt: user.createdAt, - data: user.data, - email: decrypt(user.email), - initial: decrypt(user.initial), - lastLogin: user.lastLogin, - newsletter: user.newsletter, - patron: user.patron, - role: user.role, - status: user.status, - updatedAt: user.updatedAt, - username: user.username, - lusername: user.lusername, -}) - -const getToken = (user, config) => - jwt.sign( - { - _id: user.id, - username: user.username, - role: user.role, - status: user.status, - aud: config.jwt.audience, - iss: config.jwt.issuer, - }, - config.jwt.secretOrKey, - { expiresIn: config.jwt.expiresIn } - ) - const isUsernameAvailable = async (username, prisma) => { const user = await prisme.user.findUnique({ where: { @@ -99,98 +63,20 @@ UserController.prototype.login = async function (req, res, tools) { } UserController.prototype.whoami = async (req, res, tools) => { - if (!req.user?._id) return res.status(400).send({ error: 'bearerMissing', result }) + const User = new UserModel(tools) + await User.readAsAccount({ id: req.user.uid }) - // Destructure what we need from tools - const { prisma, decrypt } = tools - - // Retrieve user account - let account - try { - account = await prisma.user.findUnique({ - where: { - id: req.user._id, - }, - }) - } catch (err) { - log.warn(err, `Could not lookup user id ${req.user._id} from token data`) - return res.status(404).send({ error: 'failedToRetrieveUserIdFromTokenData', result }) - } - if (!account) { - log.warn(err, `Could not find user id ${req.user._id} from token data`) - return res.status(404).send({ error: 'failedToLoadUserFromTokenData', result }) - } - - // Return account data - return res.status(200).send({ - result: 'success', - account: asAccount({ ...account }, decrypt), - }) + return User.sendResponse(res) } UserController.prototype.update = async (req, res, tools) => { - console.log('update please') - - // Destructure what we need from tools - const { prisma, decrypt } = tools - - // Retrieve user account - let account - try { - account = await prisma.user.findUnique({ - where: { - id: req.user._id, - }, - }) - } catch (err) { - log.warn(err, `Could not lookup user id ${req.user._id} from token data`) - return res.status(404).send({ error: 'failedToRetrieveUserIdFromTokenData', result }) - } - if (!account) { - log.warn(err, `Could not find user id ${req.user._id} from token data`) - return res.status(404).send({ error: 'failedToLoadUserFromTokenData', result }) - } - - // Account loaded - Handle various updates - const data = {} - // Username - if (req.body.username) { - if (!isUsernameAvailable(req.body.username, prisma)) { - return res.status(400).send({ error: 'usernameTaken', result }) - } - data.username = req.body.username - data.lusername = data.username.toLowerCase() - } - // Newsletter - if (req.body.newsletter === false) data.newsletter = false - if (req.body.newsletter === true) data.newsletter = true - // Consent - if (typeof req.body.consent !== 'undefined') data.consent = req.body.consent - // Bio - if (typeof req.body.bio === 'string') userData.bio = req.body.bio - // Password - if (typeof req.body.password === 'string') - userData.password = asJson(hashPassword(req.body.password)) - // Data - const userData = JSON.parse(account.data) - const uhash = hash(account.data) - if (typeof req.body.language === 'string') set(userData, 'settings.language', req.body.language) - if (typeof req.body.units === 'string') set(userData, 'settings.units', req.body.units) - if (typeof req.body.github === 'string') set(userData, 'settings.social.github', req.body.github) - if (typeof req.body.twitter === 'string') - set(userData, 'settings.social.twitter', req.body.twitter) - if (typeof req.body.instagram === 'string') - set(userData, 'settings.social.instagram', req.body.instagram) - // Did data change? - if (uhash !== hash(userData)) data.data = JSON.stringify(userData) + const User = new UserModel(tools) + await User.read({ id: req.user.uid }) // Commit - prisma.user.update({ - where: { id: account.id }, - data, - }) - + //await User.update({ id: req.user.uid }, req.body) // Email change requires confirmation + /* if (typeof req.body.email === 'string') { const currentEmail = decrypt(account.email) if (req.body.email !== currentEmail) { @@ -249,6 +135,7 @@ UserController.prototype.update = async (req, res, tools) => { } } } + */ // Now handle the /* else if (typeof data.email === 'string' && data.email !== user.email) { diff --git a/sites/backend/src/middleware.mjs b/sites/backend/src/middleware.mjs index 47130cc74a0..00f3681b6f2 100644 --- a/sites/backend/src/middleware.mjs +++ b/sites/backend/src/middleware.mjs @@ -15,7 +15,9 @@ function loadPassportMiddleware(passport, tools) { new http.BasicStrategy(async (key, secret, done) => { const Apikey = new ApikeyModel(tools) await Apikey.verify(key, secret) - return Apikey.verified ? done(null, { ...Apikey.record, apikey: true }) : done(false) + return Apikey.verified + ? done(null, { ...Apikey.record, apikey: true, uid: Apikey.record.userId }) + : done(false) }) ) passport.use( @@ -25,7 +27,7 @@ function loadPassportMiddleware(passport, tools) { ...tools.config.jwt, }, (jwt_payload, done) => { - return done(null, jwt_payload) + return done(null, { ...jwt_payload, uid: jwt_payload._id }) } ) ) diff --git a/sites/backend/src/models/apikey.mjs b/sites/backend/src/models/apikey.mjs index b91f879ee3d..d3bdea21bfe 100644 --- a/sites/backend/src/models/apikey.mjs +++ b/sites/backend/src/models/apikey.mjs @@ -125,7 +125,7 @@ ApikeyModel.prototype.create = async function ({ body, user }) { name: body.name, level: body.level, secret: asJson(hashPassword(secret)), - userId: user._id || user.userId, + userId: user.uid, }, }) } catch (err) { diff --git a/sites/backend/src/models/confirmation.mjs b/sites/backend/src/models/confirmation.mjs index 26d2117308f..d41e58385de 100644 --- a/sites/backend/src/models/confirmation.mjs +++ b/sites/backend/src/models/confirmation.mjs @@ -4,6 +4,9 @@ import { hash } from '../utils/crypto.mjs' export function ConfirmationModel(tools) { this.config = tools.config this.prisma = tools.prisma + this.decrypt = tools.decrypt + this.encrypt = tools.encrypt + this.clear = {} return this } @@ -15,6 +18,7 @@ ConfirmationModel.prototype.read = async function (where) { user: true, }, }) + this.clear.data = this.record?.data ? this.decrypt(this.record.data) : {} return this.setExists() } @@ -63,7 +67,9 @@ ConfirmationModel.prototype.setResponse = function ( ConfirmationModel.prototype.create = async function (data = {}) { try { - this.record = await this.prisma.confirmation.create({ data }) + this.record = await this.prisma.confirmation.create({ + data: { ...data, data: this.encrypt(data.data) }, + }) } catch (err) { log.warn(err, 'Could not create confirmation record') return this.setResponse(500, 'createConfirmationFailed') diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 59fb86a9971..d825e7dea3f 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -11,6 +11,7 @@ export function UserModel(tools) { this.encrypt = tools.encrypt this.mailer = tools.email this.Confirmation = new ConfirmationModel(tools) + this.clear = {} // For holding decrypted data return this } @@ -27,12 +28,40 @@ UserModel.prototype.read = async function (where) { log.warn({ err, where }, 'Could not read user') } - if (this.record?.email) this.email = this.decrypt(this.record.email) - if (this.record?.initial) this.initial = this.decrypt(this.record.initial) + this.reveal() return this.setExists() } +/* + * Helper method to decrypt at-rest data + */ +UserModel.prototype.reveal = async function (where) { + this.clear = {} + if (this.record) { + this.clear.data = JSON.parse(this.decrypt(this.record.data)) + this.clear.email = this.decrypt(this.record.email) + this.clear.initial = this.decrypt(this.record.initial) + } + + return this +} + +/* + * 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) @@ -56,6 +85,8 @@ UserModel.prototype.find = async function (body) { log.warn({ err, body }, `Error while trying to find user: ${body.username}`) } + this.reveal() + return this.setExists() } @@ -66,9 +97,8 @@ UserModel.prototype.find = async function (body) { */ UserModel.prototype.loadAuthenticatedUser = async function (user) { if (!user) return this - const where = user?.apikey ? { id: user.userId } : { id: user._id } this.authenticatedUser = await this.prisma.user.findUnique({ - where, + where: { id: user.uid }, include: { apikeys: true, }, @@ -103,22 +133,22 @@ UserModel.prototype.create = async function ({ body }) { if (this.exists) return this.setResponse(400, 'emailExists') try { - this.email = clean(body.email) + this.clear.email = clean(body.email) + this.clear.initial = this.clear.email this.language = body.language - const email = this.encrypt(this.email) + const email = this.encrypt(this.clear.email) const username = clean(randomString()) // Temporary username - this.record = await this.prisma.user.create({ - data: { - ehash, - ihash: ehash, - email, - initial: email, - username, - lusername: username, - data: this.encrypt(asJson({ settings: { language: this.language } })), - password: asJson(hashPassword(body.password)), - }, - }) + const data = { + ehash, + ihash: ehash, + email, + initial: email, + username, + lusername: username, + data: this.encrypt(asJson({ settings: { language: this.language } })), + password: asJson(hashPassword(body.password)), + } + this.record = await this.prisma.user.create({ data }) } catch (err) { log.warn(err, 'Could not create user record') return this.setResponse(500, 'createAccountFailed') @@ -126,7 +156,7 @@ UserModel.prototype.create = async function ({ body }) { // Update username try { - await this.update({ + await this.safeUpdate({ username: `user-${this.record.id}`, lusername: `user-${this.record.id}`, }) @@ -138,12 +168,12 @@ UserModel.prototype.create = async function ({ body }) { // Create confirmation this.confirmation = await this.Confirmation.create({ type: 'signup', - data: this.encrypt({ + data: { language: this.language, - email: this.email, + email: this.clear.email, id: this.record.id, ehash: ehash, - }), + }, userId: this.record.id, }) @@ -152,7 +182,7 @@ UserModel.prototype.create = async function ({ body }) { await this.mailer.send({ template: 'signup', language: this.language, - to: this.email, + to: this.clear.email, replacements: { actionUrl: i18nUrl(this.language, `/confirm/signup/${this.Confirmation.record.id}`), whyUrl: i18nUrl(this.language, `/docs/faq/email/why-signup`), @@ -161,8 +191,11 @@ UserModel.prototype.create = async function ({ body }) { }) return this.isUnitTest(body) - ? this.setResponse(201, false, { email: this.email, confirmation: this.confirmation.record.id }) - : this.setResponse(201, false, { email: this.email }) + ? this.setResponse(201, false, { + email: this.clear.email, + confirmation: this.confirmation.record.id, + }) + : this.setResponse(201, false, { email: this.clear.email }) } /* @@ -191,7 +224,7 @@ UserModel.prototype.passwordLogin = async function (req) { // Login success if (updatedPasswordField) { // Update the password field with a v3 hash - await this.update({ password: updatedPasswordField }) + await this.safeUpdate({ password: updatedPasswordField }) } return this.isOk() ? this.loginOk() : this.setResponse(401, 'loginFailed') @@ -220,7 +253,7 @@ UserModel.prototype.confirm = async function ({ body, params }) { } if (this.error) return this - const data = await this.decrypt(this.Confirmation.record.data) + const data = this.Confirmation.clear.data if (data.ehash !== this.Confirmation.record.user.ehash) return this.setResponse(404, 'confirmationEhashMismatch') if (data.id !== this.Confirmation.record.user.id) @@ -231,7 +264,7 @@ UserModel.prototype.confirm = async function ({ body, params }) { if (this.error) return this // Update user status, consent, and last login - await this.update({ + await this.safeUpdate({ status: 1, consent: body.consent, lastLogin: new Date(), @@ -243,9 +276,10 @@ UserModel.prototype.confirm = async function ({ body, params }) { } /* - * Updates the user data + * Updates the user data - Used when we create the data ourselves + * so we know it's safe */ -UserModel.prototype.update = async function (data) { +UserModel.prototype.safeUpdate = async function (data) { try { this.record = await this.prisma.user.update({ where: { id: this.record.id }, @@ -260,6 +294,49 @@ UserModel.prototype.update = async function (data) { return this.setResponse(200) } +/* + * 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) { + const data = {} + // Update consent + if ([0, 1, 2, 3].includes(body.consent)) data.consent = body.consent + // Update newsletter + if ([true, false].includes(body.newsletter)) data.newsletter = body.newsletter + // Update username + if (typeof body.username === 'string') { + if (await this.isAvailableUsername(body.username)) { + data.username = body.username.trim() + data.lusername = clean(body.username) + } + } + // Update password + if (typeof body.password === 'string') { + data.password = asJson(hashPassword(body.password)) + } + // Update data + if (typeof body.data === 'object') { + data.data = { ...this.record.data } + } + + /* + data String @default("{}") + ehash String @unique + email String + */ + + try { + this.record = await this.prisma.user.update({ where, data }) + } catch (err) { + log.warn(err, 'Could not update user record') + process.exit() + return this.setResponse(500, 'updateUserFailed') + } + + return this.setResponse(200) +} + /* * Returns account data */ @@ -268,9 +345,9 @@ UserModel.prototype.asAccount = function () { id: this.record.id, consent: this.record.consent, createdAt: this.record.createdAt, - data: JSON.parse(this.decrypt(this.record.data)), - email: this.decrypt(this.record.email), - initial: this.decrypt(this.record.initial), + data: this.clear.data, + email: this.clear.email, + initial: this.clear.initial, lastLogin: this.record.lastLogin, newsletter: this.record.newsletter, patron: this.record.patron, @@ -334,7 +411,7 @@ UserModel.prototype.sendResponse = async function (res) { * part of a unit test */ UserModel.prototype.isUnitTest = function (body) { - return body.unittest && this.email.split('@').pop() === this.config.tests.domain + return body.unittest && this.clear.email.split('@').pop() === this.config.tests.domain } /* diff --git a/sites/backend/tests/account.mjs b/sites/backend/tests/account.mjs index 5daf9532288..a47d1812988 100644 --- a/sites/backend/tests/account.mjs +++ b/sites/backend/tests/account.mjs @@ -18,7 +18,15 @@ export const accountTests = async (config, store, chai) => { .request(config.api) .put('/account/jwt') .set('Authorization', 'Bearer ' + store.account.token) - .send({ consent: 3 }) + .send({ + consent: 3, + data: { + banana: 'Sure', + }, + newsletter: true, + password: 'Something new', + username: 'new', + }) .end((err, res) => { expect(err === null).to.equal(true) expect(res.status).to.equal(200) diff --git a/sites/backend/tests/index.mjs b/sites/backend/tests/index.mjs index cf4ede6569d..034fcab39f2 100644 --- a/sites/backend/tests/index.mjs +++ b/sites/backend/tests/index.mjs @@ -33,7 +33,7 @@ store.icon = (icon1, icon2 = false) => store.icons[icon1] + (icon2 ? store.icons const runTests = async (config, store, chai) => { await setup(config, store, chai) await userTests(config, store, chai) - //await apikeyTests(config, store, chai) + await apikeyTests(config, store, chai) //await accountTests(config, store, chai) }