From 915f8cd8f5ed11383c8271e177257fa2f7fc6791 Mon Sep 17 00:00:00 2001 From: Hajbo <35660161+Hajbo@users.noreply.github.com> Date: Wed, 4 Oct 2023 19:48:58 +0200 Subject: [PATCH] feat: user auth and related endpoints (#47) * Env var typing * Move migrations folder into db, migrate on startup * Fix migrations folder * Add error handling * Add jwt plugin and generate token function * Update user schema * Update user repository * Update user controller and add endpoints * Use bun env instead of process * Working user login and protected routes * Fix token payload * Use biome as default formatter in vscode * Bring back db seed and migration * Refactor error code handling * Format files * Fix biome max file size for bun types * Use jose directly instead of elysia jwt plugin * Rename things * Update src/users/users.plugin.ts Co-authored-by: Yam Borodetsky * Refactor error handling * Use Type instead of t * Move users table to separate file --------- Co-authored-by: Yam Borodetsky --- .env | 1 + .vscode/settings.json | 5 +- biome.json | 3 + bun.lockb | Bin 89972 -> 89936 bytes db/config.ts | 2 +- db/{migrations => }/migrate.ts | 4 +- ...r.sql => 0000_perpetual_blazing_skull.sql} | 7 +- db/migrations/meta/0000_snapshot.json | 14 ++- db/migrations/meta/_journal.json | 4 +- db/seed.ts | 2 +- package.json | 3 +- src/app.module.ts | 19 +++- src/auth.ts | 89 ++++++++++++++++++ src/config.ts | 14 +++ src/errors.ts | 29 ++++++ src/main.ts | 2 +- src/users/users.model.ts | 13 +++ src/users/users.plugin.ts | 50 +++++++--- src/users/users.repository.ts | 29 +++++- src/users/users.schema.ts | 48 ++++++---- src/users/users.service.ts | 43 ++++++++- src/utils/defaultmap.ts | 7 ++ 22 files changed, 338 insertions(+), 50 deletions(-) rename db/{migrations => }/migrate.ts (69%) rename db/migrations/{0000_bored_warstar.sql => 0000_perpetual_blazing_skull.sql} (62%) create mode 100644 src/auth.ts create mode 100644 src/config.ts create mode 100644 src/errors.ts create mode 100644 src/users/users.model.ts create mode 100644 src/utils/defaultmap.ts diff --git a/.env b/.env index 8e1f69e..765ec2c 100644 --- a/.env +++ b/.env @@ -3,3 +3,4 @@ POSTGRES_PASSWORD=postgres POSTGRES_DB=medium POSTGRES_HOST=0.0.0.0 POSTGRES_PORT=5432 +JWT_SECRET=supersecretkey diff --git a/.vscode/settings.json b/.vscode/settings.json index 78664b2..9504263 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { - "editor.tabSize": 2 + "editor.tabSize": 2, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + } } diff --git a/biome.json b/biome.json index 147554b..a757e21 100644 --- a/biome.json +++ b/biome.json @@ -18,5 +18,8 @@ "rules": { "recommended": true } + }, + "files": { + "maxSize": 3145728 } } diff --git a/bun.lockb b/bun.lockb index 0105372edd0ec9667d9e0ed84d6a71fd2c63fa2c..10f407261e68fff6f61069b4dee002adf798423e 100755 GIT binary patch delta 17394 zcmeHOd0bW1_CNc;Rjzm$1mqGZAWk@-3~~kZBF=an%es&wDhdjUf&-}Ci{_jrF7-&x znP!8PsUet_Wu>O2^%`t4D@#io98xLu_g!ZY>s7z^e((45{qcL}^ZA~&zk67Ft#$U^ z=bU@)zO>JDY@caqOytSFJwA>+xOm~YJvp!24qe^Wdj6g>Q-}P}>nX2qZQD{dO?$bl zUt0Ru35D4bj_kbR!rUxLnj<91B1!HUkY>m{NIyuYEJ-1d!}Rigdj7}>6UOC2gT3r{ zaGioNtO`m@^sPrj*Opq6s|!u136Qd1!G$0jxTC2m6#;S8~NY` z6DH(Iqrn5gGaMVbRFKLTRtBB%yEj_^k00a^YzS zcwMQL0T(yjbUjIl1F<5zvp|BwTbKhGhTtLBdt{laSob8-xRtpEYS*%-BLn z=)5Rv#E7gBdD-U?-9X7Dxp9SBkIK%^wKdd+$SD-CqPM~|#Z{23@EGzraQh(H15>0{ z?^1-8za9B}YkXA@lb7d<+L7`UEEyf;gH1a=o0a@ndu0*79$F^90E0s_7mA$yaR*+66))9IAg`chkn> zF-RW26_6ol>fV3^cQ9YCFp0L;4))4@_4N*$`aKax2Ww}L)!Xeg{oI(N^Xq-rXh($C zk7ARxj@Yrv+v-8PeKHz$8g!}R3g*sLOKs1qkU#2tgDNS;x3399!|h3;kK zV~5R)u$jAowFi?`Rsq%vOsGu2UW{h08Dx{=Yg1KAyF9rzRkyU8D^Z5XbGueH({->! zn(3D!$9qv#E4w_=i>e_W_oC$1cGDFv*g@A@rkIm3xY#wV*>tdE+8dQ3?=n+zJ-fLz zMpI9<$21r`xeis;v&)rrs2U>3o00?U@Rtz@>?F~pVd13%|Q&Z`; zQX7!!!FjTe4^;=+&HXX*_3ndgre$DBTx|IqDU5M9U!Y)k)RKM-GkaozS2fCWeJQ!V z-Sh@BI3oE?Vq7PCIRo^P4w0ftI((1+FcB$scQ$r~^+-^RLjP8h+ z7Y{4BO&C>$+vTZYRE@HVFiGl1Za^gwcDYLfs)8tKK-Cd;^9v2M(Oiqiulzl# zMB3$#8d7zn-P9HHr6bLZOfi)rm87OVLn@6^a(Dz)H?o^^Bj6VLs70mq+IxYpF&1_F z`$tk$l-=A5H&!e`xT5=`Z05yaHjPEvA9s`&~^5>X=2{#G_~>sH!K@}$xhHq#`qWa=1`VtyJaUUd+* z_U*m&O0`*;Iia<-l&H&{xfCoJ6>IYdFz*55X1JiOZRV≪~x`v2;BErcPRw9ncxB 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 asOIU;+#GpzxCoYerwY@K4ugcb(*FPsmDkn) diff --git a/db/config.ts b/db/config.ts index 1ed4651..17c14fd 100644 --- a/db/config.ts +++ b/db/config.ts @@ -10,7 +10,7 @@ export const dbCredentials = { export const dbCredentialsString = `postgres://${dbCredentials.user}:${dbCredentials.password}@${dbCredentials.host}:${dbCredentials.port}/${dbCredentials.database}`; export default { - out: './src/db/migrations', + out: './db/migrations', schema: '**/*.schema.ts', breakpoints: false, driver: 'pg', diff --git a/db/migrations/migrate.ts b/db/migrate.ts similarity index 69% rename from db/migrations/migrate.ts rename to db/migrate.ts index 46c3c34..6e1b7f6 100644 --- a/db/migrations/migrate.ts +++ b/db/migrate.ts @@ -1,7 +1,9 @@ +import { exit } from 'process'; import { drizzle } from 'drizzle-orm/postgres-js'; import { migrate } from 'drizzle-orm/postgres-js/migrator'; import { migrationsClient } from '@/database.providers'; await migrate(drizzle(migrationsClient), { - migrationsFolder: `${import.meta.dir}`, + migrationsFolder: `${import.meta.dir}/migrations`, }); +exit(0); diff --git a/db/migrations/0000_bored_warstar.sql b/db/migrations/0000_perpetual_blazing_skull.sql similarity index 62% rename from db/migrations/0000_bored_warstar.sql rename to db/migrations/0000_perpetual_blazing_skull.sql index b6c813c..7f0ce12 100644 --- a/db/migrations/0000_bored_warstar.sql +++ b/db/migrations/0000_perpetual_blazing_skull.sql @@ -1,10 +1,11 @@ CREATE TABLE IF NOT EXISTS "users" ( "id" serial PRIMARY KEY NOT NULL, "email" text NOT NULL, - "bio" text NOT NULL, - "image" text NOT NULL, + "bio" text, + "image" text, "password" text NOT NULL, "username" text NOT NULL, "created_at" date DEFAULT CURRENT_DATE, - "updated_at" date DEFAULT CURRENT_DATE + "updated_at" date DEFAULT CURRENT_DATE, + CONSTRAINT "users_email_unique" UNIQUE("email") ); diff --git a/db/migrations/meta/0000_snapshot.json b/db/migrations/meta/0000_snapshot.json index 354ebd1..278e255 100644 --- a/db/migrations/meta/0000_snapshot.json +++ b/db/migrations/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "5", "dialect": "pg", - "id": "86aed854-dd08-4bcd-8138-412a71492c24", + "id": "8ed456d0-e522-4f1a-a07e-a19b3cd900bc", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "users": { @@ -24,13 +24,13 @@ "name": "bio", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, "image": { "name": "image", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, "password": { "name": "password", @@ -62,7 +62,13 @@ "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": {} + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + } } }, "enums": {}, diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 51409b4..45d927e 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "5", - "when": 1695584965813, - "tag": "0000_bored_warstar", + "when": 1695849229878, + "tag": "0000_perpetual_blazing_skull", "breakpoints": false } ] diff --git a/db/seed.ts b/db/seed.ts index 5d17cbc..961b539 100644 --- a/db/seed.ts +++ b/db/seed.ts @@ -1,6 +1,6 @@ import { exit } from 'process'; import { db } from '@/database.providers'; -import { users } from '@/users/users.schema'; +import { users } from '@/users/users.model'; const data = { id: users.id.default, diff --git a/package.json b/package.json index bef5299..aa6da43 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "docs:preview": "vitepress preview docs", "db:up": "./scripts/create-start-container-with-env.sh", "db:generate": "bun drizzle-kit generate:pg --config=db/config.ts", - "db:migrate": "bun run db/migrations/migrate.ts", + "db:migrate": "bun run db/migrate.ts", "db:push": "bun drizzle-kit push:pg --config=db/config.ts", "db:seed": "bun run db/seed.ts", "db:studio": "bun drizzle-kit studio --config=db/config.ts", @@ -25,6 +25,7 @@ "drizzle-orm": "^0.28.6", "drizzle-typebox": "^0.1.1", "elysia": "latest", + "jose": "^4.14.6", "postgres": "^3.3.5" }, "devDependencies": { diff --git a/src/app.module.ts b/src/app.module.ts index a12f597..8ad339e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,13 +1,30 @@ import { Elysia } from 'elysia'; import { swagger } from '@elysiajs/swagger'; -import { usersPlugin } from '@users/users.plugin'; import { title, version, description } from '../package.json'; +import { usersPlugin } from '@/users/users.plugin'; +import { + AuthenticationError, + AuthorizationError, + ERROR_CODE_STATUS_MAP, +} from '@/errors'; + +// the file name is in the spirit of NestJS, where app module is the device in charge of putting together all the pieces of the app +// see: https://docs.nestjs.com/modules /** * Add all plugins to the app */ export const setupApp = () => { return new Elysia() + .error({ + AUTHENTICATION: AuthenticationError, + AUTHORIZATION: AuthorizationError, + }) + .onError(({ error, code, set }) => { + set.status = ERROR_CODE_STATUS_MAP.get(code); + const errorType = 'type' in error ? error.type : 'internal'; + return { errors: { [errorType]: error.message } }; + }) .use( swagger({ documentation: { diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..a2da26e --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,89 @@ +import * as jose from 'jose'; +import { Type } from '@sinclair/typebox'; +import { Value } from '@sinclair/typebox/value'; +import { UserInDb } from '@/users/users.schema'; +import { env } from '@/config'; +import { AuthenticationError } from '@/errors'; + +export const ALG = 'HS256'; + +const VerifiedJwtSchema = Type.Object({ + payload: Type.Object({ + user: Type.Object({ + id: Type.Number(), + email: Type.String(), + username: Type.String(), + }), + iat: Type.Number(), + iss: Type.String(), + aud: Type.String(), + exp: Type.Number(), + }), + protectedHeader: Type.Object({ + alg: Type.Literal(ALG), + }), +}); + +export async function generateToken(user: UserInDb) { + const encoder = new TextEncoder(); + const secret = encoder.encode(env.JWT_SECRET); + + return await new jose.SignJWT({ + user: { id: user.id, email: user.email, username: user.username }, + }) + .setProtectedHeader({ alg: ALG }) + .setIssuedAt() + .setIssuer('agnyz') + .setAudience(user.email) + .setExpirationTime('24h') + .sign(secret); +} + +export async function verifyToken(token: string) { + const encoder = new TextEncoder(); + const secret = encoder.encode(env.JWT_SECRET); + + let verifiedToken; + try { + verifiedToken = await jose.jwtVerify(token, secret, { + algorithms: [ALG], + }); + } catch (err) { + throw new AuthenticationError('Invalid token'); + } + // I'm not sure if this is a good idea, but it at least makes sure that the token is 100% correct + // Also adds typings to the token + if (!Value.Check(VerifiedJwtSchema, verifiedToken)) + throw new AuthenticationError('Invalid token'); + const userToken = Value.Cast(VerifiedJwtSchema, verifiedToken); + return userToken; +} + +export async function getUserFromHeaders(headers: Headers) { + const rawHeader = headers.get('Authorization'); + if (!rawHeader) throw new AuthenticationError('Missing authorization header'); + + const tokenParts = rawHeader?.split(' '); + const tokenType = tokenParts?.[0]; + if (tokenType !== 'Token') + throw new AuthenticationError( + "Invalid token type. Expected header format: 'Token jwt'", + ); + + const token = tokenParts?.[1]; + const userToken = await verifyToken(token); + return userToken.payload.user; +} + +export async function requireLogin({ + request: { headers }, +}: { + request: Request; +}) { + await getUserFromHeaders(headers); +} + +export async function getUserEmailFromHeader(headers: Headers) { + const user = await getUserFromHeaders(headers); + return user.email; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..8f55904 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,14 @@ +import { Type } from '@sinclair/typebox'; +import { Value } from '@sinclair/typebox/value'; + +const envSchema = Type.Object({ + POSTGRES_DB: Type.String(), + POSTGRES_USER: Type.String(), + POSTGRES_PASSWORD: Type.String(), + POSTGRES_HOST: Type.String(), + POSTGRES_PORT: Type.String(), + JWT_SECRET: Type.String(), +}); +// TODO: this is ugly, find a better way to do this +if (!Value.Check(envSchema, Bun.env)) throw new Error('Invalid env variables'); +export const env = Value.Cast(envSchema, Bun.env); diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..e53dbc6 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,29 @@ +import { DEFAULT, MapWithDefault } from '@/utils/defaultmap'; + +export class AuthenticationError extends Error { + public status = 401; + public type = 'authentication'; + constructor(public message: string) { + super(message); + } +} + +export class AuthorizationError extends Error { + public status = 403; + public type = 'authorization'; + constructor(public message: string) { + super(message); + } +} + +export const ERROR_CODE_STATUS_MAP = new MapWithDefault([ + ['PARSE', 400], + ['VALIDATION', 422], + ['NOT_FOUND', 404], + ['INVALID_COOKIE_SIGNATURE', 401], + ['AUTHENTICATION', 401], + ['AUTHORIZATION', 403], + ['INTERNAL_SERVER_ERROR', 500], + ['UNKNOWN', 500], + [DEFAULT, 500], +]); diff --git a/src/main.ts b/src/main.ts index 45a1101..0b41c3b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ -import { setupApp } from '@/app.module'; import { Elysia } from 'elysia'; +import { setupApp } from '@/app.module'; const app = new Elysia({ prefix: '/api' }).use(setupApp).listen(3000); diff --git a/src/users/users.model.ts b/src/users/users.model.ts new file mode 100644 index 0000000..d679169 --- /dev/null +++ b/src/users/users.model.ts @@ -0,0 +1,13 @@ +import { sql } from 'drizzle-orm'; +import { date, pgTable, serial, text } from 'drizzle-orm/pg-core'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + email: text('email').unique().notNull(), + bio: text('bio'), + image: text('image'), + password: text('password').notNull(), + username: text('username').notNull(), + created_at: date('created_at').default(sql`CURRENT_DATE`), + updated_at: date('updated_at').default(sql`CURRENT_DATE`), +}); diff --git a/src/users/users.plugin.ts b/src/users/users.plugin.ts index 335374f..ae5ed0c 100644 --- a/src/users/users.plugin.ts +++ b/src/users/users.plugin.ts @@ -1,15 +1,43 @@ import { Elysia } from 'elysia'; import { setupUsers } from '@/users/users.module'; +import { + InsertUserSchema, + UserAuthSchema, + UserLoginSchema, +} from '@/users/users.schema'; +import { getUserEmailFromHeader, requireLogin } from '@/auth'; -export const usersPlugin = new Elysia().use(setupUsers).group( - '/users', - { - detail: { - tags: ['Users'], - }, - }, - (app) => +export const usersPlugin = new Elysia() + .use(setupUsers) + .group('/users', (app) => app - .post('', ({ store }) => store.usersService.findAll()) - .post('/login', ({ store }) => store.usersService.findAll()), -); + .post('', ({ body, store }) => store.usersService.createUser(body.user), { + body: InsertUserSchema, + response: UserAuthSchema, + detail: { + summary: 'Create a user', + }, + }) + .post( + '/login', + ({ body, store }) => + store.usersService.loginUser(body.user.email, body.user.password), + { + body: UserLoginSchema, + response: UserAuthSchema, + }, + ), + ) + .group('/user', (app) => + app.get( + '', + async ({ request, store }) => + store.usersService.findByEmail( + await getUserEmailFromHeader(request.headers), + ), + { + beforeHandle: requireLogin, + response: UserAuthSchema, + }, + ), + ); diff --git a/src/users/users.repository.ts b/src/users/users.repository.ts index 4b225ed..37a5e7b 100644 --- a/src/users/users.repository.ts +++ b/src/users/users.repository.ts @@ -1,10 +1,35 @@ +// users.repository.ts +// in charge of database interactions + +import { eq } from 'drizzle-orm'; import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; -import { users } from './users.schema'; +import { UserToCreate } from '@/users/users.schema'; +import { users } from '@/users/users.model'; export class UsersRepository { constructor(private readonly db: PostgresJsDatabase) {} async findAll() { - return this.db.select().from(users); + return await this.db.select().from(users); + } + + async findByEmail(email: string) { + const result = await this.db + .select() + .from(users) + .where(eq(users.email, email)); + if (result.length === 0) { + return null; + } + if (result.length > 1) { + throw new Error(`More than one user found with the same email: ${email}`); + } + return result[0]; + } + + async createUser(user: UserToCreate) { + const newUser = await this.db.insert(users).values(user).returning(); + // returning returns the inserted row in an array, so we need to get the first element + return newUser[0]; } } diff --git a/src/users/users.schema.ts b/src/users/users.schema.ts index aea35d3..b6742e7 100644 --- a/src/users/users.schema.ts +++ b/src/users/users.schema.ts @@ -1,26 +1,36 @@ import { Type } from '@sinclair/typebox'; -import { sql } from 'drizzle-orm'; -import { date, pgTable, serial, text } from 'drizzle-orm/pg-core'; import { createInsertSchema, createSelectSchema } from 'drizzle-typebox'; - -export const users = pgTable('users', { - id: serial('id').primaryKey(), - email: text('email').notNull(), - bio: text('bio').notNull(), - image: text('image').notNull(), - password: text('password').notNull(), - username: text('username').notNull(), - created_at: date('created_at').default(sql`CURRENT_DATE`), - updated_at: date('updated_at').default(sql`CURRENT_DATE`), -}); +import { users } from '@users/users.model'; // Schema for inserting a user - can be used to validate API requests const insertUserSchemaRaw = createInsertSchema(users); -export const insertUserSchema = Type.Omit(insertUserSchemaRaw, [ - 'id', - 'created_at', - 'updated_at', -]); +export const InsertUserSchema = Type.Object({ + user: Type.Omit(insertUserSchemaRaw, ['id', 'created_at', 'updated_at']), +}); + +export const UserAuthSchema = Type.Object({ + user: Type.Composite([ + Type.Omit(insertUserSchemaRaw, [ + 'id', + 'password', + 'created_at', + 'updated_at', + ]), + Type.Object({ token: Type.String() }), + ]), +}); + +export type UserToCreate = typeof users.$inferInsert; +export type UserInDb = typeof users.$inferSelect; +export type User = Omit; + +export const UserLoginSchema = Type.Object({ + user: Type.Object({ + email: Type.String(), + password: Type.String(), + }), +}); // Schema for selecting a user - can be used to validate API responses -export const selectUserSchema = createSelectSchema(users); +const selectUserSchemaRaw = createSelectSchema(users); +export const SelectUserSchema = Type.Omit(selectUserSchemaRaw, ['password']); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 86fedfb..3c22302 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,9 +1,48 @@ +// users.service.ts +// in charge of business logic - generate slug, fetch data from other services, cache something, etc. +import { NotFoundError } from 'elysia'; import { UsersRepository } from '@/users/users.repository'; +import { UserInDb, UserToCreate } from '@/users/users.schema'; +import { generateToken } from '@/auth'; +import { AuthenticationError } from '@/errors'; export class UsersService { constructor(private readonly repository: UsersRepository) {} - async findAll() { - return this.repository.findAll(); + async findByEmail(email: string) { + const user = await this.repository.findByEmail(email); + if (!user) { + throw new NotFoundError('User not found'); + } + return await this.generateUserResponse(user); + } + + async createUser(user: UserToCreate) { + user.password = await Bun.password.hash(user.password); + const newUser = await this.repository.createUser(user); + return await this.generateUserResponse(newUser); + } + + async loginUser(email: string, password: string) { + const user = await this.repository.findByEmail(email); + if (!user) { + throw new NotFoundError('User not found'); + } + if (!(await Bun.password.verify(password, user.password))) { + throw new AuthenticationError('Invalid password'); + } + return await this.generateUserResponse(user); + } + + async generateUserResponse(user: UserInDb) { + return { + user: { + email: user.email, + bio: user.bio, + image: user.image, + username: user.username, + token: await generateToken(user), + }, + }; } } diff --git a/src/utils/defaultmap.ts b/src/utils/defaultmap.ts new file mode 100644 index 0000000..c7d92fd --- /dev/null +++ b/src/utils/defaultmap.ts @@ -0,0 +1,7 @@ +export const DEFAULT = Symbol(); + +export class MapWithDefault extends Map { + get(key: K): V { + return super.get(key) ?? (super.get(DEFAULT) as V); + } +}