1
0
Fork 0
freesewing/sites/backend/src/models/user.mjs

697 lines
20 KiB
JavaScript
Raw Normal View History

2022-11-05 22:02:51 +01:00
import jwt from 'jsonwebtoken'
2022-11-03 19:56:06 +01:00
import { log } from '../utils/log.mjs'
import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs'
2022-11-12 20:05:16 +01:00
import { setUserAvatar } from '../utils/sanity.mjs'
2023-01-14 17:08:33 +01:00
import { clean, asJson, i18nUrl } from '../utils/index.mjs'
2022-11-03 19:56:06 +01:00
import { ConfirmationModel } from './confirmation.mjs'
export function UserModel(tools) {
this.config = tools.config
this.prisma = tools.prisma
this.decrypt = tools.decrypt
this.encrypt = tools.encrypt
2022-11-17 20:41:21 +01:00
this.mfa = tools.mfa
2022-11-03 19:56:06 +01:00
this.mailer = tools.email
this.Confirmation = new ConfirmationModel(tools)
2022-11-17 20:41:21 +01:00
this.encryptedFields = ['bio', 'github', 'email', 'initial', 'img', 'mfaSecret']
2022-11-08 21:04:32 +01:00
this.clear = {} // For holding decrypted data
2022-11-03 19:56:06 +01:00
return this
}
/*
* Loads a user from the database based on the where clause you pass it
*
* Stores result in this.record
*/
2022-11-05 22:02:51 +01:00
UserModel.prototype.read = async function (where) {
try {
this.record = await this.prisma.user.findUnique({ where })
} catch (err) {
log.warn({ err, where }, 'Could not read user')
}
2022-11-08 21:04:32 +01:00
this.reveal()
2022-11-03 19:56:06 +01:00
return this.setExists()
}
2022-11-08 21:04:32 +01:00
/*
* Helper method to decrypt at-rest data
*/
UserModel.prototype.reveal = async function () {
2022-11-08 21:04:32 +01:00
this.clear = {}
if (this.record) {
2022-11-12 20:36:47 +01:00
for (const field of this.encryptedFields) {
this.clear[field] = this.decrypt(this.record[field])
}
2022-11-08 21:04:32 +01:00
}
return this
}
/*
* Helper method to encrypt at-rest data
*/
UserModel.prototype.cloak = function (data) {
2022-11-12 20:36:47 +01:00
for (const field of this.encryptedFields) {
if (typeof data[field] !== 'undefined') data[field] = this.encrypt(data[field])
}
if (typeof data.password === 'string') data.password = asJson(hashPassword(data.password))
return data
}
2022-11-08 21:04:32 +01:00
/*
* 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
*/
2022-11-14 18:30:54 +01:00
UserModel.prototype.guardedRead = async function (where, { user }) {
if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel')
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
2022-11-08 21:04:32 +01:00
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}`)
}
2022-11-08 21:04:32 +01:00
this.reveal()
return this.setExists()
}
/*
* Loads the user that is making the API request
*
* Stores result in this.authenticatedUser
*/
UserModel.prototype.loadAuthenticatedUser = async function (user) {
if (!user) return this
this.authenticatedUser = await this.prisma.user.findUnique({
2022-11-08 21:04:32 +01:00
where: { id: user.uid },
include: {
apikeys: true,
},
})
return this
}
/*
* Checks this.record and sets a boolean to indicate whether
* the user exists or not
*
* Stores result in this.exists
*/
2022-11-03 19:56:06 +01:00
UserModel.prototype.setExists = function () {
this.exists = this.record ? true : false
return this
}
/*
* Creates a user+confirmation and sends out signup email
*/
2022-11-14 18:26:20 +01:00
UserModel.prototype.guardedCreate = async function ({ body }) {
2022-12-29 13:40:25 -08:00
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
2022-11-03 19:56:06 +01:00
if (!body.email) return this.setResponse(400, 'emailMissing')
if (!body.language) return this.setResponse(400, 'languageMissing')
if (!this.config.languages.includes(body.language))
return this.setResponse(400, 'unsupportedLanguage')
2022-11-03 19:56:06 +01:00
const ehash = hash(clean(body.email))
2023-01-14 22:40:07 +01:00
const check = randomString()
2022-11-05 22:02:51 +01:00
await this.read({ ehash })
if (this.exists) {
/*
* User already exists. However, if we return an error, then people can
* spam the signup endpoint to figure out who has a FreeSewing account
* which would be a privacy leak. So instead, pretend there is no user
* with that account, and that signup is proceeding as normal.
* Except that rather than a signup email, we send the user an info email.
*
* Note that we have to deal with 3 scenarios here:
*
* - Account exists, and is active (aea)
* - Account exists, but is inactive (regular signup)
* - Account exists, but is disabled (aed)
*/
// Set type of action based on the account status
let type = 'signup-aed'
if (this.record.status === 0) type = 'signup'
else if (this.record.status === 1) type = 'signup-aea'
// Create confirmation unless account is disabled
if (type !== 'signup-aed') {
this.confirmation = await this.Confirmation.create({
type,
data: {
language: body.language,
email: this.clear.email,
id: this.record.id,
ehash: ehash,
2023-01-14 22:40:07 +01:00
check,
},
userId: this.record.id,
})
}
// Always send email
await this.mailer.send({
template: type,
language: body.language,
to: this.clear.email,
replacements: {
actionUrl:
type === 'signup-aed'
? false // No actionUrl for disabled accounts
2023-01-14 22:40:07 +01:00
: i18nUrl(body.language, `/confirm/${type}/${this.Confirmation.record.id}/${check}`),
whyUrl: i18nUrl(body.language, `/docs/faq/email/why-${type}`),
supportUrl: i18nUrl(body.language, `/patrons/join`),
},
})
// Now return as if everything is fine
return this.setResponse(201, false, { email: this.clear.email })
}
2022-11-03 19:56:06 +01:00
// New signup
2022-11-03 19:56:06 +01:00
try {
2022-11-08 21:04:32 +01:00
this.clear.email = clean(body.email)
this.clear.initial = this.clear.email
2022-11-03 19:56:06 +01:00
this.language = body.language
2022-11-08 21:04:32 +01:00
const email = this.encrypt(this.clear.email)
2022-11-03 19:56:06 +01:00
const username = clean(randomString()) // Temporary username
2022-11-08 21:04:32 +01:00
const data = {
ehash,
ihash: ehash,
email,
initial: email,
username,
lusername: username,
language: body.language,
2022-11-17 20:41:21 +01:00
mfaEnabled: false,
mfaSecret: this.encrypt(''),
password: asJson(hashPassword(randomString())), // We'll change this later
github: this.encrypt(''),
bio: this.encrypt(''),
2022-11-12 20:36:47 +01:00
// Set this one initially as we need the ID to create a custom img via Sanity
img: this.encrypt(this.config.avatars.user),
2022-11-08 21:04:32 +01:00
}
this.record = await this.prisma.user.create({ data })
2022-11-03 19:56:06 +01:00
} catch (err) {
log.warn(err, 'Could not create user record')
return this.setResponse(500, 'createAccountFailed')
}
// Update username
try {
await this.unguardedUpdate({
2022-11-03 19:56:06 +01:00
username: `user-${this.record.id}`,
lusername: `user-${this.record.id}`,
})
} catch (err) {
log.warn(err, 'Could not update username after user creation')
2022-11-05 22:02:51 +01:00
return this.setResponse(500, 'usernameUpdateAfterUserCreationFailed')
2022-11-03 19:56:06 +01:00
}
// Create confirmation
this.confirmation = await this.Confirmation.create({
type: 'signup',
2022-11-08 21:04:32 +01:00
data: {
2022-11-03 19:56:06 +01:00
language: this.language,
2022-11-08 21:04:32 +01:00
email: this.clear.email,
2022-11-03 19:56:06 +01:00
id: this.record.id,
ehash: ehash,
2023-01-14 22:40:07 +01:00
check,
2022-11-08 21:04:32 +01:00
},
2022-11-05 22:02:51 +01:00
userId: this.record.id,
2022-11-03 19:56:06 +01:00
})
2023-01-14 22:40:07 +01:00
console.log(check)
2022-11-03 19:56:06 +01:00
// Send signup email
2022-11-07 19:50:51 +01:00
if (!this.isUnitTest(body) || this.config.tests.sendEmail)
await this.mailer.send({
template: 'signup',
language: this.language,
2022-11-08 21:04:32 +01:00
to: this.clear.email,
2022-11-07 19:50:51 +01:00
replacements: {
2023-01-14 22:40:07 +01:00
actionUrl: i18nUrl(
this.language,
`/confirm/signup/${this.Confirmation.record.id}/${check}`
),
2022-11-07 19:50:51 +01:00
whyUrl: i18nUrl(this.language, `/docs/faq/email/why-signup`),
supportUrl: i18nUrl(this.language, `/patrons/join`),
},
})
2022-11-03 19:56:06 +01:00
2022-11-07 19:50:51 +01:00
return this.isUnitTest(body)
2022-11-08 21:04:32 +01:00
? this.setResponse(201, false, {
email: this.clear.email,
confirmation: this.confirmation.record.id,
})
: this.setResponse(201, false, { email: this.clear.email })
2022-11-03 19:56:06 +01:00
}
/*
* Login based on username + password
*/
UserModel.prototype.passwordLogin = async function (req) {
2022-12-29 13:40:25 -08:00
if (Object.keys(req.body).length < 1) return this.setResponse(400, 'postBodyMissing')
if (!req.body.username) return this.setResponse(400, 'usernameMissing')
if (!req.body.password) return this.setResponse(400, 'passwordMissing')
await this.find(req.body)
if (!this.exists) {
log.warn(`Login attempt for non-existing user: ${req.body.username} from ${req.ip}`)
return this.setResponse(401, 'loginFailed')
}
// Account found, check password
const [valid, updatedPasswordField] = verifyPassword(req.body.password, this.record.password)
if (!valid) {
log.warn(`Wrong password for existing user: ${req.body.username} from ${req.ip}`)
return this.setResponse(401, 'loginFailed')
}
2022-11-17 20:41:21 +01:00
// Check for MFA
if (this.record.mfaEnabled) {
2022-11-18 16:04:06 +01:00
if (!req.body.token) return this.setResponse(403, 'mfaTokenRequired')
2022-11-17 20:41:21 +01:00
else if (!this.mfa.verify(req.body.token, this.clear.mfaSecret)) {
return this.setResponse(401, 'loginFailed')
}
}
// Login success
2022-11-17 20:41:21 +01:00
log.info(`Login by user ${this.record.id} (${this.record.username})`)
if (updatedPasswordField) {
// Update the password field with a v3 hash
await this.unguardedUpdate({ password: updatedPasswordField })
}
return this.isOk() ? this.loginOk() : this.setResponse(401, 'loginFailed')
}
/*
2022-11-05 22:02:51 +01:00
* Confirms a user account
*/
2022-11-05 22:02:51 +01:00
UserModel.prototype.confirm = async function ({ body, params }) {
2022-12-24 14:42:16 +01:00
if (!params.id) return this.setResponse(404)
2022-12-29 13:40:25 -08:00
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
2022-11-05 22:02:51 +01:00
if (!body.consent || typeof body.consent !== 'number' || body.consent < 1)
return this.setResponse(400, 'consentRequired')
// Retrieve confirmation record
await this.Confirmation.read({ id: params.id })
if (!this.Confirmation.exists) {
log.warn(`Could not find confirmation id ${params.id}`)
2022-12-24 14:42:16 +01:00
return this.setResponse(404)
2022-11-03 19:56:06 +01:00
}
2022-11-05 22:02:51 +01:00
if (this.Confirmation.record.type !== 'signup') {
log.warn(`Confirmation mismatch; ${params.id} is not a signup id`)
2022-12-24 14:42:16 +01:00
return this.setResponse(404)
2022-11-05 22:02:51 +01:00
}
if (this.error) return this
2022-11-08 21:04:32 +01:00
const data = this.Confirmation.clear.data
2022-12-24 14:42:16 +01:00
if (data.ehash !== this.Confirmation.record.user.ehash) return this.setResponse(404)
if (data.id !== this.Confirmation.record.user.id) return this.setResponse(404)
2022-11-05 22:02:51 +01:00
// Load user
await this.read({ id: this.Confirmation.record.user.id })
if (this.error) return this
// Update user status, consent, and last login
await this.unguardedUpdate({
status: 1,
2022-11-05 22:02:51 +01:00
consent: body.consent,
lastLogin: new Date(),
})
if (this.error) return this
// Before we return, remove the confirmation so it works only once
await this.Confirmation.unguardedDelete()
2022-11-05 22:02:51 +01:00
// Account is now active, let's return a passwordless login
return this.loginOk()
2022-11-03 19:56:06 +01:00
}
/*
2022-11-08 21:04:32 +01:00
* Updates the user data - Used when we create the data ourselves
* so we know it's safe
*/
UserModel.prototype.unguardedUpdate = async function (data) {
2022-11-03 19:56:06 +01:00
try {
this.record = await this.prisma.user.update({
where: { id: this.record.id },
data,
})
} catch (err) {
log.warn(err, 'Could not update user record')
process.exit()
2022-11-05 22:02:51 +01:00
return this.setResponse(500, 'updateUserFailed')
2022-11-03 19:56:06 +01:00
}
await this.reveal()
2022-11-03 19:56:06 +01:00
return this.setResponse(200)
}
2022-11-08 21:04:32 +01:00
/*
* Updates the user data - Used when we pass through user-provided data
* so we can't be certain it's safe
*/
2022-11-14 18:30:54 +01:00
UserModel.prototype.guardedUpdate = async function ({ body, user }) {
2022-11-12 21:19:04 +01:00
if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel')
2022-11-14 18:30:54 +01:00
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
2022-11-08 21:04:32 +01:00
const data = {}
// Bio
if (typeof body.bio === 'string') data.bio = body.bio
// Consent
2022-11-08 21:04:32 +01:00
if ([0, 1, 2, 3].includes(body.consent)) data.consent = body.consent
// Control
2022-11-14 18:45:45 +01:00
if ([1, 2, 3, 4, 5].includes(body.control)) data.control = body.control
// Github
if (typeof body.github === 'string') data.github = body.github.split('@').pop()
// Imperial
if ([true, false].includes(body.imperial)) data.imperial = body.imperial
// Language
if (this.config.languages.includes(body.language)) data.language = body.language
// Newsletter
2022-11-08 21:04:32 +01:00
if ([true, false].includes(body.newsletter)) data.newsletter = body.newsletter
// Password
if (typeof body.password === 'string') data.password = body.password // Will be cloaked below
// Username
2022-11-08 21:04:32 +01:00
if (typeof body.username === 'string') {
const available = await this.isLusernameAvailable(body.username)
if (available) {
2022-11-08 21:04:32 +01:00
data.username = body.username.trim()
data.lusername = clean(body.username)
} else {
log.info(`Rejected user name change from ${data.username} to ${body.username.trim()}`)
2022-11-08 21:04:32 +01:00
}
}
// Image (img)
if (typeof body.img === 'string') {
2022-11-12 20:05:16 +01:00
const img = await setUserAvatar(this.record.id, body.img)
data.img = img.url
}
2022-11-08 21:04:32 +01:00
// Now update the record
await this.unguardedUpdate(this.cloak(data))
const isUnitTest = this.isUnitTest(body)
if (typeof body.email === 'string' && this.clear.email !== clean(body.email)) {
// Email change (requires confirmation)
this.confirmation = await this.Confirmation.create({
type: 'emailchange',
data: {
language: this.record.language,
email: {
current: this.clear.email,
new: body.email,
},
},
userId: this.record.id,
})
if (!isUnitTest || this.config.tests.sendEmail) {
// Send confirmation email
await this.mailer.send({
template: 'emailchange',
language: this.record.language,
to: body.email,
cc: this.clear.email,
replacements: {
actionUrl: i18nUrl(this.language, `/confirm/emailchange/${this.Confirmation.record.id}`),
whyUrl: i18nUrl(this.language, `/docs/faq/email/why-emailchange`),
supportUrl: i18nUrl(this.language, `/patrons/join`),
},
})
}
} else if (typeof body.confirmation === 'string' && body.confirm === 'emailchange') {
// Handle email change confirmation
await this.Confirmation.read({ id: body.confirmation })
if (!this.Confirmation.exists) {
log.warn(`Could not find confirmation id ${body.confirmation}`)
2022-12-24 14:42:16 +01:00
return this.setResponse(404)
}
if (this.Confirmation.record.type !== 'emailchange') {
log.warn(`Confirmation mismatch; ${body.confirmation} is not an emailchange id`)
2022-12-24 14:42:16 +01:00
return this.setResponse(404)
}
const data = this.Confirmation.clear.data
if (data.email.current === this.clear.email && typeof data.email.new === 'string') {
await this.unguardedUpdate({
email: this.encrypt(data.email.new),
ehash: hash(clean(data.email.new)),
})
}
2022-11-08 21:04:32 +01:00
}
const returnData = {
result: 'success',
account: this.asAccount(),
}
if (isUnitTest) returnData.confirmation = this.Confirmation.record.id
return this.setResponse(200, false, returnData)
2022-11-08 21:04:32 +01:00
}
2022-11-17 20:41:21 +01:00
/*
* Enables/Disables MFA on the account - Used when we pass through
* user-provided data so we can't be certain it's safe
*/
2022-11-18 16:04:06 +01:00
UserModel.prototype.guardedMfaUpdate = async function ({ body, user, ip }) {
2022-11-17 20:41:21 +01:00
if (user.level < 4) return this.setResponse(403, 'insufficientAccessLevel')
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
if (body.mfa === true && this.record.mfaEnabled === true)
return this.setResponse(400, 'mfaActive')
// Disable
if (body.mfa === false) {
if (!body.token) return this.setResponse(400, 'mfaTokenMissing')
if (!body.password) return this.setResponse(400, 'passwordMissing')
2022-11-18 16:04:06 +01:00
// Check password
const [valid] = verifyPassword(body.password, this.record.password)
if (!valid) {
console.log('password check failed')
log.warn(`Wrong password for existing user while disabling MFA: ${user.uid} from ${ip}`)
return this.setResponse(401, 'authenticationFailed')
}
// Check MFA token
if (this.mfa.verify(body.token, this.clear.mfaSecret)) {
// Looks good. Disable MFA
try {
await this.unguardedUpdate({ mfaEnabled: false })
} catch (err) {
log.warn(err, 'Could not disable MFA after token check')
return this.setResponse(500, 'mfaDeactivationFailed')
}
return this.setResponse(200, false, {})
} else {
console.log('token check failed')
return this.setResponse(401, 'authenticationFailed')
}
2022-11-17 20:41:21 +01:00
}
// Confirm
else if (body.mfa === true && body.token && body.secret) {
if (body.secret === this.clear.mfaSecret && this.mfa.verify(body.token, this.clear.mfaSecret)) {
// Looks good. Enable MFA
try {
await this.unguardedUpdate({
mfaEnabled: true,
})
} catch (err) {
log.warn(err, 'Could not enable MFA after token check')
return this.setResponse(500, 'mfaActivationFailed')
}
return this.setResponse(200, false, {})
} else return this.setResponse(403, 'mfaTokenInvalid')
}
// Enroll
else if (body.mfa === true && this.record.mfaEnabled === false) {
let mfa
try {
mfa = await this.mfa.enroll(this.record.username)
} catch (err) {
log.warn(err, 'Failed to enroll MFA')
}
// Update mfaSecret
try {
await this.unguardedUpdate({
mfaSecret: this.encrypt(mfa.secret),
})
} catch (err) {
log.warn(err, 'Could not update username after user creation')
return this.setResponse(500, 'usernameUpdateAfterUserCreationFailed')
}
return this.setResponse(200, false, { mfa })
}
return this.setResponse(400, 'invalidMfaSetting')
}
2022-11-05 22:02:51 +01:00
/*
* Returns account data
*/
UserModel.prototype.asAccount = function () {
return {
id: this.record.id,
bio: this.clear.bio,
2022-11-05 22:02:51 +01:00
consent: this.record.consent,
2022-11-14 18:45:45 +01:00
control: this.record.control,
2022-11-05 22:02:51 +01:00
createdAt: this.record.createdAt,
2022-11-08 21:04:32 +01:00
email: this.clear.email,
github: this.clear.github,
img: this.record.img,
imperial: this.record.imperial,
2022-11-08 21:04:32 +01:00
initial: this.clear.initial,
language: this.record.language,
2022-11-05 22:02:51 +01:00
lastLogin: this.record.lastLogin,
2022-11-18 16:04:06 +01:00
mfaEnabled: this.record.mfaEnabled,
2022-11-05 22:02:51 +01:00
newsletter: this.record.newsletter,
patron: this.record.patron,
role: this.record.role,
status: this.record.status,
updatedAt: this.record.updatedAt,
username: this.record.username,
lusername: this.record.lusername,
}
}
/*
* Returns a JSON Web Token (jwt)
*/
UserModel.prototype.getToken = function () {
return jwt.sign(
{
_id: this.record.id,
username: this.record.username,
role: this.record.role,
status: this.record.status,
aud: this.config.jwt.audience,
iss: this.config.jwt.issuer,
},
this.config.jwt.secretOrKey,
{ expiresIn: this.config.jwt.expiresIn }
)
}
/*
* Helper method to set the response code, result, and body
*
* Will be used by this.sendResponse()
*/
UserModel.prototype.setResponse = function (status = 200, error = false, data = {}) {
this.response = {
status,
body: {
result: 'success',
...data,
},
}
if (status > 201) {
this.response.body.error = error
this.response.body.result = 'error'
this.error = true
} else this.error = false
2022-12-24 14:42:16 +01:00
if (status === 404) this.response.body = null
return this.setExists()
}
/*
* Helper method to send response
*/
UserModel.prototype.sendResponse = async function (res) {
return res.status(this.response.status).send(this.response.body)
}
2022-11-07 19:50:51 +01:00
/*
* Update method to determine whether this request is
* part of a unit test
*/
UserModel.prototype.isUnitTest = function (body) {
if (!body.unittest) return false
if (!this.clear.email.split('@').pop() === this.config.tests.domain) return false
if (body.email && !body.email.split('@').pop() === this.config.tests.domain) return false
return true
2022-11-07 19:50:51 +01:00
}
/*
* Helper method to check an account is ok
*/
UserModel.prototype.isOk = function () {
if (
this.exists &&
this.record &&
this.record.status > 0 &&
this.record.consent > 0 &&
this.record.role &&
this.record.role !== 'blocked'
)
return true
return false
}
/*
* Helper method to return from successful login
*/
UserModel.prototype.loginOk = function () {
return this.setResponse(200, false, {
result: 'success',
token: this.getToken(),
account: this.asAccount(),
})
}
/*
* Check to see if a (lowercase) username is available
* as well as making sure username is not something we
* do not allow
*/
UserModel.prototype.isLusernameAvailable = async function (lusername) {
if (lusername.length < 2) return false
try {
2022-12-29 13:40:25 -08:00
await this.prisma.user.findUnique({ where: { lusername } })
} catch (err) {
log.warn({ err, lusername }, 'Could not search for free username')
}
return true
}