This commit is contained in:
parent
57fb8d8bb1
commit
e98eebbb47
5 changed files with 191 additions and 0 deletions
|
@ -60,6 +60,28 @@ export async function invalidateSession(request: Request): Promise<void> {
|
||||||
await redis.del(keys[0]);
|
await redis.del(keys[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function invalidateSessionById(
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const keys = await redis.keys(`session:*:${sessionId}`);
|
||||||
|
if (!keys.length) return false;
|
||||||
|
await redis.del(keys[0]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invalidateAllSessionsForUser(
|
||||||
|
userId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const keys = await redis.keys(`session:${userId}:*`);
|
||||||
|
if (keys.length === 0) return 0;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
await redis.del(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys.length;
|
||||||
|
}
|
||||||
|
|
||||||
// helpers
|
// helpers
|
||||||
function extractToken(request: Request): string | null {
|
function extractToken(request: Request): string | null {
|
||||||
return request.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1] || null;
|
return request.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1] || null;
|
||||||
|
@ -118,4 +140,6 @@ export const sessionManager = {
|
||||||
verifySession,
|
verifySession,
|
||||||
decodeSession,
|
decodeSession,
|
||||||
invalidateSession,
|
invalidateSession,
|
||||||
|
invalidateSessionById,
|
||||||
|
invalidateAllSessionsForUser,
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { cassandra } from "@lib/cassandra";
|
||||||
|
import { jsonResponse } from "@lib/http";
|
||||||
|
import { sessionManager } from "@lib/jwt";
|
||||||
|
import { isValidEmail, isValidPassword } from "@lib/validators";
|
||||||
|
import type { Client } from "cassandra-driver";
|
||||||
|
|
||||||
|
const routeDef: RouteDef = {
|
||||||
|
method: "POST",
|
||||||
|
accepts: "application/json",
|
||||||
|
returns: "application/json;charset=utf-8",
|
||||||
|
needsBody: "json",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handler(
|
||||||
|
request: ExtendedRequest,
|
||||||
|
requestBody: unknown,
|
||||||
|
): Promise<Response> {
|
||||||
|
const { email, password } = requestBody as {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
const { force } = request.query;
|
||||||
|
|
||||||
|
if (request.session && force !== "true" && force !== "1") {
|
||||||
|
return jsonResponse(400, {
|
||||||
|
message: "Already logged in",
|
||||||
|
error: "You are already logged in",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!email || typeof email !== "string") {
|
||||||
|
errors.push("email is required");
|
||||||
|
} else {
|
||||||
|
const result = isValidEmail(email);
|
||||||
|
if (!result.valid) errors.push(`email: ${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password || typeof password !== "string") {
|
||||||
|
errors.push("password is required");
|
||||||
|
} else {
|
||||||
|
const result = isValidPassword(password);
|
||||||
|
if (!result.valid) errors.push(`password: ${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return jsonResponse(400, {
|
||||||
|
message: "Validation failed",
|
||||||
|
error: errors.join("; "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const client: Client = cassandra.getClient();
|
||||||
|
const result = await client.execute(
|
||||||
|
"SELECT id, username, email, password FROM users WHERE email = ?",
|
||||||
|
[email],
|
||||||
|
{ prepare: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowLength === 0) {
|
||||||
|
return jsonResponse(401, {
|
||||||
|
message: "Invalid credentials",
|
||||||
|
error: "Invalid email or password",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.first();
|
||||||
|
const isPasswordValid = await Bun.password.verify(password, user.password);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return jsonResponse(401, {
|
||||||
|
message: "Invalid credentials",
|
||||||
|
error: "Invalid email or password",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = await sessionManager.createSession(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
is_verified: false,
|
||||||
|
},
|
||||||
|
request.headers.get("User-Agent") || "unknown",
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
status: "OK",
|
||||||
|
message: "Login successful",
|
||||||
|
data: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json;charset=utf-8",
|
||||||
|
"Set-Cookie": cookie,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handler, routeDef };
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { jsonResponse } from "@lib/http";
|
||||||
|
import { sessionManager } from "@lib/jwt";
|
||||||
|
import { redis } from "bun";
|
||||||
|
|
||||||
|
const routeDef: RouteDef = {
|
||||||
|
method: "DELETE",
|
||||||
|
accepts: "*/*",
|
||||||
|
returns: "application/json;charset=utf-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
|
const { id: sessionId, all } = request.query;
|
||||||
|
|
||||||
|
if ((all === "true" || all === "1") && request.session) {
|
||||||
|
const keys = await redis.keys(`session:${request.session.id}:*`);
|
||||||
|
for (const key of keys) {
|
||||||
|
await redis.del(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie":
|
||||||
|
"session=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Strict",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionId && typeof sessionId === "string") {
|
||||||
|
const keys = await redis.keys(`session:*:${sessionId}`);
|
||||||
|
if (!keys.length) {
|
||||||
|
return jsonResponse(404, {
|
||||||
|
message: "Session not found",
|
||||||
|
error: "No session exists with the given ID",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await redis.del(keys[0]);
|
||||||
|
|
||||||
|
return jsonResponse(200, {
|
||||||
|
message: "Session invalidated",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionManager.invalidateSession(request);
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie":
|
||||||
|
"session=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Strict",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handler, routeDef };
|
|
@ -10,6 +10,7 @@ import {
|
||||||
} from "bun";
|
} from "bun";
|
||||||
|
|
||||||
import { webSocketHandler } from "@/websocket";
|
import { webSocketHandler } from "@/websocket";
|
||||||
|
import { getSession } from "@lib/jwt";
|
||||||
|
|
||||||
class ServerHandler {
|
class ServerHandler {
|
||||||
private router: FileSystemRouter;
|
private router: FileSystemRouter;
|
||||||
|
@ -174,6 +175,8 @@ class ServerHandler {
|
||||||
extendedRequest.params = params;
|
extendedRequest.params = params;
|
||||||
extendedRequest.query = query;
|
extendedRequest.query = query;
|
||||||
|
|
||||||
|
extendedRequest.session = (await getSession(request)) || null;
|
||||||
|
|
||||||
response = await routeModule.handler(
|
response = await routeModule.handler(
|
||||||
extendedRequest,
|
extendedRequest,
|
||||||
requestBody,
|
requestBody,
|
||||||
|
|
1
types/bun.d.ts
vendored
1
types/bun.d.ts
vendored
|
@ -10,5 +10,6 @@ declare global {
|
||||||
startPerf: number;
|
startPerf: number;
|
||||||
query: Query;
|
query: Query;
|
||||||
params: Params;
|
params: Params;
|
||||||
|
session: UserSession | null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue