add fast-jwt ( not tested ) and make the user actual register
All checks were successful
Code quality checks / biome (push) Successful in 8s
All checks were successful
Code quality checks / biome (push) Successful in 8s
This commit is contained in:
parent
f93cef442a
commit
57fb8d8bb1
10 changed files with 227 additions and 27 deletions
|
@ -24,17 +24,30 @@ const cassandra: CassandraConfig = {
|
||||||
authEnabled: process.env.CASSANDRA_AUTH_ENABLED === "false",
|
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 {
|
function verifyRequiredVariables(): void {
|
||||||
const requiredVariables = [
|
const requiredVariables = [
|
||||||
"HOST",
|
"HOST",
|
||||||
"PORT",
|
"PORT",
|
||||||
|
|
||||||
"REDIS_URL",
|
"REDIS_URL",
|
||||||
"REDIS_TTL",
|
"REDIS_TTL",
|
||||||
|
|
||||||
"CASSANDRA_HOST",
|
"CASSANDRA_HOST",
|
||||||
"CASSANDRA_PORT",
|
"CASSANDRA_PORT",
|
||||||
"CASSANDRA_CONTACT_POINTS",
|
"CASSANDRA_CONTACT_POINTS",
|
||||||
"CASSANDRA_AUTH_ENABLED",
|
"CASSANDRA_AUTH_ENABLED",
|
||||||
"CASSANDRA_DATACENTER",
|
"CASSANDRA_DATACENTER",
|
||||||
|
|
||||||
|
"JWT_SECRET",
|
||||||
|
"JWT_EXPIRATION",
|
||||||
|
"JWT_ISSUER",
|
||||||
];
|
];
|
||||||
|
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
@ -52,4 +65,4 @@ function verifyRequiredVariables(): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { environment, cassandra, redisTtl, verifyRequiredVariables };
|
export { environment, cassandra, redisTtl, verifyRequiredVariables, jwt };
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@creations.works/logger": "^1.0.3",
|
"@creations.works/logger": "^1.0.3",
|
||||||
"cassandra-driver": "^4.8.0",
|
"cassandra-driver": "^4.8.0",
|
||||||
|
"fast-jwt": "^6.0.1",
|
||||||
"pika-id": "^1.1.3"
|
"pika-id": "^1.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
121
src/lib/jwt.ts
Normal file
121
src/lib/jwt.ts
Normal file
|
@ -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<string> {
|
||||||
|
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<UserSession | null> {
|
||||||
|
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<string> {
|
||||||
|
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<UserSession> {
|
||||||
|
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<UserSession> {
|
||||||
|
return decoder(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invalidateSession(request: Request): Promise<void> {
|
||||||
|
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,
|
||||||
|
};
|
0
src/routes/user/login.ts
Normal file
0
src/routes/user/login.ts
Normal file
0
src/routes/user/logout.ts
Normal file
0
src/routes/user/logout.ts
Normal file
|
@ -1,3 +1,4 @@
|
||||||
|
import { logger } from "@creations.works/logger";
|
||||||
import { cassandra } from "@lib/cassandra";
|
import { cassandra } from "@lib/cassandra";
|
||||||
import { jsonResponse } from "@lib/http";
|
import { jsonResponse } from "@lib/http";
|
||||||
import { pika } from "@lib/pika";
|
import { pika } from "@lib/pika";
|
||||||
|
@ -6,6 +7,7 @@ import {
|
||||||
isValidPassword,
|
isValidPassword,
|
||||||
isValidUsername,
|
isValidUsername,
|
||||||
} from "@lib/validators";
|
} from "@lib/validators";
|
||||||
|
import type { Client } from "cassandra-driver";
|
||||||
|
|
||||||
const routeDef: RouteDef = {
|
const routeDef: RouteDef = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -19,9 +21,9 @@ async function handler(
|
||||||
requestBody: unknown,
|
requestBody: unknown,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const { username, password, email } = requestBody as {
|
const { username, password, email } = requestBody as {
|
||||||
username?: string;
|
username: string;
|
||||||
password?: string;
|
password: string;
|
||||||
email?: string;
|
email: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fields = { username, password, email };
|
const fields = { username, password, email };
|
||||||
|
@ -52,17 +54,23 @@ async function handler(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const usernameResult = await cassandra
|
const cassandraClient: Client = cassandra.getClient();
|
||||||
.getClient()
|
|
||||||
.execute("SELECT id FROM users WHERE username = ?", [username], {
|
|
||||||
prepare: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const emailResult = await cassandra
|
const usernameResult = await cassandraClient.execute(
|
||||||
.getClient()
|
"SELECT id FROM users WHERE username = ?",
|
||||||
.execute("SELECT id FROM users WHERE email = ?", [email], {
|
[username],
|
||||||
|
{
|
||||||
prepare: true,
|
prepare: true,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailResult = await cassandraClient.execute(
|
||||||
|
"SELECT id FROM users WHERE email = ?",
|
||||||
|
[email],
|
||||||
|
{
|
||||||
|
prepare: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (usernameResult.rowLength > 0 || emailResult.rowLength > 0) {
|
if (usernameResult.rowLength > 0 || emailResult.rowLength > 0) {
|
||||||
const errorMessages: string[] = [];
|
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",
|
message: "User registered successfully",
|
||||||
data: {
|
data: {
|
||||||
username,
|
id: user.id,
|
||||||
email,
|
username: user.username,
|
||||||
id: userId,
|
email: user.email,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
7
types/config.d.ts
vendored
7
types/config.d.ts
vendored
|
@ -14,3 +14,10 @@ type CassandraConfig = {
|
||||||
contactPoints: string[];
|
contactPoints: string[];
|
||||||
authEnabled: boolean;
|
authEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type JWTConfig = {
|
||||||
|
secret: string;
|
||||||
|
expiration: string;
|
||||||
|
issuer: string;
|
||||||
|
algorithm: string;
|
||||||
|
};
|
||||||
|
|
9
types/http.d.ts
vendored
9
types/http.d.ts
vendored
|
@ -4,3 +4,12 @@ interface GenericJsonResponseOptions {
|
||||||
error?: string;
|
error?: string;
|
||||||
[key: string]: unknown;
|
[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)
|
||||||
|
}
|
||||||
|
|
9
types/logger.d.ts
vendored
9
types/logger.d.ts
vendored
|
@ -1,9 +0,0 @@
|
||||||
type ILogMessagePart = { value: string; color: string };
|
|
||||||
|
|
||||||
type ILogMessageParts = {
|
|
||||||
level: ILogMessagePart;
|
|
||||||
filename: ILogMessagePart;
|
|
||||||
readableTimestamp: ILogMessagePart;
|
|
||||||
message: ILogMessagePart;
|
|
||||||
[key: string]: ILogMessagePart;
|
|
||||||
};
|
|
16
types/tables/user.d.ts
vendored
Normal file
16
types/tables/user.d.ts
vendored
Normal file
|
@ -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"
|
||||||
|
>;
|
Loading…
Add table
Reference in a new issue