1
0
Fork 0

wip(backend): Don't require password at signup

This commit is contained in:
joostdecock 2022-11-08 22:41:30 +01:00
parent 19050ce3b7
commit 73ee7cceb3
7 changed files with 159 additions and 1230 deletions

View file

@ -39,27 +39,29 @@ model Subscriber {
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
apikeys Apikey[]
bio String @default("")
confirmations Confirmation[]
consent Int @default(0) consent Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
data String @default("{}")
ehash String @unique ehash String @unique
email String email String
github String @default("")
ihash String ihash String
initial String initial String
imperial Boolean @default(false)
language String @default("en")
lastLogin DateTime? lastLogin DateTime?
lusername String @unique
newsletter Boolean @default(false) newsletter Boolean @default(false)
password String password String
patron Int @default(0) patron Int @default(0)
apikeys Apikey[]
confirmations Confirmation[]
people Person[]
patterns Pattern[] patterns Pattern[]
people Person[]
role String @default("user") role String @default("user")
status Int @default(0) status Int @default(0)
updatedAt DateTime? @updatedAt updatedAt DateTime? @updatedAt
username String username String
lusername String @unique
@@index([ihash]) @@index([ihash])
} }

Binary file not shown.

View file

@ -1,745 +0,0 @@
//import { log, email, ehash, newHandle, uniqueHandle } from '../utils'
import jwt from 'jsonwebtoken'
import axios from 'axios'
//import path from 'path'
//import fs from 'fs'
import Zip from 'jszip'
//import rimraf from 'rimraf'
import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs'
import { clean, asJson } from '../utils/index.mjs'
import { getUserAvatar } from '../utils/sanity.mjs'
import { log } from '../utils/log.mjs'
import { emailTemplate } from '../utils/email.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: {
lusername: username.toLowerCase(),
},
})
if (user === null) return true
return false
}
// We'll send this result unless it goes ok
const result = 'error'
export function UserController() {}
/*
* Signup
*
* This is the endpoint that handles account signups
* See: https://freesewing.dev/reference/backend/api
*/
UserController.prototype.signup = async (req, res, tools) => {
const User = new UserModel(tools)
await User.create(req.body)
return User.sendResponse(res)
}
/*
* Confirm account (after signup)
*
* This is the endpoint that fully unlocks the account if the user gives their consent
* See: https://freesewing.dev/reference/backend/api
*/
UserController.prototype.confirm = async (req, res, tools) => {
if (!req.params.id) return res.status(404).send({ error: 'missingConfirmationId', result })
if (Object.keys(req.body) < 1) return res.status(400).json({ error: 'postBodyMissing', result })
if (!req.body.consent || req.body.consent < 1)
return res.status(400).send({ error: 'consentRequired', result })
// Destructure what we need from tools
const { prisma, config, decrypt } = tools
// Retrieve confirmation record
let confirmation
try {
confirmation = await prisma.confirmation.findUnique({
where: {
id: req.params.id,
},
})
} catch (err) {
log.warn(err, `Could not lookup confirmation id ${req.params.id}`)
return res.status(404).send({ error: 'failedToRetrieveConfirmationId', result })
}
if (!confirmation) {
log.warn(err, `Could not find confirmation id ${req.params.id}`)
return res.status(404).send({ error: 'failedToFindConfirmationId', result })
}
if (confirmation.type !== 'signup') {
log.warn(err, `Confirmation mismatch; ${req.params.id} is not a signup id`)
return res.status(404).send({ error: 'confirmationIdTypeMismatch', result })
}
const data = decrypt(confirmation.data)
// Retrieve user account
let account
try {
account = await prisma.user.findUnique({
where: {
id: data.id,
},
})
} catch (err) {
log.warn(err, `Could not lookup user id ${data.id} from confirmation data`)
return res.status(404).send({ error: 'failedToRetrieveUserIdFromConfirmationData', result })
}
if (!account) {
log.warn(err, `Could not find user id ${data.id} from confirmation data`)
return res.status(404).send({ error: 'failedToLoadUserFromConfirmationData', result })
}
// Update user consent and status
let updateUser
try {
updateUser = await prisma.user.update({
where: {
id: account.id,
},
data: {
status: 1,
consent: req.body.consent,
lastLogin: new Date(),
},
})
} catch (err) {
log.warn(err, `Could not update user id ${data.id} after confirmation`)
return res.status(404).send({ error: 'failedToUpdateUserAfterConfirmation', result })
}
// Account is now active, let's return a passwordless login
return res.status(200).send({
result: 'success',
token: getToken(account, config),
account: asAccount({ ...account, status: 1, consent: req.body.consent }, decrypt),
})
}
/*
* Login (with username and password)
*
* This is the endpoint that provides traditional username/password login
* See: https://freesewing.dev/reference/backend/api
*/
UserController.prototype.login = async function (req, res, tools) {
if (Object.keys(req.body) < 1) return res.status(400).json({ error: 'postBodyMissing', result })
if (!req.body.username) return res.status(400).json({ error: 'usernameMissing', result })
if (!req.body.password) return res.status(400).json({ error: 'passwordMissing', result })
// Destructure what we need from tools
const { prisma, config, decrypt } = tools
// Retrieve user account
let account
try {
account = await prisma.user.findFirst({
where: {
OR: [
{ lusername: { equals: clean(req.body.username) } },
{ ehash: { equals: hash(clean(req.body.username)) } },
{ id: { equals: parseInt(req.body.username) || -1 } },
],
},
})
} catch (err) {
log.warn(err, `Error while trying to find username: ${req.body.username}`)
return res.status(401).send({ error: 'loginFailed', result })
}
if (!account) {
log.warn(`Login attempt for non-existing user: ${req.body.username} from ${req.ip}`)
return res.status(401).send({ error: 'loginFailed', result })
}
// Account found, check password
const [valid, updatedPasswordField] = verifyPassword(req.body.password, account.password)
if (!valid) {
log.warn(`Wrong password for existing user: ${req.body.username} from ${req.ip}`)
return res.status(401).send({ error: 'loginFailed', result })
}
// Login success
log.info(`Login by user ${account.id} (${account.username})`)
if (updatedPasswordField) {
// Update the password field with a v3 hash
let updateUser
try {
updateUser = await prisma.user.update({
where: {
id: account.id,
},
data: {
password: updatedPasswordField,
},
})
} catch (err) {
log.warn(
err,
`Could not update password field with v3 hash for user id ${account.id} (${account.username})`
)
}
}
return res.status(200).send({
result: 'success',
token: getToken(account, config),
account: asAccount({ ...account }, decrypt),
})
}
UserController.prototype.readAccount = async (req, res, tools) => {
if (!req.user?._id) return res.status(400).send({ error: 'bearerMissing', result })
// 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),
})
}
UserController.prototype.update = async (req, res, tools) => {
if (!req.user?._id) return res.status(400).send({ error: 'bearerMissing', result })
// 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)
// Commit
prisma.user.update({
where: { id: account.id },
data,
})
// Email change requires confirmation
if (typeof req.body.email === 'string') {
const currentEmail = decrypt(account.email)
if (req.body.email !== currentEmail) {
if (req.body.confirmation) {
// Find confirmation
let confirmation
try {
prisma.confirmation.findUnique({
where: { id: req.body.confirmation },
})
} catch (err) {
log.warn(err, `Failed to find confirmation for email change`)
return res.status(500).send({ error: 'failedToFindEmailChangeConfirmation', result })
}
if (!confirmation) {
log.warn(err, `Missing confirmation for email change`)
return res.status(400).send({ error: 'missingEmailChangeConfirmation', result })
}
} else {
// Create confirmation
let confirmation
try {
confirmation = prisma.confirmation.create({
data: {
type: 'emailchange',
data: encrypt({
language: userData.settings.language || 'en',
email: {
new: req.body.email,
current: currentEmail,
},
}),
},
})
} catch (err) {
log.warn(err, `Failed to create confirmation for email change`)
return res.status(500).send({ error: 'failedToCreateEmailChangeConfirmation', result })
}
// Send out confirmation email
let sent
try {
sent = await email.send(
req.body.email,
currentEmail,
...emailTemplate.emailchange(
req.body.email,
currentEmail,
userData.settings.language,
confirmation.id
)
)
} catch (err) {
log.warn(err, 'Unable to send email')
return res.status(500).send({ error: 'failedToSendEmailChangeConfirmationEmail', result })
}
}
}
}
// Now handle the
/*
else if (typeof data.email === 'string' && data.email !== user.email) {
if (typeof data.confirmation === 'string') {
Confirmation.findById(req.body.confirmation, (err, confirmation) => {
if (err) return res.sendStatus(400)
if (confirmation === null) return res.sendStatus(401)
if (confirmation.data.email.new === req.body.email) {
user.ehash = ehash(req.body.email)
user.email = req.body.email
return saveAndReturnAccount(res, user)
} else return res.sendStatus(400)
})
} else {
let confirmation = new Confirmation({
type: 'emailchange',
data: {
handle: user.handle,
language: user.settings.language,
email: {
new: req.body.email,
current: user.email,
},
},
})
confirmation.save(function (err) {
if (err) return res.sendStatus(500)
log.info('emailchangeRequest', {
newEmail: req.body.email,
confirmation: confirmation._id,
})
email.emailchange(req.body.email, user.email, user.settings.language, confirmation._id)
return saveAndReturnAccount(res, user)
})
}
}
})
*/
return res.status(200).send({})
}
/*
// For people who have forgotten their password, or password-less logins
UserController.prototype.confirmationLogin = function (req, res) {
if (!req.body || !req.body.id) return res.sendStatus(400)
Confirmation.findById(req.body.id, (err, confirmation) => {
if (err) return res.sendStatus(400)
if (confirmation === null) return res.sendStatus(401)
User.findOne({ handle: confirmation.data.handle }, (err, user) => {
if (err) return res.sendStatus(400)
if (user === null) {
return res.sendStatus(401)
}
if (user.status !== 'active') return res.sendStatus(403)
else {
log.info('confirmationLogin', { user, req })
let account = user.account()
let token = getToken(account)
let people = {}
Person.find({ user: user.handle }, (err, personList) => {
if (err) return res.sendStatus(400)
for (let person of personList) people[person.handle] = person.info()
let patterns = {}
Pattern.find({ user: user.handle }, (err, patternList) => {
if (err) return res.sendStatus(400)
for (let pattern of patternList) patterns[pattern.handle] = pattern
return user.updateLoginTime(() => res.send({ account, people, patterns, token }))
})
})
}
})
})
}
// CRUD basics
// Note that the user is already crearted (in signup)
// we just need to active the account
UserController.prototype.create = (req, res) => {
if (!req.body) return res.sendStatus(400)
if (!req.body.consent || !req.body.consent.profile) return res.status(400).send('consentRequired')
Confirmation.findById(req.body.id, (err, confirmation) => {
if (err) return res.sendStatus(400)
if (confirmation === null) return res.sendStatus(401)
User.findOne({ handle: confirmation.data.handle }, (err, user) => {
if (err) return res.sendStatus(400)
if (user === null) return res.sendStatus(401)
user.status = 'active'
user.consent = req.body.consent
user.time.login = new Date()
log.info('accountActivated', { handle: user.handle })
let account = user.account()
let token = getToken(account)
user.save(function (err) {
if (err) return res.sendStatus(400)
Confirmation.findByIdAndDelete(req.body.id, (err, confirmation) => {
return res.send({ account, people: {}, patterns: {}, token })
})
})
})
})
}
UserController.prototype.readProfile = (req, res) => {
User.findOne({ username: req.params.username }, (err, user) => {
if (err) return res.sendStatus(404)
if (user === null) return res.sendStatus(404)
else res.send(user.profile())
})
}
function saveAndReturnAccount(res, user) {
user.save(function (err, updatedUser) {
if (err) {
log.error('accountUpdateFailed', err)
return res.sendStatus(500)
} else return res.send({ account: updatedUser.account() })
})
}
function temporaryStoragePath(dir) {
return path.join(config.storage, 'tmp', dir)
}
UserController.prototype.isUsernameAvailable = (req, res) => {
if (!req.user._id) return res.sendStatus(400)
let username = req.body.username.toLowerCase().trim()
if (username === '') return res.sendStatus(400)
User.findOne({ username: username }, (err, user) => {
if (err) return res.sendStatus(400)
if (user === null) return res.sendStatus(200)
if (user._id + '' === req.user._id) return res.sendStatus(200)
else return res.sendStatus(400)
})
}
// // Re-send activation email
UserController.prototype.resend = (req, res) => {
if (!req.body) return res.sendStatus(400)
if (!req.body.email) return res.status(400).send('emailMissing')
if (!req.body.language) return res.status(400).send('languageMissing')
User.findOne(
{
ehash: ehash(req.body.email),
},
(err, user) => {
if (err) return res.sendStatus(500)
if (user === null) return res.status(404).send('noSuchUser')
else {
let confirmation = new Confirmation({
type: 'signup',
data: {
language: req.body.language,
email: user.email,
handle: user.handle,
},
})
confirmation.save(function (err) {
if (err) return res.sendStatus(500)
log.info('resendActivationRequest', {
email: req.body.email,
confirmation: confirmation._id,
})
email.signup(req.body.email, req.body.language, confirmation._id)
return res.sendStatus(200)
})
}
}
)
}
UserController.prototype.resetPassword = (req, res) => {
if (!req.body) return res.sendStatus(400)
User.findOne(
{
$or: [
{ username: req.body.username.toLowerCase().trim() },
{ ehash: ehash(req.body.username) },
],
},
(err, user) => {
if (err) {
console.log(err)
return res.sendStatus(400)
}
if (user === null) return res.sendStatus(401)
let confirmation = new Confirmation({
type: 'passwordreset',
data: {
handle: user.handle,
},
})
confirmation.save(function (err) {
if (err) return res.sendStatus(500)
log.info('passwordresetRequest', { user: user.handle, confirmation: confirmation._id })
email.passwordreset(user.email, user.settings.language, confirmation._id)
return res.sendStatus(200)
})
}
)
}
UserController.prototype.setPassword = (req, res) => {
if (!req.body) return res.sendStatus(400)
Confirmation.findById(req.body.confirmation, (err, confirmation) => {
if (err) return res.sendStatus(400)
if (confirmation === null) return res.sendStatus(401)
User.findOne({ handle: req.body.handle }, (err, user) => {
if (err) return res.sendStatus(400)
if (user === null) return res.sendStatus(401)
if (confirmation.type === 'passwordreset' && confirmation.data.handle === user.handle) {
user.password = req.body.password
user.save(function (err) {
log.info('passwordSet', { user, req })
let account = user.account()
let token = getToken(account)
return user.updateLoginTime(() => res.send({ account, token }))
})
} else return res.sendStatus(401)
})
})
return
}
UserController.prototype.confirmChangedEmail = (req, res) => {
if (!req.body || !req.body.id || !req.user._id) return res.sendStatus(400)
Confirmation.findById(req.body.id, (err, confirmation) => {
if (err || confirmation === null) return res.sendStatus(401)
User.findById(req.user._id, async (err, user) => {
if (err || confirmation.data.handle !== user.handle) return res.sendStatus(401)
user.ehash = ehash(confirmation.data.email.new)
user.email = confirmation.data.email.new
return saveAndReturnAccount(res, user)
})
})
}
// // Other
UserController.prototype.patronList = (req, res) => {
User.find({ patron: { $gte: 2 } })
.sort('username')
.exec((err, users) => {
if (err || users === null) return res.sendStatus(400)
let patrons = {
2: [],
4: [],
8: [],
}
for (let key of Object.keys(users)) {
let user = users[key].profile()
patrons[user.patron].push({
handle: user.handle,
username: user.username,
bio: user.bio,
picture: user.picture,
social: user.social,
pictureUris: user.pictureUris,
})
}
return res.send(patrons)
})
}
UserController.prototype.export = (req, res) => {
if (!req.user._id) return res.sendStatus(400)
User.findById(req.user._id, (err, user) => {
if (user === null) return res.sendStatus(400)
let dir = createTempDir()
if (!dir) return res.sendStatus(500)
let zip = new Zip()
zip.file('account.json', asJson(user.export(), null, 2))
loadAvatar(user).then((avatar) => {
if (avatar) zip.file(user.picture, data)
zip
.generateAsync({
type: 'uint8array',
comment: 'freesewing.org',
streamFiles: true,
})
.then(function (data) {
let file = path.join(dir, 'export.zip')
fs.writeFile(file, data, (err) => {
log.info('dataExport', { user, req })
return res.send({ export: uri(file) })
})
})
})
})
}
const loadAvatar = async (user) => {
if (user.picture)
await fs.readFile(path.join(user.storagePath(), user.picture), (err, data) => data)
else return false
}
// restrict processing of data, aka freeze account
UserController.prototype.restrict = (req, res) => {
if (!req.user._id) return res.sendStatus(400)
User.findById(req.user._id, (err, user) => {
if (user === null) return res.sendStatus(400)
user.status = 'frozen'
user.save(function (err) {
if (err) {
log.error('accountFreezeFailed', user)
return res.sendStatus(500)
}
return res.sendStatus(200)
})
})
}
// Remove account
UserController.prototype.remove = (req, res) => {
if (!req.user._id) return res.sendStatus(400)
User.findById(req.user._id, (err, user) => {
if (user === null) return res.sendStatus(400)
rimraf(user.storagePath(), (err) => {
if (err) {
console.log('rimraf', err)
log.error('accountRemovalFailed', { err, user, req })
return res.sendStatus(500)
}
user.remove((err, usr) => {
if (err !== null) {
log.error('accountRemovalFailed', { err, user, req })
return res.sendStatus(500)
} else return res.sendStatus(200)
})
})
})
}
const getToken = (account) => {
return jwt.sign(
{
_id: account._id,
handle: account.handle,
role: account.role,
aud: config.jwt.audience,
iss: config.jwt.issuer,
},
config.jwt.secretOrKey
)
}
const createTempDir = () => {
let path = temporaryStoragePath(newHandle(10))
fs.mkdir(path, { recursive: true }, (err) => {
if (err) {
log.error('mkdirFailed', err)
path = false
}
})
return path
}
const uri = (path) => config.static + path.substring(config.storage.length)
*/

View file

@ -1,26 +1,5 @@
import jwt from 'jsonwebtoken'
import axios from 'axios'
import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs'
import { clean, asJson } from '../utils/index.mjs'
import { getUserAvatar } from '../utils/sanity.mjs'
import { log } from '../utils/log.mjs'
import set from 'lodash.set'
import { UserModel } from '../models/user.mjs' import { UserModel } from '../models/user.mjs'
const isUsernameAvailable = async (username, prisma) => {
const user = await prisme.user.findUnique({
where: {
lusername: username.toLowerCase(),
},
})
if (user === null) return true
return false
}
// We'll send this result unless it goes ok
const result = 'error'
export function UserController() {} export function UserController() {}
/* /*
@ -62,6 +41,11 @@ UserController.prototype.login = async function (req, res, tools) {
return User.sendResponse(res) return User.sendResponse(res)
} }
/*
* Returns the account of the authenticated user (with JWT)
*
* See: https://freesewing.dev/reference/backend/api
*/
UserController.prototype.whoami = async (req, res, tools) => { UserController.prototype.whoami = async (req, res, tools) => {
const User = new UserModel(tools) const User = new UserModel(tools)
await User.readAsAccount({ id: req.user.uid }) await User.readAsAccount({ id: req.user.uid })
@ -69,433 +53,15 @@ UserController.prototype.whoami = async (req, res, tools) => {
return User.sendResponse(res) return User.sendResponse(res)
} }
/*
* Updates the account of the authenticated user
*
* See: https://freesewing.dev/reference/backend/api
*/
UserController.prototype.update = async (req, res, tools) => { UserController.prototype.update = async (req, res, tools) => {
const User = new UserModel(tools) const User = new UserModel(tools)
await User.read({ id: req.user.uid }) await User.read({ id: req.user.uid })
await User.unsafeUpdate(req.body)
// Commit return User.sendResponse(res)
//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) {
if (req.body.confirmation) {
// Find confirmation
let confirmation
try {
prisma.confirmation.findUnique({
where: { id: req.body.confirmation },
})
} catch (err) {
log.warn(err, `Failed to find confirmation for email change`)
return res.status(500).send({ error: 'failedToFindEmailChangeConfirmation', result })
}
if (!confirmation) {
log.warn(err, `Missing confirmation for email change`)
return res.status(400).send({ error: 'missingEmailChangeConfirmation', result })
}
} else {
// Create confirmation
let confirmation
try {
confirmation = prisma.confirmation.create({
data: {
type: 'emailchange',
data: encrypt({
language: userData.settings.language || 'en',
email: {
new: req.body.email,
current: currentEmail,
},
}),
},
})
} catch (err) {
log.warn(err, `Failed to create confirmation for email change`)
return res.status(500).send({ error: 'failedToCreateEmailChangeConfirmation', result })
}
// Send out confirmation email
let sent
try {
sent = await email.send(
req.body.email,
currentEmail,
...emailTemplate.emailchange(
req.body.email,
currentEmail,
userData.settings.language,
confirmation.id
)
)
} catch (err) {
log.warn(err, 'Unable to send email')
return res.status(500).send({ error: 'failedToSendEmailChangeConfirmationEmail', result })
}
}
}
}
*/
// Now handle the
/*
else if (typeof data.email === 'string' && data.email !== user.email) {
if (typeof data.confirmation === 'string') {
Confirmation.findById(req.body.confirmation, (err, confirmation) => {
if (err) return res.sendStatus(400)
if (confirmation === null) return res.sendStatus(401)
if (confirmation.data.email.new === req.body.email) {
user.ehash = ehash(req.body.email)
user.email = req.body.email
return saveAndReturnAccount(res, user)
} else return res.sendStatus(400)
})
} else {
let confirmation = new Confirmation({
type: 'emailchange',
data: {
handle: user.handle,
language: user.settings.language,
email: {
new: req.body.email,
current: user.email,
},
},
})
confirmation.save(function (err) {
if (err) return res.sendStatus(500)
log.info('emailchangeRequest', {
newEmail: req.body.email,
confirmation: confirmation._id,
})
email.emailchange(req.body.email, user.email, user.settings.language, confirmation._id)
return saveAndReturnAccount(res, user)
})
}
}
})
*/
return res.status(200).send({})
} }
/*
// For people who have forgotten their password, or password-less logins
UserController.prototype.confirmationLogin = function (req, res) {
if (!req.body || !req.body.id) return res.sendStatus(400)
Confirmation.findById(req.body.id, (err, confirmation) => {
if (err) return res.sendStatus(400)
if (confirmation === null) return res.sendStatus(401)
User.findOne({ handle: confirmation.data.handle }, (err, user) => {
if (err) return res.sendStatus(400)
if (user === null) {
return res.sendStatus(401)
}
if (user.status !== 'active') return res.sendStatus(403)
else {
log.info('confirmationLogin', { user, req })
let account = user.account()
let token = getToken(account)
let people = {}
Person.find({ user: user.handle }, (err, personList) => {
if (err) return res.sendStatus(400)
for (let person of personList) people[person.handle] = person.info()
let patterns = {}
Pattern.find({ user: user.handle }, (err, patternList) => {
if (err) return res.sendStatus(400)
for (let pattern of patternList) patterns[pattern.handle] = pattern
return user.updateLoginTime(() => res.send({ account, people, patterns, token }))
})
})
}
})
})
}
// CRUD basics
// Note that the user is already crearted (in signup)
// we just need to active the account
UserController.prototype.create = (req, res) => {
if (!req.body) return res.sendStatus(400)
if (!req.body.consent || !req.body.consent.profile) return res.status(400).send('consentRequired')
Confirmation.findById(req.body.id, (err, confirmation) => {
if (err) return res.sendStatus(400)
if (confirmation === null) return res.sendStatus(401)
User.findOne({ handle: confirmation.data.handle }, (err, user) => {
if (err) return res.sendStatus(400)
if (user === null) return res.sendStatus(401)
user.status = 'active'
user.consent = req.body.consent
user.time.login = new Date()
log.info('accountActivated', { handle: user.handle })
let account = user.account()
let token = getToken(account)
user.save(function (err) {
if (err) return res.sendStatus(400)
Confirmation.findByIdAndDelete(req.body.id, (err, confirmation) => {
return res.send({ account, people: {}, patterns: {}, token })
})
})
})
})
}
UserController.prototype.readProfile = (req, res) => {
User.findOne({ username: req.params.username }, (err, user) => {
if (err) return res.sendStatus(404)
if (user === null) return res.sendStatus(404)
else res.send(user.profile())
})
}
function saveAndReturnAccount(res, user) {
user.save(function (err, updatedUser) {
if (err) {
log.error('accountUpdateFailed', err)
return res.sendStatus(500)
} else return res.send({ account: updatedUser.account() })
})
}
function temporaryStoragePath(dir) {
return path.join(config.storage, 'tmp', dir)
}
UserController.prototype.isUsernameAvailable = (req, res) => {
if (!req.user._id) return res.sendStatus(400)
let username = req.body.username.toLowerCase().trim()
if (username === '') return res.sendStatus(400)
User.findOne({ username: username }, (err, user) => {
if (err) return res.sendStatus(400)
if (user === null) return res.sendStatus(200)
if (user._id + '' === req.user._id) return res.sendStatus(200)
else return res.sendStatus(400)
})
}
// // Re-send activation email
UserController.prototype.resend = (req, res) => {
if (!req.body) return res.sendStatus(400)
if (!req.body.email) return res.status(400).send('emailMissing')
if (!req.body.language) return res.status(400).send('languageMissing')
User.findOne(
{
ehash: ehash(req.body.email),
},
(err, user) => {
if (err) return res.sendStatus(500)
if (user === null) return res.status(404).send('noSuchUser')
else {
let confirmation = new Confirmation({
type: 'signup',
data: {
language: req.body.language,
email: user.email,
handle: user.handle,
},
})
confirmation.save(function (err) {
if (err) return res.sendStatus(500)
log.info('resendActivationRequest', {
email: req.body.email,
confirmation: confirmation._id,
})
email.signup(req.body.email, req.body.language, confirmation._id)
return res.sendStatus(200)
})
}
}
)
}
UserController.prototype.resetPassword = (req, res) => {
if (!req.body) return res.sendStatus(400)
User.findOne(
{
$or: [
{ username: req.body.username.toLowerCase().trim() },
{ ehash: ehash(req.body.username) },
],
},
(err, user) => {
if (err) {
console.log(err)
return res.sendStatus(400)
}
if (user === null) return res.sendStatus(401)
let confirmation = new Confirmation({
type: 'passwordreset',
data: {
handle: user.handle,
},
})
confirmation.save(function (err) {
if (err) return res.sendStatus(500)
log.info('passwordresetRequest', { user: user.handle, confirmation: confirmation._id })
email.passwordreset(user.email, user.settings.language, confirmation._id)
return res.sendStatus(200)
})
}
)
}
UserController.prototype.setPassword = (req, res) => {
if (!req.body) return res.sendStatus(400)
Confirmation.findById(req.body.confirmation, (err, confirmation) => {
if (err) return res.sendStatus(400)
if (confirmation === null) return res.sendStatus(401)
User.findOne({ handle: req.body.handle }, (err, user) => {
if (err) return res.sendStatus(400)
if (user === null) return res.sendStatus(401)
if (confirmation.type === 'passwordreset' && confirmation.data.handle === user.handle) {
user.password = req.body.password
user.save(function (err) {
log.info('passwordSet', { user, req })
let account = user.account()
let token = getToken(account)
return user.updateLoginTime(() => res.send({ account, token }))
})
} else return res.sendStatus(401)
})
})
return
}
UserController.prototype.confirmChangedEmail = (req, res) => {
if (!req.body || !req.body.id || !req.user._id) return res.sendStatus(400)
Confirmation.findById(req.body.id, (err, confirmation) => {
if (err || confirmation === null) return res.sendStatus(401)
User.findById(req.user._id, async (err, user) => {
if (err || confirmation.data.handle !== user.handle) return res.sendStatus(401)
user.ehash = ehash(confirmation.data.email.new)
user.email = confirmation.data.email.new
return saveAndReturnAccount(res, user)
})
})
}
// // Other
UserController.prototype.patronList = (req, res) => {
User.find({ patron: { $gte: 2 } })
.sort('username')
.exec((err, users) => {
if (err || users === null) return res.sendStatus(400)
let patrons = {
2: [],
4: [],
8: [],
}
for (let key of Object.keys(users)) {
let user = users[key].profile()
patrons[user.patron].push({
handle: user.handle,
username: user.username,
bio: user.bio,
picture: user.picture,
social: user.social,
pictureUris: user.pictureUris,
})
}
return res.send(patrons)
})
}
UserController.prototype.export = (req, res) => {
if (!req.user._id) return res.sendStatus(400)
User.findById(req.user._id, (err, user) => {
if (user === null) return res.sendStatus(400)
let dir = createTempDir()
if (!dir) return res.sendStatus(500)
let zip = new Zip()
zip.file('account.json', asJson(user.export(), null, 2))
loadAvatar(user).then((avatar) => {
if (avatar) zip.file(user.picture, data)
zip
.generateAsync({
type: 'uint8array',
comment: 'freesewing.org',
streamFiles: true,
})
.then(function (data) {
let file = path.join(dir, 'export.zip')
fs.writeFile(file, data, (err) => {
log.info('dataExport', { user, req })
return res.send({ export: uri(file) })
})
})
})
})
}
const loadAvatar = async (user) => {
if (user.picture)
await fs.readFile(path.join(user.storagePath(), user.picture), (err, data) => data)
else return false
}
// restrict processing of data, aka freeze account
UserController.prototype.restrict = (req, res) => {
if (!req.user._id) return res.sendStatus(400)
User.findById(req.user._id, (err, user) => {
if (user === null) return res.sendStatus(400)
user.status = 'frozen'
user.save(function (err) {
if (err) {
log.error('accountFreezeFailed', user)
return res.sendStatus(500)
}
return res.sendStatus(200)
})
})
}
// Remove account
UserController.prototype.remove = (req, res) => {
if (!req.user._id) return res.sendStatus(400)
User.findById(req.user._id, (err, user) => {
if (user === null) return res.sendStatus(400)
rimraf(user.storagePath(), (err) => {
if (err) {
console.log('rimraf', err)
log.error('accountRemovalFailed', { err, user, req })
return res.sendStatus(500)
}
user.remove((err, usr) => {
if (err !== null) {
log.error('accountRemovalFailed', { err, user, req })
return res.sendStatus(500)
} else return res.sendStatus(200)
})
})
})
}
const getToken = (account) => {
return jwt.sign(
{
_id: account._id,
handle: account.handle,
role: account.role,
aud: config.jwt.audience,
iss: config.jwt.issuer,
},
config.jwt.secretOrKey
)
}
const createTempDir = () => {
let path = temporaryStoragePath(newHandle(10))
fs.mkdir(path, { recursive: true }, (err) => {
if (err) {
log.error('mkdirFailed', err)
path = false
}
})
return path
}
const uri = (path) => config.static + path.substring(config.storage.length)
*/

View file

@ -39,7 +39,8 @@ UserModel.prototype.read = async function (where) {
UserModel.prototype.reveal = async function (where) { UserModel.prototype.reveal = async function (where) {
this.clear = {} this.clear = {}
if (this.record) { if (this.record) {
this.clear.data = JSON.parse(this.decrypt(this.record.data)) this.clear.bio = this.decrypt(this.record.bio)
this.clear.github = this.decrypt(this.record.github)
this.clear.email = this.decrypt(this.record.email) this.clear.email = this.decrypt(this.record.email)
this.clear.initial = this.decrypt(this.record.initial) this.clear.initial = this.decrypt(this.record.initial)
} }
@ -47,6 +48,18 @@ UserModel.prototype.reveal = async function (where) {
return this return this
} }
/*
* Helper method to encrypt at-rest data
*/
UserModel.prototype.cloak = function (data) {
for (const field of ['bio', 'github', 'email']) {
if (typeof data[field] !== 'undefined') data[field] = this.encrypt(data[field])
}
if (typeof data.password === 'string') data.password = asJson(hashPassword(data.password))
return data
}
/* /*
* Loads a user from the database based on the where clause you pass it * Loads a user from the database based on the where clause you pass it
* In addition prepares it for returning the account data * In addition prepares it for returning the account data
@ -125,8 +138,9 @@ UserModel.prototype.setExists = function () {
UserModel.prototype.create = async function ({ body }) { UserModel.prototype.create = async function ({ body }) {
if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing') if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing')
if (!body.email) return this.setResponse(400, 'emailMissing') if (!body.email) return this.setResponse(400, 'emailMissing')
if (!body.password) return this.setResponse(400, 'passwordMissing')
if (!body.language) return this.setResponse(400, 'languageMissing') if (!body.language) return this.setResponse(400, 'languageMissing')
if (!this.config.languages.includes(body.language))
return this.setResponse(400, 'unsupportedLanguage')
const ehash = hash(clean(body.email)) const ehash = hash(clean(body.email))
await this.read({ ehash }) await this.read({ ehash })
@ -145,8 +159,10 @@ UserModel.prototype.create = async function ({ body }) {
initial: email, initial: email,
username, username,
lusername: username, lusername: username,
data: this.encrypt(asJson({ settings: { language: this.language } })), language: body.language,
password: asJson(hashPassword(body.password)), password: asJson(hashPassword(randomString())), // We'll change this later
github: this.encrypt(''),
bio: this.encrypt(''),
} }
this.record = await this.prisma.user.create({ data }) this.record = await this.prisma.user.create({ data })
} catch (err) { } catch (err) {
@ -300,41 +316,92 @@ UserModel.prototype.safeUpdate = async function (data) {
*/ */
UserModel.prototype.unsafeUpdate = async function (body) { UserModel.prototype.unsafeUpdate = async function (body) {
const data = {} const data = {}
// Update consent const notes = []
// Bio
if (typeof body.bio === 'string') data.bio = body.bio
// Consent
if ([0, 1, 2, 3].includes(body.consent)) data.consent = body.consent if ([0, 1, 2, 3].includes(body.consent)) data.consent = body.consent
// Update newsletter // 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
if ([true, false].includes(body.newsletter)) data.newsletter = body.newsletter if ([true, false].includes(body.newsletter)) data.newsletter = body.newsletter
// Update username // Password
if (typeof body.password === 'string') data.password = body.password // Will be cloaked below
// Patron
if ([0, 2, 4, 8].includes(body.patron)) data.patron = body.patron
// Username
if (typeof body.username === 'string') { if (typeof body.username === 'string') {
if (await this.isAvailableUsername(body.username)) { const available = await this.isLusernameAvailable(body.username)
if (available) {
data.username = body.username.trim() data.username = body.username.trim()
data.lusername = clean(body.username) data.lusername = clean(body.username)
} else {
log.info(`Rejected user name change from ${data.username} to ${body.username.trim()}`)
notes.push('usernameChangeRejected')
} }
} }
// Update password
if (typeof body.password === 'string') { // Now update the record
data.password = asJson(hashPassword(body.password)) await this.safeUpdate(this.cloak(data))
}
// Update data // Email change requires confirmation
if (typeof body.data === 'object') { if (typeof body.email === 'string' && this.clear.email !== clean(body.email)) {
data.data = { ...this.record.data } if (typeof body.confirmation === 'string') {
// Retrieve confirmation record
await this.Confirmation.read({ id: body.confirmation })
if (!this.Confirmation.exists) {
log.warn(err, `Could not find confirmation id ${params.id}`)
return this.setResponse(404, 'failedToFindConfirmationId')
}
if (this.Confirmation.record.type !== 'emailchange') {
log.warn(err, `Confirmation mismatch; ${params.id} is not an emailchange id`)
return this.setResponse(404, 'confirmationIdTypeMismatch')
}
const data = this.Confirmation.clear.data
if (data.email.current === this.clear.email && typeof data.email.new === 'string') {
await this.saveUpdate({
email: this.encrypt(data.email),
})
}
} else {
// Create confirmation for email change
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,
})
// 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`),
},
})
}
} }
/* return this.setResponse(200, false, {
data String @default("{}") result: 'success',
ehash String @unique account: this.asAccount(),
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)
} }
/* /*
@ -441,3 +508,21 @@ UserModel.prototype.loginOk = function () {
account: this.asAccount(), 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
if (lusername.slice(0, 5) === 'user-') return false
let found
try {
found = await this.prisma.user.findUnique({ where: { lusername } })
} catch (err) {
log.warn({ err, where }, 'Could not search for free username')
}
return true
}

View file

@ -9,7 +9,8 @@ export const setup = async function (config, store, chai) {
.request(config.api) .request(config.api)
.post('/signup') .post('/signup')
.send({ .send({
...store.account, email: store.account.email,
language: store.account.language,
unittest: true, unittest: true,
}) })
.end((err, res) => { .end((err, res) => {
@ -65,6 +66,28 @@ export const setup = async function (config, store, chai) {
done() done()
}) })
}) })
step(`${store.icon('user')} Should set the initial password`, (done) => {
chai
.request(config.api)
.put('/account/jwt')
.set('Authorization', 'Bearer ' + store.account.token)
.send({
password: store.account.password,
})
.end((err, res) => {
expect(res.status).to.equal(200)
expect(res.type).to.equal('application/json')
expect(res.charset).to.equal('utf-8')
expect(res.body.result).to.equal(`success`)
expect(res.body.account.email).to.equal(store.account.email)
expect(res.body.account.username).to.equal(store.account.username)
expect(res.body.account.lusername).to.equal(store.account.username.toLowerCase())
expect(typeof res.body.account.id).to.equal(`number`)
store.token = res.body.token
done()
})
})
}) })
} }

View file

@ -19,7 +19,6 @@ export const userTests = async (config, store, chai) => {
const fields = { const fields = {
email: 'test@freesewing.dev', email: 'test@freesewing.dev',
password: 'test',
language: 'fr', language: 'fr',
} }
Object.keys(fields).map((key) => { Object.keys(fields).map((key) => {
@ -193,7 +192,6 @@ export const userTests = async (config, store, chai) => {
done() done()
}) })
}) })
step(`${store.icon('user', 'jwt')} Should load account (jwt)`, (done) => { step(`${store.icon('user', 'jwt')} Should load account (jwt)`, (done) => {
chai chai
.request(config.api) .request(config.api)