diff --git a/config/environment.ts b/config/environment.ts index 91ada96..32938f3 100644 --- a/config/environment.ts +++ b/config/environment.ts @@ -24,17 +24,30 @@ const cassandra: CassandraConfig = { authEnabled: process.env.CASSANDRA_AUTH_ENABLED === "false", }; +const jwt: JWTConfig = { + secret: process.env.JWT_SECRET || "", + expiration: process.env.JWT_EXPIRATION || "1h", + issuer: process.env.JWT_ISSUER || "", + algorithm: process.env.JWT_ALGORITHM || "HS256", +}; + function verifyRequiredVariables(): void { const requiredVariables = [ "HOST", "PORT", + "REDIS_URL", "REDIS_TTL", + "CASSANDRA_HOST", "CASSANDRA_PORT", "CASSANDRA_CONTACT_POINTS", "CASSANDRA_AUTH_ENABLED", "CASSANDRA_DATACENTER", + + "JWT_SECRET", + "JWT_EXPIRATION", + "JWT_ISSUER", ]; let hasError = false; @@ -52,4 +65,4 @@ function verifyRequiredVariables(): void { } } -export { environment, cassandra, redisTtl, verifyRequiredVariables }; +export { environment, cassandra, redisTtl, verifyRequiredVariables, jwt }; diff --git a/package.json b/package.json index 62d5f6b..215861a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "@creations.works/logger": "^1.0.3", "cassandra-driver": "^4.8.0", + "fast-jwt": "^6.0.1", "pika-id": "^1.1.3" } } diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts new file mode 100644 index 0000000..09e22dc --- /dev/null +++ b/src/lib/jwt.ts @@ -0,0 +1,121 @@ +import { environment, jwt } from "@config/environment"; +import { redis } from "bun"; +import { createDecoder, createSigner, createVerifier } from "fast-jwt"; + +const signer = createSigner({ key: jwt.secret, expiresIn: jwt.expiration }); +const verifier = createVerifier({ key: jwt.secret }); +const decoder = createDecoder(); + +export async function createSession( + payload: UserSession, + userAgent: string, +): Promise { + const token = signer(payload); + const sessionKey = `session:${payload.id}:${token}`; + await redis.set(sessionKey, JSON.stringify({ ...payload, userAgent })); + await redis.expire(sessionKey, getExpirationInSeconds()); + return generateCookie(token); +} + +export async function getSession( + request: Request, +): Promise { + const token = extractToken(request); + if (!token) return null; + const keys = await redis.keys(`session:*:${token}`); + if (!keys.length) return null; + const raw = await redis.get(keys[0]); + return raw ? JSON.parse(raw) : null; +} + +export async function updateSession( + request: Request, + payload: UserSession, + userAgent: string, +): Promise { + const token = extractToken(request); + if (!token) throw new Error("Session token not found"); + const keys = await redis.keys(`session:*:${token}`); + if (!keys.length) throw new Error("Session not found or expired"); + await redis.set(keys[0], JSON.stringify({ ...payload, userAgent })); + await redis.expire(keys[0], getExpirationInSeconds()); + return generateCookie(token); +} + +export async function verifySession(token: string): Promise { + const keys = await redis.keys(`session:*:${token}`); + if (!keys.length) throw new Error("Session not found or expired"); + return verifier(token); +} + +export async function decodeSession(token: string): Promise { + return decoder(token); +} + +export async function invalidateSession(request: Request): Promise { + const token = extractToken(request); + if (!token) return; + const keys = await redis.keys(`session:*:${token}`); + if (!keys.length) return; + await redis.del(keys[0]); +} + +// helpers +function extractToken(request: Request): string | null { + return request.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1] || null; +} + +function generateCookie( + token: string, + maxAge = getExpirationInSeconds(), + options?: { + secure?: boolean; + httpOnly?: boolean; + sameSite?: "Strict" | "Lax" | "None"; + path?: string; + domain?: string; + }, +): string { + const { + secure = !environment.development, + httpOnly = true, + sameSite = environment.development ? "Lax" : "None", + path = "/", + domain, + } = options || {}; + + let cookie = `session=${encodeURIComponent(token)}; Path=${path}; Max-Age=${maxAge}`; + if (httpOnly) cookie += "; HttpOnly"; + if (secure) cookie += "; Secure"; + if (sameSite) cookie += `; SameSite=${sameSite}`; + if (domain) cookie += `; Domain=${domain}`; + return cookie; +} + +function getExpirationInSeconds(): number { + const match = jwt.expiration.match(/^(\d+)([smhd])$/); + if (!match) throw new Error("Invalid expiresIn format in jwt config"); + const [, value, unit] = match; + const num = Number(value); + switch (unit) { + case "s": + return num; + case "m": + return num * 60; + case "h": + return num * 3600; + case "d": + return num * 86400; + default: + throw new Error("Invalid time unit in expiresIn"); + } +} + +export const sessionManager = { + createSession, + getSession, + updateSession, + verifySession, + decodeSession, + invalidateSession, +}; diff --git a/src/routes/user/login.ts b/src/routes/user/login.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/user/logout.ts b/src/routes/user/logout.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/user/register.ts b/src/routes/user/register.ts index 815ecd5..dc7e23e 100644 --- a/src/routes/user/register.ts +++ b/src/routes/user/register.ts @@ -1,3 +1,4 @@ +import { logger } from "@creations.works/logger"; import { cassandra } from "@lib/cassandra"; import { jsonResponse } from "@lib/http"; import { pika } from "@lib/pika"; @@ -6,6 +7,7 @@ import { isValidPassword, isValidUsername, } from "@lib/validators"; +import type { Client } from "cassandra-driver"; const routeDef: RouteDef = { method: "POST", @@ -19,9 +21,9 @@ async function handler( requestBody: unknown, ): Promise { const { username, password, email } = requestBody as { - username?: string; - password?: string; - email?: string; + username: string; + password: string; + email: string; }; const fields = { username, password, email }; @@ -52,17 +54,23 @@ async function handler( }); } - const usernameResult = await cassandra - .getClient() - .execute("SELECT id FROM users WHERE username = ?", [username], { - prepare: true, - }); + const cassandraClient: Client = cassandra.getClient(); - const emailResult = await cassandra - .getClient() - .execute("SELECT id FROM users WHERE email = ?", [email], { + const usernameResult = await cassandraClient.execute( + "SELECT id FROM users WHERE username = ?", + [username], + { prepare: true, - }); + }, + ); + + const emailResult = await cassandraClient.execute( + "SELECT id FROM users WHERE email = ?", + [email], + { + prepare: true, + }, + ); if (usernameResult.rowLength > 0 || emailResult.rowLength > 0) { const errorMessages: string[] = []; @@ -80,14 +88,48 @@ async function handler( }); } - const userId = pika.gen("user"); + const user: UserInsert = { + id: pika.gen("user"), + username, + email, + password: await Bun.password.hash(password), + created_at: new Date(), + updated_at: new Date(), + }; - return jsonResponse(200, { + try { + await cassandraClient.execute( + "INSERT INTO users (id, username, email, password, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + [ + user.id, + user.username, + user.email, + user.password, + user.created_at, + user.updated_at, + ], + { prepare: true }, + ); + + logger.custom( + "[REGISTER]", + `(${user.id})`, + `${user.username} - ${user.email}`, + "34", + ); + } catch (error) { + return jsonResponse(500, { + message: "Internal server error", + error: "Failed to register user", + }); + } + + return jsonResponse(201, { message: "User registered successfully", data: { - username, - email, - id: userId, + id: user.id, + username: user.username, + email: user.email, }, }); } diff --git a/types/config.d.ts b/types/config.d.ts index d826680..729f900 100644 --- a/types/config.d.ts +++ b/types/config.d.ts @@ -14,3 +14,10 @@ type CassandraConfig = { contactPoints: string[]; authEnabled: boolean; }; + +type JWTConfig = { + secret: string; + expiration: string; + issuer: string; + algorithm: string; +}; diff --git a/types/http.d.ts b/types/http.d.ts index 303fea2..17dc166 100644 --- a/types/http.d.ts +++ b/types/http.d.ts @@ -4,3 +4,12 @@ interface GenericJsonResponseOptions { error?: string; [key: string]: unknown; } + +interface UserSession { + id: string; + username: string; + email: string; + is_verified: boolean; + iat?: number; // issued at (added by JWT libs) + exp?: number; // expiration (added by JWT libs) +} diff --git a/types/logger.d.ts b/types/logger.d.ts deleted file mode 100644 index ff6a601..0000000 --- a/types/logger.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -type ILogMessagePart = { value: string; color: string }; - -type ILogMessageParts = { - level: ILogMessagePart; - filename: ILogMessagePart; - readableTimestamp: ILogMessagePart; - message: ILogMessagePart; - [key: string]: ILogMessagePart; -}; diff --git a/types/tables/user.d.ts b/types/tables/user.d.ts new file mode 100644 index 0000000..ac4a01b --- /dev/null +++ b/types/tables/user.d.ts @@ -0,0 +1,16 @@ +type User = { + id: string; + username: string; + display_name: string; + email: string; + password: string; + avatar_url: string; + is_verified: boolean; + created_at: Date; + updated_at: Date; +}; + +type UserInsert = Pick< + User, + "id" | "username" | "email" | "password" | "created_at" | "updated_at" +>;