Skip to content

Commit

Permalink
feat: user auth and related endpoints (#47)
Browse files Browse the repository at this point in the history
* 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 <yamyam263@gmail.com>

* Refactor error handling

* Use Type instead of t

* Move users table to separate file

---------

Co-authored-by: Yam Borodetsky <yamyam263@gmail.com>
  • Loading branch information
Hajbo and yamcodes committed Oct 4, 2023
1 parent 0169b72 commit 915f8cd
Show file tree
Hide file tree
Showing 22 changed files with 338 additions and 50 deletions.
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ POSTGRES_PASSWORD=postgres
POSTGRES_DB=medium
POSTGRES_HOST=0.0.0.0
POSTGRES_PORT=5432
JWT_SECRET=supersecretkey
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
{
"editor.tabSize": 2
"editor.tabSize": 2,
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
3 changes: 3 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,8 @@
"rules": {
"recommended": true
}
},
"files": {
"maxSize": 3145728
}
}
Binary file modified bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion db/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion db/migrations/migrate.ts → db/migrate.ts
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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")
);
14 changes: 10 additions & 4 deletions db/migrations/meta/0000_snapshot.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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",
Expand Down Expand Up @@ -62,7 +62,13 @@
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
}
}
},
"enums": {},
Expand Down
4 changes: 2 additions & 2 deletions db/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
{
"idx": 0,
"version": "5",
"when": 1695584965813,
"tag": "0000_bored_warstar",
"when": 1695849229878,
"tag": "0000_perpetual_blazing_skull",
"breakpoints": false
}
]
Expand Down
2 changes: 1 addition & 1 deletion db/seed.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
19 changes: 18 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
89 changes: 89 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 14 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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);
29 changes: 29 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>([
['PARSE', 400],
['VALIDATION', 422],
['NOT_FOUND', 404],
['INVALID_COOKIE_SIGNATURE', 401],
['AUTHENTICATION', 401],
['AUTHORIZATION', 403],
['INTERNAL_SERVER_ERROR', 500],
['UNKNOWN', 500],
[DEFAULT, 500],
]);
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down
13 changes: 13 additions & 0 deletions src/users/users.model.ts
Original file line number Diff line number Diff line change
@@ -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`),
});
50 changes: 39 additions & 11 deletions src/users/users.plugin.ts
Original file line number Diff line number Diff line change
@@ -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,
},
),
);
Loading

0 comments on commit 915f8cd

Please sign in to comment.