1
0
Fork 0

wip(backend): Work on signup flow

This commit is contained in:
joostdecock 2022-10-31 17:54:49 +01:00
parent 6837f1c8df
commit 98087677be
8 changed files with 135 additions and 90 deletions

View file

@ -1 +1,2 @@
dev.sqlite *.sqlite
*.sqlite-journal

View file

@ -11,8 +11,7 @@ model Confirmation {
id String @id @default(uuid()) id String @id @default(uuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
data String data String
type Int type String
updatedAt DateTime @updatedAt
} }
model Subscriber { model Subscriber {

View file

@ -2,6 +2,7 @@ import path from 'path'
import fs from 'fs' import fs from 'fs'
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import { clean, hash, encryption } from '../src/utils/crypto.mjs' import { clean, hash, encryption } from '../src/utils/crypto.mjs'
import { clean } from '../src/utils/index.mjs'
import { verifyConfig } from '../src/config.mjs' import { verifyConfig } from '../src/config.mjs'
const prisma = new PrismaClient() const prisma = new PrismaClient()

View file

@ -1,6 +1,7 @@
import chalk from 'chalk' import chalk from 'chalk'
// Load environment variables // Load environment variables
import dotenv from 'dotenv' import dotenv from 'dotenv'
import { asJson } from './utils/index.mjs'
dotenv.config() dotenv.config()
const port = process.env.API_PORT || 3000 const port = process.env.API_PORT || 3000
@ -166,7 +167,7 @@ export function verifyConfig() {
if (process.env.API_DUMP_CONFIG_AT_STARTUP) { if (process.env.API_DUMP_CONFIG_AT_STARTUP) {
console.log( console.log(
'Dumping configuration:', 'Dumping configuration:',
JSON.stringify( asJson(
{ {
...config, ...config,
encryption: { encryption: {

View file

@ -4,68 +4,94 @@ import jwt from 'jsonwebtoken'
//import fs from 'fs' //import fs from 'fs'
import Zip from 'jszip' import Zip from 'jszip'
//import rimraf from 'rimraf' //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() {} export function UserController() {}
// Signup // Signup
UserController.prototype.signup = async (req, res, tools) => { UserController.prototype.signup = async (req, res, tools) => {
if (!req.body) return res.sendStatus(400) if (!req.body) return res.sendStatus(400)
if (!req.body.email) return res.status(400).send('emailMissing') if (!req.body.email) return res.status(400).json({ error: 'emailMissing'})
if (!req.body.password) return res.status(400).send('passwordMissing') if (!req.body.password) return res.status(400).json({ error: 'passwordMissing'})
if (!req.body.language) return res.status(400).send('languageMissing') if (!req.body.language) return res.status(400).json({ error: 'languageMissing'})
// Requests looks ok - does the user exist?
const emailhash = hash(clean(req.body.email))
// Destructure what we need from tools // Destructure what we need from tools
const { prisma, config, encrypt } = 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 // 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 // Create confirmation
//const user = await.prisma.user.create({ let confirmation
// ehash: hash, // Hash of the email to search on try {
// ihash: hash, // Hash of the (initial) email to search on confirmation = await prisma.confirmation.create({
// email: encrypt(clean(req.body.email)), // Encrypt email at rest data: {
// initial: encrypt(clean(req.body.email)), // Encrypt initial email at rest type: 'signup',
// username: username, // User will be suggested to change this data: encrypt({
// settings: JSON.stringify({ language: req.body.language }), // Set languuge in settings language: req.body.language,
//})
/*
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({
email: req.body.email, email: req.body.email,
initial: req.body.email, id: record.id,
ehash: ehash(req.body.email), ehash: emailhash
handle,
username,
password: req.body.password,
settings: { language: req.body.language },
status: 'pending',
picture: handle + '.svg',
time: {
created: new Date(),
},
}) })
user.save(function (err) { }
if (err) { })
log.error('accountCreationFailed', user) }
console.log(err) catch(err) {
return res.sendStatus(500) log.warn(err, 'Unable to create confirmation at signup')
} return res.status(500).send({error: 'updateCreatedAccountUsernameFailed'})
log.info('accountCreated', { handle: user.handle }) }
// TODO: Send email with confirmation UUID and ehash
console.log({confirmation})
return res.status(200).send(asAccount(updated))
/*
user.createAvatar(handle) user.createAvatar(handle)
let confirmation = new Confirmation({ let confirmation = new Confirmation({
type: 'signup', type: 'signup',
@ -477,7 +503,7 @@ UserController.prototype.export = (req, res) => {
let dir = createTempDir() let dir = createTempDir()
if (!dir) return res.sendStatus(500) if (!dir) return res.sendStatus(500)
let zip = new Zip() 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) => { loadAvatar(user).then((avatar) => {
if (avatar) zip.file(user.picture, data) if (avatar) zip.file(user.picture, data)
zip zip

View file

@ -1,21 +1,21 @@
import bcrypt from 'bcryptjs' // Required for legacy password hashes import bcrypt from 'bcryptjs' // Required for legacy password hashes
import { createHash, createCipheriv, createDecipheriv, scryptSync, randomBytes } from 'crypto' import { createHash, createCipheriv, createDecipheriv, scryptSync, randomBytes } from 'crypto'
import { log } from './log.mjs' import { log } from './log.mjs'
import { asJson, clean } from './index.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()
}
/* /*
* Hashes an email address (or other string) * Hashes an email address (or other string)
*/ */
export const hash = (string) => createHash('sha256').update(string).digest('hex') 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 * 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. * 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. * 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. * go off the rails here.
*/ */
try { try {
data = JSON.stringify(data) data = asJson(data)
} catch (err) { } catch (err) {
throw ('Could not parse input to encrypt() call', 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 * Always return a string so we can store this in SQLite no problemo
*/ */
return JSON.stringify({ return asJson({
iv: iv.toString('hex'), iv: iv.toString('hex'),
encrypted: Buffer.concat([cipher.update(data), cipher.final()]).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 * Salts and hashes a password
*/ */
function hashPassword(input, salt = false) { export function hashPassword(input, salt = false) {
if (salt === false) salt = Buffer.from(randomBytes(16)) if (salt === false) salt = Buffer.from(randomBytes(16))
else salt = Buffer.from(salt, 'hex') else salt = Buffer.from(salt, 'hex')
const hash = scryptSync(input, salt, 64) const hash = scryptSync(input, salt, 64)
return { return {
type: 'v3',
hash: hash.toString('hex'), hash: hash.toString('hex'),
salt: salt.toString('hex'), salt: salt.toString('hex'),
} }
@ -150,7 +151,7 @@ export function verifyPassword(input, passwordField) {
* Correct password for legacy password field. * Correct password for legacy password field.
* Re-hash and return updated password field value. * Re-hash and return updated password field value.
*/ */
return [true, JSON.stringify(hashPassword(input))] return [true, asJson(hashPassword(input))]
} }
} else if (data.type === 'v3') { } else if (data.type === 'v3') {
if (data.hash && data.salt) { if (data.hash && data.salt) {

View file

@ -2,9 +2,17 @@ import { createHash } from 'node:crypto'
import dotenv from 'dotenv' import dotenv from 'dotenv'
dotenv.config() 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 return string.toLowerCase().trim()
export const hash = (string) => createHash('sha256').update(clean(string)).digest('hex') }
/*
* I find JSON.stringify to long to type, and prone to errors
* So I make an alias here: asJson
*/
export const asJson = JSON.stringify

View file

@ -1,6 +1,7 @@
import chai from 'chai' import chai from 'chai'
import http from 'chai-http' import http from 'chai-http'
import { verifyConfig } from '../src/config.mjs' import { verifyConfig } from '../src/config.mjs'
import { randomString } from '../src/utils/crypto.mjs'
const config = verifyConfig() const config = verifyConfig()
const expect = chai.expect const expect = chai.expect
@ -20,9 +21,9 @@ describe('Non language-specific User controller signup routes', () => {
}) })
let data = { let data = {
email: 'test@freesewing.org', email: '__test__@freesewing.dev',
language: 'en', 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) => { Object.keys(data).map((key) => {
@ -40,39 +41,46 @@ describe('Non language-specific User controller signup routes', () => {
.end((err, res) => { .end((err, res) => {
expect(err === null).to.equal(true) expect(err === null).to.equal(true)
expect(res.status).to.equal(400) 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() 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 chai
.request(config.api) .request(config.api)
.post('/signup') .post('/signup')
.send(data) .send(data)
.end((err, res) => { .end((err, res) => {
expect(res.status).to.equal(400) 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() 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', () => { describe('Login/Logout and session handling', () => {
it('should login with the username', (done) => { it('should login with the username', (done) => {