aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/server/routes
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/routes')
-rw-r--r--src/server/routes/auth.test.ts50
-rw-r--r--src/server/routes/auth.ts116
2 files changed, 113 insertions, 53 deletions
diff --git a/src/server/routes/auth.test.ts b/src/server/routes/auth.test.ts
index 5bf9f86..c3b0158 100644
--- a/src/server/routes/auth.test.ts
+++ b/src/server/routes/auth.test.ts
@@ -62,6 +62,56 @@ describe("POST /login", () => {
app.route("/api/auth", auth);
});
+ it("returns rate limit headers on login request", async () => {
+ vi.mocked(mockUserRepo.findByUsername).mockResolvedValue(undefined);
+
+ const res = await app.request("/api/auth/login", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Forwarded-For": "192.168.1.1",
+ },
+ body: JSON.stringify({
+ username: "testuser",
+ password: "somepassword",
+ }),
+ });
+
+ expect(res.headers.get("RateLimit-Limit")).toBe("5");
+ expect(res.headers.get("RateLimit-Remaining")).toBeDefined();
+ });
+
+ it("returns 429 after exceeding rate limit", async () => {
+ vi.mocked(mockUserRepo.findByUsername).mockResolvedValue(undefined);
+
+ const makeRequest = () =>
+ app.request("/api/auth/login", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Forwarded-For": "10.0.0.1",
+ },
+ body: JSON.stringify({
+ username: "testuser",
+ password: "wrongpassword",
+ }),
+ });
+
+ // Make 5 requests (the limit)
+ for (let i = 0; i < 5; i++) {
+ const res = await makeRequest();
+ expect(res.status).toBe(401);
+ }
+
+ // 6th request should be rate limited
+ const rateLimitedRes = await makeRequest();
+ expect(rateLimitedRes.status).toBe(429);
+ const body = (await rateLimitedRes.json()) as {
+ error?: { code: string; message: string };
+ };
+ expect(body.error?.code).toBe("RATE_LIMIT_EXCEEDED");
+ });
+
it("returns access token for valid credentials", async () => {
vi.mocked(mockUserRepo.findByUsername).mockResolvedValue({
id: "user-uuid-123",
diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts
index 06c88a6..73deb83 100644
--- a/src/server/routes/auth.ts
+++ b/src/server/routes/auth.ts
@@ -3,7 +3,7 @@ import { zValidator } from "@hono/zod-validator";
import * as argon2 from "argon2";
import { Hono } from "hono";
import { sign } from "hono/jwt";
-import { Errors } from "../middleware/index.js";
+import { Errors, loginRateLimiter } from "../middleware/index.js";
import {
type RefreshTokenRepository,
refreshTokenRepository,
@@ -39,63 +39,73 @@ export function createAuthRouter(deps: AuthDependencies) {
const { userRepo, refreshTokenRepo } = deps;
return new Hono()
- .post("/login", zValidator("json", loginSchema), async (c) => {
- const { username, password } = c.req.valid("json");
-
- // Find user by username
- const user = await userRepo.findByUsername(username);
-
- if (!user) {
- throw Errors.unauthorized(
- "Invalid username or password",
- "INVALID_CREDENTIALS",
+ .post(
+ "/login",
+ loginRateLimiter,
+ zValidator("json", loginSchema),
+ async (c) => {
+ const { username, password } = c.req.valid("json");
+
+ // Find user by username
+ const user = await userRepo.findByUsername(username);
+
+ if (!user) {
+ throw Errors.unauthorized(
+ "Invalid username or password",
+ "INVALID_CREDENTIALS",
+ );
+ }
+
+ // Verify password
+ const isPasswordValid = await argon2.verify(
+ user.passwordHash,
+ password,
);
- }
-
- // Verify password
- const isPasswordValid = await argon2.verify(user.passwordHash, password);
- if (!isPasswordValid) {
- throw Errors.unauthorized(
- "Invalid username or password",
- "INVALID_CREDENTIALS",
+ if (!isPasswordValid) {
+ throw Errors.unauthorized(
+ "Invalid username or password",
+ "INVALID_CREDENTIALS",
+ );
+ }
+
+ // Generate JWT access token
+ const now = Math.floor(Date.now() / 1000);
+ const accessToken = await sign(
+ {
+ sub: user.id,
+ iat: now,
+ exp: now + ACCESS_TOKEN_EXPIRES_IN,
+ },
+ getJwtSecret(),
);
- }
-
- // Generate JWT access token
- const now = Math.floor(Date.now() / 1000);
- const accessToken = await sign(
- {
- sub: user.id,
- iat: now,
- exp: now + ACCESS_TOKEN_EXPIRES_IN,
- },
- getJwtSecret(),
- );
- // Generate refresh token
- const refreshToken = generateRefreshToken();
- const tokenHash = hashToken(refreshToken);
- const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000);
-
- // Store refresh token in database
- await refreshTokenRepo.create({
- userId: user.id,
- tokenHash,
- expiresAt,
- });
+ // Generate refresh token
+ const refreshToken = generateRefreshToken();
+ const tokenHash = hashToken(refreshToken);
+ const expiresAt = new Date(
+ Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000,
+ );
- return c.json(
- {
- accessToken,
- refreshToken,
- user: {
- id: user.id,
- username: user.username,
+ // Store refresh token in database
+ await refreshTokenRepo.create({
+ userId: user.id,
+ tokenHash,
+ expiresAt,
+ });
+
+ return c.json(
+ {
+ accessToken,
+ refreshToken,
+ user: {
+ id: user.id,
+ username: user.username,
+ },
},
- },
- 200,
- );
- })
+ 200,
+ );
+ },
+ )
.post("/refresh", zValidator("json", refreshTokenSchema), async (c) => {
const { refreshToken } = c.req.valid("json");
const tokenHash = hashToken(refreshToken);