From c07d33bcc9c8ad32dae05cefa7f0ff6d2c00942ef4caf42a474e97891829d74c Mon Sep 17 00:00:00 2001 From: aslan Date: Thu, 1 Jan 2026 17:06:31 +0100 Subject: [PATCH] Rework authentication --- env | 1 + package-lock.json | 62 ++++++++++++++++- package.json | 4 +- .../migration.sql | 10 +++ prisma/schema.prisma | 2 +- src/controllers/auth/auth.ts | 68 +++++++++++++----- src/controllers/auth/routes.ts | 1 + src/controllers/auth/types.ts | 37 ++++++---- src/controllers/test/test.ts | 38 ---------- src/controllers/user/user.ts | 2 +- src/index.ts | 14 +++- src/services/auth/auth.ts | 47 +++++++------ src/services/auth/helpers.ts | 69 +++++++++++++------ src/services/auth/types.ts | 13 +++- tests/1.user.test.js | 24 ++++++- tests/2.community.test.js | 25 +++++-- tests/api.js | 28 +++++++- 17 files changed, 317 insertions(+), 128 deletions(-) create mode 100644 prisma/migrations/20260101134725_use_session_cookie/migration.sql diff --git a/env b/env index 5534241..a9420de 100644 --- a/env +++ b/env @@ -1,2 +1,3 @@ DATABASE_URL="postgresql://tetheruser:password@localhost:5432/tetherdb" JWT_SECRET="" +COOKIE_SECRET="" diff --git a/package-lock.json b/package-lock.json index 4e8d2dc..e3cbc03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "tether", - "version": "0.2.0", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tether", - "version": "0.2.0", + "version": "0.3.4", "license": "GPL-3.0-only", "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.2.0", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "argon2": "^0.44.0", @@ -134,6 +136,46 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -1066,6 +1108,22 @@ "toad-cache": "^3.7.0" } }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", diff --git a/package.json b/package.json index 10fabb6..ade185a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tether", - "version": "0.3.4", + "version": "0.3.5", "description": "Communication server using the Nexlink protocol", "repository": { "type": "git", @@ -25,6 +25,8 @@ "typescript": "^5.9.3" }, "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.2.0", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "argon2": "^0.44.0", diff --git a/prisma/migrations/20260101134725_use_session_cookie/migration.sql b/prisma/migrations/20260101134725_use_session_cookie/migration.sql new file mode 100644 index 0000000..d488d6f --- /dev/null +++ b/prisma/migrations/20260101134725_use_session_cookie/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `token` on the `Session` table. All the data in the column will be lost. + - Added the required column `cookie` to the `Session` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Session" DROP COLUMN "token", +ADD COLUMN "cookie" TEXT NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8f3c152..c9028c6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -59,7 +59,7 @@ model Session { id String @id @unique @default(uuid()) owner User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String - token String + cookie String creationDate DateTime @default(now()) } diff --git a/src/controllers/auth/auth.ts b/src/controllers/auth/auth.ts index 24dcb0d..f6a882d 100644 --- a/src/controllers/auth/auth.ts +++ b/src/controllers/auth/auth.ts @@ -1,17 +1,23 @@ import { type FastifyReply, type FastifyRequest } from "fastify"; import type { - ILoginRequest, - IRegisterResponseError, - IRegisterResponseSuccess, - IRegisterRequest, - ILoginResponseError, - ILoginResponseSuccess, + IPostLoginRequest, + IPostRegisterResponseError, + IPostRegisterResponseSuccess, + IPostRegisterRequest, + IPostLoginResponseError, + IPostLoginResponseSuccess, + IGetRefreshResponseError, + IGetRefreshResponseSuccess, } from "./types.js"; -import { loginUser, registerUser } from "../../services/auth/auth.js"; +import { + loginUser, + refreshSession, + registerUser, +} from "../../services/auth/auth.js"; import { API_ERROR } from "../errors.js"; -const postRegister = async (request: FastifyRequest, _reply: FastifyReply) => { - const { username, password, email } = request.body as IRegisterRequest; +const postRegister = async (request: FastifyRequest, reply: FastifyReply) => { + const { username, password, email } = request.body as IPostRegisterRequest; const newUser = await registerUser({ username: username, @@ -20,20 +26,21 @@ const postRegister = async (request: FastifyRequest, _reply: FastifyReply) => { }); if (!newUser) { + reply.status(409); return { error: API_ERROR.USER_ALREADY_EXISTS, - } as IRegisterResponseError; + } as IPostRegisterResponseError; } return { id: newUser.id, username: newUser.username, registerDate: newUser.registerDate?.getTime(), - } as IRegisterResponseSuccess; + } as IPostRegisterResponseSuccess; }; -const postLogin = async (request: FastifyRequest, _reply: FastifyReply) => { - const { username, password } = request.body as ILoginRequest; +const postLogin = async (request: FastifyRequest, reply: FastifyReply) => { + const { username, password } = request.body as IPostLoginRequest; const session = await loginUser({ username: username, @@ -41,17 +48,44 @@ const postLogin = async (request: FastifyRequest, _reply: FastifyReply) => { }); if (!session) { + reply.status(403); return { username: username, error: API_ERROR.ACCESS_DENIED, - } as ILoginResponseError; + } as IPostLoginResponseError; } + reply.setCookie("token", session.cookie, { + path: "/", + httpOnly: true, + sameSite: "none", + secure: true, + maxAge: 60 * 60 * 24 * 365 * 100, + }); + return { id: session.id, ownerId: session.userId, - token: session.token, - } as ILoginResponseSuccess; + } as IPostLoginResponseSuccess; }; -export { postRegister, postLogin }; +const getRefresh = async (request: FastifyRequest, reply: FastifyReply) => { + const cookie = request.cookies["token"]; + + const refresh = await refreshSession(cookie); + + if (!refresh) { + reply.status(403); + return { + error: API_ERROR.ACCESS_DENIED, + } as IGetRefreshResponseError; + } + + return { + id: refresh[0].id, + ownerId: refresh[0].userId, + token: refresh[1], + } as IGetRefreshResponseSuccess; +}; + +export { postRegister, postLogin, getRefresh }; diff --git a/src/controllers/auth/routes.ts b/src/controllers/auth/routes.ts index 87b5bd5..f2a2f50 100644 --- a/src/controllers/auth/routes.ts +++ b/src/controllers/auth/routes.ts @@ -4,6 +4,7 @@ import * as controller from "./auth.js"; const authRoutes = async (fastify: FastifyInstance) => { fastify.post(`/register`, controller.postRegister); fastify.post(`/login`, controller.postLogin); + fastify.get(`/refresh`, controller.getRefresh); }; export { authRoutes }; diff --git a/src/controllers/auth/types.ts b/src/controllers/auth/types.ts index 3bc267f..2e18914 100644 --- a/src/controllers/auth/types.ts +++ b/src/controllers/auth/types.ts @@ -1,42 +1,53 @@ import type { API_ERROR } from "../errors.js"; -interface IRegisterRequest { +interface IPostRegisterRequest { username: string; password: string; email?: string; } -interface IRegisterResponseSuccess { +interface IPostRegisterResponseSuccess { id: string; username: string; registerDate: number; } -interface IRegisterResponseError { +interface IPostRegisterResponseError { error: API_ERROR; } -interface ILoginRequest { +interface IPostLoginRequest { username: string; password: string; } -interface ILoginResponseSuccess { +interface IPostLoginResponseSuccess { + id: string; + ownerId: string; +} + +interface IPostLoginResponseError { + username: string; + error: API_ERROR; +} + +interface IGetRefreshResponseSuccess { id: string; ownerId: string; token: string; } -interface ILoginResponseError { - username: string; +interface IGetRefreshResponseError { error: API_ERROR; } export { - type IRegisterRequest, - type IRegisterResponseSuccess, - type IRegisterResponseError, - type ILoginRequest, - type ILoginResponseSuccess, - type ILoginResponseError, + type IPostRegisterRequest, + type IPostRegisterResponseSuccess, + type IPostRegisterResponseError, + type IPostLoginRequest, + type IPostLoginResponseSuccess, + type IPostLoginResponseError, + type IGetRefreshResponseError, + type IGetRefreshResponseSuccess, }; diff --git a/src/controllers/test/test.ts b/src/controllers/test/test.ts index f56a90a..c1da3b8 100644 --- a/src/controllers/test/test.ts +++ b/src/controllers/test/test.ts @@ -1,6 +1,4 @@ import { type FastifyReply, type FastifyRequest } from "fastify"; -import { getDB } from "../../store/store.js"; -import { hashPassword } from "../../services/auth/auth.js"; import { getUserFromAuth } from "../../services/auth/helpers.js"; const getPing = async (_request: FastifyRequest, _reply: FastifyReply) => { @@ -10,42 +8,6 @@ const getPing = async (_request: FastifyRequest, _reply: FastifyReply) => { const getTest = async (_request: FastifyRequest, _reply: FastifyReply) => { const authHeader = _request.headers["authorization"]; return [{ user: await getUserFromAuth(authHeader) }]; - - const owner = await getDB().user.create({ - data: { - username: "TestUser", - passwordHash: await hashPassword("29144"), - email: "testuser@aslan2142.space", - admin: true, - }, - }); - - await getDB().community.create({ - data: { - name: "TestCommunity", - ownerId: owner.id, - members: { - connect: { - id: owner.id, - }, - create: [ - { - username: "Aslo", - passwordHash: await hashPassword("pass"), - admin: false, - }, - { - username: "Aslan", - passwordHash: await hashPassword("8556"), - email: "aslan@aslan2142.space", - admin: false, - }, - ], - }, - }, - }); - - return [{ message: "ok" }]; }; export { getPing, getTest }; diff --git a/src/controllers/user/user.ts b/src/controllers/user/user.ts index 38712c9..7d2b729 100644 --- a/src/controllers/user/user.ts +++ b/src/controllers/user/user.ts @@ -38,7 +38,7 @@ const getUserLogged = async (request: FastifyRequest, reply: FastifyReply) => { const user = await getLoggedUserAuth(authHeader); if (user === API_ERROR.ACCESS_DENIED) { - reply.status(404); + reply.status(403); return { error: API_ERROR.ACCESS_DENIED, } as IGetLoggedUserResponseError; diff --git a/src/index.ts b/src/index.ts index f203476..06c2997 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,11 @@ +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import cookie from "@fastify/cookie"; + import { config } from "./config.js"; -import Fastify from "fastify"; +import { getCookieSecret } from "./services/auth/helpers.js"; + import { testRoutes } from "./controllers/test/routes.js"; import { authRoutes } from "./controllers/auth/routes.js"; import { userRoutes } from "./controllers/user/routes.js"; @@ -14,6 +19,13 @@ const app = Fastify({ logger: true, }); +app.register(cors, { + origin: "http://localhost:3000", + credentials: true, +}); + +app.register(cookie, { secret: getCookieSecret() }); + app.register(testRoutes); app.register(authRoutes, { prefix: "/api/v1/auth" }); app.register(userRoutes, { prefix: "/api/v1/user" }); diff --git a/src/services/auth/auth.ts b/src/services/auth/auth.ts index 0de2d7e..1c3031c 100644 --- a/src/services/auth/auth.ts +++ b/src/services/auth/auth.ts @@ -1,10 +1,12 @@ -import argon2 from "argon2"; -import jwt from "jsonwebtoken"; - import type { User, Session } from "../../generated/prisma/client.js"; import { getDB } from "../../store/store.js"; +import { + createSessionCookie, + createToken, + hashPassword, + verifyPassword, +} from "./helpers.js"; import type { IUserLogin, IUserRegistration } from "./types.js"; -import { getJwtSecret } from "./helpers.js"; const registerUser = async ( registration: IUserRegistration, @@ -50,11 +52,7 @@ const loginUser = async (login: IUserLogin): Promise => { return null; } - const passwordCorrect = await argon2.verify( - user.passwordHash, - login.password, - ); - if (!passwordCorrect) { + if (!(await verifyPassword(user.passwordHash, login.password))) { return null; } @@ -69,23 +67,30 @@ const loginUser = async (login: IUserLogin): Promise => { return await getDB().session.create({ data: { - token: createToken(user.id), + cookie: createSessionCookie(), userId: user.id, }, }); }; -const hashPassword = async (password: string): Promise => { - return await argon2.hash(password, { - type: argon2.argon2id, - memoryCost: 2 ** 16, - timeCost: 4, - parallelism: 1, +const refreshSession = async ( + cookie: string | undefined, +): Promise<[Session, string] | null> => { + if (!cookie) { + return null; + } + + const session = await getDB().session.findFirst({ + where: { + cookie: cookie, + }, }); + + if (!session) { + return null; + } + + return [session, createToken(session.id)]; }; -const createToken = (userId: string) => { - return jwt.sign({ sub: userId }, getJwtSecret()); -}; - -export { registerUser, loginUser, hashPassword }; +export { registerUser, loginUser, refreshSession }; diff --git a/src/services/auth/helpers.ts b/src/services/auth/helpers.ts index 9c667c1..aa0379c 100644 --- a/src/services/auth/helpers.ts +++ b/src/services/auth/helpers.ts @@ -1,39 +1,60 @@ +import argon2 from "argon2"; import jwt from "jsonwebtoken"; -import type { - Community, - Session, - User, -} from "../../generated/prisma/client.js"; +import crypto from "crypto"; +import type { Community, User } from "../../generated/prisma/client.js"; import { getDB } from "../../store/store.js"; -import type { IOwnerCheck } from "./types.js"; +import type { AccessTokenPayload, IOwnerCheck } from "./types.js"; import type { PERMISSION } from "./permission.js"; -const getJwtSecret = () => { +const getJwtSecret = (): string => { return process.env.JWT_SECRET || ""; }; -const verifyToken = (token: string): string | jwt.JwtPayload | null => { +const getCookieSecret = (): string => { + return process.env.COOKIE_SECRET || ""; +}; + +const createSessionCookie = () => { + return crypto.randomBytes(32).toString("hex"); +}; + +const createToken = (sessionId: string) => { + return jwt.sign({ sessionId: sessionId }, getJwtSecret()); +}; + +const verifyToken = (token: string): string | null => { try { - return jwt.verify(token, getJwtSecret()); + const payload = jwt.verify(token, getJwtSecret()) as AccessTokenPayload; + return payload.sessionId; } catch { return null; } }; -const getSessionFromToken = async (token: string): Promise => { - return await getDB().session.findFirst({ - where: { - token: token, - }, +const hashPassword = async (password: string): Promise => { + return await argon2.hash(password, { + type: argon2.argon2id, + memoryCost: 2 ** 16, + timeCost: 4, + parallelism: 1, }); }; -const getUserFromToken = async (token: string): Promise => { - const session = await getSessionFromToken(token); +const verifyPassword = async ( + passwordHash: string, + passwordToCheck: string, +) => { + return await argon2.verify(passwordHash, passwordToCheck); +}; +const getUserBySessionId = async (sessionId: string): Promise => { return await getDB().user.findFirst({ where: { - id: session?.userId ?? "invalid", + Session: { + some: { + id: sessionId, + }, + }, }, }); }; @@ -43,12 +64,12 @@ const getUserFromAuth = async ( ): Promise => { const token = authHeader?.replace("Bearer ", ""); - const verified = verifyToken(token ?? "") !== null; - if (!verified || !token) { + const sessionId = verifyToken(token ?? ""); + if (!sessionId || !token) { return null; } - const user = await getUserFromToken(token); + const user = await getUserBySessionId(sessionId); if (!user) { return null; } @@ -187,9 +208,13 @@ const isUserInCommunity = async ( export { getJwtSecret, + getCookieSecret, + createSessionCookie, + createToken, verifyToken, - getSessionFromToken, - getUserFromToken, + hashPassword, + verifyPassword, + getUserBySessionId, getUserFromAuth, isUserOwnerOrAdmin, getUserPermissions, diff --git a/src/services/auth/types.ts b/src/services/auth/types.ts index 4265074..b9d2f4e 100644 --- a/src/services/auth/types.ts +++ b/src/services/auth/types.ts @@ -1,3 +1,4 @@ +import type { JwtPayload } from "jsonwebtoken"; import type { Channel, Community, @@ -6,7 +7,10 @@ import type { Session, User, } from "../../generated/prisma/client.js"; -import type { PERMISSION } from "./permission.js"; + +interface AccessTokenPayload extends JwtPayload { + sessionId: string; +} interface IUserRegistration { username: string; @@ -28,4 +32,9 @@ interface IOwnerCheck { role?: Role | null; } -export { type IUserRegistration, type IUserLogin, type IOwnerCheck }; +export { + type AccessTokenPayload, + type IUserRegistration, + type IUserLogin, + type IOwnerCheck, +}; diff --git a/tests/1.user.test.js b/tests/1.user.test.js index 2e99532..6ca4464 100644 --- a/tests/1.user.test.js +++ b/tests/1.user.test.js @@ -1,7 +1,14 @@ import assert from "node:assert"; import { test } from "node:test"; import { validate } from "uuid"; -import { apiGet, apiPost, apiPatch, apiDelete } from "./api.js"; +import { + apiCookie, + apiToken, + apiGet, + apiPost, + apiPatch, + apiDelete, +} from "./api.js"; const state = {}; @@ -40,14 +47,25 @@ test("shouldn't be able to login", async () => { }); test("can login", async () => { - const response = await apiPost(`auth/login`, { + const responseArray = await apiCookie(`auth/login`, { username: state.username, password: state.password, }); + const response = responseArray[0]; + state.cookie = responseArray[1]; + + assert.equal(validate(response.id), true); + assert.equal(validate(response.ownerId), true); + assert.equal(response.ownerId, state.userId); + + state.sessionId = response.id; +}); + +test("can get access token", async () => { + const response = await apiToken(`auth/refresh`, state.cookie); assert.equal(validate(response.id), true); assert.equal(validate(response.ownerId), true); - assert.equal(response.token.length > 0, true); assert.equal(response.ownerId, state.userId); state.sessionId = response.id; diff --git a/tests/2.community.test.js b/tests/2.community.test.js index cd9b914..1209248 100644 --- a/tests/2.community.test.js +++ b/tests/2.community.test.js @@ -1,7 +1,14 @@ import assert from "node:assert"; import { test } from "node:test"; import { validate } from "uuid"; -import { apiGet, apiPost, apiPatch, apiDelete } from "./api.js"; +import { + apiCookie, + apiToken, + apiGet, + apiPost, + apiPatch, + apiDelete, +} from "./api.js"; const state = {}; @@ -30,19 +37,27 @@ test("can create community", async () => { }); state.userId2 = responseRegister2.id; - const responseLogin1 = await apiPost(`auth/login`, { + const responseLogin1Array = await apiCookie(`auth/login`, { username: state.username1, password: state.password1, }); + const responseLogin1 = responseLogin1Array[0]; + state.cookie1 = responseLogin1Array[1]; state.sessionId1 = responseLogin1.id; - state.token1 = responseLogin1.token; - const responseLogin2 = await apiPost(`auth/login`, { + const responseLogin2Array = await apiCookie(`auth/login`, { username: state.username2, password: state.password2, }); + const responseLogin2 = responseLogin2Array[0]; + state.cookie2 = responseLogin2Array[1]; state.sessionId2 = responseLogin2.id; - state.token2 = responseLogin2.token; + + const responseRefresh1 = await apiToken(`auth/refresh`, state.cookie1); + state.token1 = responseRefresh1.token; + + const responseRefresh2 = await apiToken(`auth/refresh`, state.cookie2); + state.token2 = responseRefresh2.token; const responseCreate = await apiPost( `community`, diff --git a/tests/api.js b/tests/api.js index 2cfb511..4f11075 100644 --- a/tests/api.js +++ b/tests/api.js @@ -2,6 +2,32 @@ import config from "../src/config.json" with { type: "json" }; const url = `http://localhost:${config.port}/api/v1`; +const apiCookie = async (endpoint, request) => { + const response = await fetch(`${url}/${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + + const responseCookie = response.headers.getSetCookie().at(0) ?? ""; + + return [await response.json(), responseCookie]; +}; + +const apiToken = async (endpoint, cookie) => { + const response = await fetch(`${url}/${endpoint}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Cookie: cookie, + }, + }); + + return await response.json(); +}; + const apiGet = async (endpoint, token) => { const response = await fetch(`${url}/${endpoint}`, { method: "GET", @@ -53,4 +79,4 @@ const apiDelete = async (endpoint, request, token) => { return await response.json(); }; -export { apiGet, apiPost, apiPatch, apiDelete }; +export { apiCookie, apiToken, apiGet, apiPost, apiPatch, apiDelete };