diff --git a/package-lock.json b/package-lock.json index 9125d04..a57fe61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/ws": "^8.5.10", "axios": "^1.7.7", "bcrypt": "^5.1.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.4", "express": "^5.1.0", @@ -29,6 +30,7 @@ "ws": "8.17.1" }, "devDependencies": { + "@types/cookie-parser": "^1.4.9", "@types/cors": "^2.8.17", "@types/supertest": "^6.0.3", "@vitest/coverage-v8": "^3.2.4", @@ -1280,6 +1282,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -2173,6 +2185,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -6951,6 +6982,13 @@ "@types/node": "*" } }, + "@types/cookie-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", + "dev": true, + "requires": {} + }, "@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -7605,6 +7643,22 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" }, + "cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "requires": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + } + } + }, "cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", diff --git a/package.json b/package.json index 8f4bbba..8f17f56 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@types/ws": "^8.5.10", "axios": "^1.7.7", "bcrypt": "^5.1.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.4", "express": "^5.1.0", @@ -36,6 +37,7 @@ "ws": "8.17.1" }, "devDependencies": { + "@types/cookie-parser": "^1.4.9", "@types/cors": "^2.8.17", "@types/supertest": "^6.0.3", "@vitest/coverage-v8": "^3.2.4", diff --git a/src/index.ts b/src/index.ts index 846b1ad..14cd02e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,12 +8,15 @@ import cors from "cors"; import {SpotifyTokenGenerator} from "./rest/spotifyTokenGenerator"; import {RestAuth} from "./rest/auth"; import { config } from "./config"; +import cookieParser from 'cookie-parser'; import {authLimiter, spotifyLimiter} from "./rest/middleware/rateLimit"; +import {cookieJwtAuth} from "./rest/middleware/cookieAuth"; const app = express(); const port = config.port; app.set("trust proxy", 1); +app.use(cookieParser()); app.use(cors({ origin: config.cors.origin, @@ -48,6 +51,7 @@ const spotify = new SpotifyTokenGenerator(); app.use("/api/auth", authLimiter, auth.createRouter()); +app.use(cookieJwtAuth); app.use("/api/spotify", authenticateJwt, spotifyLimiter, spotify.createRouter()); app.use("/api/websocket", authenticateJwt, restWebSocket.createRouter()); diff --git a/src/rest/auth.ts b/src/rest/auth.ts index 722f5e4..8faf41c 100644 --- a/src/rest/auth.ts +++ b/src/rest/auth.ts @@ -81,10 +81,25 @@ export class RestAuth { uuid: user.uuid }); + res.cookie('auth-token', jwtToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 24 * 60 * 60 * 1000 + }); + return ok(res, { token: jwtToken }); }) ); + router.post( + "/logout", + asyncHandler(async (req, res) => { + res.clearCookie('auth-token'); + return ok(res, { message: "Successfully logged out" }); + }) + ); + return router; } } \ No newline at end of file diff --git a/src/rest/middleware/cookieAuth.ts b/src/rest/middleware/cookieAuth.ts new file mode 100644 index 0000000..976f6e6 --- /dev/null +++ b/src/rest/middleware/cookieAuth.ts @@ -0,0 +1,15 @@ +import { Request, Response, NextFunction } from 'express'; + +export const cookieJwtAuth = (req: Request, res: Response, next: NextFunction) => { + if (req.headers.authorization) { + return next(); + } + + const token = req.cookies['auth-token']; + + if (token) { + req.headers.authorization = `Bearer ${token}`; + } + + next(); +}; \ No newline at end of file diff --git a/tests/rest/auth.test.ts b/tests/rest/auth.test.ts index 70a61ce..de67c62 100644 --- a/tests/rest/auth.test.ts +++ b/tests/rest/auth.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import {describe, it, expect, vi, beforeEach, afterEach} from "vitest"; import request from "supertest"; import express from "express"; -import { RestAuth } from "../../src/rest/auth"; -import { UserService } from "../../src/db/services/db/UserService"; -import { JwtAuthenticator } from "../../src/utils/jwtAuthenticator"; -import { PasswordUtils } from "../../src/utils/passwordUtils"; +import {RestAuth} from "../../src/rest/auth"; +import {UserService} from "../../src/db/services/db/UserService"; +import {JwtAuthenticator} from "../../src/utils/jwtAuthenticator"; +import {PasswordUtils} from "../../src/utils/passwordUtils"; import {createMockJwtAuthenticator, createMockUserService, createPublicTestApp} from "../helpers/testSetup"; import crypto from "crypto"; @@ -75,11 +75,11 @@ describe("RestAuth", () => { uuid: mockUUID, timezone: "Europe/Berlin", location: "Berlin, Germany", - config: { isVisible: false, isAdmin: false, canBeModified: false }, + config: {isVisible: false, isAdmin: false, canBeModified: false}, }; mockUserService.existsUserByName.mockResolvedValue(false); - mockPasswordUtils.validatePassword.mockReturnValue({ valid: true }); + mockPasswordUtils.validatePassword.mockReturnValue({valid: true}); mockPasswordUtils.hashPassword.mockResolvedValue(hashedPassword); mockCrypto.randomUUID.mockReturnValue(mockUUID); mockUserService.createUser.mockResolvedValue(createdUser); @@ -92,7 +92,7 @@ describe("RestAuth", () => { name: "testuser", password: hashedPassword, uuid: mockUUID, - config: { isVisible: false, isAdmin: false, canBeModified: false }, + config: {isVisible: false, isAdmin: false, canBeModified: false}, timezone: "Europe/Berlin", location: "Berlin, Germany", }); @@ -118,12 +118,12 @@ describe("RestAuth", () => { }); it.each([ - { field: "username" }, - { field: "password" }, - { field: "timezone" }, - { field: "location" }, - ])("should return bad request when $field is missing", async ({ field }) => { - const invalidData = { ...validRegistrationData }; + {field: "username"}, + {field: "password"}, + {field: "timezone"}, + {field: "location"}, + ])("should return bad request when $field is missing", async ({field}) => { + const invalidData = {...validRegistrationData}; delete (invalidData as any)[field]; const response = await request(app).post("/auth/register").send(invalidData).expect(400); @@ -132,16 +132,16 @@ describe("RestAuth", () => { }); it.each([ - { field: "username", value: "", message: "username" }, - { field: "username", value: "ab", message: "username" }, - { field: "password", value: "", message: "password" }, - { field: "password", value: "short", message: "password" }, - { field: "timezone", value: "", message: "timezone" }, - { field: "location", value: "", message: "location" }, - ])("should return bad request for invalid value in $field", async ({ field, value, message }) => { + {field: "username", value: "", message: "username"}, + {field: "username", value: "ab", message: "username"}, + {field: "password", value: "", message: "password"}, + {field: "password", value: "short", message: "password"}, + {field: "timezone", value: "", message: "timezone"}, + {field: "location", value: "", message: "location"}, + ])("should return bad request for invalid value in $field", async ({field, value, message}) => { const response = await request(app) .post("/auth/register") - .send({ ...validRegistrationData, [field]: value }) + .send({...validRegistrationData, [field]: value}) .expect(400); expect(response.body.ok).toBe(false); expect(response.body.data.details[0]).toContain(message); @@ -149,10 +149,10 @@ describe("RestAuth", () => { }); describe("POST /login", () => { - const validLoginData = { username: "testuser", password: "TestPassword123!" }; + const validLoginData = {username: "testuser", password: "TestPassword123!"}; it("should login successfully with valid credentials", async () => { - const mockUser = { name: "testuser", password: "hashed", uuid: "uuid-123", id: "user-id-123" }; + const mockUser = {name: "testuser", password: "hashed", uuid: "uuid-123", id: "user-id-123"}; const mockToken = "jwt-token-123"; mockUserService.getUserAuthByName.mockResolvedValue(mockUser); @@ -168,58 +168,88 @@ describe("RestAuth", () => { id: "user-id-123", uuid: "uuid-123", }); + + const cookieHeader = response.headers['set-cookie']; + expect(cookieHeader).toBeDefined(); + + const cookies = Array.isArray(cookieHeader) ? cookieHeader : [cookieHeader!]; + + const authTokenCookie = cookies.find((cookie: string) => cookie.startsWith("auth-token=")); + expect(authTokenCookie).toBeDefined(); + expect(authTokenCookie).toContain(`auth-token=${mockToken}`); + expect(authTokenCookie).toContain("HttpOnly"); + expect(authTokenCookie).toContain("Path=/"); + expect(authTokenCookie).toContain("SameSite=Lax"); }); - it("should handle user with _id instead of id", async () => { - const mockUser = { name: "testuser", password: "hashed", uuid: "uuid-123", _id: "user-id-123" }; - const mockToken = "jwt-token-123"; + describe("POST /logout", () => { + it("should clear the auth-token cookie and return a success message", async () => { + const response = await request(app).post("/auth/logout").send().expect(200); - mockUserService.getUserAuthByName.mockResolvedValue(mockUser); - mockPasswordUtils.comparePassword.mockResolvedValue(true); - mockJwtAuthenticator.generateToken.mockReturnValue(mockToken); + expect(response.body.ok).toBe(true); + expect(response.body.data.message).toBe("Successfully logged out"); - await request(app).post("/auth/login").send(validLoginData).expect(200); + const cookieHeader = response.headers['set-cookie']; + expect(cookieHeader).toBeDefined(); + const cookies = Array.isArray(cookieHeader) ? cookieHeader : [cookieHeader!]; - expect(mockJwtAuthenticator.generateToken).toHaveBeenCalledWith({ - username: "testuser", - id: "user-id-123", - uuid: "uuid-123", + const authTokenCookie = cookies.find((cookie: string) => cookie.startsWith("auth-token=")); + expect(authTokenCookie).toBeDefined(); + expect(authTokenCookie).toContain("auth-token=;"); + expect(authTokenCookie).toContain("Expires=Thu, 01 Jan 1970 00:00:00 GMT"); }); - }); - it("should return not found when user does not exist", async () => { - mockUserService.getUserAuthByName.mockResolvedValue(null); - const response = await request(app).post("/auth/login").send(validLoginData).expect(404); - expect(response.body.ok).toBe(false); - expect(response.body.data.message).toBe("User not found"); - }); + it("should handle user with _id instead of id", async () => { + const mockUser = {name: "testuser", password: "hashed", uuid: "uuid-123", _id: "user-id-123"}; + const mockToken = "jwt-token-123"; - it("should return unauthorized for invalid password", async () => { - const mockUser = { name: "testuser", password: "hashed" }; - mockUserService.getUserAuthByName.mockResolvedValue(mockUser); - mockPasswordUtils.comparePassword.mockResolvedValue(false); - const response = await request(app).post("/auth/login").send(validLoginData).expect(401); - expect(response.body.ok).toBe(false); - expect(response.body.data.message).toBe("Invalid password"); - }); + mockUserService.getUserAuthByName.mockResolvedValue(mockUser); + mockPasswordUtils.comparePassword.mockResolvedValue(true); + mockJwtAuthenticator.generateToken.mockReturnValue(mockToken); - it.each([ - { field: "username", value: "" }, - { field: "password", value: "" }, - { field: "username", value: undefined }, - { field: "password", value: undefined }, - ])("should return bad request if $field is '$value'", async ({ field, value }) => { - const invalidData = { ...validLoginData }; - if (value === undefined) { - delete (invalidData as any)[field]; - } else { - (invalidData as any)[field] = value; - } + await request(app).post("/auth/login").send(validLoginData).expect(200); - const response = await request(app).post("/auth/login").send(invalidData).expect(400); + expect(mockJwtAuthenticator.generateToken).toHaveBeenCalledWith({ + username: "testuser", + id: "user-id-123", + uuid: "uuid-123", + }); + }); - expect(response.body.ok).toBe(false); - expect(response.body.data.details[0]).toContain(field); + it("should return not found when user does not exist", async () => { + mockUserService.getUserAuthByName.mockResolvedValue(null); + const response = await request(app).post("/auth/login").send(validLoginData).expect(404); + expect(response.body.ok).toBe(false); + expect(response.body.data.message).toBe("User not found"); + }); + + it("should return unauthorized for invalid password", async () => { + const mockUser = {name: "testuser", password: "hashed"}; + mockUserService.getUserAuthByName.mockResolvedValue(mockUser); + mockPasswordUtils.comparePassword.mockResolvedValue(false); + const response = await request(app).post("/auth/login").send(validLoginData).expect(401); + expect(response.body.ok).toBe(false); + expect(response.body.data.message).toBe("Invalid password"); + }); + + it.each([ + {field: "username", value: ""}, + {field: "password", value: ""}, + {field: "username", value: undefined}, + {field: "password", value: undefined}, + ])("should return bad request if $field is '$value'", async ({field, value}) => { + const invalidData = {...validLoginData}; + if (value === undefined) { + delete (invalidData as any)[field]; + } else { + (invalidData as any)[field] = value; + } + + const response = await request(app).post("/auth/login").send(invalidData).expect(400); + + expect(response.body.ok).toBe(false); + expect(response.body.data.details[0]).toContain(field); + }); }); }); }); \ No newline at end of file diff --git a/tests/rest/middleware/cookieAuth.test.ts b/tests/rest/middleware/cookieAuth.test.ts new file mode 100644 index 0000000..d45b45b --- /dev/null +++ b/tests/rest/middleware/cookieAuth.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from "vitest"; +import { Request, Response, NextFunction } from "express"; +import {cookieJwtAuth} from "../../../src/rest/middleware/cookieAuth"; + +describe("cookieJwtAuth Middleware", () => { + it("should do nothing if Authorization header already exists", () => { + const req = { + headers: { + authorization: "Bearer existing-token", + }, + cookies: { + "auth-token": "some-cookie-token", + }, + } as unknown as Request; + const res = {} as Response; + const next = vi.fn() as NextFunction; + + cookieJwtAuth(req, res, next); + + expect(req.headers.authorization).toBe("Bearer existing-token"); + expect(next).toHaveBeenCalledOnce(); + }); + + it("should add Authorization header if it is missing but cookie exists", () => { + const req = { + headers: {}, + cookies: { + "auth-token": "my-secret-cookie-token", + }, + } as unknown as Request; + const res = {} as Response; + const next = vi.fn() as NextFunction; + + cookieJwtAuth(req, res, next); + + expect(req.headers.authorization).toBe("Bearer my-secret-cookie-token"); + expect(next).toHaveBeenCalledOnce(); + }); + + it("should do nothing if neither Authorization header nor cookie exist", () => { + const req = { + headers: {}, + cookies: {}, + } as unknown as Request; + const res = {} as Response; + const next = vi.fn() as NextFunction; + + cookieJwtAuth(req, res, next); + + expect(req.headers.authorization).toBeUndefined(); + expect(next).toHaveBeenCalledOnce(); + }); +}); \ No newline at end of file