diff --git a/drizzle/0002_sweet_mentallo.sql b/drizzle/0002_sweet_mentallo.sql new file mode 100644 index 0000000..e96af5a --- /dev/null +++ b/drizzle/0002_sweet_mentallo.sql @@ -0,0 +1,2 @@ +ALTER TABLE `users_table` ADD `verification_code` varchar(255);--> statement-breakpoint +ALTER TABLE `users_table` ADD `verifcationCodeExpires` date; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..c89ec04 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,380 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "43f0adbb-ed0a-4d90-bb8a-1629fa070717", + "prevId": "20bd9f86-c801-4ead-841c-558127978194", + "tables": { + "sessions": { + "name": "sessions", + "columns": { + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_userId_users_table_id_fk": { + "name": "sessions_userId_users_table_id_fk", + "tableFrom": "sessions", + "tableTo": "users_table", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_token": { + "name": "sessions_token", + "columns": [ + "token" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "survey_access_table": { + "name": "survey_access_table", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "surveyId": { + "name": "surveyId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recepientEmail": { + "name": "recepientEmail", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accessToken": { + "name": "accessToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "token_index": { + "name": "token_index", + "columns": [ + "accessToken" + ], + "isUnique": false + } + }, + "foreignKeys": { + "survey_access_table_surveyId_surveys_table_id_fk": { + "name": "survey_access_table_surveyId_surveys_table_id_fk", + "tableFrom": "survey_access_table", + "tableTo": "surveys_table", + "columnsFrom": [ + "surveyId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "survey_access_table_id": { + "name": "survey_access_table_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "survey_answers_table": { + "name": "survey_answers_table", + "columns": { + "participantId": { + "name": "participantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skillId": { + "name": "skillId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating": { + "name": "rating", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "survey_answers_table_participantId_survey_access_table_id_fk": { + "name": "survey_answers_table_participantId_survey_access_table_id_fk", + "tableFrom": "survey_answers_table", + "tableTo": "survey_access_table", + "columnsFrom": [ + "participantId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "survey_answers_table_skillId_survey_skills_table_id_fk": { + "name": "survey_answers_table_skillId_survey_skills_table_id_fk", + "tableFrom": "survey_answers_table", + "tableTo": "survey_skills_table", + "columnsFrom": [ + "skillId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "survey_answers_table_participantId_skillId_pk": { + "name": "survey_answers_table_participantId_skillId_pk", + "columns": [ + "participantId", + "skillId" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "survey_skills_table": { + "name": "survey_skills_table", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "surveyId": { + "name": "surveyId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "survey_skills_table_surveyId_surveys_table_id_fk": { + "name": "survey_skills_table_surveyId_surveys_table_id_fk", + "tableFrom": "survey_skills_table", + "tableTo": "surveys_table", + "columnsFrom": [ + "surveyId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "survey_skills_table_id": { + "name": "survey_skills_table_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "surveys_table": { + "name": "surveys_table", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "surveys_table_owner_users_table_id_fk": { + "name": "surveys_table_owner_users_table_id_fk", + "tableFrom": "surveys_table", + "tableTo": "users_table", + "columnsFrom": [ + "owner" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "surveys_table_id": { + "name": "surveys_table_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verification_code": { + "name": "verification_code", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verifcationCodeExpires": { + "name": "verifcationCodeExpires", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_table_id": { + "name": "users_table_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_table_email_unique": { + "name": "users_table_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 51c282d..a265166 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1732727438120, "tag": "0000_harsh_warlock", "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1734971924726, + "tag": "0001_lucky_proteus", + "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1734972757800, + "tag": "0002_sweet_mentallo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3ac4c2c..457eaa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "drizzle-orm": "^0.36.4", "layerchart": "^0.59.1", "markdown-it": "^14.1.0", - "mysql2": "^3.11.4" + "mysql2": "^3.11.4", + "nodemailer": "^6.9.16" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.0.0", @@ -35,6 +36,7 @@ "@types/debug": "^4.1.12", "@types/eslint": "^9.6.0", "@types/markdown-it": "^14.1.2", + "@types/nodemailer": "^6.4.17", "autoprefixer": "^10.4.20", "drizzle-kit": "^0.28.1", "eslint": "^9.7.0", @@ -2444,6 +2446,16 @@ "undici-types": "~6.19.8" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", + "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -5568,6 +5580,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "6.9.16", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", + "integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index 459aca3..2c92d3a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@types/debug": "^4.1.12", "@types/eslint": "^9.6.0", "@types/markdown-it": "^14.1.2", + "@types/nodemailer": "^6.4.17", "autoprefixer": "^10.4.20", "drizzle-kit": "^0.28.1", "eslint": "^9.7.0", @@ -53,6 +54,7 @@ "drizzle-orm": "^0.36.4", "layerchart": "^0.59.1", "markdown-it": "^14.1.0", - "mysql2": "^3.11.4" + "mysql2": "^3.11.4", + "nodemailer": "^6.9.16" } } diff --git a/src/db/schema.ts b/src/db/schema.ts index 38dc3ab..fbff521 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,9 +1,11 @@ -import { index, int, primaryKey, mysqlTable, text, varchar, bigint } from "drizzle-orm/mysql-core"; +import { index, int, primaryKey, mysqlTable, text, varchar, bigint, date } from "drizzle-orm/mysql-core"; export const usersTable = mysqlTable("users_table", { id: int().autoincrement().primaryKey(), email: text().notNull().unique(), password_hash: text().notNull(), + verification_code: varchar({ length: 255 }), + verifcationCodeExpires: date(), }); export const sessions = mysqlTable("sessions", { diff --git a/src/db/users.ts b/src/db/users.ts new file mode 100644 index 0000000..de87e15 --- /dev/null +++ b/src/db/users.ts @@ -0,0 +1,61 @@ +import { config } from "$lib/configuration"; +import { generateRandomToken } from "$lib/randomToken"; +import type { Email, Password, VerificationCode } from "$lib/types"; +import { hash } from "@node-rs/argon2"; +import { db } from "."; +import { usersTable } from "./schema"; +import { eq } from "drizzle-orm"; +import { err, ok, type Result } from "$lib/result"; +import debug from "debug"; + +const log = debug('db:users'); + +export type User = { + id: number; + email: Email; +} + +export async function createNewUser(email: Email, password: Password): Promise<{ verificationCode: VerificationCode | undefined }> { + const hashedPassword = await hash(password); + + if (config.emailVerificationDisabled) { + log("WARNING: Email verification is disabled. Accounts will be enabled immediately. You should not run this in production."); + } + + const verificationCode = config.emailVerificationDisabled ? undefined : generateRandomToken() as VerificationCode; + + await db.insert(usersTable).values({ + email, + password_hash: hashedPassword, + verification_code: verificationCode, + verifcationCodeExpires: verificationCode ? new Date(Date.now() + 1000 * 60 * 60 * 24 * 3) : undefined + }); + + return { + verificationCode + } +} + +export enum VerificationError { + InvalidVerificationCode = 'Invalid verification code', + VerificationCodeExpired = 'Verification code expired' +} + +export async function verifyUser(verificationCode: VerificationCode): Promise> { + let user = await db.select().from(usersTable).where(eq(usersTable.verification_code, verificationCode)).limit(1); + + if (user.length === 0) { + return err(VerificationError.InvalidVerificationCode); + } + + if (user[0].verifcationCodeExpires && user[0].verifcationCodeExpires < new Date()) { + return err(VerificationError.VerificationCodeExpired); + } + + await db.update(usersTable).set({ verification_code: null, verifcationCodeExpires: null }).where(eq(usersTable.verification_code, verificationCode)); + + return ok({ + id: user[0].id, + email: user[0].email as Email + }); +} \ No newline at end of file diff --git a/src/lib/branded.ts b/src/lib/branded.ts new file mode 100644 index 0000000..3fd7739 --- /dev/null +++ b/src/lib/branded.ts @@ -0,0 +1,7 @@ +declare const BRAND: unique symbol; + +export type Brand = { + [BRAND]: B +} + +export type Branded = T & Brand; \ No newline at end of file diff --git a/src/lib/configuration.ts b/src/lib/configuration.ts new file mode 100644 index 0000000..d267f7e --- /dev/null +++ b/src/lib/configuration.ts @@ -0,0 +1,44 @@ +import { env } from '$env/dynamic/private'; + +let allowedDomains: string[] + +export const config = { + + get allowableDomains() { + if (!allowedDomains) { + allowedDomains = env.ALLOWABLE_DOMAINS?.split(',') ?? []; + } + return allowedDomains; + }, + + /// Returns true if the config disabled the verification of emails on registration + get emailVerificationDisabled() { + return env.EMAIL_VERIFICATION_DISABLED === 'true'; + }, + + /// Get the sender email address + get senderFrom() { + return env.SENDER_FROM; + }, + + /// Get the server config + get emailServer() { + return emailServerConfig; + } +} + +/// The config for the email server +const emailServerConfig = { + get host() { + return env.EMAIL_SERVER_HOST; + }, + get port() { + return env.EMAIL_SERVER_PORT ? parseInt(env.EMAIL_SERVER_PORT) : 587; + }, + get user() { + return env.EMAIL_SERVER_USER; + }, + get password() { + return env.EMAIL_SERVER_PASSWORD; + } +} \ No newline at end of file diff --git a/src/lib/emails/index.ts b/src/lib/emails/index.ts new file mode 100644 index 0000000..b5a9bb7 --- /dev/null +++ b/src/lib/emails/index.ts @@ -0,0 +1,36 @@ +import * as nodemailer from "nodemailer"; + +import type { Email } from "$lib/types"; +import { config } from "$lib/configuration"; +import { lazy } from "$lib/lazy"; + +const emailTransport = lazy(() => { + + + if (!config.emailServer.host) { + throw new Error("Tried to send email without a configured email server"); + } + + return nodemailer.createTransport({ + host: config.emailServer.host, + port: config.emailServer.port ?? 587, + auth: { + user: config.emailServer.user, + pass: config.emailServer.password + } + }) +}); + +export function sendEmail(recepientEmail: Email, title: string, bodyText: string) { + try { + emailTransport().sendMail({ + from: config.senderFrom, + to: recepientEmail, + subject: title, + text: bodyText + }); + } + catch (e) { + console.error("Failed to send email", e); + } +} \ No newline at end of file diff --git a/src/lib/emails/verification.ts b/src/lib/emails/verification.ts new file mode 100644 index 0000000..1553377 --- /dev/null +++ b/src/lib/emails/verification.ts @@ -0,0 +1,15 @@ +import type { Email, VerificationCode } from "$lib/types"; +import { sendEmail } from "."; + +export function sendVerificationEmail(email: Email, verificationCode: VerificationCode) { + sendEmail(email, 'Welcome to Three60 surveys', `Hello, + +please confirm your account email address by clicking the link below: + + http://localhost:5173/verify/${verificationCode} + +Thank you. + +P.S.: If you didn't request this email, please ignore it. The registration will expire in a few days and all related data will be deleted. +`); +} \ No newline at end of file diff --git a/src/lib/lazy.ts b/src/lib/lazy.ts new file mode 100644 index 0000000..e231a66 --- /dev/null +++ b/src/lib/lazy.ts @@ -0,0 +1,12 @@ + +/// lazily instantiate an instance of T and store it for further use +export function lazy(fn: () => T): () => T { + let value: T; + + return () => { + if (!value) { + value = fn(); + } + return value; + }; +} \ No newline at end of file diff --git a/src/lib/result.ts b/src/lib/result.ts new file mode 100644 index 0000000..ea30655 --- /dev/null +++ b/src/lib/result.ts @@ -0,0 +1,19 @@ +export type Ok = { type: 'OK', value: T }; +export type Err = { type: 'ERR', error: E }; +export type Result = Ok | Err; + +export function ok(value: T): Ok { + return { type: 'OK', value }; +} + +export function isOk(result: Result): result is Ok { + return result.type === 'OK'; +} + +export function err(error: E): Err { + return { type: 'ERR', error }; +} + +export function isErr(result: Result): result is Err { + return result.type === 'ERR'; +} \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..a3f0767 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,5 @@ +import type { Branded } from "./branded"; + +export type Email = Branded; +export type Password = Branded; +export type VerificationCode = Branded; \ No newline at end of file diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 1210710..e4b2fe1 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -26,6 +26,10 @@ export const actions = { if (userCredentials.length === 0) { error(403, 'Invalid credentials'); } + if (userCredentials[0].verification_code) { + log('Email %s not yet verified', email); + error(403, "Your account is not yet verified"); + } const isPasswordValid = await verify(userCredentials[0].password_hash, password); if (isPasswordValid) { // create the session diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts index 8c7488a..43bece3 100644 --- a/src/routes/register/+page.server.ts +++ b/src/routes/register/+page.server.ts @@ -1,37 +1,35 @@ -import { hash } from '@node-rs/argon2'; import type { Actions, PageServerLoad } from './$types'; -import { usersTable } from '../../db/schema'; -import { db } from '../../db'; import { error, redirect } from '@sveltejs/kit'; -import { env } from '$env/dynamic/private'; +import { sendVerificationEmail } from '$lib/emails/verification'; +import { createNewUser } from '../../db/users'; +import type { Email, Password } from '$lib/types'; +import { config } from '$lib/configuration'; export const load: PageServerLoad = () => { return { - allowableDomains: env.ALLOWABLE_DOMAINS + allowableDomains: config.allowableDomains } } export const actions = { default: async (event) => { - const allowedDomains = env.ALLOWABLE_DOMAINS?.split(','); const formData = await event.request.formData(); - const email = formData.get('email')?.toString(); - const password = formData.get('password')?.toString(); + const email = formData.get('email')?.toString() as Email | undefined; + const password = formData.get('password')?.toString() as Password | undefined; if (!email || !password) { error(400, 'Email and password are required'); } - if (allowedDomains && !allowedDomains?.some(domain => email.endsWith(domain))) { + if (config.allowableDomains && !config.allowableDomains?.some(domain => email.endsWith(domain))) { error(400, 'Invalid email domain'); } - const hashedPassword = await hash(password); + const result = await createNewUser(email, password); - await db.insert(usersTable).values({ - email, - password_hash: hashedPassword - }); + if (result.verificationCode) { + sendVerificationEmail(email, result.verificationCode); + } redirect(303, '/login'); } diff --git a/src/routes/verify/[verificationCode]/+page.server.ts b/src/routes/verify/[verificationCode]/+page.server.ts new file mode 100644 index 0000000..f1ee944 --- /dev/null +++ b/src/routes/verify/[verificationCode]/+page.server.ts @@ -0,0 +1,23 @@ +import { isErr, isOk } from "$lib/result"; +import type { VerificationCode } from "$lib/types"; +import debug from "debug"; +import { verifyUser } from "../../../db/users"; +import type { PageServerLoad } from "./$types" +import { error } from "@sveltejs/kit"; + +let log = debug('verify'); + +export const load: PageServerLoad = async ({ params }) => { + const verificationCode = params.verificationCode; + + const userResult = await verifyUser(verificationCode as VerificationCode); + + if (isErr(userResult)) { + log('Failed to verify user with code %s: %s', verificationCode, userResult.error); + error(403, `Cannot verify user: ${userResult.error}`); + } + + return { + email: userResult.value.email + }; +} \ No newline at end of file diff --git a/src/routes/verify/[verificationCode]/+page.svelte b/src/routes/verify/[verificationCode]/+page.svelte new file mode 100644 index 0000000..66ec125 --- /dev/null +++ b/src/routes/verify/[verificationCode]/+page.svelte @@ -0,0 +1,9 @@ + + +

Verification successful

+
+ Congratulations! You've successfully verified your account. Click here to log + in. +