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

1358 lines
36 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'
2023-08-13 09:39:05 +02:00
import { replaceImage, importImage } from '../utils/cloudflare-images.mjs'
2023-01-14 17:08:33 +01:00
import { clean, asJson, i18nUrl } from '../utils/index.mjs'
2023-08-13 09:39:05 +02:00
import { decorateModel } from '../utils/model-decorator.mjs'
2022-11-08 21:04:32 +01:00
/*
2023-08-13 09:39:05 +02:00
* This model handles all user updates
*/
2023-08-13 09:39:05 +02:00
export function UserModel(tools) {
return decorateModel(this, tools, {
name: 'user',
encryptedFields: ['bio', 'github', 'email', 'initial', 'img', 'mfaSecret'],
models: ['confirmation', 'set', 'pattern'],
})
}
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
2023-08-13 09:39:05 +02:00
* This is guarded so it enforces access control and validates input
2022-11-08 21:04:32 +01:00
*
2023-08-13 09:39:05 +02:00
* @param {where} object - The where clasuse for the Prisma query
* @returns {UserModel} object - The UserModel
2022-11-08 21:04:32 +01:00
*/
2022-11-14 18:30:54 +01:00
UserModel.prototype.guardedRead = async function (where, { user }) {
2023-08-13 09:39:05 +02:00
/*
* Enforce RBAC
*/
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
2023-08-13 09:39:05 +02:00
/*
* Ensure the account is active
*/
2022-11-14 18:30:54 +01:00
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
2023-08-13 09:39:05 +02:00
/*
* Read record from database
*/
2022-11-08 21:04:32 +01:00
await this.read(where)
2023-08-13 09:39:05 +02:00
return this.setResponse200({
2022-11-08 21:04:32 +01:00
result: 'success',
account: this.asAccount(),
})
}
/*
* Finds a user based on one of the accepted unique fields which are:
* - lusername (lowercase username)
* - ehash
* - id
*
2023-08-13 09:39:05 +02:00
* @param {body} object - The request body
* @returns {UserModel} object - The UserModel
*/
UserModel.prototype.find = async function (body) {
2023-08-13 09:39:05 +02:00
/*
* Attempt to load record (one) from the database
*/
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) {
2023-08-13 09:39:05 +02:00
/*
* Failed to run database query. Log warning and return 404
*/
log.warn({ err, body }, `Error while trying to find user: ${body.username}`)
2023-08-13 09:54:56 +02:00
return this.setResponse(404)
}
2023-08-13 09:39:05 +02:00
/*
* Decrypt data that is encrypted at rest
*/
await this.reveal()
2022-11-08 21:04:32 +01:00
2023-08-13 09:39:05 +02:00
return this.recordExists()
}
/*
* Loads the user that is making the API request
*
2023-08-13 09:39:05 +02:00
* @param {user} object - The user as loaded by the authentication middleware
* @returns {UserModel} object - The UserModel
*/
UserModel.prototype.loadAuthenticatedUser = async function (user) {
2023-08-13 09:39:05 +02:00
/*
* Guard against missing input
*/
if (!user) return this
2023-08-13 09:39:05 +02:00
/*
* Now attempt to load the full user record from the database
*/
try {
this.authenticatedUser = await this.prisma.user.findUnique({
where: { id: user.uid },
include: {
apikeys: true,
},
})
} catch (err) {
/*
* Failed to run database query. Log warning and return 404
*/
2023-08-13 09:54:56 +02:00
log.warn({ err, user }, `Error while trying to find user: ${user.uid}`)
return this.setResponse(404)
2023-08-13 09:39:05 +02:00
}
return this
}
/*
* Loads & reveals the user that is making the API request
2023-08-13 09:39:05 +02:00
*
* @param {user} object - The user as loaded by the authentication middleware
* @returns {UserModel} object - The UserModel
*/
UserModel.prototype.revealAuthenticatedUser = async function (user) {
2023-08-13 09:39:05 +02:00
/*
* Guard against missing input
*/
if (!user) return this
2023-08-13 09:39:05 +02:00
/*
* Now attempt to load the full user record from the database
*/
try {
this.record = await this.prisma.user.findUnique({
where: { id: user.uid },
include: {
apikeys: true,
},
})
} catch (err) {
/*
* Failed to run database query. Log warning and return 404
*/
2023-08-13 09:54:56 +02:00
log.warn({ err, user }, `Error while trying to find and reveal user: ${user.uid}`)
return this.setResponse(404)
2023-08-13 09:39:05 +02:00
}
return this.reveal()
}
/*
2023-08-13 09:39:05 +02:00
* Creates a user+confirmation and sends out signup email - Anonymous route
*
2023-08-13 09:39:05 +02:00
* @param {body} object - The request body
* @returns {UserModel} object - The UserModel
*/
2022-11-14 18:26:20 +01:00
UserModel.prototype.guardedCreate = async function ({ body }) {
2023-08-13 09:39:05 +02:00
/*
* Do we have a POST body?
*/
2022-12-29 13:40:25 -08:00
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
2023-08-13 09:39:05 +02:00
/*
* Is email set?
*/
2022-11-03 19:56:06 +01:00
if (!body.email) return this.setResponse(400, 'emailMissing')
2023-08-13 09:39:05 +02:00
/*
* Is language set?
*/
2022-11-03 19:56:06 +01:00
if (!body.language) return this.setResponse(400, 'languageMissing')
2023-08-13 09:39:05 +02:00
/*
* Is language a supported language?
*/
if (!this.config.languages.includes(body.language))
return this.setResponse(400, 'unsupportedLanguage')
2022-11-03 19:56:06 +01:00
2023-08-13 09:39:05 +02:00
/*
* Create ehash and check
*/
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()
2023-08-13 09:39:05 +02:00
/*
* Check if we already have a user with this email address
*/
2022-11-05 22:02:51 +01:00
await this.read({ ehash })
2023-08-13 09:39:05 +02:00
/*
* Check for unit tests only once
*/
const isTest = this.isTest(body)
if (this.exists) {
/*
* User already exists. However, if we return an error, then baddies 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)
*/
2023-08-13 09:39:05 +02:00
/*
* 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'
2023-08-13 09:39:05 +02:00
/*
* Create confirmation unless account is disabled
*/
if (type !== 'signup-aed') {
2023-08-13 09:39:05 +02:00
this.confirmation = await this.Confirmation.createRecord({
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,
})
}
2023-08-13 09:39:05 +02:00
/*
* Set the action url based on the account status
*/
let actionUrl = false
if (this.record.status === 0)
actionUrl = i18nUrl(body.language, `/confirm/${type}/${this.Confirmation.record.id}/${check}`)
else if (this.record.status === 1)
actionUrl = i18nUrl(body.language, `/confirm/signin/${this.Confirmation.record.id}/${check}`)
2023-08-13 09:39:05 +02:00
/*
* Send email unless it's a test and we don't want to send test emails
*/
if (!isTest || this.config.tests.sendEmail)
await this.mailer.send({
template: type,
language: body.language,
to: this.clear.email,
replacements: {
actionUrl,
whyUrl: i18nUrl(body.language, `/docs/faq/email/why-${type}`),
supportUrl: i18nUrl(body.language, `/patrons/join`),
},
})
2023-08-13 09:39:05 +02:00
/*
* Now return as if everything is fine
*/
return this.setResponse201({ email: this.clear.email })
}
2022-11-03 19:56:06 +01:00
2023-08-13 09:39:05 +02:00
/*
* New signup, attempt to create database record
*/
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)
2023-08-13 09:39:05 +02:00
/*
* Create a temporary username because we need one
*/
const username = clean(randomString())
2022-11-08 21:04:32 +01:00
const data = {
ehash,
2023-08-13 09:39:05 +02:00
/*
* The ihash (initial email hash) is the hash of the email that was used to
* create the account. The initial email itself is stored in the intial field.
* Once an account created, the ihash and initial fields can never be changed
* by a user.
* We keep them because in the case somebody claims their account was taken
* over. We can check the original email address that was used to create it
* even if the email address on the account was changed.
*/
2022-11-08 21:04:32 +01:00
ihash: ehash,
email,
initial: email,
username,
lusername: username,
language: body.language,
2022-11-17 20:41:21 +01:00
mfaEnabled: false,
2023-08-13 09:39:05 +02:00
mfaSecret: '',
/*
* The user will change this later. Or not. They can juse get a magic link via email
*/
password: asJson(hashPassword(randomString())),
/*
* These are all placeholders, but fields that get encrypted need _some_ value
* because encrypting null will cause an error.
*/
github: this.encrypt(''),
bio: this.encrypt(''),
2022-11-12 20:36:47 +01:00
img: this.encrypt(this.config.avatars.user),
2022-11-08 21:04:32 +01:00
}
2023-08-13 09:39:05 +02:00
/*
* During tests, users can set their own permission level so you can test admin stuff
*/
if (isTest && body.role) data.role = body.role
2023-08-13 09:39:05 +02:00
/*
* Now attempt to create the record in the database
*/
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) {
2023-08-13 09:39:05 +02:00
/*
* Could not create record. Log warning and return 500
*/
2022-11-03 19:56:06 +01:00
log.warn(err, 'Could not create user record')
return this.setResponse(500, 'createAccountFailed')
}
2023-08-13 09:39:05 +02:00
/*
* Update username now that we have the databse ID
*/
2022-11-03 19:56:06 +01:00
try {
2023-08-13 09:39:05 +02:00
await this.update({
2022-11-03 19:56:06 +01:00
username: `user-${this.record.id}`,
lusername: `user-${this.record.id}`,
})
} catch (err) {
2023-08-13 09:39:05 +02:00
/*
* This is very unlikely, but it is possible that the username is taken
* Which is not really a problem, so we will swallow this error and
* continue with the random username
*/
log.info(`Username collision for user-${this.record.id}`)
2022-11-03 19:56:06 +01:00
}
2023-08-13 09:39:05 +02:00
/*
* Now create the confirmation
*/
this.confirmation = await this.Confirmation.createRecord({
2022-11-03 19:56:06 +01:00
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-08-13 09:39:05 +02:00
/*
* And send out the signup email
*/
if (!this.isTest(body) || this.config.tests.sendEmail)
2022-11-07 19:50:51 +01:00
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
2023-08-13 09:39:05 +02:00
/*
* For unit tests, we return the confirmation code so no email is needed
* Obviously, that would defeat the point for production use.
*/
return this.isTest(body)
2023-08-13 09:39:05 +02:00
? this.setResponse201({
2022-11-08 21:04:32 +01:00
email: this.clear.email,
confirmation: this.confirmation.record.id,
})
2023-08-13 09:39:05 +02:00
: this.setResponse201({ email: this.clear.email })
2022-11-03 19:56:06 +01:00
}
/*
* Sign in based on username + password
2023-08-13 09:39:05 +02:00
*
* @param {req} object - The request object.
* We use the entire request object here because we log the IP of failed log attempts
* so we can detect if people are attempting to brute-force logins and block those IPs.
* @returns {UserModel} object - The UserModel
*/
UserModel.prototype.passwordSignIn = async function (req) {
2023-08-13 09:39:05 +02:00
/*
* Do we have a POST body?
*/
2022-12-29 13:40:25 -08:00
if (Object.keys(req.body).length < 1) return this.setResponse(400, 'postBodyMissing')
2023-08-13 09:39:05 +02:00
/*
* Is the username set?
*/
if (!req.body.username) return this.setResponse(400, 'usernameMissing')
2023-08-13 09:39:05 +02:00
/*
* Is the password set?
*/
if (!req.body.password) return this.setResponse(400, 'passwordMissing')
2023-08-13 09:39:05 +02:00
/*
* Attempt to find the user
*/
await this.find(req.body)
2023-08-13 09:39:05 +02:00
/*
* If it does not exist, don't say so but just pretend the login failed.
* This stops people from figuring out whether someone has a FreeSewing
* account, which would be a privacy leak if we said 'not found' here'
*/
if (!this.exists) {
log.warn(`Sign-in attempt for non-existing user: ${req.body.username} from ${req.ip}`)
return this.setResponse(401, 'signInFailed')
}
2023-08-13 09:39:05 +02:00
/*
* Account found, check the password
*/
const [valid, updatedPasswordField] = verifyPassword(req.body.password, this.record.password)
2023-08-13 09:39:05 +02:00
/*
* If the password is incorrect, log a warning with IP and return 401
*/
if (!valid) {
log.warn(`Wrong password for existing user: ${req.body.username} from ${req.ip}`)
return this.setResponse(401, 'signInFailed')
}
2023-08-13 09:39:05 +02:00
/*
* Check if the user has MFA enabled and if so handle the second factor
*/
2022-11-17 20:41:21 +01:00
if (this.record.mfaEnabled) {
2023-08-13 09:39:05 +02:00
/*
* If there is no token, return 403 so the front-end can present the token
*/
2022-11-18 16:04:06 +01:00
if (!req.body.token) return this.setResponse(403, 'mfaTokenRequired')
2023-08-13 09:39:05 +02:00
/*
* If there is a token, verify it and if it is not correct, return 401
*/ else if (!this.mfa.verify(req.body.token, this.clear.mfaSecret)) {
return this.setResponse(401, 'signInFailed')
2022-11-17 20:41:21 +01:00
}
}
2023-08-13 09:39:05 +02:00
/*
* At this point sign in is a success. We will update the lastLogin value
*
* However, the way passwords are handled in v2 and v3 is slightly different.
* So v2 users who have been migrated have a v2 hash. So now that we
* have their password and we know it's good, let's rehash it the v3 way
* if this happens to be a v2 user.
*/
2023-08-13 10:51:44 +02:00
if (updatedPasswordField) await this.update({ password: updatedPasswordField })
2023-08-13 09:39:05 +02:00
/*
* Final check for account status and other things before returning
*/
return this.isOk() ? this.signInOk() : this.setResponse(401, 'signInFailed')
}
/*
* Sign in based on a sign-in link
2023-08-13 09:39:05 +02:00
*
* @param {req} object - The request object.
* @returns {UserModel} object - The UserModel
*/
UserModel.prototype.linkSignIn = async function (req) {
2023-08-13 09:39:05 +02:00
/*
* Is the id set?
*/
if (!req.params.id) return this.setResponse(400, 'signInIdMissing')
2023-08-13 09:39:05 +02:00
/*
* Is the check set?
*/
if (!req.params.check) return this.setResponse(400, 'signInCheckMissing')
2023-08-13 09:39:05 +02:00
/*
* Attempt to retrieve confirmation record
*/
await this.Confirmation.read({ id: req.params.id })
2023-08-13 09:39:05 +02:00
/*
* If the confirmation does not exist, return 404
*/
if (!this.Confirmation.exists) return this.setResponse(404)
2023-08-13 09:39:05 +02:00
/*
* If the confirmation is not of of the right type, return 404
*/
if (!['signinlink', 'signup-aea'].includes(this.Confirmation.record.type)) {
return this.setResponse(404)
}
2023-08-13 09:39:05 +02:00
/*
* If the confirmation check is not valid, return 404
*/
if (this.Confirmation.clear.data.check !== req.params.check) {
return this.setResponse(404)
}
2023-08-13 09:39:05 +02:00
/*
* Looks like we're good, so attempt to read the user from the database
*/
2023-08-13 16:15:06 +02:00
await this.read({ id: this.Confirmation.record.userId })
2023-08-13 09:39:05 +02:00
/*
* if anything went wrong, this.error will be set
*/
if (this.error) return this
2023-08-13 09:39:05 +02:00
/*
* Check if the user has MFA enabled and if so handle the second factor
*/
if (this.record.mfaEnabled) {
2023-08-13 09:39:05 +02:00
/*
* If there is no token, return 403 so the front-end can present the token
*/
if (!req.body.token) return this.setResponse(403, 'mfaTokenRequired')
2023-08-13 09:39:05 +02:00
/*
* If there is a token, verify it and if it is not correct, return 401
*/ else if (!this.mfa.verify(req.body.token, this.clear.mfaSecret)) {
return this.setResponse(401, 'signInFailed')
}
}
2023-08-13 09:39:05 +02:00
/*
* Before we return, remove the confirmation so it works only once
*/
await this.Confirmation.delete()
2023-08-13 09:39:05 +02:00
/*
* Sign in was a success, run a final check before returning
*/
return this.isOk() ? this.signInOk() : this.setResponse(401, 'signInFailed')
}
/*
* Send a magic link for user sign in
2023-08-13 09:39:05 +02:00
*
* @param {req} object - The request object.
* @returns {UserModel} object - The UserModel
*/
UserModel.prototype.sendSigninlink = async function (req) {
2023-08-13 09:39:05 +02:00
/*
* Do we have a POST body?
*/
if (Object.keys(req.body).length < 1) return this.setResponse(400, 'postBodyMissing')
2023-08-13 09:39:05 +02:00
/*
* Is username set?
*/
if (!req.body.username) return this.setResponse(400, 'usernameMissing')
2023-08-13 09:39:05 +02:00
/*
* Attempt to find the user
*/
await this.find(req.body)
2023-08-13 09:39:05 +02:00
/*
* If we could not find it, log a warning but send a 401
* to not reveal such a user does not exist.
*/
if (!this.exists) {
log.warn(`Magic link attempt for non-existing user: ${req.body.username} from ${req.ip}`)
return this.setResponse(401, 'signInFailed')
}
2023-08-13 09:39:05 +02:00
/*
* Account found, generate random check and create the confirmation
*/
const check = randomString()
2023-08-13 09:39:05 +02:00
this.confirmation = await this.Confirmation.createRecord({
type: 'signinlink',
data: {
language: this.record.language,
check,
},
userId: this.record.id,
})
2023-08-13 09:39:05 +02:00
/*
* Figure out whether this is part of a unit test
*/
const isTest = this.isTest(req.body)
2023-08-13 09:39:05 +02:00
/*
* Only send out this email if it is not a unit test
*/
if (!isTest) {
2023-08-13 09:39:05 +02:00
/*
* Send sign-in link email
*/
await this.mailer.send({
template: 'signinlink',
language: this.record.language,
to: this.clear.email,
replacements: {
actionUrl: i18nUrl(
this.record.language,
`/confirm/signin/${this.Confirmation.record.id}/${check}`
),
whyUrl: i18nUrl(this.record.language, `/docs/faq/email/why-signin-link`),
supportUrl: i18nUrl(this.record.language, `/patrons/join`),
},
})
}
2023-08-13 09:39:05 +02:00
return this.setResponse200({ result: 'emailSent' })
}
/*
2022-11-05 22:02:51 +01:00
* Confirms a user account
2023-08-13 09:39:05 +02:00
*
* @param {body} object - The request body
* @param {params} object - The request (URL) params
* @returns {UserModel} object - The UserModel
*/
2022-11-05 22:02:51 +01:00
UserModel.prototype.confirm = async function ({ body, params }) {
2023-08-13 09:39:05 +02:00
/*
* Is the id set?
*/
2022-12-24 14:42:16 +01:00
if (!params.id) return this.setResponse(404)
2023-08-13 09:39:05 +02:00
/*
* Do we have a POST body?
*/
2022-12-29 13:40:25 -08:00
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
2023-08-13 09:39:05 +02:00
/*
* Do we have consent from the user to process their data?
*/
2022-11-05 22:02:51 +01:00
if (!body.consent || typeof body.consent !== 'number' || body.consent < 1)
return this.setResponse(400, 'consentRequired')
2023-08-13 09:39:05 +02:00
/*
* Attempt to read the confirmation from the database
*/
await this.Confirmation.read({ id: params.id }, { user: true })
2022-11-05 22:02:51 +01:00
2023-08-13 09:39:05 +02:00
/*
* If the confirmation does not exist, log a warning and return 404
*/
2022-11-05 22:02:51 +01:00
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
}
2023-08-13 09:39:05 +02:00
/*
* If the confirmation is of the wrong type, log a warning and return 404
*/
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
}
2023-08-13 09:39:05 +02:00
/*
* If an error occured, it will be in this.error and we can return here
*/
2022-11-05 22:02:51 +01:00
if (this.error) return this
2023-08-13 09:39:05 +02:00
/*
* Get the unencrypted data from the confirmation
*/
2022-11-08 21:04:32 +01:00
const data = this.Confirmation.clear.data
2023-08-13 09:39:05 +02:00
/*
* If the ehash does not match, return 404
*/
2022-12-24 14:42:16 +01:00
if (data.ehash !== this.Confirmation.record.user.ehash) return this.setResponse(404)
2023-08-13 09:39:05 +02:00
/*
* If the id does not match, return 404
*/
2023-08-13 16:15:06 +02:00
if (data.id !== this.Confirmation.record.userId) return this.setResponse(404)
2022-11-05 22:02:51 +01:00
2023-08-13 09:39:05 +02:00
/*
* Attempt to load the user from the database
*/
2023-08-13 16:15:06 +02:00
await this.read({ id: this.Confirmation.record.useId })
2023-08-13 09:39:05 +02:00
/*
* If an error occured, it will be in this.error and we can return here
*/
2022-11-05 22:02:51 +01:00
if (this.error) return this
2023-08-13 09:39:05 +02:00
/*
* Update user status, consent, and last sign in
*/
await this.update({
status: 1,
2022-11-05 22:02:51 +01:00
consent: body.consent,
})
2023-08-13 09:39:05 +02:00
/*
* If an error occured, it will be in this.error and we can return here
*/
2022-11-05 22:02:51 +01:00
if (this.error) return this
2023-08-13 09:39:05 +02:00
/*
* Before we return, remove the confirmation so it works only once
*/
await this.Confirmation.delete()
2023-08-13 09:39:05 +02:00
/*
* Account is now active, return a passwordless sign in
*/
return this.signInOk()
2022-11-03 19:56:06 +01:00
}
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
2023-08-13 09:39:05 +02:00
*
* @param {body} object - The request body
* @param {user} object - The user as loaded by auth middleware
* @returns {UserModel} object - The UserModel
2022-11-08 21:04:32 +01:00
*/
2022-11-14 18:30:54 +01:00
UserModel.prototype.guardedUpdate = async function ({ body, user }) {
2023-08-13 09:39:05 +02:00
/*
* Enforce RBAC
*/
if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
2023-08-13 09:39:05 +02:00
/*
* Make sure the account is in a state where it's allowed to do this
*/
2022-11-14 18:30:54 +01:00
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
2023-08-13 09:39:05 +02:00
/*
* Create data to update the record
*/
2022-11-08 21:04:32 +01:00
const data = {}
2023-08-13 09:39:05 +02:00
/*
* String fields
*/
for (const field of ['bio', 'github']) {
if (typeof body[field] === 'string') data[field] = body[field]
}
/*
* Enum fields
*/
for (const [field, values] of Object.entries(this.config.enums.user)) {
if (values.includes(body[field])) data[field] = body[field]
}
/*
* Password
*/
if (typeof body.password === 'string') data.password = body.password
/*
* 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
}
}
2023-08-13 09:39:05 +02:00
/*
* Image (img)
*/
if (typeof body.img === 'string')
data.img = await replaceImage({
id: `user-${this.record.ihash}`,
metadata: {
user: user.uid,
ihash: this.record.ihash,
},
b64: body.img,
})
2022-11-08 21:04:32 +01:00
2023-08-13 09:39:05 +02:00
/*
* Now update the database record
*/
await this.update(data)
2023-08-13 09:39:05 +02:00
/*
* Figure out whether this is a unit test
*/
const isTest = this.isTest(body)
2023-08-13 09:39:05 +02:00
/*
* If there's an email change, we need to trigger confirmation
*/
if (typeof body.email === 'string' && this.clear.email !== clean(body.email)) {
2023-08-13 09:39:05 +02:00
/*
* Generate the check
*/
const check = randomString()
2023-08-13 09:39:05 +02:00
/*
* Generate the confirmation record
*/
this.confirmation = await this.Confirmation.createRecord({
type: 'emailchange',
data: {
language: this.record.language,
check,
email: {
current: this.clear.email,
new: body.email,
},
},
userId: this.record.id,
})
2023-08-13 09:39:05 +02:00
/*
* Send out confirmation email (unless it's a test)
*/
if (!isTest || this.config.tests.sendEmail) {
await this.mailer.send({
template: 'emailchange',
language: this.record.language,
to: body.email,
2023-08-13 09:39:05 +02:00
/*
* CC the old address to guard against account take-over
*/
cc: this.clear.email,
replacements: {
actionUrl: i18nUrl(
this.record.language,
`/confirm/emailchange/${this.Confirmation.record.id}/${check}`
),
whyUrl: i18nUrl(this.record.language, `/docs/faq/email/why-emailchange`),
supportUrl: i18nUrl(this.record.language, `/patrons/join`),
},
})
}
} else if (
2023-08-13 09:54:56 +02:00
/*
* Could be an email change confirmation
*/
typeof body.confirmation === 'string' &&
body.confirm === 'emailchange' &&
typeof body.check === 'string'
) {
2023-08-13 09:39:05 +02:00
/*
* Attemt to read the confirmation record from the database
*/
await this.Confirmation.read({ id: body.confirmation })
2023-08-13 09:39:05 +02:00
/*
* If it does not exist, log a warning and return 404
*/
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)
}
2023-08-13 09:39:05 +02:00
/*
* If it is the wrong confirmation type, log a warning and return 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)
}
2023-08-13 09:39:05 +02:00
/*
* Load unencrypted data
*/
const data = this.Confirmation.clear.data
2023-08-13 09:39:05 +02:00
/*
* Verify confirmation ID and check. Update email if it checks out.
*/
if (
data.check === body.check &&
data.email.current === this.clear.email &&
typeof data.email.new === 'string'
) {
2023-08-13 09:39:05 +02:00
/*
* Update the email address and ehash
*/
await this.update({
email: this.encrypt(data.email.new),
ehash: hash(clean(data.email.new)),
})
}
2022-11-08 21:04:32 +01:00
}
2023-08-13 09:39:05 +02:00
/*
* Construct data to return
*/
const returnData = {
result: 'success',
account: this.asAccount(),
}
2023-08-13 09:39:05 +02:00
/*
* If it is a unit test, include the confirmation id
*/
if (isTest && this.Confirmation.record?.id) returnData.confirmation = this.Confirmation.record.id
2023-08-13 09:39:05 +02:00
/*
* Return data
*/
return this.setResponse200(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
2023-08-13 09:39:05 +02:00
*
* @param {body} object - The request body
* @param {user} object - The user as loaded by auth middleware
* @param {ip} object - The user as loaded by auth middleware
* @returns {UserModel} object - The UserModel
2022-11-17 20:41:21 +01:00
*/
2022-11-18 16:04:06 +01:00
UserModel.prototype.guardedMfaUpdate = async function ({ body, user, ip }) {
2023-08-13 09:39:05 +02:00
/*
* Enforce RBAC
*/
if (!this.rbac.user(user)) return this.setResponse(403, 'insufficientAccessLevel')
2023-08-13 09:39:05 +02:00
/*
* Ensure account is in the proper state to do this
*/
2022-11-17 20:41:21 +01:00
if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking')
2023-08-13 09:39:05 +02:00
/*
* If MFA is active and it is an attempt to active it, return 400
*/
2022-11-17 20:41:21 +01:00
if (body.mfa === true && this.record.mfaEnabled === true)
return this.setResponse(400, 'mfaActive')
2023-08-13 09:39:05 +02:00
/*
* Option 1/3: Is this an attempt to disable MFA?
*/
2022-11-17 20:41:21 +01:00
if (body.mfa === false) {
2023-08-13 09:39:05 +02:00
/*
* Is token set in the POST body?
*/
if (!body.token) return this.setResponse(400, 'mfaTokenMissing')
2023-08-13 09:39:05 +02:00
/*
* Is password set in the POST body?
*/
if (!body.password) return this.setResponse(400, 'passwordMissing')
2023-08-13 09:39:05 +02:00
/*
* Verify the password
*/
2022-11-18 16:04:06 +01:00
const [valid] = verifyPassword(body.password, this.record.password)
2023-08-13 09:39:05 +02:00
/*
* If the password is not correct, log a warning including the IP and reutrn 401
*/
2022-11-18 16:04:06 +01:00
if (!valid) {
log.warn(`Wrong password for existing user while disabling MFA: ${user.uid} from ${ip}`)
return this.setResponse(401, 'authenticationFailed')
}
2023-08-13 09:39:05 +02:00
/*
* Verify the MFA token
*/
2022-11-18 16:04:06 +01:00
if (this.mfa.verify(body.token, this.clear.mfaSecret)) {
2023-08-13 09:39:05 +02:00
/*
* Token is valid. Update user record to disable MFA
*/
2022-11-18 16:04:06 +01:00
try {
2023-08-13 09:39:05 +02:00
await this.update({ mfaEnabled: false })
2022-11-18 16:04:06 +01:00
} catch (err) {
2023-08-13 09:39:05 +02:00
/*
* Problem occured while updating the record. Log warning and reurn 500
*/
2022-11-18 16:04:06 +01:00
log.warn(err, 'Could not disable MFA after token check')
return this.setResponse(500, 'mfaDeactivationFailed')
}
2023-08-13 09:39:05 +02:00
/*
* All done here. Return account data
*/
return this.setResponse200({
result: 'success',
account: this.asAccount(),
})
2022-11-18 16:04:06 +01:00
} else {
2023-08-13 09:39:05 +02:00
/*
* MFA token not valid. Return 401
*/
2022-11-18 16:04:06 +01:00
return this.setResponse(401, 'authenticationFailed')
}
2023-08-13 09:39:05 +02:00
} else if (body.mfa === true && body.token && body.secret) {
2023-08-13 09:54:56 +02:00
/*
* Option 2/3: Is this is a confirmation after enabling MFA?
*/
2023-08-13 09:39:05 +02:00
/*
* Verify secret and token
*/
2022-11-17 20:41:21 +01:00
if (body.secret === this.clear.mfaSecret && this.mfa.verify(body.token, this.clear.mfaSecret)) {
2023-08-13 09:39:05 +02:00
/*
* Looks good. Update the user record to enable MFA
*/
2022-11-17 20:41:21 +01:00
try {
2023-08-13 09:39:05 +02:00
await this.update({ mfaEnabled: true })
2022-11-17 20:41:21 +01:00
} catch (err) {
2023-08-13 09:39:05 +02:00
/*
* Problem occured while updating the record. Log warning and reurn 500
*/
2022-11-17 20:41:21 +01:00
log.warn(err, 'Could not enable MFA after token check')
return this.setResponse(500, 'mfaActivationFailed')
}
2023-08-13 09:39:05 +02:00
/*
* All done here. Return account data
*/
return this.setResponse200({
result: 'success',
account: this.asAccount(),
})
2022-11-17 20:41:21 +01:00
} else return this.setResponse(403, 'mfaTokenInvalid')
2023-08-13 09:39:05 +02:00
/*
* Secret and/or token don't match. Return 403
*/
} else if (body.mfa === true && this.record.mfaEnabled === false) {
2023-08-13 09:54:56 +02:00
/*
* Option 3/3: Is this an initial request to enable MFA?
*/
2023-08-13 09:39:05 +02:00
/*
* Setup MFA
*/
2022-11-17 20:41:21 +01:00
let mfa
try {
mfa = await this.mfa.enroll(this.record.username)
} catch (err) {
2023-08-13 09:39:05 +02:00
/*
* Problem occured while creating MFA setup. Return 500.
*/
log.warn(err, 'Failed to setup MFA')
return this.setResponse(500, 'mfaSetupFailed')
2022-11-17 20:41:21 +01:00
}
2023-08-13 09:39:05 +02:00
/*
* Update record with the MFA secret
*/
2022-11-17 20:41:21 +01:00
try {
2023-08-13 09:39:05 +02:00
await this.update({ mfaSecret: mfa.secret })
2022-11-17 20:41:21 +01:00
} catch (err) {
2023-08-13 09:39:05 +02:00
/*
* Problem occured while updating record. Return 500.
*/
log.warn(err, 'Could not update MFA secret after setup')
return this.setResponse(500, 'mfaUpdateAfterSetupFailed')
2022-11-17 20:41:21 +01:00
}
2023-08-13 09:39:05 +02:00
/*
* Return the MFA data so the user can add them to their MFA app
*/
return this.setResponse200({ mfa })
2022-11-17 20:41:21 +01:00
}
2023-08-13 09:39:05 +02:00
/*
* We should not ever arrive here, so return 400 at this point
*/
2022-11-17 20:41:21 +01:00
return this.setResponse(400, 'invalidMfaSetting')
}
2022-11-05 22:02:51 +01:00
/*
2023-08-13 09:39:05 +02:00
* Returns the database record as account data for for consumption
*
* @return {account} object - The account data as a plain object
2022-11-05 22:02:51 +01:00
*/
UserModel.prototype.asAccount = function () {
2023-08-13 09:39:05 +02:00
/*
* Nothing to do here but construct the object to return
*/
2022-11-05 22:02:51 +01:00
return {
id: this.record.id,
bio: this.clear.bio,
compare: this.record.compare,
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,
2023-08-13 09:39:05 +02:00
ihash: this.ihash,
img: this.clear.img,
imperial: this.record.imperial,
2022-11-08 21:04:32 +01:00
initial: this.clear.initial,
jwtCalls: this.record.jwtCalls,
keyCalls: this.record.keyCalls,
language: this.record.language,
lastSeen: this.record.lastSeen,
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,
/*
* Add this so we can give a note to users about migrating their password
*/
passwordType: JSON.parse(this.record.password).type,
2022-11-05 22:02:51 +01:00
}
}
/*
2023-08-13 09:39:05 +02:00
* Creates and returns a JSON Web Token (jwt)
*
* @return {jwt} string - The JWT
2022-11-05 22:02:51 +01:00
*/
UserModel.prototype.getToken = function () {
2023-08-13 09:39:05 +02:00
/*
* Call the jwt library with the correct config
*/
2022-11-05 22:02:51 +01:00
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 check an account is ok
*/
UserModel.prototype.isOk = function () {
2023-08-13 09:39:05 +02:00
/*
* These are all the checks we run to see if an account is 'ok'
*/
if (
this.exists &&
this.record &&
this.record.status > 0 &&
this.record.consent > 0 &&
this.record.role &&
this.record.role !== 'blocked'
)
return true
return false
}
/*
2023-08-13 09:39:05 +02:00
* Helper method to handle the return after a successful sign in
*
* @returns {UserModel} object - The UserModel
*/
UserModel.prototype.signInOk = function () {
2023-08-13 09:39:05 +02:00
return this.setResponse200({
result: 'success',
token: this.getToken(),
account: this.asAccount(),
})
}
/*
2023-08-13 09:39:05 +02:00
* Helper method to see if a (lowercase) username is available
* as well as making sure username is not something we
* do not allow
2023-08-13 09:39:05 +02:00
*
* @param {lusername} string - The lowercased username
* @returns {isTest} boolean - True if it's a test. False if not.
*/
UserModel.prototype.isLusernameAvailable = async function (lusername) {
2023-08-13 09:39:05 +02:00
/*
* We do not allow usernames shorter than 2 characters
*/
if (lusername.length < 2) return false
2023-08-13 09:39:05 +02:00
/*
* Attempt to find a user with the provided lusername
*/
let user
try {
user = await this.prisma.user.findUnique({ where: { lusername } })
} catch (err) {
2023-08-13 09:39:05 +02:00
/*
* An error means it's not good. Return false
*/
log.warn({ err, lusername }, 'Could not search for free username')
return false
}
2023-08-13 09:39:05 +02:00
/*
* If a user is found, the lusername is not available, so return false
*/
if (user) return false
2023-08-13 09:39:05 +02:00
/*
* If we get here, the lusername is available, so return true
*/
return true
}
/*
* Helper method to update the `lastSeen` field of the user
* This is called from middleware with the user ID passed in.
*
* @param {id} string - The user ID
* @param {type} string - The authentication type (one of 'jwt' or 'key')
* @returns {success} boolean - True if it worked, false if not
*/
UserModel.prototype.seen = async function (id, type) {
/*
* Construct data object for update operation
*/
const data = { lastSeen: new Date() }
data[`${type}Calls`] = { increment: 1 }
/*
* Now update the dabatase record
*/
try {
await this.prisma.user.update({ where: { id }, data })
} catch (err) {
/*
* An error means it's not good. Return false
*/
log.warn({ id, err }, 'Could not update lastSeen field from middleware')
return false
}
/*
* If we get here, the lastSeen field was updated and user exists, so return true
*/
return true
}
2023-08-13 09:39:05 +02:00
/*
* Everything below this comment is migration code.
* This can all be removed after v3 is in production and all users have been migrated.
*/
const migrateUser = (v2) => {
const email = clean(v2.email)
const initial = clean(v2.initial)
const data = {
bio: v2.bio,
consent: 0,
createdAt: v2.time?.created ? new Date(v2.time.created) : new Date(),
email,
ehash: hash(email),
github: v2.social?.github,
ihash: hash(initial),
img:
v2.picture.slice(-4).toLowerCase() === '.svg' // Don't bother with default avatars
? ''
: v2.picture,
initial,
imperial: v2.units === 'imperial',
language: v2.settings.language,
2023-08-13 16:15:06 +02:00
lastSeen: new Date(),
lusername: v2.username.toLowerCase(),
mfaEnabled: false,
newsletter: false,
password: JSON.stringify({
type: 'v2',
data: v2.password,
}),
patron: v2.patron,
role: v2._id === '5d62aa44ce141a3b816a3dd9' ? 'admin' : 'user',
status: v2.status === 'active' ? 1 : 0,
username: v2.username,
}
if (data.consent.profile) data.consent++
if (data.consent.measurements) data.consent++
if (data.consent.openData) data.consent++
return data
}
/*
* This is a special route not available for API users
*/
2023-08-13 16:15:06 +02:00
UserModel.prototype.import = async function (user) {
let created = 0
const skipped = []
2023-08-13 16:15:06 +02:00
if (user.status === 'active') {
const data = migrateUser(user)
if (user.consent.profile) data.consent++
if (user.consent.model || user.consent.measurements) {
data.consent++
if (user.consent.openData) data.consent++
}
await this.read({ ehash: data.ehash })
if (!this.record) {
/*
* Skip images for now
*/
if (false && data.img) {
/*
* Figure out what image to grab from the FreeSewing v2 backend server
*/
const imgId = `user-${data.ihash}`
const imgUrl =
'https://static.freesewing.org/users/' +
encodeURIComponent(user.handle.slice(0, 1)) +
'/' +
encodeURIComponent(user.handle) +
'/' +
encodeURIComponent(data.img)
data.img = await importImage({
id: imgId,
metadata: {
user: `v2-${user.handle}`,
ihash: data.ihash,
},
url: imgUrl,
})
data.img = imgId
} else data.img = 'default-avatar'
try {
await this.createRecord(data)
created++
} catch (err) {
if (
err.toString().indexOf('Unique constraint failed on the fields: (`lusername`)') !== -1
) {
// Just add a '+' to the username
data.username += '+'
data.lusername += '+'
try {
2023-08-13 16:15:06 +02:00
await this.createRecord(data)
created++
} catch (err) {
2023-08-13 16:15:06 +02:00
log.warn(err, 'Could not create user record')
console.log(user)
return this.setResponse(500, 'createUserFailed')
}
}
2023-08-13 16:15:06 +02:00
}
// That's the user, now load their people as sets
2023-08-09 20:40:38 +02:00
let lut = false
2023-08-13 16:15:06 +02:00
if (user.people) lut = await this.Set.import(user, this.record.id)
if (user.patterns) await this.Pattern.import(user, lut, this.record.id)
}
}
2023-08-13 16:15:06 +02:00
return this.setResponse200()
}