wip(backend): Work on signup flow
This commit is contained in:
parent
6837f1c8df
commit
98087677be
8 changed files with 135 additions and 90 deletions
3
sites/backend/.gitignore
vendored
3
sites/backend/.gitignore
vendored
|
@ -1 +1,2 @@
|
||||||
dev.sqlite
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue