Email verification

This commit is contained in:
Markus Brueckner 2024-12-23 17:27:35 +01:00
parent 1ca3b6e20c
commit 66b82012c3
18 changed files with 671 additions and 17 deletions

View file

@ -0,0 +1,2 @@
ALTER TABLE `users_table` ADD `verification_code` varchar(255);--> statement-breakpoint
ALTER TABLE `users_table` ADD `verifcationCodeExpires` date;

View file

@ -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": {}
}
}

View file

@ -8,6 +8,20 @@
"when": 1732727438120, "when": 1732727438120,
"tag": "0000_harsh_warlock", "tag": "0000_harsh_warlock",
"breakpoints": true "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
} }
] ]
} }

23
package-lock.json generated
View file

@ -22,7 +22,8 @@
"drizzle-orm": "^0.36.4", "drizzle-orm": "^0.36.4",
"layerchart": "^0.59.1", "layerchart": "^0.59.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"mysql2": "^3.11.4" "mysql2": "^3.11.4",
"nodemailer": "^6.9.16"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
@ -35,6 +36,7 @@
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.0",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^6.4.17",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"drizzle-kit": "^0.28.1", "drizzle-kit": "^0.28.1",
"eslint": "^9.7.0", "eslint": "^9.7.0",
@ -2444,6 +2446,16 @@
"undici-types": "~6.19.8" "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": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -5568,6 +5580,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",

View file

@ -22,6 +22,7 @@
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.0",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/nodemailer": "^6.4.17",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"drizzle-kit": "^0.28.1", "drizzle-kit": "^0.28.1",
"eslint": "^9.7.0", "eslint": "^9.7.0",
@ -53,6 +54,7 @@
"drizzle-orm": "^0.36.4", "drizzle-orm": "^0.36.4",
"layerchart": "^0.59.1", "layerchart": "^0.59.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"mysql2": "^3.11.4" "mysql2": "^3.11.4",
"nodemailer": "^6.9.16"
} }
} }

View file

@ -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", { export const usersTable = mysqlTable("users_table", {
id: int().autoincrement().primaryKey(), id: int().autoincrement().primaryKey(),
email: text().notNull().unique(), email: text().notNull().unique(),
password_hash: text().notNull(), password_hash: text().notNull(),
verification_code: varchar({ length: 255 }),
verifcationCodeExpires: date(),
}); });
export const sessions = mysqlTable("sessions", { export const sessions = mysqlTable("sessions", {

61
src/db/users.ts Normal file
View file

@ -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<Result<User, VerificationError>> {
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
});
}

7
src/lib/branded.ts Normal file
View file

@ -0,0 +1,7 @@
declare const BRAND: unique symbol;
export type Brand<B> = {
[BRAND]: B
}
export type Branded<T, B> = T & Brand<B>;

44
src/lib/configuration.ts Normal file
View file

@ -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;
}
}

36
src/lib/emails/index.ts Normal file
View file

@ -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);
}
}

View file

@ -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.
`);
}

12
src/lib/lazy.ts Normal file
View file

@ -0,0 +1,12 @@
/// lazily instantiate an instance of T and store it for further use
export function lazy<T>(fn: () => T): () => T {
let value: T;
return () => {
if (!value) {
value = fn();
}
return value;
};
}

19
src/lib/result.ts Normal file
View file

@ -0,0 +1,19 @@
export type Ok<T> = { type: 'OK', value: T };
export type Err<E> = { type: 'ERR', error: E };
export type Result<T, E> = Ok<T> | Err<E>;
export function ok<T>(value: T): Ok<T> {
return { type: 'OK', value };
}
export function isOk<T>(result: Result<T, unknown>): result is Ok<T> {
return result.type === 'OK';
}
export function err<E>(error: E): Err<E> {
return { type: 'ERR', error };
}
export function isErr<E>(result: Result<unknown, E>): result is Err<E> {
return result.type === 'ERR';
}

5
src/lib/types.ts Normal file
View file

@ -0,0 +1,5 @@
import type { Branded } from "./branded";
export type Email = Branded<string, 'email'>;
export type Password = Branded<string, 'password'>;
export type VerificationCode = Branded<string, 'verificationCode'>;

View file

@ -26,6 +26,10 @@ export const actions = {
if (userCredentials.length === 0) { if (userCredentials.length === 0) {
error(403, 'Invalid credentials'); 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); const isPasswordValid = await verify(userCredentials[0].password_hash, password);
if (isPasswordValid) { if (isPasswordValid) {
// create the session // create the session

View file

@ -1,37 +1,35 @@
import { hash } from '@node-rs/argon2';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { usersTable } from '../../db/schema';
import { db } from '../../db';
import { error, redirect } from '@sveltejs/kit'; 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 = () => { export const load: PageServerLoad = () => {
return { return {
allowableDomains: env.ALLOWABLE_DOMAINS allowableDomains: config.allowableDomains
} }
} }
export const actions = { export const actions = {
default: async (event) => { default: async (event) => {
const allowedDomains = env.ALLOWABLE_DOMAINS?.split(',');
const formData = await event.request.formData(); const formData = await event.request.formData();
const email = formData.get('email')?.toString(); const email = formData.get('email')?.toString() as Email | undefined;
const password = formData.get('password')?.toString(); const password = formData.get('password')?.toString() as Password | undefined;
if (!email || !password) { if (!email || !password) {
error(400, 'Email and password are required'); 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'); error(400, 'Invalid email domain');
} }
const hashedPassword = await hash(password); const result = await createNewUser(email, password);
await db.insert(usersTable).values({ if (result.verificationCode) {
email, sendVerificationEmail(email, result.verificationCode);
password_hash: hashedPassword }
});
redirect(303, '/login'); redirect(303, '/login');
} }

View file

@ -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
};
}

View file

@ -0,0 +1,9 @@
<script lang="ts">
const { data } = $props();
</script>
<h1 class="text-3xl">Verification successful</h1>
<div>
Congratulations! You've successfully verified your account. <a href="/login">Click here</a> to log
in.
</div>