feat: enhance authentication and user management with token-based system

- Implemented robust token-based authentication with access and refresh tokens
- Added JWT token generation, verification, and rotation mechanisms
- Created services for token management, Memcached, and MinIO storage
- Enhanced user registration and login with device-specific tokens
- Added support for profile picture upload and management via MinIO
- Implemented secure password hashing with crypto
- Updated Prisma schema to support refresh tokens and profile picture storage
- Added GraphQL mutations for logout, token refresh, and profile picture handling
- Integrated environment configuration with Zod validation
- Improved error handling and authentication middleware
This commit is contained in:
Juan Sebastián Montoya 2025-03-09 22:34:57 -05:00
parent d4d99fb5e7
commit d29d116214
22 changed files with 1992 additions and 388 deletions

View file

@ -10,7 +10,7 @@
"test": "ts-node --test test/**/*.test.ts", "test": "ts-node --test test/**/*.test.ts",
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "nodemon --delay 2000ms src/index.ts", "dev": "nodemon --delay 2000ms src/index.ts",
"prisma:generate": "dotenv -e ../../.env.local -- prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "dotenv -e ../../.env.local -- prisma migrate dev", "prisma:migrate": "dotenv -e ../../.env.local -- prisma migrate dev",
"prisma:init": "dotenv -e ../../.env.local -- prisma migrate dev --name init", "prisma:init": "dotenv -e ../../.env.local -- prisma migrate dev --name init",
"build": "tsc" "build": "tsc"
@ -19,19 +19,25 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.0.0", "@fastify/cors": "^11.0.0",
"@keyv/memcache": "^2.0.1",
"@prisma/client": "^6.4.1", "@prisma/client": "^6.4.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"fastify": "^5.2.1", "fastify": "^5.2.1",
"fastify-plugin": "^5.0.0", "fastify-plugin": "^5.0.0",
"graphql": "^16.10.0", "graphql": "^16.10.0",
"jsonwebtoken": "^9.0.2",
"mercurius": "^16.1.0", "mercurius": "^16.1.0",
"mercurius-codegen": "^6.0.1", "mercurius-codegen": "^6.0.1",
"minio": "^8.0.4",
"uuid": "^11.1.0",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@repo/eslint-config": "*", "@repo/eslint-config": "*",
"@repo/typescript-config": "*", "@repo/typescript-config": "*",
"@types/jsonwebtoken": "^9.0.9",
"prisma": "^6.4.1", "prisma": "^6.4.1",
"typescript": "5.8.2" "typescript": "5.8.2"
} }

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE `Message` MODIFY `content` TEXT NOT NULL;

View file

@ -3,7 +3,8 @@ CREATE TABLE `User` (
`id` VARCHAR(191) NOT NULL, `id` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL, `email` VARCHAR(191) NOT NULL,
`username` VARCHAR(191) NOT NULL, `username` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL, `password` TEXT NOT NULL,
`s3ProfilePicObjectKey` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL, `updatedAt` DATETIME(3) NOT NULL,
@ -28,7 +29,7 @@ CREATE TABLE `Room` (
-- CreateTable -- CreateTable
CREATE TABLE `Message` ( CREATE TABLE `Message` (
`id` VARCHAR(191) NOT NULL, `id` VARCHAR(191) NOT NULL,
`content` VARCHAR(191) NOT NULL, `content` TEXT NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL, `updatedAt` DATETIME(3) NOT NULL,
`userId` VARCHAR(191) NOT NULL, `userId` VARCHAR(191) NOT NULL,
@ -37,6 +38,21 @@ CREATE TABLE `Message` (
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `RefreshToken` (
`id` VARCHAR(191) NOT NULL,
`jti` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
`deviceId` VARCHAR(191) NOT NULL,
`hash` VARCHAR(191) NOT NULL,
`expiresAt` DATETIME(3) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `RefreshToken_jti_key`(`jti`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable -- CreateTable
CREATE TABLE `_RoomMembers` ( CREATE TABLE `_RoomMembers` (
`A` VARCHAR(191) NOT NULL, `A` VARCHAR(191) NOT NULL,
@ -55,6 +71,9 @@ ALTER TABLE `Message` ADD CONSTRAINT `Message_userId_fkey` FOREIGN KEY (`userId`
-- AddForeignKey -- AddForeignKey
ALTER TABLE `Message` ADD CONSTRAINT `Message_roomId_fkey` FOREIGN KEY (`roomId`) REFERENCES `Room`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE `Message` ADD CONSTRAINT `Message_roomId_fkey` FOREIGN KEY (`roomId`) REFERENCES `Room`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `RefreshToken` ADD CONSTRAINT `RefreshToken_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE `_RoomMembers` ADD CONSTRAINT `_RoomMembers_A_fkey` FOREIGN KEY (`A`) REFERENCES `Room`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE `_RoomMembers` ADD CONSTRAINT `_RoomMembers_A_fkey` FOREIGN KEY (`A`) REFERENCES `Room`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -14,15 +14,17 @@ datasource db {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
username String @unique username String @unique
password String password String @db.Text
createdAt DateTime @default(now()) s3ProfilePicObjectKey String?
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
messages Message[] updatedAt DateTime @updatedAt
rooms Room[] @relation("RoomMembers") messages Message[]
ownedRooms Room[] @relation("RoomOwner") rooms Room[] @relation("RoomMembers")
ownedRooms Room[] @relation("RoomOwner")
refreshTokens RefreshToken[]
} }
model Room { model Room {
@ -48,3 +50,15 @@ model Message {
roomId String roomId String
room Room @relation(fields: [roomId], references: [id]) room Room @relation(fields: [roomId], references: [id])
} }
model RefreshToken {
id String @id @default(uuid())
jti String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deviceId String
hash String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

68
apps/api/src/config.ts Normal file
View file

@ -0,0 +1,68 @@
import { z } from 'zod';
import dotenv from 'dotenv';
import path from 'path';
const rootDir = path.resolve(process.cwd(), '../../');
dotenv.config({ path: `${rootDir}/.env.local` });
const schema = z
.object({
ALLOWED_ORIGINS: z.string(),
API_HOST: z.string(),
API_PORT: z.coerce.number(),
COOKIE_SECRET: z.string(),
MINIO_ENDPOINT: z.string(),
MINIO_PORT: z.coerce.number(),
MINIO_ACCESS_KEY: z.string(),
MINIO_SECRET_KEY: z.string(),
MINIO_BUCKET_NAME: z.string(),
MINIO_REGION: z.string().default('us-east-1'),
MINIO_USE_SSL: z.string().transform((val) => val === 'true'),
MEMC_HOST: z.string(),
MEMC_PORT: z.coerce.number(),
MEMC_TTL: z.coerce.number().default(30 * 60), // 30 minutes in seconds
MEMC_NAMESPACE: z.string().default('unreal-chat'),
NODE_ENV: z.enum(['development', 'staging', 'production']),
TOKEN_ACCESS_EXPIRES_IN: z.coerce.number().default(30 * 60), // 30 minutes in seconds
TOKEN_REFRESH_EXPIRES_IN: z.coerce.number().default(7 * 24 * 60 * 60), // 7 days in seconds
TOKEN_SECRET: z.string(),
})
.transform((env) => {
return {
isProduction: env.NODE_ENV === 'production',
server: {
allowedOrigins: env.ALLOWED_ORIGINS.split(','),
port: env.API_PORT,
host: env.API_HOST,
},
minio: {
endPoint: env.MINIO_ENDPOINT,
port: env.MINIO_PORT,
useSSL: env.MINIO_USE_SSL,
accessKey: env.MINIO_ACCESS_KEY,
secretKey: env.MINIO_SECRET_KEY,
bucketName: env.MINIO_BUCKET_NAME,
region: env.MINIO_REGION,
},
token: {
accessTokenExpiresIn: env.TOKEN_ACCESS_EXPIRES_IN,
refreshTokenExpiresIn: env.TOKEN_REFRESH_EXPIRES_IN,
secret: env.TOKEN_SECRET,
},
memc: {
host: env.MEMC_HOST,
port: env.MEMC_PORT,
ttl: env.MEMC_TTL,
namespace: env.MEMC_NAMESPACE,
},
cookie: {
secret: env.COOKIE_SECRET,
},
};
});
export type Config = z.infer<typeof schema>;
export type MinioConfig = Config['minio'];
export type TokenConfig = Config['token'];
export type MemcConfig = Config['memc'];
export default schema.parse(process.env);

View file

@ -42,6 +42,7 @@ export type User = {
id: Scalars["ID"]; id: Scalars["ID"];
email: Scalars["String"]; email: Scalars["String"];
username: Scalars["String"]; username: Scalars["String"];
s3ProfilePicObjectKey?: Maybe<Scalars["String"]>;
createdAt: Scalars["DateTime"]; createdAt: Scalars["DateTime"];
updatedAt?: Maybe<Scalars["DateTime"]>; updatedAt?: Maybe<Scalars["DateTime"]>;
messages?: Maybe<Array<Message>>; messages?: Maybe<Array<Message>>;
@ -74,10 +75,23 @@ export type Message = {
room?: Maybe<Room>; room?: Maybe<Room>;
}; };
export type RefreshTokenPayload = {
__typename?: "RefreshTokenPayload";
accessToken: Scalars["String"];
refreshToken: Scalars["String"];
};
export type AuthPayload = { export type AuthPayload = {
__typename?: "AuthPayload"; __typename?: "AuthPayload";
token: Scalars["String"];
user: User; user: User;
accessToken: Scalars["String"];
refreshToken: Scalars["String"];
};
export type PresignedUrl = {
__typename?: "PresignedUrl";
url: Scalars["String"];
expiresIn: Scalars["Int"];
}; };
export type Query = { export type Query = {
@ -88,6 +102,8 @@ export type Query = {
rooms: Array<Room>; rooms: Array<Room>;
room?: Maybe<Room>; room?: Maybe<Room>;
messages: Array<Message>; messages: Array<Message>;
getProfilePicUploadUrl: PresignedUrl;
getProfilePicUrl?: Maybe<Scalars["String"]>;
}; };
export type QueryuserArgs = { export type QueryuserArgs = {
@ -102,25 +118,43 @@ export type QuerymessagesArgs = {
roomId: Scalars["ID"]; roomId: Scalars["ID"];
}; };
export type QuerygetProfilePicUploadUrlArgs = {
fileExtension: Scalars["String"];
};
export type Mutation = { export type Mutation = {
__typename?: "Mutation"; __typename?: "Mutation";
register: AuthPayload; register: AuthPayload;
login: AuthPayload; login: AuthPayload;
logout: Scalars["Boolean"];
refreshToken: AuthPayload;
logoutAllDevices: Scalars["Boolean"];
createRoom: Room; createRoom: Room;
joinRoom: Room; joinRoom: Room;
leaveRoom: Scalars["Boolean"]; leaveRoom: Scalars["Boolean"];
sendMessage: Message; sendMessage: Message;
deleteProfilePic: Scalars["Boolean"];
}; };
export type MutationregisterArgs = { export type MutationregisterArgs = {
email: Scalars["String"]; email: Scalars["String"];
username: Scalars["String"]; username: Scalars["String"];
password: Scalars["String"]; password: Scalars["String"];
deviceId: Scalars["String"];
}; };
export type MutationloginArgs = { export type MutationloginArgs = {
email: Scalars["String"]; email: Scalars["String"];
password: Scalars["String"]; password: Scalars["String"];
deviceId: Scalars["String"];
};
export type MutationlogoutArgs = {
deviceId: Scalars["String"];
};
export type MutationrefreshTokenArgs = {
deviceId: Scalars["String"];
}; };
export type MutationcreateRoomArgs = { export type MutationcreateRoomArgs = {
@ -260,7 +294,10 @@ export type ResolversTypes = {
Room: ResolverTypeWrapper<Room>; Room: ResolverTypeWrapper<Room>;
Boolean: ResolverTypeWrapper<Scalars["Boolean"]>; Boolean: ResolverTypeWrapper<Scalars["Boolean"]>;
Message: ResolverTypeWrapper<Message>; Message: ResolverTypeWrapper<Message>;
RefreshTokenPayload: ResolverTypeWrapper<RefreshTokenPayload>;
AuthPayload: ResolverTypeWrapper<AuthPayload>; AuthPayload: ResolverTypeWrapper<AuthPayload>;
PresignedUrl: ResolverTypeWrapper<PresignedUrl>;
Int: ResolverTypeWrapper<Scalars["Int"]>;
Query: ResolverTypeWrapper<{}>; Query: ResolverTypeWrapper<{}>;
Mutation: ResolverTypeWrapper<{}>; Mutation: ResolverTypeWrapper<{}>;
Subscription: ResolverTypeWrapper<{}>; Subscription: ResolverTypeWrapper<{}>;
@ -275,7 +312,10 @@ export type ResolversParentTypes = {
Room: Room; Room: Room;
Boolean: Scalars["Boolean"]; Boolean: Scalars["Boolean"];
Message: Message; Message: Message;
RefreshTokenPayload: RefreshTokenPayload;
AuthPayload: AuthPayload; AuthPayload: AuthPayload;
PresignedUrl: PresignedUrl;
Int: Scalars["Int"];
Query: {}; Query: {};
Mutation: {}; Mutation: {};
Subscription: {}; Subscription: {};
@ -294,6 +334,11 @@ export type UserResolvers<
id?: Resolver<ResolversTypes["ID"], ParentType, ContextType>; id?: Resolver<ResolversTypes["ID"], ParentType, ContextType>;
email?: Resolver<ResolversTypes["String"], ParentType, ContextType>; email?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
username?: Resolver<ResolversTypes["String"], ParentType, ContextType>; username?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
s3ProfilePicObjectKey?: Resolver<
Maybe<ResolversTypes["String"]>,
ParentType,
ContextType
>;
createdAt?: Resolver<ResolversTypes["DateTime"], ParentType, ContextType>; createdAt?: Resolver<ResolversTypes["DateTime"], ParentType, ContextType>;
updatedAt?: Resolver< updatedAt?: Resolver<
Maybe<ResolversTypes["DateTime"]>, Maybe<ResolversTypes["DateTime"]>,
@ -371,13 +416,34 @@ export type MessageResolvers<
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}; };
export type RefreshTokenPayloadResolvers<
ContextType = MercuriusContext,
ParentType extends
ResolversParentTypes["RefreshTokenPayload"] = ResolversParentTypes["RefreshTokenPayload"],
> = {
accessToken?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
refreshToken?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type AuthPayloadResolvers< export type AuthPayloadResolvers<
ContextType = MercuriusContext, ContextType = MercuriusContext,
ParentType extends ParentType extends
ResolversParentTypes["AuthPayload"] = ResolversParentTypes["AuthPayload"], ResolversParentTypes["AuthPayload"] = ResolversParentTypes["AuthPayload"],
> = { > = {
token?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
user?: Resolver<ResolversTypes["User"], ParentType, ContextType>; user?: Resolver<ResolversTypes["User"], ParentType, ContextType>;
accessToken?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
refreshToken?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type PresignedUrlResolvers<
ContextType = MercuriusContext,
ParentType extends
ResolversParentTypes["PresignedUrl"] = ResolversParentTypes["PresignedUrl"],
> = {
url?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
expiresIn?: Resolver<ResolversTypes["Int"], ParentType, ContextType>;
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}; };
@ -407,6 +473,17 @@ export type QueryResolvers<
ContextType, ContextType,
RequireFields<QuerymessagesArgs, "roomId"> RequireFields<QuerymessagesArgs, "roomId">
>; >;
getProfilePicUploadUrl?: Resolver<
ResolversTypes["PresignedUrl"],
ParentType,
ContextType,
RequireFields<QuerygetProfilePicUploadUrlArgs, "fileExtension">
>;
getProfilePicUrl?: Resolver<
Maybe<ResolversTypes["String"]>,
ParentType,
ContextType
>;
}; };
export type MutationResolvers< export type MutationResolvers<
@ -418,13 +495,33 @@ export type MutationResolvers<
ResolversTypes["AuthPayload"], ResolversTypes["AuthPayload"],
ParentType, ParentType,
ContextType, ContextType,
RequireFields<MutationregisterArgs, "email" | "username" | "password"> RequireFields<
MutationregisterArgs,
"email" | "username" | "password" | "deviceId"
>
>; >;
login?: Resolver< login?: Resolver<
ResolversTypes["AuthPayload"], ResolversTypes["AuthPayload"],
ParentType, ParentType,
ContextType, ContextType,
RequireFields<MutationloginArgs, "email" | "password"> RequireFields<MutationloginArgs, "email" | "password" | "deviceId">
>;
logout?: Resolver<
ResolversTypes["Boolean"],
ParentType,
ContextType,
RequireFields<MutationlogoutArgs, "deviceId">
>;
refreshToken?: Resolver<
ResolversTypes["AuthPayload"],
ParentType,
ContextType,
RequireFields<MutationrefreshTokenArgs, "deviceId">
>;
logoutAllDevices?: Resolver<
ResolversTypes["Boolean"],
ParentType,
ContextType
>; >;
createRoom?: Resolver< createRoom?: Resolver<
ResolversTypes["Room"], ResolversTypes["Room"],
@ -450,6 +547,11 @@ export type MutationResolvers<
ContextType, ContextType,
RequireFields<MutationsendMessageArgs, "content" | "roomId"> RequireFields<MutationsendMessageArgs, "content" | "roomId">
>; >;
deleteProfilePic?: Resolver<
ResolversTypes["Boolean"],
ParentType,
ContextType
>;
}; };
export type SubscriptionResolvers< export type SubscriptionResolvers<
@ -483,7 +585,9 @@ export type Resolvers<ContextType = MercuriusContext> = {
User?: UserResolvers<ContextType>; User?: UserResolvers<ContextType>;
Room?: RoomResolvers<ContextType>; Room?: RoomResolvers<ContextType>;
Message?: MessageResolvers<ContextType>; Message?: MessageResolvers<ContextType>;
RefreshTokenPayload?: RefreshTokenPayloadResolvers<ContextType>;
AuthPayload?: AuthPayloadResolvers<ContextType>; AuthPayload?: AuthPayloadResolvers<ContextType>;
PresignedUrl?: PresignedUrlResolvers<ContextType>;
Query?: QueryResolvers<ContextType>; Query?: QueryResolvers<ContextType>;
Mutation?: MutationResolvers<ContextType>; Mutation?: MutationResolvers<ContextType>;
Subscription?: SubscriptionResolvers<ContextType>; Subscription?: SubscriptionResolvers<ContextType>;
@ -515,6 +619,12 @@ export interface Loaders<
id?: LoaderResolver<Scalars["ID"], User, {}, TContext>; id?: LoaderResolver<Scalars["ID"], User, {}, TContext>;
email?: LoaderResolver<Scalars["String"], User, {}, TContext>; email?: LoaderResolver<Scalars["String"], User, {}, TContext>;
username?: LoaderResolver<Scalars["String"], User, {}, TContext>; username?: LoaderResolver<Scalars["String"], User, {}, TContext>;
s3ProfilePicObjectKey?: LoaderResolver<
Maybe<Scalars["String"]>,
User,
{},
TContext
>;
createdAt?: LoaderResolver<Scalars["DateTime"], User, {}, TContext>; createdAt?: LoaderResolver<Scalars["DateTime"], User, {}, TContext>;
updatedAt?: LoaderResolver<Maybe<Scalars["DateTime"]>, User, {}, TContext>; updatedAt?: LoaderResolver<Maybe<Scalars["DateTime"]>, User, {}, TContext>;
messages?: LoaderResolver<Maybe<Array<Message>>, User, {}, TContext>; messages?: LoaderResolver<Maybe<Array<Message>>, User, {}, TContext>;
@ -550,9 +660,30 @@ export interface Loaders<
room?: LoaderResolver<Maybe<Room>, Message, {}, TContext>; room?: LoaderResolver<Maybe<Room>, Message, {}, TContext>;
}; };
RefreshTokenPayload?: {
accessToken?: LoaderResolver<
Scalars["String"],
RefreshTokenPayload,
{},
TContext
>;
refreshToken?: LoaderResolver<
Scalars["String"],
RefreshTokenPayload,
{},
TContext
>;
};
AuthPayload?: { AuthPayload?: {
token?: LoaderResolver<Scalars["String"], AuthPayload, {}, TContext>;
user?: LoaderResolver<User, AuthPayload, {}, TContext>; user?: LoaderResolver<User, AuthPayload, {}, TContext>;
accessToken?: LoaderResolver<Scalars["String"], AuthPayload, {}, TContext>;
refreshToken?: LoaderResolver<Scalars["String"], AuthPayload, {}, TContext>;
};
PresignedUrl?: {
url?: LoaderResolver<Scalars["String"], PresignedUrl, {}, TContext>;
expiresIn?: LoaderResolver<Scalars["Int"], PresignedUrl, {}, TContext>;
}; };
} }
declare module "mercurius" { declare module "mercurius" {

View file

@ -1,58 +1,82 @@
import dotenv from 'dotenv';
import './types'; import './types';
import fastify, { FastifyRequest } from 'fastify'; import fastify, { FastifyReply, FastifyRequest } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyCors from '@fastify/cors';
import mercurius from 'mercurius'; import mercurius from 'mercurius';
import mercuriusCodegen from 'mercurius-codegen'; import mercuriusCodegen from 'mercurius-codegen';
import schema from './schema';
import { resolvers } from './resolvers';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import fastifyCors from '@fastify/cors'; import config from './config';
import { z } from 'zod'; import { resolvers } from './resolvers';
import schema from './schema';
import { MemcService, MinioService, TokenService } from './services';
dotenv.config({ path: '../../.env.local' }); const {
server: { allowedOrigins, ...serverConfig },
const { allowedOrigins, port, host } = z minio: minioConfig,
.object({ token: tokenConfig,
ALLOWED_ORIGINS: z.string(), memc: memcConfig,
API_HOST: z.string(), cookie: cookieConfig,
API_PORT: z.coerce.number(), } = config;
})
.transform((env) => {
return {
allowedOrigins: env.ALLOWED_ORIGINS.split(','),
port: env.API_PORT,
host: env.API_HOST,
};
})
.parse(process.env);
const app = fastify({ const app = fastify({
logger: true, logger: true,
exposeHeadRoutes: true,
}); });
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const minio = new MinioService(minioConfig);
const memc = new MemcService(memcConfig);
const token = new TokenService({ ...tokenConfig, memc, prisma });
const context = async (req: FastifyRequest) => { setInterval(
const userId = (req.headers['user-id'] as string) || null; () => {
token.cleanupExpiredTokens().catch((err) => {
app.log.error('Failed to clean up expired tokens:', err);
});
},
60 * 60 * 1000
); // 1 hour
const context = async (req: FastifyRequest, reply: FastifyReply) => {
const { authorization } = req.headers;
const accessToken = authorization?.replace('Bearer ', '');
const jwt = await token.verifyAccessToken(accessToken);
await minio.initialize();
return { return {
prisma, prisma,
userId, jwt,
minio,
token,
memc,
req,
reply,
}; };
}; };
app.register(fastifyCookie, {
secret: cookieConfig.secret,
parseOptions: {
secure: config.isProduction,
httpOnly: config.isProduction,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
signed: true,
},
});
app.register(fastifyCors, { app.register(fastifyCors, {
origin: (origin, callback) => { origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) return callback(null, true); if (!origin || allowedOrigins.includes(origin)) return callback(null, true);
return callback(new Error('Not allowed'), false); return callback(new Error('Not allowed'), false);
}, },
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'user-id'], allowedHeaders: ['Content-Type', 'Authorization'],
}); });
app.register(mercurius, { app.register(mercurius, {
schema, schema,
subscription: true, subscription: true,
jit: 1,
cache: true,
context, context,
}); });
@ -75,10 +99,10 @@ mercuriusCodegen(app, {
}, },
}); });
app.listen({ host, port }, (err, address) => { app.listen({ ...serverConfig }, (err, address) => {
if (err) { if (err) {
console.error(err); console.error(err);
process.exit(1); process.exit(1);
} }
console.log(`Server listening at ${address}`); console.log(`Server listening at ${address} with config:`, config);
}); });

View file

@ -1,112 +1,72 @@
import { GraphQLError } from 'graphql'; import { GraphQLError } from 'graphql';
import { IResolvers, withFilter } from 'mercurius'; import { IResolvers, withFilter } from 'mercurius';
import { MutationResolvers, QueryResolvers } from '../generated/graphql';
import { withAuth } from '../utils';
export const MESSAGE_ADDED = 'MESSAGE_ADDED'; export const MESSAGE_ADDED = 'MESSAGE_ADDED';
export const messageResolvers: IResolvers = { export const messageResolvers: IResolvers = {
Query: { Query: {
messages: async (_, { roomId }, { prisma, userId }) => { messages: withAuth<QueryResolvers['messages']>(
if (!userId) { async (_, { roomId }, { prisma, jwt }) => {
throw new GraphQLError('You must be logged in to view messages', { const room = await prisma.room.findUnique({
extensions: { where: { id: roomId, members: { some: { id: jwt.sub } } },
code: 'UNAUTHENTICATED', });
},
if (!room) {
throw new GraphQLError('Room not found', {
extensions: {
code: 'NOT_FOUND',
},
});
}
return prisma.message.findMany({
where: { roomId },
orderBy: { createdAt: 'asc' },
}); });
} }
),
// Check if user is a member of the room
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { members: true },
});
if (!room) {
throw new GraphQLError('Room not found', {
extensions: {
code: 'NOT_FOUND',
},
});
}
const isMember = room.members.some(
(member: { id: string }) => member.id === userId
);
if (!isMember) {
throw new GraphQLError('You are not a member of this room', {
extensions: {
code: 'FORBIDDEN',
},
});
}
return prisma.message.findMany({
where: { roomId },
orderBy: { createdAt: 'asc' },
});
},
}, },
Mutation: { Mutation: {
sendMessage: async ( sendMessage: withAuth<MutationResolvers['sendMessage']>(
_: any, async (_, { content, roomId }, { prisma, jwt, pubsub }) => {
{ content, roomId }: { content: string; roomId: string }, const room = await prisma.room.findUnique({
{ prisma, userId, pubsub } where: { id: roomId, members: { some: { id: jwt.sub } } },
) => { });
if (!userId) {
throw new GraphQLError('You must be logged in to send a message', { if (!room) {
extensions: { throw new GraphQLError('Room not found', {
code: 'UNAUTHENTICATED', extensions: {
code: 'NOT_FOUND',
},
});
}
const message = await prisma.message.create({
data: {
content,
user: {
connect: { id: jwt.sub },
},
room: {
connect: { id: roomId },
},
},
include: {
user: true,
room: true,
}, },
}); });
}
// Check if user is a member of the room pubsub.publish({
const room = await prisma.room.findUnique({ topic: MESSAGE_ADDED,
where: { id: roomId }, payload: { messageAdded: message, roomId },
include: { members: true },
});
if (!room) {
throw new GraphQLError('Room not found', {
extensions: {
code: 'NOT_FOUND',
},
}); });
return message;
} }
),
const isMember = room.members.some(
(member: { id: string }) => member.id === userId
);
if (!isMember) {
throw new GraphQLError('You are not a member of this room', {
extensions: {
code: 'FORBIDDEN',
},
});
}
const message = await prisma.message.create({
data: {
content,
user: {
connect: { id: userId },
},
room: {
connect: { id: roomId },
},
},
include: {
user: true,
room: true,
},
});
pubsub.publish({
topic: MESSAGE_ADDED,
payload: { messageAdded: message, roomId },
});
return message;
},
}, },
Subscription: { Subscription: {
messageAdded: { messageAdded: {
@ -119,21 +79,17 @@ export const messageResolvers: IResolvers = {
}, },
}, },
Message: { Message: {
user: async (parent, _, { prisma }) => { user: async (parent, _, { prisma }) =>
if (parent.user) { parent.user
return parent.user; ? parent.user
} : prisma.user.findUnique({
return prisma.user.findUnique({ where: { id: parent.userId },
where: { id: parent.userId }, }),
}); room: async (parent, _, { prisma }) =>
}, parent.room
room: async (parent, _, { prisma }) => { ? parent.room
if (parent.room) { : prisma.room.findUnique({
return parent.room; where: { id: parent.roomId },
} }),
return prisma.room.findUnique({
where: { id: parent.roomId },
});
},
}, },
}; };

View file

@ -1,5 +1,7 @@
import { GraphQLError } from 'graphql'; import { GraphQLError } from 'graphql';
import { IResolvers } from 'mercurius'; import { IResolvers } from 'mercurius';
import { withAuth } from '../utils';
import { MutationResolvers } from '../generated/graphql';
export const ROOM_ADDED = 'ROOM_ADDED'; export const ROOM_ADDED = 'ROOM_ADDED';
export const ROOM_UPDATED = 'ROOM_UPDATED'; export const ROOM_UPDATED = 'ROOM_UPDATED';
@ -11,176 +13,133 @@ export const roomResolvers: IResolvers = {
where: { isPrivate: false }, where: { isPrivate: false },
}); });
}, },
room: async (_: any, { id }: { id: string }, { prisma }) => { room: async (_, { id }, { prisma }) => {
return prisma.room.findUnique({ return prisma.room.findUnique({
where: { id }, where: { id },
}); });
}, },
}, },
Mutation: { Mutation: {
createRoom: async ( createRoom: withAuth<MutationResolvers['createRoom']>(
_, async (_, { name, description, isPrivate }, { prisma, jwt, pubsub }) => {
{ name, description, isPrivate }, const room = await prisma.room.create({
{ prisma, userId, pubsub } data: {
) => { name,
if (!userId) { description,
throw new GraphQLError('You must be logged in to create a room', { isPrivate: isPrivate ?? false,
extensions: { owner: {
code: 'UNAUTHENTICATED', connect: { id: jwt.sub },
},
members: {
connect: { id: jwt.sub },
},
}, },
}); });
}
const room = await prisma.room.create({ pubsub.publish({
data: { topic: ROOM_ADDED,
name, payload: { roomAdded: room },
description,
isPrivate: isPrivate ?? false,
owner: {
connect: { id: userId },
},
members: {
connect: { id: userId },
},
},
});
pubsub.publish({
topic: ROOM_ADDED,
payload: { roomAdded: room },
});
return room;
},
joinRoom: async (
_,
{ roomId }: { roomId: string },
{ prisma, userId, pubsub }
) => {
if (!userId) {
throw new GraphQLError('You must be logged in to join a room', {
extensions: {
code: 'UNAUTHENTICATED',
},
}); });
return room;
} }
),
const room = await prisma.room.findUnique({ joinRoom: withAuth<MutationResolvers['joinRoom']>(
where: { id: roomId }, async (_, { roomId }, { prisma, jwt, pubsub }) => {
include: { members: true }, const room = await prisma.room.findUnique({
}); where: { id: roomId },
include: { members: true },
if (!room) {
throw new GraphQLError('Room not found', {
extensions: {
code: 'NOT_FOUND',
},
}); });
}
if (room.isPrivate) { if (!room) {
// In a real application, you would check if the user has been invited throw new GraphQLError('Room not found', {
throw new GraphQLError( extensions: {
'You cannot join a private room without an invitation', code: 'NOT_FOUND',
{ },
});
}
if (room.isPrivate) {
// In a real application, you would check if the user has been invited
throw new GraphQLError(
'You cannot join a private room without an invitation',
{
extensions: {
code: 'FORBIDDEN',
},
}
);
}
// Check if user is already a member
const isMember = room.members.some(
(member: { id: string }) => member.id === jwt.sub
);
if (isMember) {
return room;
}
const updatedRoom = await prisma.room.update({
where: { id: roomId },
data: {
members: {
connect: { id: jwt.sub },
},
},
include: { members: true },
});
// Publish room updated event
pubsub.publish({
topic: ROOM_UPDATED,
payload: { roomUpdated: updatedRoom },
});
return updatedRoom;
}
),
leaveRoom: withAuth<MutationResolvers['leaveRoom']>(
async (_, { roomId }, { prisma, jwt, pubsub }) => {
const room = await prisma.room.findUnique({
where: { id: roomId, members: { some: { id: jwt.sub } } },
include: { members: true },
});
if (!room) {
throw new GraphQLError('Room not found', {
extensions: {
code: 'NOT_FOUND',
},
});
}
if (room.ownerId === jwt.sub) {
throw new GraphQLError('You cannot leave a room you own', {
extensions: { extensions: {
code: 'FORBIDDEN', code: 'FORBIDDEN',
}, },
} });
); }
}
// Check if user is already a member const updatedRoom = await prisma.room.update({
const isMember = room.members.some( where: { id: roomId },
(member: { id: string }) => member.id === userId data: {
); members: {
if (isMember) { disconnect: { id: jwt.sub },
return room; },
}
const updatedRoom = await prisma.room.update({
where: { id: roomId },
data: {
members: {
connect: { id: userId },
},
},
include: { members: true },
});
// Publish room updated event
pubsub.publish({
topic: ROOM_UPDATED,
payload: { roomUpdated: updatedRoom },
});
return updatedRoom;
},
leaveRoom: async (
_,
{ roomId }: { roomId: string },
{ prisma, userId, pubsub }
) => {
if (!userId) {
throw new GraphQLError('You must be logged in to leave a room', {
extensions: {
code: 'UNAUTHENTICATED',
}, },
include: { members: true },
}); });
}
const room = await prisma.room.findUnique({ // Publish room updated event
where: { id: roomId }, pubsub.publish({
include: { members: true }, topic: ROOM_UPDATED,
}); payload: { roomUpdated: updatedRoom },
if (!room) {
throw new GraphQLError('Room not found', {
extensions: {
code: 'NOT_FOUND',
},
}); });
return true;
} }
),
// Check if user is a member
const isMember = room.members.some(
(member: { id: string }) => member.id === userId
);
if (!isMember) {
throw new GraphQLError('You are not a member of this room', {
extensions: {
code: 'FORBIDDEN',
},
});
}
// If user is the owner, they cannot leave
if (room.ownerId === userId) {
throw new GraphQLError('You cannot leave a room you own', {
extensions: {
code: 'FORBIDDEN',
},
});
}
const updatedRoom = await prisma.room.update({
where: { id: roomId },
data: {
members: {
disconnect: { id: userId },
},
},
include: { members: true },
});
// Publish room updated event
pubsub.publish({
topic: ROOM_UPDATED,
payload: { roomUpdated: updatedRoom },
});
return true;
},
}, },
Subscription: { Subscription: {
roomAdded: { roomAdded: {

View file

@ -1,48 +1,79 @@
import { GraphQLError } from 'graphql'; import { GraphQLError } from 'graphql';
import { IResolvers } from 'mercurius'; import { IResolvers } from 'mercurius';
// In a real application, you would use bcrypt for password hashing import { MutationResolvers, QueryResolvers } from '../generated/graphql';
// import bcrypt from 'bcryptjs'; import { withAuth, hashPassword, verifyPassword } from '../utils';
// import jwt from 'jsonwebtoken';
export const userResolvers: IResolvers = { export const userResolvers: IResolvers = {
Query: { Query: {
me: async (_, __, { prisma, userId }) => { me: withAuth<QueryResolvers['me']>(async (_, __, { prisma, jwt }) =>
// In a real application, you would get the user from the context prisma.user.findUnique({
// which would be set by an authentication middleware where: { id: jwt.sub },
if (!userId) { })
return null; ),
users: withAuth<QueryResolvers['users']>(async (_, __, { prisma }) =>
prisma.user.findMany()
),
user: withAuth<QueryResolvers['user']>(async (_, { id }, { prisma }) =>
prisma.user.findUnique({ where: { id } })
),
getProfilePicUploadUrl: withAuth<QueryResolvers['getProfilePicUploadUrl']>(
async (_, { fileExtension }, { jwt, minio, prisma }) => {
const validExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
const normalizedExtension = fileExtension
.toLowerCase()
.replace('.', '');
if (!validExtensions.includes(normalizedExtension)) {
throw new GraphQLError('Invalid file extension', {
extensions: {
code: 'BAD_USER_INPUT',
},
});
}
const objectName = `${jwt.sub}.${normalizedExtension}`;
const [url] = await Promise.all([
minio.generateUploadUrl(objectName),
prisma.user.update({
where: { id: jwt.sub },
data: { s3ProfilePicObjectKey: objectName },
}),
]);
return {
url,
expiresIn: 60 * 60, // 1 hour in seconds
};
} }
return prisma.user.findUnique({ ),
where: { id: userId }, getProfilePicUrl: withAuth<QueryResolvers['getProfilePicUrl']>(
}); async (_, __, { jwt, minio, prisma }) => {
}, const user = await prisma.user.findUnique({
users: async (_, __, { prisma }) => { where: { id: jwt.sub },
return prisma.user.findMany(); select: { s3ProfilePicObjectKey: true },
}, });
user: async (_, { id }: { id: string }, { prisma }) => {
return prisma.user.findUnique({ const { s3ProfilePicObjectKey } = user ?? {};
where: { id }, if (!s3ProfilePicObjectKey) return null;
});
}, const exists = await minio.objectExists(s3ProfilePicObjectKey);
if (!exists) return null;
return minio.generateDownloadUrl(s3ProfilePicObjectKey);
}
),
}, },
Mutation: { Mutation: {
register: async ( register: async (
_: any, _,
{ { email, username, password, deviceId },
email, { prisma, token, reply }
username,
password,
}: { email: string; username: string; password: string },
{ prisma }
) => { ) => {
// Check if user already exists
const existingUser = await prisma.user.findFirst({ const existingUser = await prisma.user.findFirst({
where: { where: {
OR: [{ email }, { username }], OR: [{ email }, { username }],
}, },
}); });
if (existingUser) { if (existingUser) {
throw new GraphQLError('User already exists', { throw new GraphQLError('User already exists', {
extensions: { extensions: {
@ -50,63 +81,107 @@ export const userResolvers: IResolvers = {
}, },
}); });
} }
const hashedPassword = hashPassword(password);
// In a real application, you would hash the password const { user, accessToken, refreshToken } = await prisma.$transaction(
// const hashedPassword = await bcrypt.hash(password, 10); async (tx) => {
const user = await tx.user.create({
const user = await prisma.user.create({ data: {
data: { email,
email, username,
username, password: hashedPassword,
password, // In a real app: hashedPassword },
}, });
}); const tokens = await token.generateTokens({
userId: user.id,
// In a real application, you would generate a JWT token role: 'user',
// const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET); deviceId,
txn: tx,
});
return { user, ...tokens };
}
);
reply.setCookie('refreshToken', refreshToken);
return { return {
token: 'dummy-token', // In a real app: token accessToken,
refreshToken,
user, user,
}; };
}, },
login: async ( login: async (
_: any, _,
{ email, password }: { email: string; password: string }, { email, password, deviceId },
{ prisma } { prisma, token, reply }
) => { ) => {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email }, where: { email },
}); });
if (!user) { if (!user || !verifyPassword(password, user.password)) {
throw new GraphQLError('Invalid credentials', { throw new GraphQLError('Invalid credentials', {
extensions: { extensions: {
code: 'INVALID_CREDENTIALS', code: 'INVALID_CREDENTIALS',
}, },
}); });
} }
const { accessToken, refreshToken } = await token.generateTokens({
// In a real application, you would verify the password userId: user.id,
// const valid = await bcrypt.compare(password, user.password); role: 'user',
const valid = password === user.password; // This is just for demo purposes deviceId,
});
if (!valid) { reply.setCookie('refreshToken', refreshToken);
throw new GraphQLError('Invalid credentials', {
extensions: {
code: 'INVALID_CREDENTIALS',
},
});
}
// In a real application, you would generate a JWT token
// const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
return { return {
token: 'dummy-token', // In a real app: token
user, user,
accessToken,
refreshToken,
}; };
}, },
logout: async (_, { deviceId }, { token, jwt, reply }) => {
jwt &&
(await token.revokeTokensByDevice({
userId: jwt.sub,
deviceId,
}));
reply.clearCookie('refreshToken');
return true;
},
logoutAllDevices: withAuth<MutationResolvers['logoutAllDevices']>(
async (_, __, { token, jwt, reply }) => {
await token.revokeAllTokens(jwt.sub);
reply.clearCookie('refreshToken');
return true;
}
),
refreshToken: withAuth<MutationResolvers['refreshToken']>(
async (_, { deviceId }, { token, jwt, reply }) => {
const { accessToken, refreshToken } = await token.rotateRefreshToken({
userId: jwt.sub,
oldToken: jwt.jti,
deviceId,
});
reply.setCookie('refreshToken', refreshToken);
return {
accessToken,
refreshToken,
};
}
),
deleteProfilePic: withAuth<MutationResolvers['deleteProfilePic']>(
async (_, __, { jwt, prisma, minio }) => {
const user = await prisma.user.findUnique({
where: { id: jwt.sub! },
select: { s3ProfilePicObjectKey: true },
});
await Promise.all([
user?.s3ProfilePicObjectKey &&
minio.deleteObject(user.s3ProfilePicObjectKey),
prisma.user.update({
where: { id: jwt.sub! },
data: { s3ProfilePicObjectKey: null },
}),
]);
return true;
}
),
}, },
User: { User: {
messages: async (user, _, { prisma }) => messages: async (user, _, { prisma }) =>

View file

@ -7,6 +7,7 @@ export default gql`
id: ID! id: ID!
email: String! email: String!
username: String! username: String!
s3ProfilePicObjectKey: String
createdAt: DateTime! createdAt: DateTime!
updatedAt: DateTime updatedAt: DateTime
messages: [Message!] messages: [Message!]
@ -37,9 +38,20 @@ export default gql`
room: Room room: Room
} }
type RefreshTokenPayload {
accessToken: String!
refreshToken: String!
}
type AuthPayload { type AuthPayload {
token: String!
user: User! user: User!
accessToken: String!
refreshToken: String!
}
type PresignedUrl {
url: String!
expiresIn: Int!
} }
type Query { type Query {
@ -49,15 +61,26 @@ export default gql`
rooms: [Room!]! rooms: [Room!]!
room(id: ID!): Room room(id: ID!): Room
messages(roomId: ID!): [Message!]! messages(roomId: ID!): [Message!]!
getProfilePicUploadUrl(fileExtension: String!): PresignedUrl!
getProfilePicUrl: String
} }
type Mutation { type Mutation {
register(email: String!, username: String!, password: String!): AuthPayload! register(
login(email: String!, password: String!): AuthPayload! email: String!
username: String!
password: String!
deviceId: String!
): AuthPayload!
login(email: String!, password: String!, deviceId: String!): AuthPayload!
logout(deviceId: String!): Boolean!
refreshToken(deviceId: String!): AuthPayload!
logoutAllDevices: Boolean!
createRoom(name: String!, description: String, isPrivate: Boolean): Room! createRoom(name: String!, description: String, isPrivate: Boolean): Room!
joinRoom(roomId: ID!): Room! joinRoom(roomId: ID!): Room!
leaveRoom(roomId: ID!): Boolean! leaveRoom(roomId: ID!): Boolean!
sendMessage(content: String!, roomId: ID!): Message! sendMessage(content: String!, roomId: ID!): Message!
deleteProfilePic: Boolean!
} }
type Subscription { type Subscription {

View file

@ -0,0 +1,3 @@
export * from './memc.service';
export * from './minio.service';
export * from './token.service';

View file

@ -0,0 +1,65 @@
import Keyv from 'keyv';
import MemcacheStore from '@keyv/memcache';
import { MemcConfig } from '../config';
/**
* Service for interacting with Memcached using Keyv as an abstraction layer
*/
export class MemcService {
private keyv: Keyv<string>;
/**
* Creates a new MemcService instance
* @param config - Memcached configuration including host, port and TTL
*/
constructor(config: MemcConfig) {
const memcache = new MemcacheStore(`${config.host}:${config.port}`);
this.keyv = new Keyv<string>({
store: memcache as any,
ttl: config.ttl,
});
}
/**
* Retrieves a value from Memcached by key
* @param key - The key to retrieve
* @returns The value if found, undefined otherwise
*/
public async get(key: string): Promise<string | undefined> {
try {
return await this.keyv.get(key);
} catch (error) {
console.error('Error getting value from Memcached:', error);
return undefined;
}
}
/**
* Stores a value in Memcached
* @param key - The key to store the value under
* @param value - The value to store
* @param ttl - Optional time-to-live in seconds (overrides default TTL)
*/
public async set(key: string, value: string, ttl?: number): Promise<boolean> {
try {
const ttlMilliseconds = ttl ? ttl * 1000 : undefined;
return await this.keyv.set(key, value, ttlMilliseconds);
} catch (error) {
console.error('Error setting value in Memcached:', error);
return false;
}
}
/**
* Deletes a value from Memcached
* @param key - The key to delete
*/
public async delete(key: string): Promise<boolean> {
try {
return await this.keyv.delete(key);
} catch (error) {
console.error('Error deleting value from Memcached:', error);
return false;
}
}
}

View file

@ -0,0 +1,106 @@
import { Client } from 'minio';
import { MinioConfig } from '../config';
/**
* Service for handling MinIO operations
*/
export class MinioService {
private client: Client;
private bucketName: string;
private readonly defaultExpiry = 60 * 60; // 1 hour in seconds
private initialized = false;
/**
* Creates a new MinioService instance
* @param config - MinIO configuration
*/
constructor(config: MinioConfig) {
this.client = new Client(config);
this.bucketName = config.bucketName;
}
/**
* Initializes the MinIO service by ensuring the bucket exists
*/
public async initialize(): Promise<void> {
if (this.initialized) {
return;
}
const bucketExists = await this.client.bucketExists(this.bucketName);
if (!bucketExists) {
await this.client.makeBucket(this.bucketName, 'us-east-1');
// Set the bucket policy to allow public read access
const policy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${this.bucketName}/*`],
},
],
};
await this.client.setBucketPolicy(
this.bucketName,
JSON.stringify(policy)
);
}
this.initialized = true;
}
/**
* Generates a presigned URL for uploading a file
* @param objectName - Name of the object to upload
* @param expiryInSeconds - Expiry time in seconds (default: 1 hour)
* @returns Presigned URL for uploading
*/
public async generateUploadUrl(
objectName: string,
expiryInSeconds: number = this.defaultExpiry
): Promise<string> {
return this.client.presignedPutObject(
this.bucketName,
objectName,
expiryInSeconds
);
}
/**
* Generates a presigned URL for downloading a file
* @param objectName - Name of the object to download
* @param expiryInSeconds - Expiry time in seconds (default: 1 hour)
* @returns Presigned URL for downloading
*/
public async generateDownloadUrl(
objectName: string,
expiryInSeconds: number = this.defaultExpiry
): Promise<string> {
return this.client.presignedGetObject(
this.bucketName,
objectName,
expiryInSeconds
);
}
/**
* Checks if an object exists in the bucket
* @param objectName - Name of the object to check
* @returns True if the object exists, false otherwise
*/
public async objectExists(objectName: string): Promise<boolean> {
try {
await this.client.statObject(this.bucketName, objectName);
return true;
} catch (error) {
return false;
}
}
/**
* Deletes an object from the bucket
* @param objectName - Name of the object to delete
*/
public async deleteObject(objectName: string): Promise<void> {
await this.client.removeObject(this.bucketName, objectName);
}
}

View file

@ -0,0 +1,232 @@
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import { TokenConfig } from '../config';
import { PrismaClient } from '@prisma/client';
import { MemcService } from './memc.service';
type TransactionClient = Omit<
PrismaClient,
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
>;
export interface TokenPayload {
sub: string;
role: string;
deviceId: string;
jti: string;
}
interface GenerateTokensParams {
userId: string;
role: string;
deviceId: string;
txn?: TransactionClient;
}
interface RotateRefreshTokenParams {
userId: string;
oldToken: string;
deviceId: string;
}
interface RevokeTokensByDeviceParams {
userId: string;
deviceId: string;
}
/**
* Service for handling token generation, verification, and management
*/
export class TokenService {
private prisma: PrismaClient;
private memc: MemcService;
private config: TokenConfig;
/**
* Creates a new TokenService instance
* @param config - Token configuration
* @param prisma - Prisma client instance
* @param memc - Memcached service instance
*/
constructor({
prisma,
memc,
...config
}: TokenConfig & { prisma: PrismaClient; memc: MemcService }) {
this.prisma = prisma;
this.memc = memc;
this.config = config;
}
/**
* Hashes a token using SHA-256
* @param token - The token to hash
* @returns Hashed token string
*/
private async hashToken(token: string): Promise<string> {
return crypto.createHash('sha256').update(token).digest('hex');
}
/**
* Generates new access and refresh tokens for a user
* @param params - Object containing userId, role and deviceId
* @returns Object containing accessToken and refreshToken
*/
async generateTokens(params: GenerateTokensParams) {
const { userId, role, deviceId, txn } = params;
const jti = uuidv4();
const refreshToken = uuidv4();
const hash = await this.hashToken(refreshToken);
const accessToken = jwt.sign(
{
sub: userId,
role,
deviceId,
jti,
},
this.config.secret,
{ expiresIn: this.config.accessTokenExpiresIn }
);
const tx = txn
? (callback: Function) => callback(txn)
: this.prisma.$transaction.bind(this.prisma);
await tx(async (tx) => {
await tx.refreshToken.create({
data: {
jti,
userId,
deviceId,
hash,
expiresAt: new Date(Date.now() + this.config.refreshTokenExpiresIn),
},
});
await this.whitelistAccessToken(jti);
});
return { accessToken, refreshToken };
}
/**
* Verifies an access token and checks if it's whitelisted
* @param token - The token to verify
* @returns Decoded token payload or null if invalid
*/
async verifyAccessToken(token?: string) {
try {
if (!token) return null;
const decoded = jwt.verify(token, this.config.secret) as TokenPayload;
const cached = await this.memc.get(`token:whitelist:${decoded.jti}`);
if (!cached) throw new Error('Token inválido');
return decoded;
} catch (error) {
console.error('Error verifying access token', error);
return null;
}
}
/**
* Verifies and rotates a refresh token
* @param params - Object containing userId, oldToken and deviceId
* @returns New access and refresh tokens
*/
async rotateRefreshToken(params: RotateRefreshTokenParams) {
const { userId, oldToken, deviceId } = params;
return await this.prisma.$transaction(async (txn) => {
const storedTokens = await txn.refreshToken.findMany({
where: { userId, deviceId },
});
const hash = await this.hashToken(oldToken);
const validToken = storedTokens.find((token) => token.hash === hash);
if (!validToken || validToken.expiresAt < new Date())
throw new Error('Refresh token inválido');
await Promise.all(
storedTokens.map((token) => {
this.memc.delete(`token:whitelist:${token.jti}`);
})
);
await txn.refreshToken.deleteMany({
where: { id: { in: storedTokens.map((token) => token.id) } },
});
return this.generateTokens({ userId, role: 'user', deviceId, txn });
});
}
/**
* Revokes all tokens for a user, useful for logout from all devices
* @param userId - The user ID
* @returns True if successful
*/
async revokeAllTokens(userId: string) {
await this.prisma.$transaction(async (tx) => {
const tokens = await tx.refreshToken.findMany({
where: { userId },
});
await Promise.all(
tokens.map((token) => {
this.memc.delete(`token:whitelist:${token.jti}`);
})
);
await tx.refreshToken.deleteMany({
where: { id: { in: tokens.map((token) => token.id) } },
});
});
return true;
}
/**
* Revokes tokens for a specific device
* @param params - Object containing userId and deviceId
* @returns True if successful
*/
async revokeTokensByDevice(params: RevokeTokensByDeviceParams) {
const { userId, deviceId } = params;
await this.prisma.$transaction(async (tx) => {
const tokens = await tx.refreshToken.findMany({
where: { userId, deviceId },
});
await Promise.all(
tokens.map((token) => {
this.memc.delete(`token:whitelist:${token.jti}`);
})
);
await tx.refreshToken.deleteMany({
where: { id: { in: tokens.map((token) => token.id) } },
});
});
return true;
}
/**
* Adds an access token to the whitelist
* @param jti - The JWT ID
*/
async whitelistAccessToken(jti: string) {
const ttl = this.config.accessTokenExpiresIn;
await this.memc.set(`token:whitelist:${jti}`, '1', ttl);
}
/**
* Cleans up expired tokens from the database and Memcached
*/
async cleanupExpiredTokens() {
const now = new Date();
const expired = await this.prisma.refreshToken.findMany({
where: { expiresAt: { lt: now } },
});
await Promise.all(
expired.map((token) => {
this.memc.delete(`token:whitelist:${token.jti}`);
})
);
await this.prisma.refreshToken.deleteMany({
where: { id: { in: expired.map((token) => token.id) } },
});
}
}

View file

@ -1,8 +1,20 @@
import type { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import {
MinioService,
TokenService,
MemcService,
TokenPayload,
} from './services';
import { FastifyReply, FastifyRequest } from 'fastify';
declare module 'mercurius' { declare module 'mercurius' {
interface MercuriusContext { interface MercuriusContext {
prisma: PrismaClient; prisma: PrismaClient;
userId: string | null; jwt: TokenPayload;
minio: MinioService;
token: TokenService;
memc: MemcService;
req: FastifyRequest;
reply: FastifyReply;
} }
} }

View file

@ -0,0 +1,14 @@
import { pbkdf2Sync, randomBytes, timingSafeEqual } from 'crypto';
export const hashPassword = (password: string) => {
const salt = randomBytes(16).toString('hex');
const hash = pbkdf2Sync(password, salt, 1000, 128, 'sha512').toString('hex');
const hashedPassword = `${salt}:${hash}`;
return hashedPassword;
};
export const verifyPassword = (password: string, hashedPassword: string) => {
const [salt, hash] = hashedPassword.split(':');
const hashBuffer = Buffer.from(hash, 'hex');
const result = pbkdf2Sync(password, salt, 1000, 128, 'sha512');
return timingSafeEqual(hashBuffer, result);
};

View file

@ -0,0 +1,2 @@
export * from './crypto';
export * from './middlewares';

View file

@ -0,0 +1,18 @@
import { MercuriusContext } from 'mercurius';
const isCallback = (
maybeFunction: unknown | Function
): maybeFunction is Function => typeof maybeFunction === 'function';
export const withAuth = <T>(callback: T) =>
async function (
_: unknown,
__: unknown,
context: MercuriusContext,
info: unknown
) {
if (!context.jwt) {
throw new Error('Not authenticated!');
}
return isCallback(callback) && callback(_, __, context, info);
};

View file

@ -6,12 +6,21 @@ services:
container_name: unreal-chat-api container_name: unreal-chat-api
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=your-secret-key
- API_PORT=4000
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
- API_HOST=${API_HOST} - API_HOST=${API_HOST}
- API_PORT=${API_PORT}
- COOKIE_SECRET=${COOKIE_SECRET}
- DATABASE_URL=${DATABASE_URL}
- MEMC_HOST=${MEMC_HOST}
- MEMC_PORT=${MEMC_PORT}
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME}
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
- MINIO_PORT=${MINIO_PORT}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- MINIO_USE_SSL=${MINIO_USE_SSL}
- NODE_ENV=production
- TOKEN_SECRET=${TOKEN_SECRET}
networks: networks:
- default-network - default-network
@ -31,9 +40,7 @@ services:
networks: networks:
- default-network - default-network
volumes:
db_data:
networks: networks:
default-network: default:
name: default-network
external: true external: true

863
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,8 +6,21 @@
"ALLOWED_ORIGINS", "ALLOWED_ORIGINS",
"API_HOST", "API_HOST",
"API_PORT", "API_PORT",
"DATABASE_URL", "COOKIE_SECRET",
"MINIO_ENDPOINT",
"MINIO_PORT",
"MINIO_ACCESS_KEY",
"MINIO_SECRET_KEY",
"MINIO_BUCKET_NAME",
"MINIO_USE_SSL",
"MEMC_HOST",
"MEMC_PORT",
"MEMC_TTL",
"MEMC_NAMESPACE",
"NODE_ENV", "NODE_ENV",
"TOKEN_ACCESS_EXPIRES_IN",
"TOKEN_REFRESH_EXPIRES_IN",
"TOKEN_SECRET",
"VITE_API_URL", "VITE_API_URL",
"VITE_WS_URL" "VITE_WS_URL"
], ],