Skip to content

Commit

Permalink
Merge pull request #3 from GenieWizards/feat/add-lucia-auth
Browse files Browse the repository at this point in the history
feat: add auth and related utilities
  • Loading branch information
shivamvijaywargi authored Nov 10, 2024
2 parents e739933 + 789719d commit b1436da
Show file tree
Hide file tree
Showing 38 changed files with 2,067 additions and 76 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,6 @@
"scss",
"pcss",
"postcss"
]
],
"cSpell.words": ["openapi"]
}
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
# Finance Management API

<!--toc:start-->
- [Finance Management API](#finance-management-api)
- [To setup locally](#to-setup-locally)
- [Check API documentation](#check-api-documentation)

- [Finance Management API](#finance-management-api) - [To setup locally](#to-setup-locally) - [Check API documentation](#check-api-documentation)
<!--toc:end-->

### To setup locally

- This project uses bun as its package manager, you need to have bun installed in your system.
- To install bun follow the official guide at https://bun.sh


```bash
git clone https://github.com/GenieWizards/finance-management-api.git # clones the repo in cwd
cd finance-management-api # navigate to the cloned folder
Expand Down
Binary file modified bun.lockb
Binary file not shown.
19 changes: 12 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,31 @@
"dev": "bun run --watch src/index.ts",
"lint": "eslint .",
"lint:fix": "bun lint --fix",
"start": "bun src/index.ts"
"start": "bun src/index.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"deps:update": "npx npm-check-updates --interactive --format group"
},
"dependencies": {
"@hono/zod-openapi": "^0.16.4",
"@scalar/hono-api-reference": "^0.5.158",
"drizzle-orm": "^0.36.0",
"@hono/zod-openapi": "^0.17.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@scalar/hono-api-reference": "^0.5.159",
"drizzle-orm": "^0.36.1",
"drizzle-zod": "^0.5.1",
"hono": "^4.6.9",
"hono-pino": "^0.5.1",
"hono-pino": "^0.6.0",
"pino": "^9.5.0",
"postgres": "^3.4.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@antfu/eslint-config": "^3.8.0",
"@types/bun": "latest",
"drizzle-kit": "^0.26.2",
"drizzle-kit": "^0.28.0",
"eslint": "^9.14.0",
"eslint-plugin-format": "^0.1.2",
"pino-pretty": "^11.3.0"
"pino-pretty": "^12.1.0"
},
"maintainers": [
{
Expand Down
9 changes: 6 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { configureOpenAPI } from "./common/lib/configure-open-api.lib";
import { createApp } from "./common/lib/create-app.lib";
import { authRouter } from "./modules/auth/auth.index";
import { healthCheckRouter } from "./modules/health-check/health-check.index";

export const app = createApp();

const routes = [healthCheckRouter];
const routesV1 = [authRouter];

configureOpenAPI(app);

routes.forEach((route) => {
app.route("/", route);
app.route("/api", healthCheckRouter);

routesV1.forEach((route) => {
app.route("/api/v1", route);
});

app.get("/", (c) => {
Expand Down
24 changes: 18 additions & 6 deletions src/common/enums/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
// NOTE: If updating values update in both
export const AuthRoles = ["user", "admin"] as const;
// NOTE: Updating the array will auto update the AuthRoles object
export const authRolesArr = ["user", "admin"] as const;

export enum AuthRole {
USER = "user",
ADMIN = "admin",
}
type AuthRolesTuple = typeof authRolesArr;
type AuthRolesValues = AuthRolesTuple[number];
type AuthRolesType = {
[K in Uppercase<AuthRolesValues>]: Lowercase<K>;
};

export const AuthRoles = authRolesArr.reduce(
(acc, role) => ({
...acc,
[role.toUpperCase()]: role,
}),
{} as AuthRolesType,
);

export type UpperCaseAuthRole = keyof typeof AuthRoles;
export type AuthRole = (typeof AuthRoles)[UpperCaseAuthRole];
15 changes: 15 additions & 0 deletions src/common/helpers/json-content-required.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { ZodSchema } from "../lib/types";

import { jsonContent } from "./json-content.helper";

function jsonContentRequired<T extends ZodSchema>(
schema: T,
description: string,
) {
return {
...jsonContent(schema, description),
required: true,
};
}

export default jsonContentRequired;
7 changes: 7 additions & 0 deletions src/common/lib/constants.lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as HTTPStatusPhrases from "@/common/utils/http-status-phrases.util";

import { createMessageObjectSchema } from "../schema/create-message-object.schema";

export const notFoundSchema = createMessageObjectSchema(
HTTPStatusPhrases.NOT_FOUND,
);
5 changes: 5 additions & 0 deletions src/common/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { OpenAPIHono, RouteConfig, RouteHandler } from "@hono/zod-openapi";
import type { PinoLogger } from "hono-pino";

import type { TSelectSessionSchema } from "@/db/schemas/session.model";
import type { TSelectUserSchema } from "@/db/schemas/user.model";

export interface AppBindings {
Variables: {
logger: PinoLogger;
user: TSelectUserSchema | null;
session: TSelectSessionSchema | null;
};
}

Expand Down
91 changes: 91 additions & 0 deletions src/common/middlewares/auth.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { MiddlewareHandler } from "hono";

import { getCookie } from "hono/cookie";

import * as HTTPStatusCodes from "@/common/utils/http-status-codes.util";

import type { AuthRole } from "../enums";

import { validateSessionToken } from "../utils/sessions.util";

export function authMiddleware(): MiddlewareHandler {
return async (c, next) => {
const sessionId
= c.req.header("session") || getCookie(c, "session") || null;

if (!sessionId) {
c.set("user", null);
c.set("session", null);

return next();
}

const { session, user } = await validateSessionToken(sessionId);

if (!session) {
c.set("user", null);
c.set("session", null);

return next();
}

c.set("user", user);
c.set("session", session);

return await next();
};
}

export function requireAuth(): MiddlewareHandler {
return async (c, next) => {
if (!c.get("user")) {
return c.json(
{
success: false,
message: "You are not authorized, please login",
},
HTTPStatusCodes.UNAUTHORIZED,
);
}

return await next();
};
}

export function checkRoleGuard(...allowedRoles: AuthRole[]): MiddlewareHandler {
return async (c, next) => {
const user = c.get("user");

if (!user) {
return c.json(
{
success: false,
message: "You are not authorized, please login",
},
HTTPStatusCodes.UNAUTHORIZED,
);
}

if (!user.role) {
return c.json(
{
success: false,
message: "You are not allowed to perform this action",
},
HTTPStatusCodes.FORBIDDEN,
);
}

if (!allowedRoles.includes(user.role)) {
return c.json(
{
success: false,
message: "You are not allowed to perform this action",
},
HTTPStatusCodes.FORBIDDEN,
);
}

await next();
};
}
2 changes: 1 addition & 1 deletion src/common/middlewares/pino-logger.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { logger } from "hono-pino";
import { pinoLogger as logger } from "hono-pino";
import pino from "pino";

import env from "@/env";
Expand Down
41 changes: 41 additions & 0 deletions src/common/utils/crypto.lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { hash, verify } from "@node-rs/argon2";
import { sha1 } from "@oslojs/crypto/sha1";
import { encodeHexLowerCase } from "@oslojs/encoding";

export async function hashPassword(password: string): Promise<string> {
return await hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
}

export async function verifyPasswordHash(
hash: string,
password: string,
): Promise<boolean> {
return await verify(hash, password);
}

export async function verifyPasswordStrength(
password: string,
): Promise<boolean> {
if (password.length < 8 || password.length > 255) {
return false;
}
const hash = encodeHexLowerCase(sha1(new TextEncoder().encode(password)));
const hashPrefix = hash.slice(0, 5);
const response = await fetch(
`https://api.pwnedpasswords.com/range/${hashPrefix}`,
);
const data = await response.text();
const items = data.split("\n");
for (const item of items) {
const hashSuffix = item.slice(0, 35).toLowerCase();
if (hash === hashPrefix + hashSuffix) {
return false;
}
}
return true;
}
68 changes: 68 additions & 0 deletions src/common/utils/sessions.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding";
import { eq } from "drizzle-orm";

import type { TSelectSessionSchema } from "@/db/schemas/session.model";
import type { TSelectUserSchema } from "@/db/schemas/user.model";

import { db } from "@/db/adapter";
import sessionModel from "@/db/schemas/session.model";
import userModel from "@/db/schemas/user.model";

export type SessionValidationResult =
| { session: TSelectSessionSchema; user: TSelectUserSchema }
| { session: null; user: null };

export function generateSessionToken(): string {
const tokenBytes = new Uint8Array(20);
crypto.getRandomValues(tokenBytes);
const token = encodeBase32LowerCaseNoPadding(tokenBytes).toLowerCase();
return token;
}

export async function validateSessionToken(
token: string,
): Promise<SessionValidationResult> {
const result = await db
.select({ user: userModel, session: sessionModel })
.from(sessionModel)
.innerJoin(userModel, eq(sessionModel.userId, userModel.id))
.where(eq(sessionModel.id, token));

if (result.length < 1) {
return { session: null, user: null };
}

const { user, session } = result[0];

if (!user) {
await db.delete(sessionModel).where(eq(sessionModel.id, session.id));
return { session: null, user: null };
}

if (Date.now() >= session.expiresAt.getTime()) {
await db.delete(sessionModel).where(eq(sessionModel.id, session.id));

return { session: null, user: null };
}

if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) {
session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);

await db
.update(sessionModel)
.set({
expiresAt: session.expiresAt,
})
.where(eq(sessionModel.id, session.id));
}

return { session, user };
}

export async function invalidateSession(sessionId: string): Promise<void> {
await db.delete(sessionModel).where(eq(sessionModel.id, sessionId));
}

export async function invalidateUserSessions(userId: string): Promise<void> {
await db.delete(sessionModel).where(eq(sessionModel.id, userId));
}
2 changes: 1 addition & 1 deletion src/db/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import postgres from "postgres";

import env from "@/env";

import * as schema from "./schema";
import * as schema from "./schemas";

const queryClient = postgres(env.DATABASE_URL);
export const db = drizzle(queryClient, {
Expand Down
Loading

0 comments on commit b1436da

Please sign in to comment.