Add session management

This commit is contained in:
2025-01-03 12:32:43 +05:30
parent 83786e2994
commit 14c9b0210c
12 changed files with 341 additions and 16 deletions

View File

@@ -13,7 +13,10 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.717.0",
"@aws-sdk/s3-request-presigner": "^3.717.0",
"@fastify/cookie": "^11.0.1",
"@fastify/cors": "^10.0.1",
"@fastify/multipart": "^9.0.1",
"@fastify/oauth2": "^8.1.0",
"@paralleldrive/cuid2": "^2.2.2",
"bcrypt": "^5.1.1",
"fastify": "^5.2.0",

126
pnpm-lock.yaml generated
View File

@@ -14,9 +14,18 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: ^3.717.0
version: 3.717.0
'@fastify/cookie':
specifier: ^11.0.1
version: 11.0.1
'@fastify/cors':
specifier: ^10.0.1
version: 10.0.1
'@fastify/multipart':
specifier: ^9.0.1
version: 9.0.1
'@fastify/oauth2':
specifier: ^8.1.0
version: 8.1.0
'@paralleldrive/cuid2':
specifier: ^2.2.2
version: 2.2.2
@@ -232,6 +241,12 @@ packages:
'@fastify/busboy@3.1.1':
resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==}
'@fastify/cookie@11.0.1':
resolution: {integrity: sha512-n1Ooz4bgQ5LcOlJQboWPfsMNxIrGV0SgU85UkctdpTlCQE0mtA3rlspOPUdqk9ubiiZn053ucnia4DjTquI4/g==}
'@fastify/cors@10.0.1':
resolution: {integrity: sha512-O8JIf6448uQbOgzSkCqhClw6gFTAqrdfeA6R3fc/3gwTJGUp7gl8/3tbNB+6INuu4RmgVOq99BmvdGbtu5pgOA==}
'@fastify/deepmerge@2.0.1':
resolution: {integrity: sha512-hx+wJQr9Ph1hY/dyzY0SxqjumMyqZDlIF6oe71dpRKDHUg7dFQfjG94qqwQ274XRjmUrwKiYadex8XplNHx3CA==}
@@ -247,6 +262,9 @@ packages:
'@fastify/multipart@9.0.1':
resolution: {integrity: sha512-vt2gOCw/O4EwpN4KlLVJxth4iQlDf7T5ggw2Db2C+UbO2WJBG7y0jEBvu/HT6JIW/lBYaqrrUy9MmTpCKgXEpw==}
'@fastify/oauth2@8.1.0':
resolution: {integrity: sha512-SMcvStTvhF+UcyH+7uLWvKrxM4g5evZafjtQWRAg02C/dlsOrqLK0s9/1aszhWY6PBklJ6jduFPpl+sB4l2DlQ==}
'@fastify/send@2.1.0':
resolution: {integrity: sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==}
@@ -259,6 +277,24 @@ packages:
'@fastify/swagger@8.15.0':
resolution: {integrity: sha512-zy+HEEKFqPMS2sFUsQU5X0MHplhKJvWeohBwTCkBAJA/GDYGLGUWQaETEhptiqxK7Hs0fQB9B4MDb3pbwIiCwA==}
'@hapi/boom@10.0.1':
resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==}
'@hapi/bourne@3.0.0':
resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==}
'@hapi/hoek@11.0.7':
resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==}
'@hapi/hoek@9.3.0':
resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==}
'@hapi/topo@5.1.0':
resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==}
'@hapi/wreck@18.1.0':
resolution: {integrity: sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==}
'@lukeed/ms@2.0.2':
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
@@ -277,6 +313,15 @@ packages:
'@paralleldrive/cuid2@2.2.2':
resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==}
'@sideway/address@4.1.5':
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
'@sideway/formula@3.0.1':
resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==}
'@sideway/pinpoint@2.0.0':
resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==}
'@smithy/abort-controller@3.1.9':
resolution: {integrity: sha512-yiW0WI30zj8ZKoSYNx90no7ugVn3khlyH/z5W8qtKBtVE6awRALbhSG+2SAHA1r6bO/6M9utxYKVZ3PCJ1rWxw==}
engines: {node: '>=16.0.0'}
@@ -728,6 +773,9 @@ packages:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
joi@17.13.3:
resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==}
js-yaml@4.1.0:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
@@ -788,6 +836,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
mnemonist@0.39.8:
resolution: {integrity: sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==}
mongodb-connection-string-url@3.0.1:
resolution: {integrity: sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==}
@@ -861,6 +912,9 @@ packages:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
obliterator@2.0.4:
resolution: {integrity: sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
@@ -980,6 +1034,9 @@ packages:
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
simple-oauth2@5.1.0:
resolution: {integrity: sha512-gWDa38Ccm4MwlG5U7AlcJxPv3lvr80dU7ARJWrGdgvOKyzSj1gr3GBPN1rABTedAYvC/LsGYoFuFxwDBPtGEbw==}
snake-case@3.0.4:
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
@@ -1637,6 +1694,16 @@ snapshots:
'@fastify/busboy@3.1.1': {}
'@fastify/cookie@11.0.1':
dependencies:
cookie: 1.0.2
fastify-plugin: 5.0.1
'@fastify/cors@10.0.1':
dependencies:
fastify-plugin: 5.0.1
mnemonist: 0.39.8
'@fastify/deepmerge@2.0.1': {}
'@fastify/error@4.0.0': {}
@@ -1657,6 +1724,14 @@ snapshots:
fastify-plugin: 5.0.1
secure-json-parse: 3.0.1
'@fastify/oauth2@8.1.0':
dependencies:
'@fastify/cookie': 11.0.1
fastify-plugin: 5.0.1
simple-oauth2: 5.1.0
transitivePeerDependencies:
- supports-color
'@fastify/send@2.1.0':
dependencies:
'@lukeed/ms': 2.0.2
@@ -1692,6 +1767,26 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@hapi/boom@10.0.1':
dependencies:
'@hapi/hoek': 11.0.7
'@hapi/bourne@3.0.0': {}
'@hapi/hoek@11.0.7': {}
'@hapi/hoek@9.3.0': {}
'@hapi/topo@5.1.0':
dependencies:
'@hapi/hoek': 9.3.0
'@hapi/wreck@18.1.0':
dependencies:
'@hapi/boom': 10.0.1
'@hapi/bourne': 3.0.0
'@hapi/hoek': 11.0.7
'@lukeed/ms@2.0.2': {}
'@mapbox/node-pre-gyp@1.0.11':
@@ -1719,6 +1814,14 @@ snapshots:
dependencies:
'@noble/hashes': 1.6.1
'@sideway/address@4.1.5':
dependencies:
'@hapi/hoek': 9.3.0
'@sideway/formula@3.0.1': {}
'@sideway/pinpoint@2.0.0': {}
'@smithy/abort-controller@3.1.9':
dependencies:
'@smithy/types': 3.7.2
@@ -2345,6 +2448,14 @@ snapshots:
is-fullwidth-code-point@3.0.0: {}
joi@17.13.3:
dependencies:
'@hapi/hoek': 9.3.0
'@hapi/topo': 5.1.0
'@sideway/address': 4.1.5
'@sideway/formula': 3.0.1
'@sideway/pinpoint': 2.0.0
js-yaml@4.1.0:
dependencies:
argparse: 2.0.1
@@ -2404,6 +2515,10 @@ snapshots:
mkdirp@1.0.4: {}
mnemonist@0.39.8:
dependencies:
obliterator: 2.0.4
mongodb-connection-string-url@3.0.1:
dependencies:
'@types/whatwg-url': 11.0.5
@@ -2468,6 +2583,8 @@ snapshots:
object-assign@4.1.1: {}
obliterator@2.0.4: {}
on-exit-leak-free@2.1.2: {}
once@1.4.0:
@@ -2578,6 +2695,15 @@ snapshots:
signal-exit@3.0.7: {}
simple-oauth2@5.1.0:
dependencies:
'@hapi/hoek': 11.0.7
'@hapi/wreck': 18.1.0
debug: 4.4.0
joi: 17.13.3
transitivePeerDependencies:
- supports-color
snake-case@3.0.4:
dependencies:
dot-case: 3.0.4

View File

@@ -2,8 +2,11 @@ import bcrypt from "bcrypt";
import { FastifyReply, FastifyRequest } from "fastify";
import { getToken } from "./tokens/token.service";
import { Claim } from "./utils/claims";
import { OAuth2Namespace } from "@fastify/oauth2";
import { getSession } from "./auth/auth.service";
export type AuthenticatedUser = {
sid?: string;
userId?: string;
tenantId: string;
claims: Array<Claim>;
@@ -16,6 +19,7 @@ declare module "fastify" {
export interface FastifyInstance {
authorize: (req: FastifyRequest, res: FastifyReply) => Promise<unknown>;
microsoftOauth: OAuth2Namespace;
}
export interface FastifyContextConfig {
@@ -26,19 +30,46 @@ declare module "fastify" {
export async function authHandler(req: FastifyRequest, res: FastifyReply) {
if (!req.headers.authorization) return res.code(401).send();
const [tokenId, token] = req.headers.authorization.split(" ")[1].split(".");
if (!tokenId || !token) return res.code(401).send({ error: "invalid token" });
const authHeader = req.headers.authorization.split(" ")[1];
if (!authHeader || authHeader == "")
return res.code(401).send({ error: "invalid_token" });
const tokenInDb = await getToken(tokenId);
if (tokenInDb === null) return res.code(401).send({ error: "invalid token" });
if (authHeader.includes(".")) {
const [tokenId, token] = authHeader.split(".");
if (!tokenId || !token)
return res.code(401).send({ error: "invalid_token" });
const valid = await bcrypt.compare(token, tokenInDb.hash);
if (!valid) return res.code(401).send({ error: "invalid token" });
const tokenInDb = await getToken(tokenId);
if (tokenInDb === null)
return res.code(401).send({ error: "invalid_token" });
req.user = {
tenantId: tokenInDb.tenantId,
claims: tokenInDb.claims as Array<Claim>,
};
const valid = await bcrypt.compare(token, tokenInDb.hash);
if (!valid) return res.code(401).send({ error: "invalid_token" });
req.user = {
tenantId: tokenInDb.tenantId,
claims: tokenInDb.claims as Array<Claim>,
};
} else {
const sessionInDb = await getSession(authHeader);
if (sessionInDb === null)
return res.code(401).send({ error: "invalid_token" });
if (new Date() > new Date(sessionInDb.expiresAt)) {
await sessionInDb.deleteOne();
return res.code(401).send({ error: "session_expired" });
}
req.user = {
sid: authHeader,
//@ts-ignore
userId: sessionInDb.user.id,
//@ts-ignore
tenantId: sessionInDb.user.tenantId,
//@ts-ignore
claims: sessionInDb.user.claims,
};
}
}
export function hasValidClaims(

64
src/auth/auth.route.ts Normal file
View File

@@ -0,0 +1,64 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { getUserByEmail, updateUser } from "../user/user.service";
import { createSession, getSession } from "./auth.service";
export async function authRoutes(fastify: FastifyInstance) {
fastify.get(
"/microsoft/token",
{},
async (req: FastifyRequest, res: FastifyReply) => {
try {
const { token } =
await fastify.microsoftOauth.getAccessTokenFromAuthorizationCodeFlow(
req
);
const user = (await fastify.microsoftOauth.userinfo(token)) as {
givenname: string;
familyname: string;
email: string;
picture: string;
};
const userInDb = await getUserByEmail(user.email);
if (userInDb == null)
return res.code(401).send({ error: "not_allowed" });
await updateUser(userInDb.pid, {
firstName: user.givenname,
lastName: user.familyname,
email: user.email,
avatar: user.picture,
});
const session = await createSession(
userInDb.id,
req.ip,
req.headers["user-agent"]
);
return res.code(201).send({ session_token: session.sid });
} catch (err) {
//@ts-ignore
if (err.data) {
//@ts-ignore
fastify.log.warn(err.data.payload);
return res.code(400).send();
} else {
return err;
}
}
}
);
fastify.delete("/logout", {}, async (req, res) => {
if (!req.headers.authorization) return res.code(200).send();
const auth = req.headers.authorization.split(" ")[1];
const sessionInDb = await getSession(auth);
if (sessionInDb === null) return res.code(200).send();
await sessionInDb.deleteOne();
return res.code(200).send();
});
}

20
src/auth/auth.schema.ts Normal file
View File

@@ -0,0 +1,20 @@
import mongoose from "mongoose";
export const sessionModel = mongoose.model(
"session",
new mongoose.Schema({
sid: {
type: String,
unique: true,
required: true,
},
user: { type: mongoose.Types.ObjectId, ref: "user" },
ip: String,
userAgent: String,
createdAt: Date,
expiresAt: {
type: Date,
required: true,
},
})
);

24
src/auth/auth.service.ts Normal file
View File

@@ -0,0 +1,24 @@
import { generateToken } from "../utils/id";
import { sessionModel } from "./auth.schema";
export async function createSession(userId: string, ip?: string, ua?: string) {
const allUserSessions = await sessionModel.find({ user: userId });
for (const session of allUserSessions) {
await session.deleteOne();
}
const newSession = await sessionModel.create({
sid: await generateToken(),
user: userId,
ip: ip,
userAgent: ua,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 3600 * 24 * 30 * 1000),
});
return newSession;
}
export async function getSession(sessionId: string) {
return await sessionModel.findOne({ sid: sessionId }).populate("user");
}

24
src/oauth.ts Normal file
View File

@@ -0,0 +1,24 @@
import oauthPlugin, { FastifyOAuth2Options } from "@fastify/oauth2";
import { FastifyInstance } from "fastify";
export async function oauth(fastify: FastifyInstance) {
fastify.register(oauthPlugin, {
name: "microsoftOauth",
scope: ["openid", "email", "profile"],
credentials: {
client: {
id: process.env.MICROSOFT_CLIENT_ID,
secret: process.env.MICROSOFT_CLIENT_SECRET,
},
},
startRedirectPath: "/auth/microsoft",
callbackUri: process.env.MICROSOFT_REDIRECT_URI,
discovery: {
issuer:
"https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration",
},
cookie: {
sameSite: "none",
},
} as FastifyOAuth2Options);
}

View File

@@ -38,7 +38,7 @@ export async function getOrgHandler(req: FastifyRequest, res: FastifyReply) {
export async function listOrgsHandler(req: FastifyRequest, res: FastifyReply) {
const queryParams = req.query as PageQueryParams;
console.log(req.user);
try {
const authUser = req.user;
const orgList = await listOrgs(queryParams, authUser.tenantId);

View File

@@ -1,4 +1,5 @@
import fastify from "fastify";
import cors from "@fastify/cors";
import multipart from "@fastify/multipart";
import routes from "./routes";
@@ -9,6 +10,8 @@ import { errorHandler } from "./utils/errors";
import { authorize } from "./auth";
import { permitSchemas } from "./permit/permit.schema";
import { fileSchemas } from "./file/file.schema";
import { oauth } from "./oauth";
import { authRoutes } from "./auth/auth.route";
const app = fastify({ logger: true });
@@ -16,9 +19,16 @@ app.get("/health", (req, res) => {
return { status: "OK" };
});
oauth(app);
app.decorate("authorize", authorize);
app.setErrorHandler(errorHandler);
app.register(cors, {
origin: [process.env.UI_DOMAIN || ""],
credentials: true,
});
app.register(multipart, { limits: { fileSize: 50000000 } });
app.register(authRoutes, { prefix: "/auth" });
app.register(routes, { prefix: "/api/v1" });
for (const schema of [

View File

@@ -21,8 +21,7 @@ export async function getUserHandler(req: FastifyRequest, res: FastifyReply) {
const { userId } = req.params as { userId: string };
try {
const authUser = req.user;
const user = await getUser(userId, authUser.tenantId);
const user = await getUser(userId);
if (user == null)
return res.code(404).send({ error: "resource not found" });

View File

@@ -24,6 +24,7 @@ export const userModel = mongoose.model(
},
avatar: String,
status: String,
claims: [String],
createdAt: Date,
createdBy: mongoose.Types.ObjectId,
lastLogin: Date,
@@ -40,6 +41,7 @@ const userCore = {
})
.email(),
avatar: z.string().url().optional(),
claims: z.array(z.string()).optional(),
};
const createUserInput = z.object({
@@ -51,7 +53,21 @@ const createUserResponse = z.object({
...userCore,
});
const updateUserInput = z.object({
firstName: z.string().max(30).optional(),
lastName: z.string().max(30).optional(),
email: z
.string({
required_error: "Email is required",
invalid_type_error: "Email must be a valid string",
})
.email()
.optional(),
avatar: z.string().url().optional(),
});
export type CreateUserInput = z.infer<typeof createUserInput>;
export type UpdateUserInput = z.infer<typeof updateUserInput>;
export const { schemas: userSchemas, $ref: $user } = buildJsonSchemas(
{

View File

@@ -1,5 +1,5 @@
import { generateId } from "../utils/id";
import { CreateUserInput, userModel } from "./user.schema";
import { CreateUserInput, UpdateUserInput, userModel } from "./user.schema";
export async function createUser(input: CreateUserInput, tenantId: string) {
const user = await userModel.create({
@@ -13,9 +13,17 @@ export async function createUser(input: CreateUserInput, tenantId: string) {
return user;
}
export async function getUser(userId: string, tenantId: string) {
export async function getUser(userId: string) {
const user = await userModel.findOne({
$and: [{ tenantId: tenantId }, { pid: userId }],
$and: [{ pid: userId }],
});
return user;
}
export async function getUserByEmail(email: string) {
return await userModel.findOne({ email: email });
}
export async function updateUser(userId: string, input: UpdateUserInput) {
return await userModel.findOneAndUpdate({ pid: userId }, input);
}