From 98087677be07e356492c9c3bedf272476245e48a Mon Sep 17 00:00:00 2001 From: joostdecock Date: Mon, 31 Oct 2022 17:54:49 +0100 Subject: [PATCH] wip(backend): Work on signup flow --- sites/backend/.gitignore | 3 +- sites/backend/prisma/schema.prisma | 3 +- sites/backend/scripts/mongo-to-sqlite.mjs | 1 + sites/backend/src/config.mjs | 3 +- sites/backend/src/controllers/user.mjs | 124 +++++++++++++--------- sites/backend/src/utils/crypto.mjs | 29 ++--- sites/backend/src/utils/index.mjs | 16 ++- sites/backend/tests/user.test.mjs | 46 ++++---- 8 files changed, 135 insertions(+), 90 deletions(-) diff --git a/sites/backend/.gitignore b/sites/backend/.gitignore index ff89c2c5229..97fc976772a 100644 --- a/sites/backend/.gitignore +++ b/sites/backend/.gitignore @@ -1 +1,2 @@ -dev.sqlite +*.sqlite +*.sqlite-journal diff --git a/sites/backend/prisma/schema.prisma b/sites/backend/prisma/schema.prisma index 7b8d12bdba1..3c8df7523cf 100644 --- a/sites/backend/prisma/schema.prisma +++ b/sites/backend/prisma/schema.prisma @@ -11,8 +11,7 @@ model Confirmation { id String @id @default(uuid()) createdAt DateTime @default(now()) data String - type Int - updatedAt DateTime @updatedAt + type String } model Subscriber { diff --git a/sites/backend/scripts/mongo-to-sqlite.mjs b/sites/backend/scripts/mongo-to-sqlite.mjs index 22f6856b0f5..7f1a44e5c7a 100644 --- a/sites/backend/scripts/mongo-to-sqlite.mjs +++ b/sites/backend/scripts/mongo-to-sqlite.mjs @@ -2,6 +2,7 @@ import path from 'path' import fs from 'fs' import { PrismaClient } from '@prisma/client' import { clean, hash, encryption } from '../src/utils/crypto.mjs' +import { clean } from '../src/utils/index.mjs' import { verifyConfig } from '../src/config.mjs' const prisma = new PrismaClient() diff --git a/sites/backend/src/config.mjs b/sites/backend/src/config.mjs index ec9f2a0e2cf..ab541b5c8ee 100644 --- a/sites/backend/src/config.mjs +++ b/sites/backend/src/config.mjs @@ -1,6 +1,7 @@ import chalk from 'chalk' // Load environment variables import dotenv from 'dotenv' +import { asJson } from './utils/index.mjs' dotenv.config() const port = process.env.API_PORT || 3000 @@ -166,7 +167,7 @@ export function verifyConfig() { if (process.env.API_DUMP_CONFIG_AT_STARTUP) { console.log( 'Dumping configuration:', - JSON.stringify( + asJson( { ...config, encryption: { diff --git a/sites/backend/src/controllers/user.mjs b/sites/backend/src/controllers/user.mjs index f7c570816c1..60bf50ec43f 100644 --- a/sites/backend/src/controllers/user.mjs +++ b/sites/backend/src/controllers/user.mjs @@ -4,68 +4,94 @@ import jwt from 'jsonwebtoken' //import fs from 'fs' import Zip from 'jszip' //import rimraf from 'rimraf' -import { clean, hash } from '../utils/crypto.mjs' +import { hash, hashPassword, randomString } from '../utils/crypto.mjs' +import { clean, asJson } from '../utils/index.mjs' +import { log } from '../utils/log.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 => { + return user +} export function UserController() {} // Signup UserController.prototype.signup = async (req, res, tools) => { if (!req.body) return res.sendStatus(400) - if (!req.body.email) return res.status(400).send('emailMissing') - if (!req.body.password) return res.status(400).send('passwordMissing') - if (!req.body.language) return res.status(400).send('languageMissing') - - // Requests looks ok - does the user exist? - const emailhash = hash(clean(req.body.email)) + if (!req.body.email) return res.status(400).json({ error: 'emailMissing'}) + if (!req.body.password) return res.status(400).json({ error: 'passwordMissing'}) + if (!req.body.language) return res.status(400).json({ error: 'languageMissing'}) // Destructure what we need from tools const { prisma, config, encrypt } = tools - if (await prisma.user.findUnique({ where: { ehash: emailhash } })) - return res.status(400).send('emailExists') + // Requests looks ok - does the user exist? + const emailhash = hash(clean(req.body.email)) + if (await prisma.user.findUnique({ where: { ehash: emailhash } })) { + return res.status(400).json({ error: 'emailExists'}) + } // It does not. Creating user entry + let record + try { + record = await prisma.user.create({ + data: { + data: asJson({ settings: { language: req.body.language } }), + ehash: emailhash, + email: encrypt(clean(req.body.email)), + ihash: emailhash, + initial: encrypt(clean(req.body.email)), + password: asJson(hashPassword(req.body.password)), + username: randomString() // Temporary username, + }, + }) + } catch (err) { + log.warn(err, 'Could not create user record') + return res.status(500).send({error: 'createAccountFailed'}) + } + // Now set username to user-ID + let updated + console.log({record}) + try { + updated = await prisma.user.update({ + where: { id: record.id }, + data: { + username: `user-${record.id}` + } + }) + } catch (err) { + log.warn(err, 'Could not update username on created user record') + return res.status(500).send({error: 'updateCreatedAccountUsernameFailed'}) + } + log.info({ user: updated.id }, 'Account created') - const username = `user-${hash.slice(0, 6)}-${time().slice(-6)}` // Temporary username - //const user = await.prisma.user.create({ - // ehash: hash, // Hash of the email to search on - // ihash: hash, // Hash of the (initial) email to search on - // email: encrypt(clean(req.body.email)), // Encrypt email at rest - // initial: encrypt(clean(req.body.email)), // Encrypt initial email at rest - // username: username, // User will be suggested to change this - // settings: JSON.stringify({ language: req.body.language }), // Set languuge in settings - //}) - /* - password String -*/ - return res.status(200).send({}) - /* - (err, user) => { - if (err) return res.sendStatus(500) - if (user !== null) return res.status(400).send('userExists') - else { - let handle = uniqueHandle() - let username = 'user-' + handle - let user = new User({ + // Create confirmation + let confirmation + try { + confirmation = await prisma.confirmation.create({ + data: { + type: 'signup', + data: encrypt({ + language: req.body.language, email: req.body.email, - initial: req.body.email, - ehash: ehash(req.body.email), - handle, - username, - password: req.body.password, - settings: { language: req.body.language }, - status: 'pending', - picture: handle + '.svg', - time: { - created: new Date(), - }, + id: record.id, + ehash: emailhash }) - user.save(function (err) { - if (err) { - log.error('accountCreationFailed', user) - console.log(err) - return res.sendStatus(500) - } - log.info('accountCreated', { handle: user.handle }) + } + }) + } + catch(err) { + log.warn(err, 'Unable to create confirmation at signup') + return res.status(500).send({error: 'updateCreatedAccountUsernameFailed'}) + } + // TODO: Send email with confirmation UUID and ehash + console.log({confirmation}) + + return res.status(200).send(asAccount(updated)) + /* user.createAvatar(handle) let confirmation = new Confirmation({ type: 'signup', @@ -477,7 +503,7 @@ UserController.prototype.export = (req, res) => { let dir = createTempDir() if (!dir) return res.sendStatus(500) let zip = new Zip() - zip.file('account.json', JSON.stringify(user.export(), null, 2)) + zip.file('account.json', asJson(user.export(), null, 2)) loadAvatar(user).then((avatar) => { if (avatar) zip.file(user.picture, data) zip diff --git a/sites/backend/src/utils/crypto.mjs b/sites/backend/src/utils/crypto.mjs index c0094b6cc51..4f4f5f818ef 100644 --- a/sites/backend/src/utils/crypto.mjs +++ b/sites/backend/src/utils/crypto.mjs @@ -1,21 +1,21 @@ import bcrypt from 'bcryptjs' // Required for legacy password hashes import { createHash, createCipheriv, createDecipheriv, scryptSync, randomBytes } from 'crypto' import { log } from './log.mjs' - -/* - * Cleans a string (typically email) for hashing - */ -export const clean = (string) => { - if (typeof string !== 'string') throw 'clean() only takes a string as input' - - return string.toLowerCase().trim() -} +import { asJson, clean } from './index.mjs' /* * Hashes an email address (or other string) */ export const hash = (string) => createHash('sha256').update(string).digest('hex') +/* + * Generates a random string + * + * This is not used in anything cryptographic. It is only used as a temporary + * username to avoid username collisions + */ +export const randomString = (bytes=8) => randomBytes(bytes).toString('hex') + /* * Returns an object holding encrypt() and decrypt() methods * @@ -43,11 +43,11 @@ export const encryption = (stringKey, salt = 'FreeSewing') => { /* * With undefined out of the way, there's still some things we cannot encrypt. * Essentially, anything that can't be serialized to JSON, such as functions. - * So let's catch the JSON.stringify() call and once again bail out if things + * So let's catch the asJson() call and once again bail out if things * go off the rails here. */ try { - data = JSON.stringify(data) + data = asJson(data) } catch (err) { throw ('Could not parse input to encrypt() call', err) } @@ -66,7 +66,7 @@ export const encryption = (stringKey, salt = 'FreeSewing') => { /* * Always return a string so we can store this in SQLite no problemo */ - return JSON.stringify({ + return asJson({ iv: iv.toString('hex'), encrypted: Buffer.concat([cipher.update(data), cipher.final()]).toString('hex'), }) @@ -106,12 +106,13 @@ export const encryption = (stringKey, salt = 'FreeSewing') => { /* * Salts and hashes a password */ -function hashPassword(input, salt = false) { +export function hashPassword(input, salt = false) { if (salt === false) salt = Buffer.from(randomBytes(16)) else salt = Buffer.from(salt, 'hex') const hash = scryptSync(input, salt, 64) return { + type: 'v3', hash: hash.toString('hex'), salt: salt.toString('hex'), } @@ -150,7 +151,7 @@ export function verifyPassword(input, passwordField) { * Correct password for legacy password field. * Re-hash and return updated password field value. */ - return [true, JSON.stringify(hashPassword(input))] + return [true, asJson(hashPassword(input))] } } else if (data.type === 'v3') { if (data.hash && data.salt) { diff --git a/sites/backend/src/utils/index.mjs b/sites/backend/src/utils/index.mjs index 0e247f7b381..0371038855a 100644 --- a/sites/backend/src/utils/index.mjs +++ b/sites/backend/src/utils/index.mjs @@ -2,9 +2,17 @@ import { createHash } from 'node:crypto' import dotenv from 'dotenv' dotenv.config() -// Cleans an email for hashing -export const clean = (string) => string.toLowerCase().trim() +/* + * Cleans a string (typically email) for hashing + */ +export const clean = (string) => { + if (typeof string !== 'string') throw 'clean() only takes a string as input' -// Hashes a (cleaned) string -export const hash = (string) => createHash('sha256').update(clean(string)).digest('hex') + return string.toLowerCase().trim() +} +/* + * I find JSON.stringify to long to type, and prone to errors + * So I make an alias here: asJson + */ +export const asJson = JSON.stringify diff --git a/sites/backend/tests/user.test.mjs b/sites/backend/tests/user.test.mjs index fab81aa496e..ce371c48da9 100644 --- a/sites/backend/tests/user.test.mjs +++ b/sites/backend/tests/user.test.mjs @@ -1,6 +1,7 @@ import chai from 'chai' import http from 'chai-http' import { verifyConfig } from '../src/config.mjs' +import { randomString } from '../src/utils/crypto.mjs' const config = verifyConfig() const expect = chai.expect @@ -20,9 +21,9 @@ describe('Non language-specific User controller signup routes', () => { }) let data = { - email: 'test@freesewing.org', + email: '__test__@freesewing.dev', language: 'en', - password: 'one two one two, this is just a test', + password: 'One two one two, this is just a test', } Object.keys(data).map((key) => { @@ -40,39 +41,46 @@ describe('Non language-specific User controller signup routes', () => { .end((err, res) => { expect(err === null).to.equal(true) expect(res.status).to.equal(400) - expect(res.text).to.equal(`${key}Missing`) + expect(res.type).to.equal('application/json') + expect(res.charset).to.equal('utf-8') + expect(res.body.error).to.equal(`${key}Missing`) done() }) }) }) - it('should not create signup without password', (done) => { + it('should fail to signup an existing email address', (done) => { + chai + .request(config.api) + .post('/signup') + .send({ + ...data, + email: 'nidhubhs@gmail.com', + }) + .end((err, res) => { + expect(res.status).to.equal(400) + expect(res.type).to.equal('application/json') + expect(res.charset).to.equal('utf-8') + expect(res.body.error).to.equal('emailExists') + done() + }) + }) + + it('should signup a new user', (done) => { chai .request(config.api) .post('/signup') .send(data) .end((err, res) => { expect(res.status).to.equal(400) + expect(res.type).to.equal('application/json') + expect(res.charset).to.equal('utf-8') + expect(res.body.error).to.equal('emailExists') done() }) }) /* - it('should not create signup without language', (done) => { - chai - .request(config.backend) - .post('/signup') - .send({ - email: config.user.email, - password: config.user.password, - }) - .end((err, res) => { - res.should.have.status(400) - res.text.should.equal('languageMissing') - done() - }) - }) -}) describe('Login/Logout and session handling', () => { it('should login with the username', (done) => {