Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/api-gateway/gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import express from "express";

import authRouter from "../modules/auth/auth.routes";
import sessionRouter from "../modules/sessions/session.routes";

import { accountRateLimit } from "./rate-limit.middleware";
import { authMiddleware } from "./auth.middleware";

const app = express();

app.use(express.json());

app.use(
"/auth",
accountRateLimit({
limit: 5,
windowSeconds: 15 * 60,
key: (req) => `auth:${req.ip}`,
}),
authRouter
);

app.use(
"/sessions",
authMiddleware,
accountRateLimit({
limit: 10,
windowSeconds: 60 * 60,
key: (req) => `session:${req.context.userId}`,
}),
sessionRouter
);

export default app;
30 changes: 30 additions & 0 deletions src/api-gateway/rate-limit.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Request, Response, NextFunction } from "express";
import { RateLimitCache } from "../cache/rateLimit.cache";

interface RateLimitOptions {
limit: number;
windowSeconds: number;
key: (req: Request) => string;
}

export function accountRateLimit(options: RateLimitOptions) {
return async (req: Request, res: Response, next: NextFunction) => {
const redisKey = options.key(req);

const count = await RateLimitCache.increment(
redisKey,
options.windowSeconds
);

if (count > options.limit) {
const retryAfter = await RateLimitCache.ttl(redisKey);

return res.status(429).json({
error: "Too many requests",
retryAfter,
});
}

next();
};
}
17 changes: 17 additions & 0 deletions src/cache/rateLimit.cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { redis } from "./redis.client";

export class RateLimitCache {
static async increment(key: string, windowSeconds: number) {
const count = await redis.incr(key);

if (count === 1) {
await redis.expire(key, windowSeconds);
}

return count;
}

static async ttl(key: string) {
return redis.ttl(key);
}
}
29 changes: 2 additions & 27 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { hashPassword, verifyPassword } from "../../crypto/password";
import { createToken } from "../../crypto/jwt";
import {
createUser,
findUserByEmail,
incrementFailedAttempts,
resetFailedAttempts,
lockAccount,
Expand All @@ -13,66 +11,43 @@ import { validatePassword } from "./password.policy";
const MAX_FAILED_ATTEMPTS = 5;
const LOCK_TIME_MINUTES = 15;

/**
* SIGNUP FLOW
* Goal: make signup predictable and safe
*/
export async function signup(email: string, password: string) {
// 🔹 CHANGE: Explicitly check if user already exists
// This prevents duplicate accounts
const existingUser = await findUserByEmail(email);
if (existingUser) {
throw new Error("User already exists");
}

// 🔹 CHANGE: Enforce password policy BEFORE hashing
validatePassword(password);

// Hash password securely
const passwordHash = await hashPassword(password);

// Create user in DB
await createUser(email, passwordHash);

// Signup does not auto-login (clean separation of concerns)

return { success: true };
}

/**
* LOGIN FLOW
* Goal: stabilize login + add security (failed attempts & lock)
*/

export async function login(email: string, password: string) {
const user = await findUserByEmail(email);

// 🔹 CHANGE: Clear error for invalid credentials
if (!user) {
throw new Error("Invalid credentials");
}

// 🔹 CHANGE: Block login if account is temporarily locked
if (user.locked_until && new Date(user.locked_until) > new Date()) {
throw new Error("Account temporarily locked. Try again later.");
}

const isValid = await verifyPassword(password, user.password_hash);

if (!isValid) {
// 🔹 CHANGE: Track failed login attempts
await incrementFailedAttempts(user.id);

// 🔹 CHANGE: Lock account after max failed attempts
if (user.failed_login_attempts + 1 >= MAX_FAILED_ATTEMPTS) {
await lockAccount(user.id, LOCK_TIME_MINUTES);
}

throw new Error("Invalid credentials");
}

// 🔹 CHANGE: Reset failed attempts on successful login
await resetFailedAttempts(user.id);

// Create JWT + session
const token = createToken(user.id);
await createSession(user.id, token);

Expand Down