Skip to content

Commit

Permalink
Add user authentication support
Browse files Browse the repository at this point in the history
  • Loading branch information
Yureien committed Oct 8, 2023
1 parent 6f4af71 commit 3b33146
Show file tree
Hide file tree
Showing 30 changed files with 980 additions and 15 deletions.
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1 +1,13 @@
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
SALT="this is a very insecure salt, change it"

MAIL_ENABLED=false
MAIL_SERVER="smtp.gmail.com"
MAIL_PORT=465
MAIL_USE_SSL=true
MAIL_USERNAME=""
MAIL_PASSWORD=""
MAIL_FROM='"YABin" <yabin@sohamsen.me>'

PUBLIC_REGISRATION_ENABLED=true
PUBLIC_URL="http://localhost:5173"
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Well, cause no pastebin I could find had ALL of the following features:
- View raw pastes. Normally, encrypted pastebins do not have this. With this site, you can either get the Base64-encoded encrypted paste, or decrypt it on the server side (even with the password) and get the raw paste.
- Keyboard shortcuts!
- And of course, being fully open-source and easily self-hostable.
- **NEW** Ability to edit pastes after creation, and a dashboard for viewing all your pastes.
- **Comes with a CLI tool to create and read pastes from the command line!**
- **It can even be run on edge servers and in serverless environments!**

Expand All @@ -50,6 +51,16 @@ See [cli/README.md](cli/README.md) for detailed instructions and library usage.

Right now, my instance is using PostgreSQL on Vercel. However, it can be run using any SQL DB such as SQLite or MySQL. To use other backends, please update the provider in [schema.prisma](src/lib/server/prisma/schema.prisma)

### .env Configuration

`DATABASE_URL` needs to point to a running SQL database. It uses PostgreSQL by default, but can be changed to MySQL or SQLite by modifying the provider in [schema.prisma](src/lib/server/prisma/schema.prisma).

Remember to modify `SALT` to something secure if you plan on using user accounts.

You can disable or enable public registration by modifying the `PUBLIC_REGISRATION_ENABLED` variable to `true` or `false`.

By default, if no e-mail services are configured, all user accounts will be marked as validated. To enable e-mail validation, please configure the `MAIL_*` variables.

#### Locally

```bash
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@sveltejs/adapter-node": "^1.2.4",
"@sveltejs/kit": "^1.5.0",
"@types/node-cron": "^3.0.7",
"@types/nodemailer": "^6.4.11",
"@types/prismjs": "^1.26.0",
"@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.45.0",
Expand All @@ -41,7 +42,9 @@
"dependencies": {
"@prisma/client": "^4.15.0",
"base64-js": "^1.5.1",
"nanoid": "^5.0.1",
"node-cron": "^3.0.2",
"nodemailer": "^6.9.5",
"prism-themes": "^1.9.0",
"prismjs": "^1.29.0",
"sanitize-html": "^2.10.0"
Expand Down
18 changes: 15 additions & 3 deletions src/lib/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import base64 from 'base64-js';

export async function encrypt(plaintext: string) {
export async function encrypt(plaintext: string, keyStr: string | undefined = undefined) {
const encoder = new TextEncoder();

const iv: Uint8Array = crypto.getRandomValues(new Uint8Array(12));
const ivStr = base64.fromByteArray(iv);

const alg = { name: 'AES-GCM', iv, length: 256 };

const key = (await crypto.subtle.generateKey(alg, true, ['encrypt'])) as CryptoKey;
const keyStr = base64.fromByteArray(new Uint8Array(await crypto.subtle.exportKey('raw', key)));
let key: CryptoKey;
if (!keyStr) {
key = (await crypto.subtle.generateKey(alg, true, ['encrypt'])) as CryptoKey;
keyStr = base64.fromByteArray(new Uint8Array(await crypto.subtle.exportKey('raw', key)));
} else {
key = await crypto.subtle.importKey('raw', base64.toByteArray(keyStr), alg, false, ['encrypt']);
}
const enc = await crypto.subtle.encrypt(alg, key, encoder.encode(plaintext));
const encStr = base64.fromByteArray(new Uint8Array(enc));

Expand Down Expand Up @@ -92,3 +97,10 @@ export async function decryptWithPassword(ciphertext: string, iv: string, passwo
const dec = await crypto.subtle.decrypt(alg, key, base64.toByteArray(ciphertext));
return decoder.decode(dec);
}

export async function hashPassword(password: string, salt: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(password + salt);
const hash = await crypto.subtle.digest('SHA-512', data);
return base64.fromByteArray(new Uint8Array(hash));
}
40 changes: 40 additions & 0 deletions src/lib/server/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { SALT } from '$env/static/private';
import { hashPassword } from '$lib/crypto';
import prisma from '@db';
import type { Cookies } from '@sveltejs/kit';

export const getUserIdFromCookie = async (cookies: Cookies) => {
const token = cookies.get('token');
if (!token) return null;

const authToken = await prisma.authToken.findFirst({
where: { token, expiresAt: { gt: new Date() } },
include: { user: { select: { id: true, verified: true } } }
});
if (!authToken) return null;
if (!authToken.user.verified) return null;

return authToken.user.id;
};

export const generateVerificationHash = async (userId: string) => {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new Error('User not found');

const hash = await hashPassword(`${user.email}${user.id}${user.password}${user.verified}`, SALT);
return hash;
};

export const validateVerificationHash = async (userId: string, hash: string) => {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return false;

const newHash = await hashPassword(
`${user.email}${user.id}${user.password}${user.verified}`,
SALT
);
if (newHash !== hash) return false;

await prisma.user.update({ where: { id: userId }, data: { verified: true } });
return true;
};
39 changes: 39 additions & 0 deletions src/lib/server/email/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import nodemailer from 'nodemailer';
import {
MAIL_ENABLED,
MAIL_SERVER,
MAIL_PASSWORD,
MAIL_PORT,
MAIL_USERNAME,
MAIL_USE_SSL,
MAIL_FROM
} from '$env/static/private';

export async function sendEmail(to: string, subject: string, content: string) {
if (MAIL_ENABLED !== 'true') {
return false;
}

const transporter = nodemailer.createTransport({
host: MAIL_SERVER,
port: Number(MAIL_PORT),
secure: MAIL_USE_SSL === 'true',
auth: {
user: MAIL_USERNAME,
pass: MAIL_PASSWORD
}
});

const info = await transporter.sendMail({
from: MAIL_FROM,
to,
subject,
text: content
});

if (info.accepted.length === 0) {
return false;
}

return true;
}
23 changes: 23 additions & 0 deletions src/lib/server/email/verify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { PUBLIC_URL } from '$env/static/public';
import prisma from '@db';
import { generateVerificationHash } from '../auth';
import { sendEmail } from './base';

export const sendVerificationEmail = async (userId: string) => {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return false;

const hash = await generateVerificationHash(userId);

const verifyUrl = `${PUBLIC_URL}/validate?hash=${encodeURIComponent(
hash
)}&userId=${encodeURIComponent(userId)}`;

const content = `To verify your email, please click the following link: ${verifyUrl}`;
const subject = 'YABin: Verify your email';

const sent = await sendEmail(user.email, subject, content);
if (!sent) return false;

return true;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE "Paste" ADD COLUMN "ownerId" BIGINT;

-- CreateTable
CREATE TABLE "User" (
"id" BIGSERIAL NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"name" TEXT NOT NULL,

CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");

-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");

-- AddForeignKey
ALTER TABLE "Paste" ADD CONSTRAINT "Paste_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "AuthToken" (
"id" BIGSERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"userId" BIGINT NOT NULL,

CONSTRAINT "AuthToken_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "AuthToken_token_key" ON "AuthToken"("token");

-- AddForeignKey
ALTER TABLE "AuthToken" ADD CONSTRAINT "AuthToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "AuthToken" DROP CONSTRAINT "AuthToken_userId_fkey";

-- AddForeignKey
ALTER TABLE "AuthToken" ADD CONSTRAINT "AuthToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
Warnings:
- The primary key for the `AuthToken` table will be changed. If it partially fails, the table could be left without primary key constraint.
- The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- DropForeignKey
ALTER TABLE "AuthToken" DROP CONSTRAINT "AuthToken_userId_fkey";

-- DropForeignKey
ALTER TABLE "Paste" DROP CONSTRAINT "Paste_ownerId_fkey";

-- AlterTable
ALTER TABLE "AuthToken" DROP CONSTRAINT "AuthToken_pkey",
ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "id" SET DATA TYPE TEXT,
ALTER COLUMN "userId" SET DATA TYPE TEXT,
ADD CONSTRAINT "AuthToken_pkey" PRIMARY KEY ("id");
DROP SEQUENCE "AuthToken_id_seq";

-- AlterTable
ALTER TABLE "Paste" ALTER COLUMN "ownerId" SET DATA TYPE TEXT;

-- AlterTable
ALTER TABLE "User" DROP CONSTRAINT "User_pkey",
ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "id" SET DATA TYPE TEXT,
ADD CONSTRAINT "User_pkey" PRIMARY KEY ("id");
DROP SEQUENCE "User_id_seq";

-- AddForeignKey
ALTER TABLE "AuthToken" ADD CONSTRAINT "AuthToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Paste" ADD CONSTRAINT "Paste_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "verified" BOOLEAN NOT NULL DEFAULT false;
22 changes: 22 additions & 0 deletions src/lib/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ datasource db {
url = env("DATABASE_URL")
}

model User {
id String @id @default(nanoid(16))
username String @unique
email String @unique
password String
name String
verified Boolean @default(false)
pastes Paste[]
AuthToken AuthToken[]
}

model AuthToken {
id String @id @default(nanoid(16))
createdAt DateTime @default(now())
expiresAt DateTime
token String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model Paste {
id BigInt @id @default(autoincrement())
createdAt DateTime @default(now())
Expand All @@ -22,6 +42,8 @@ model Paste {
expiresAt DateTime?
expiresCount Int?
readCount Int @default(0)
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull)
@@index([key])
}
7 changes: 4 additions & 3 deletions src/lib/server/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export async function getPaste(key: string) {
initVector: true,
language: true,
expiresCount: true,
readCount: true
readCount: true,
ownerId: true
}
});

Expand All @@ -28,9 +29,9 @@ export async function getPaste(key: string) {
throw error(404, 'Not found');
}

const { content, encrypted, passwordProtected, initVector, language } = data;
const { content, encrypted, passwordProtected, initVector, language, ownerId } = data;

return { key, content, encrypted, passwordProtected, initVector, language };
return { key, content, encrypted, passwordProtected, initVector, language, ownerId };
}

export async function deleteExpiredPastes() {
Expand Down
15 changes: 15 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ export interface Paste {
initVector?: string;
}

export interface PastePatch {
key: string;
content: string;
encrypted?: boolean;
initVector?: string;
}

export interface PasteCreateResponse {
success: boolean;
data?: {
Expand All @@ -22,3 +29,11 @@ export interface PasteCreateResponse {
code: number;
};
}

export interface PastePatchResponse {
success: boolean;
data?: {
key: string;
};
error?: string;
}
Loading

0 comments on commit 3b33146

Please sign in to comment.