diff --git a/docker-compose.yml b/docker-compose.yml index d33a323..43e2190 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,24 @@ services: ports: - ${PGADMIN_PORT}:80 + redis: + image: redis:7.2.4 + restart: always + ports: + - ${REDIS_PORT}:6379 + command: redis-server --save 20 1 --loglevel warning --requirepass ${REDIS_PASSWORD} + volumes: + - redis-cache:/data + + redisInsight: + container_name: redis-insight + image: redislabs/redisinsight + restart: always + ports: + - ${REDIS_INSIGHT_PORT}:8001 + volumes: postgres-data-volume: - external: true \ No newline at end of file + external: true + redis-cache: + external: true diff --git a/server/@types/session.d.ts b/server/@types/session.d.ts new file mode 100644 index 0000000..67c31d2 --- /dev/null +++ b/server/@types/session.d.ts @@ -0,0 +1,7 @@ +import "express-session"; + +declare module "express-session" { + interface SessionData { + userId: string | undefined; + } +} diff --git a/server/package-lock.json b/server/package-lock.json index 5a51a1f..9c7362a 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,15 +11,19 @@ "dependencies": { "@prisma/client": "^5.9.1", "argon2": "^0.31.2", + "connect-redis": "^7.1.1", "dotenv": "^16.4.1", "envalid": "^8.0.0", "express": "^4.18.2", + "express-session": "^1.18.0", "http-errors": "^2.0.0", "morgan": "^1.10.0", + "redis": "^4.6.12", "zod": "^3.22.4" }, "devDependencies": { "@types/express": "^4.17.21", + "@types/express-session": "^1.17.10", "@types/http-errors": "^2.0.4", "@types/morgan": "^1.9.9", "@types/node": "^20.11.16", @@ -351,6 +355,59 @@ "@prisma/debug": "5.9.1" } }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.13.tgz", + "integrity": "sha512-epkUM9D0Sdmt93/8Ozk43PNjLi36RZzG+d/T1Gdu5AI8jvghonTeLYV69WVWdilvFo+PYxbP0TZ0saMvr6nscQ==", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.6.tgz", + "integrity": "sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.6.tgz", + "integrity": "sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.5.tgz", + "integrity": "sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -418,6 +475,15 @@ "@types/send": "*" } }, + "node_modules/@types/express-session": { + "version": "1.17.10", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.17.10.tgz", + "integrity": "sha512-U32bC/s0ejXijw5MAzyaV4tuZopCh/K7fPoUDyNbsRXHvPSeymygYD1RFL99YOLhF5PNOkzswvOTRaVHdL1zMw==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", @@ -1169,6 +1235,14 @@ "node": ">=10" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1200,6 +1274,17 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/connect-redis": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-7.1.1.tgz", + "integrity": "sha512-M+z7alnCJiuzKa8/1qAYdGUXHYfDnLolOGAUjOioB07pP39qxjG+X9ibsud7qUBc4jMV5Mcy3ugGv8eFcgamJQ==", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "express-session": ">=1" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -1630,6 +1715,37 @@ "node": ">= 0.10.0" } }, + "node_modules/express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==", + "dependencies": { + "cookie": "0.6.0", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1834,6 +1950,14 @@ "node": ">=10" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "engines": { + "node": ">= 4" + } + }, "node_modules/get-intrinsic": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", @@ -2819,6 +2943,14 @@ } ] }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2866,6 +2998,19 @@ "node": ">=8.10.0" } }, + "node_modules/redis": { + "version": "4.6.12", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.12.tgz", + "integrity": "sha512-41Xuuko6P4uH4VPe5nE3BqXHB7a9lkFL0J29AlxKaIfD6eWO8VO/5PDF9ad2oS+mswMsfFxaM5DlE3tnXT+P8Q==", + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.5.13", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.6", + "@redis/search": "1.1.6", + "@redis/time-series": "1.0.5" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3319,6 +3464,17 @@ "node": ">=14.17" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/server/package.json b/server/package.json index e745546..cbd3dc8 100644 --- a/server/package.json +++ b/server/package.json @@ -12,6 +12,7 @@ "license": "ISC", "devDependencies": { "@types/express": "^4.17.21", + "@types/express-session": "^1.17.10", "@types/http-errors": "^2.0.4", "@types/morgan": "^1.9.9", "@types/node": "^20.11.16", @@ -26,11 +27,14 @@ "dependencies": { "@prisma/client": "^5.9.1", "argon2": "^0.31.2", + "connect-redis": "^7.1.1", "dotenv": "^16.4.1", "envalid": "^8.0.0", "express": "^4.18.2", + "express-session": "^1.18.0", "http-errors": "^2.0.0", "morgan": "^1.10.0", + "redis": "^4.6.12", "zod": "^3.22.4" } } diff --git a/server/src/app.ts b/server/src/app.ts index 1e08597..18dcd86 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -3,15 +3,49 @@ import "dotenv/config"; import createHttpError, { isHttpError } from "http-errors"; import express, { NextFunction, Request, Response } from "express"; +import RedisStore from "connect-redis"; import { ZodError } from "zod"; +import { createClient } from "redis"; +import env from "./utils/validateEnv"; import morgan from "morgan"; +import session from "express-session"; import usersRouter from "./routes/users"; const app = express(); +// Redis +const redisClient = createClient({ + url: env.REDIS_URL, +}); +redisClient.connect().catch(console.error); + +// Initialize redis store +const redisStore = new RedisStore({ + client: redisClient, + prefix: "lanten:", +}); + +// Middlewares app.use(morgan("dev")); app.use(express.json()); +// Session middleware with redis +app.use( + session({ + store: redisStore, + resave: false, + saveUninitialized: false, + secret: env.SESSION_SECRET, + rolling: true, + cookie: { + maxAge: 1000 * 60 * 60 * 24 * 5, // 15 days + httpOnly: true, + secure: false, + sameSite: "lax", + }, + }) +); + // Endpoints app.get("/", (req, res) => { res.send("Hello World"); @@ -20,6 +54,7 @@ app.get("/", (req, res) => { // Routers app.use("/users", usersRouter); +// Error handling app.use((req, res, next) => { next(createHttpError(404, "Endpoint not found")); }); diff --git a/server/src/controllers/users.ts b/server/src/controllers/users.ts index 22d046d..ec6c2f8 100644 --- a/server/src/controllers/users.ts +++ b/server/src/controllers/users.ts @@ -55,6 +55,9 @@ export const signUp: RequestHandler = async (req, res, next) => { }, }); + // Create session + req.session.userId = user.id; + res.status(201).json(user); } catch (error) { next(error); @@ -83,9 +86,47 @@ export const login: RequestHandler = async (req, res, next) => { throw createHttpError(401, "Invalid email or password"); } + // Create session + req.session.userId = user.id; + // Return user const { id, name, userType } = user; - res.json({ id, email, name, userType }); + res.status(201).json({ id, email, name, userType }); + } catch (error) { + next(error); + } +}; + +export const logout: RequestHandler = (req, res, next) => { + req.session.destroy((error) => { + if (error) { + next(error); + } else { + res.sendStatus(204); + } + }); +}; + +export const me: RequestHandler = async (req, res, next) => { + try { + if (!req.session.userId) { + throw createHttpError(401, "Not authenticated"); + } + + const user = await prisma.user.findUnique({ + where: { id: req.session.userId }, + select: { + email: true, + name: true, + id: true, + userType: true, + }, + }); + if (!user) { + throw createHttpError(401, "Not authenticated"); + } + + res.json(user); } catch (error) { next(error); } diff --git a/server/src/middleware/requiresAuth.ts b/server/src/middleware/requiresAuth.ts index 23d0450..8baadca 100644 --- a/server/src/middleware/requiresAuth.ts +++ b/server/src/middleware/requiresAuth.ts @@ -2,13 +2,10 @@ import { RequestHandler } from "express"; import createHttpError from "http-errors"; export const requiresAuth: RequestHandler = (req, res, next) => { - // todo: - next(); - - // Check if user is authenticated - // if (req.session.userId) { - // next(); - // } else { - // next(createHttpError(401, "User not authenticated")); - // } + // Check authenticated user + if (req.session.userId) { + next(); + } else { + next(createHttpError(401, "User not authenticated")); + } }; diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 1c19f1c..b04d608 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -5,7 +5,9 @@ import { requiresAuth } from "../middleware/requiresAuth"; const router = express.Router(); -router.post("/signup", requiresAuth, UserController.signUp); +router.post("/signup", UserController.signUp); router.post("/login", UserController.login); +router.post("/logout", UserController.logout); +router.get("/me", requiresAuth, UserController.me); export default router; diff --git a/server/src/utils/validateEnv.ts b/server/src/utils/validateEnv.ts index 6765cd9..cf02d31 100644 --- a/server/src/utils/validateEnv.ts +++ b/server/src/utils/validateEnv.ts @@ -1,6 +1,9 @@ +import { port, str, url } from "envalid/dist/validators"; + import { cleanEnv } from "envalid"; -import { port } from "envalid/dist/validators"; export default cleanEnv(process.env, { PORT: port(), + REDIS_URL: url(), + SESSION_SECRET: str(), }); diff --git a/server/tsconfig.json b/server/tsconfig.json index 6bc9e20..2e3e6f5 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -31,7 +31,10 @@ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "typeRoots": [ + "node_modules/@types", + "@types" + ], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ @@ -105,5 +108,8 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "ts-node": { + "files": true } }