use cookie to store the bearer token
This commit is contained in:
Generated
+54
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
+95
-65
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user