From 35e23357efdc4ae5525dd273e25a18e88970da4d Mon Sep 17 00:00:00 2001 From: greysoh Date: Wed, 17 Apr 2024 15:39:29 -0400 Subject: [PATCH] feature: Adds user creation endpoint. --- package-lock.json | 10 ++ package.json | 1 + .../migration.sql | 21 +++++ prisma/schema.prisma | 2 +- routes/NextNet API/Create User.bru | 11 +++ routes/NextNet API/bruno.json | 5 + src/index.ts | 25 ++++- src/libs/generateToken.ts | 20 ++++ src/libs/types.ts | 14 +++ src/routes/user/create.ts | 92 +++++++++++++++++++ 10 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20240417173957_fix_name_no_longer_optional/migration.sql create mode 100644 routes/NextNet API/Create User.bru create mode 100644 routes/NextNet API/bruno.json create mode 100644 src/libs/generateToken.ts create mode 100644 src/libs/types.ts create mode 100644 src/routes/user/create.ts diff --git a/package-lock.json b/package-lock.json index 7c76b2b..73be2ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "fastify": "^4.26.2" }, "devDependencies": { + "@types/bcrypt": "^5.0.2", "@types/node": "^20.12.7", "nodemon": "^3.0.3", "prisma": "^5.12.1", @@ -146,6 +147,15 @@ "@prisma/debug": "5.12.1" } }, + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "20.12.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", diff --git a/package.json b/package.json index 6b8e92d..1e0ece9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "author": "greysoh", "license": "BSD-3-Clause", "devDependencies": { + "@types/bcrypt": "^5.0.2", "@types/node": "^20.12.7", "nodemon": "^3.0.3", "prisma": "^5.12.1", diff --git a/prisma/migrations/20240417173957_fix_name_no_longer_optional/migration.sql b/prisma/migrations/20240417173957_fix_name_no_longer_optional/migration.sql new file mode 100644 index 0000000..7d4372f --- /dev/null +++ b/prisma/migrations/20240417173957_fix_name_no_longer_optional/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - Made the column `name` on table `User` required. This step will fail if there are existing NULL values in that column. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL, + "password" TEXT NOT NULL, + "rootToken" TEXT +); +INSERT INTO "new_User" ("email", "id", "name", "password", "rootToken") SELECT "email", "id", "name", "password", "rootToken" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fbc790a..19fc005 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -48,7 +48,7 @@ model User { id Int @id @default(autoincrement()) email String @unique - name String? + name String password String // Will be hashed using bcrypt diff --git a/routes/NextNet API/Create User.bru b/routes/NextNet API/Create User.bru new file mode 100644 index 0000000..b1b2c56 --- /dev/null +++ b/routes/NextNet API/Create User.bru @@ -0,0 +1,11 @@ +meta { + name: Create User + type: http + seq: 1 +} + +post { + url: http://127.0.0.1:3000/api/v1/users/create + body: none + auth: none +} diff --git a/routes/NextNet API/bruno.json b/routes/NextNet API/bruno.json new file mode 100644 index 0000000..af51919 --- /dev/null +++ b/routes/NextNet API/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "NextNet API", + "type": "collection" +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 6c29cad..b743692 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,15 +3,34 @@ import process from "node:process"; import { PrismaClient } from '@prisma/client'; import Fastify from "fastify"; +import { ServerOptions, SessionToken } from "./libs/types.js"; +import { route as create } from "./routes/user/create.js"; + const prisma = new PrismaClient(); +const isSignupEnabled: boolean = Boolean(process.env.IS_SIGNUP_ENABLED); +const unsafeAdminSignup: boolean = Boolean(process.env.UNSAFE_ADMIN_SIGNUP); + +const noUsersCheck: boolean = await prisma.user.count() == 0; + +if (unsafeAdminSignup) { + console.error("WARNING: You have admin sign up on! This means that anyone that signs up will have admin rights!"); +} + +const serverOptions: ServerOptions = { + isSignupEnabled: isSignupEnabled ? true : noUsersCheck, + isSignupAsAdminEnabled: unsafeAdminSignup ? true : noUsersCheck, + + allowUnsafeGlobalTokens: process.env.NODE_ENV != "production" +}; + +const sessionTokens: Record = {}; + const fastify = Fastify({ logger: true }); -fastify.get('/', async function handler (request, reply) { - return { hello: 'world' }; -}) +create(fastify, prisma, sessionTokens, serverOptions); // Run the server! try { diff --git a/src/libs/generateToken.ts b/src/libs/generateToken.ts new file mode 100644 index 0000000..a98ae53 --- /dev/null +++ b/src/libs/generateToken.ts @@ -0,0 +1,20 @@ +function getRandomInt(min: number, max: number): number { + const minCeiled = Math.ceil(min); + const maxFloored = Math.floor(max); + return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled); // The maximum is exclusive and the minimum is inclusive +} + +export function generateRandomData(length: number): string { + let newString = ""; + + for (let i = 0; i < length; i += 2) { + const randomNumber = getRandomInt(0, 255); + newString += randomNumber.toString(16); + } + + return newString; +} + +export function generateToken() { + return generateRandomData(128); +} \ No newline at end of file diff --git a/src/libs/types.ts b/src/libs/types.ts new file mode 100644 index 0000000..66c2efd --- /dev/null +++ b/src/libs/types.ts @@ -0,0 +1,14 @@ +export type ServerOptions = { + isSignupEnabled: boolean; + isSignupAsAdminEnabled: boolean; + + allowUnsafeGlobalTokens: boolean; +} + +// NOTE: Someone should probably use Redis for this, but this is fine... +export type SessionToken = { + createdAt: number, + expiresAt: number, // Should be (createdAt + (30 minutes)) + + token: string +}; \ No newline at end of file diff --git a/src/routes/user/create.ts b/src/routes/user/create.ts new file mode 100644 index 0000000..b43fbb7 --- /dev/null +++ b/src/routes/user/create.ts @@ -0,0 +1,92 @@ +import type { PrismaClient } from "@prisma/client"; +import type { FastifyInstance } from "fastify"; + +import { hash } from "bcrypt"; + +import { ServerOptions, SessionToken } from "../../libs/types.js"; +import { generateToken } from "../../libs/generateToken.js"; + +export function route(fastify: FastifyInstance, prisma: PrismaClient, tokens: Record, options: ServerOptions) { + // TODO: Permissions + + fastify.post("/api/v1/users/create", { + schema: { + body: { + type: "object", + required: ["name", "email", "password"], + + properties: { + name: { type: "string" }, + email: { type: "string" }, + password: { type: "string" } + } + } + } + }, async(req, res) => { + // @ts-ignore + const body: { + name: string, + email: string, + password: string + } = req.body; + + if (!options.isSignupEnabled) { + return res.status(400).send({ + error: "Signing up is not enabled at this time." + }); + }; + + const userSearch = await prisma.user.findFirst({ + where: { + email: body.email + } + }); + + if (userSearch) { + return res.status(400).send({ + error: "User already exists" + }) + }; + + const saltedPassword: string = await hash(body.password, 15); + + let userData = { + name: body.name, + email: body.email, + password: saltedPassword, + rootToken: null + }; + + if (options.allowUnsafeGlobalTokens) { + userData.rootToken = generateToken() as unknown as null; + } + + const userCreateResults = await prisma.user.create({ + data: userData + }); + + // FIXME(?): Redundant checks + if (options.allowUnsafeGlobalTokens) { + return { + success: true, + token: userCreateResults.rootToken + }; + } else { + const generatedToken = generateToken(); + + tokens[userCreateResults.id] = []; + + tokens[userCreateResults.id].push({ + createdAt: Date.now(), + expiresAt: Date.now() + (30 * 60_000), + + token: generatedToken + }); + + return { + success: true, + token: generatedToken + }; + }; + }); +} \ No newline at end of file