(Feat-Fix): Lots of fixes done, reporting system fixed, stricter types
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Context, Next } from "@oak/oak";
|
||||
import { verify, create, getNumericDate } from "djwt";
|
||||
import { create, getNumericDate, verify } from "djwt";
|
||||
import { config } from "../config/env.ts";
|
||||
import type { JWTPayload, UserRole } from "../types/index.ts";
|
||||
|
||||
@@ -12,14 +12,16 @@ const cryptoKey = await crypto.subtle.importKey(
|
||||
keyData,
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"]
|
||||
["sign", "verify"],
|
||||
);
|
||||
|
||||
// Generate JWT token
|
||||
export async function generateToken(payload: Omit<JWTPayload, "exp" | "iat">): Promise<string> {
|
||||
export async function generateToken(
|
||||
payload: Omit<JWTPayload, "exp" | "iat">,
|
||||
): Promise<string> {
|
||||
const expiresIn = config.JWT_EXPIRES_IN;
|
||||
let expSeconds = 7 * 24 * 60 * 60; // Default 7 days
|
||||
|
||||
|
||||
if (expiresIn.endsWith("d")) {
|
||||
expSeconds = parseInt(expiresIn) * 24 * 60 * 60;
|
||||
} else if (expiresIn.endsWith("h")) {
|
||||
@@ -27,7 +29,7 @@ export async function generateToken(payload: Omit<JWTPayload, "exp" | "iat">): P
|
||||
} else if (expiresIn.endsWith("m")) {
|
||||
expSeconds = parseInt(expiresIn) * 60;
|
||||
}
|
||||
|
||||
|
||||
const token = await create(
|
||||
{ alg: "HS256", typ: "JWT" },
|
||||
{
|
||||
@@ -35,9 +37,9 @@ export async function generateToken(payload: Omit<JWTPayload, "exp" | "iat">): P
|
||||
exp: getNumericDate(expSeconds),
|
||||
iat: getNumericDate(0),
|
||||
},
|
||||
cryptoKey
|
||||
cryptoKey,
|
||||
);
|
||||
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -52,24 +54,27 @@ export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
||||
}
|
||||
|
||||
// Authentication middleware
|
||||
export async function authenticateToken(ctx: Context, next: Next): Promise<void> {
|
||||
export async function authenticateToken(
|
||||
ctx: Context,
|
||||
next: Next,
|
||||
): Promise<void> {
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
||||
|
||||
|
||||
if (!token) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "Access token required" };
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const payload = await verifyToken(token);
|
||||
|
||||
|
||||
if (!payload) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Invalid or expired token" };
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Attach user to context state
|
||||
ctx.state.user = payload;
|
||||
await next();
|
||||
@@ -79,19 +84,19 @@ export async function authenticateToken(ctx: Context, next: Next): Promise<void>
|
||||
export function authorize(...roles: UserRole[]) {
|
||||
return async (ctx: Context, next: Next): Promise<void> => {
|
||||
const user = ctx.state.user as JWTPayload | undefined;
|
||||
|
||||
|
||||
if (!user) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "Unauthorized" };
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!roles.includes(user.role)) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Insufficient permissions" };
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,54 +10,57 @@ export async function rateLimit(ctx: Context, next: Next): Promise<void> {
|
||||
const now = Date.now();
|
||||
const windowMs = config.RATE_LIMIT_WINDOW_MS;
|
||||
const maxRequests = config.RATE_LIMIT_MAX_REQUESTS;
|
||||
|
||||
|
||||
const record = rateLimitStore.get(ip);
|
||||
|
||||
|
||||
if (!record || now > record.resetTime) {
|
||||
rateLimitStore.set(ip, { count: 1, resetTime: now + windowMs });
|
||||
} else {
|
||||
record.count++;
|
||||
|
||||
|
||||
if (record.count > maxRequests) {
|
||||
ctx.response.status = 429;
|
||||
ctx.response.body = {
|
||||
ctx.response.body = {
|
||||
error: "Too many requests",
|
||||
retryAfter: Math.ceil((record.resetTime - now) / 1000)
|
||||
retryAfter: Math.ceil((record.resetTime - now) / 1000),
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
// Security headers middleware
|
||||
export async function securityHeaders(ctx: Context, next: Next): Promise<void> {
|
||||
await next();
|
||||
|
||||
|
||||
// Prevent clickjacking
|
||||
ctx.response.headers.set("X-Frame-Options", "DENY");
|
||||
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
|
||||
|
||||
// XSS protection
|
||||
ctx.response.headers.set("X-XSS-Protection", "1; mode=block");
|
||||
|
||||
|
||||
// Referrer policy
|
||||
ctx.response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
|
||||
ctx.response.headers.set(
|
||||
"Referrer-Policy",
|
||||
"strict-origin-when-cross-origin",
|
||||
);
|
||||
|
||||
// Content Security Policy
|
||||
ctx.response.headers.set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';"
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';",
|
||||
);
|
||||
|
||||
|
||||
// Strict Transport Security (only in production with HTTPS)
|
||||
if (config.isProduction()) {
|
||||
ctx.response.headers.set(
|
||||
"Strict-Transport-Security",
|
||||
"max-age=31536000; includeSubDomains"
|
||||
"max-age=31536000; includeSubDomains",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,27 +68,35 @@ export async function securityHeaders(ctx: Context, next: Next): Promise<void> {
|
||||
// CORS middleware
|
||||
export async function cors(ctx: Context, next: Next): Promise<void> {
|
||||
const origin = ctx.request.headers.get("Origin");
|
||||
const allowedOrigins = config.CORS_ORIGIN.split(",").map(o => o.trim());
|
||||
|
||||
const allowedOrigins = config.CORS_ORIGIN.split(",").map((o) => o.trim());
|
||||
|
||||
// Check if origin is allowed
|
||||
if (origin && (allowedOrigins.includes(origin) || allowedOrigins.includes("*"))) {
|
||||
if (
|
||||
origin && (allowedOrigins.includes(origin) || allowedOrigins.includes("*"))
|
||||
) {
|
||||
ctx.response.headers.set("Access-Control-Allow-Origin", origin);
|
||||
} else if (config.isDevelopment()) {
|
||||
// Allow all origins in development
|
||||
ctx.response.headers.set("Access-Control-Allow-Origin", origin || "*");
|
||||
}
|
||||
|
||||
ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||
ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
|
||||
ctx.response.headers.set(
|
||||
"Access-Control-Allow-Methods",
|
||||
"GET, POST, PUT, DELETE, OPTIONS",
|
||||
);
|
||||
ctx.response.headers.set(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization",
|
||||
);
|
||||
ctx.response.headers.set("Access-Control-Allow-Credentials", "true");
|
||||
ctx.response.headers.set("Access-Control-Max-Age", "86400");
|
||||
|
||||
|
||||
// Handle preflight requests
|
||||
if (ctx.request.method === "OPTIONS") {
|
||||
ctx.response.status = 204;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
@@ -93,19 +104,21 @@ export async function cors(ctx: Context, next: Next): Promise<void> {
|
||||
export async function requestLogger(ctx: Context, next: Next): Promise<void> {
|
||||
const start = Date.now();
|
||||
const { method, url } = ctx.request;
|
||||
|
||||
|
||||
await next();
|
||||
|
||||
|
||||
const ms = Date.now() - start;
|
||||
const status = ctx.response.status;
|
||||
|
||||
|
||||
// Color code based on status
|
||||
let statusColor = "\x1b[32m"; // Green for 2xx
|
||||
if (status >= 400 && status < 500) statusColor = "\x1b[33m"; // Yellow for 4xx
|
||||
if (status >= 500) statusColor = "\x1b[31m"; // Red for 5xx
|
||||
|
||||
|
||||
console.log(
|
||||
`${new Date().toISOString()} - ${method} ${url.pathname} ${statusColor}${status}\x1b[0m ${ms}ms`
|
||||
`${
|
||||
new Date().toISOString()
|
||||
} - ${method} ${url.pathname} ${statusColor}${status}\x1b[0m ${ms}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,18 +138,32 @@ export function isValidEmail(email: string): boolean {
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
export function isStrongPassword(password: string): { valid: boolean; message?: string } {
|
||||
export function isStrongPassword(
|
||||
password: string,
|
||||
): { valid: boolean; message?: string } {
|
||||
if (password.length < 8) {
|
||||
return { valid: false, message: "Password must be at least 8 characters long" };
|
||||
return {
|
||||
valid: false,
|
||||
message: "Password must be at least 8 characters long",
|
||||
};
|
||||
}
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
return { valid: false, message: "Password must contain at least one uppercase letter" };
|
||||
return {
|
||||
valid: false,
|
||||
message: "Password must contain at least one uppercase letter",
|
||||
};
|
||||
}
|
||||
if (!/[a-z]/.test(password)) {
|
||||
return { valid: false, message: "Password must contain at least one lowercase letter" };
|
||||
return {
|
||||
valid: false,
|
||||
message: "Password must contain at least one lowercase letter",
|
||||
};
|
||||
}
|
||||
if (!/[0-9]/.test(password)) {
|
||||
return { valid: false, message: "Password must contain at least one number" };
|
||||
return {
|
||||
valid: false,
|
||||
message: "Password must contain at least one number",
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user