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",
"start": "node dist/index.js",
"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:init": "dotenv -e ../../.env.local -- prisma migrate dev --name init",
"build": "tsc"
@ -19,19 +19,25 @@
"author": "",
"license": "ISC",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.0.0",
"@keyv/memcache": "^2.0.1",
"@prisma/client": "^6.4.1",
"dotenv": "^16.4.7",
"fastify": "^5.2.1",
"fastify-plugin": "^5.0.0",
"graphql": "^16.10.0",
"jsonwebtoken": "^9.0.2",
"mercurius": "^16.1.0",
"mercurius-codegen": "^6.0.1",
"minio": "^8.0.4",
"uuid": "^11.1.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@repo/eslint-config": "*",
"@repo/typescript-config": "*",
"@types/jsonwebtoken": "^9.0.9",
"prisma": "^6.4.1",
"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,
`email` 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),
`updatedAt` DATETIME(3) NOT NULL,
@ -28,7 +29,7 @@ CREATE TABLE `Room` (
-- CreateTable
CREATE TABLE `Message` (
`id` VARCHAR(191) NOT NULL,
`content` VARCHAR(191) NOT NULL,
`content` TEXT NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
`userId` VARCHAR(191) NOT NULL,
@ -37,6 +38,21 @@ CREATE TABLE `Message` (
PRIMARY KEY (`id`)
) 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
CREATE TABLE `_RoomMembers` (
`A` VARCHAR(191) NOT NULL,
@ -55,6 +71,9 @@ ALTER TABLE `Message` ADD CONSTRAINT `Message_userId_fkey` FOREIGN KEY (`userId`
-- AddForeignKey
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
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 {
id String @id @default(uuid())
email String @unique
username String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[]
rooms Room[] @relation("RoomMembers")
ownedRooms Room[] @relation("RoomOwner")
id String @id @default(uuid())
email String @unique
username String @unique
password String @db.Text
s3ProfilePicObjectKey String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[]
rooms Room[] @relation("RoomMembers")
ownedRooms Room[] @relation("RoomOwner")
refreshTokens RefreshToken[]
}
model Room {
@ -48,3 +50,15 @@ model Message {
roomId String
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"];
email: Scalars["String"];
username: Scalars["String"];
s3ProfilePicObjectKey?: Maybe<Scalars["String"]>;
createdAt: Scalars["DateTime"];
updatedAt?: Maybe<Scalars["DateTime"]>;
messages?: Maybe<Array<Message>>;
@ -74,10 +75,23 @@ export type Message = {
room?: Maybe<Room>;
};
export type RefreshTokenPayload = {
__typename?: "RefreshTokenPayload";
accessToken: Scalars["String"];
refreshToken: Scalars["String"];
};
export type AuthPayload = {
__typename?: "AuthPayload";
token: Scalars["String"];
user: User;
accessToken: Scalars["String"];
refreshToken: Scalars["String"];
};
export type PresignedUrl = {
__typename?: "PresignedUrl";
url: Scalars["String"];
expiresIn: Scalars["Int"];
};
export type Query = {
@ -88,6 +102,8 @@ export type Query = {
rooms: Array<Room>;
room?: Maybe<Room>;
messages: Array<Message>;
getProfilePicUploadUrl: PresignedUrl;
getProfilePicUrl?: Maybe<Scalars["String"]>;
};
export type QueryuserArgs = {
@ -102,25 +118,43 @@ export type QuerymessagesArgs = {
roomId: Scalars["ID"];
};
export type QuerygetProfilePicUploadUrlArgs = {
fileExtension: Scalars["String"];
};
export type Mutation = {
__typename?: "Mutation";
register: AuthPayload;
login: AuthPayload;
logout: Scalars["Boolean"];
refreshToken: AuthPayload;
logoutAllDevices: Scalars["Boolean"];
createRoom: Room;
joinRoom: Room;
leaveRoom: Scalars["Boolean"];
sendMessage: Message;
deleteProfilePic: Scalars["Boolean"];
};
export type MutationregisterArgs = {
email: Scalars["String"];
username: Scalars["String"];
password: Scalars["String"];
deviceId: Scalars["String"];
};
export type MutationloginArgs = {
email: Scalars["String"];
password: Scalars["String"];
deviceId: Scalars["String"];
};
export type MutationlogoutArgs = {
deviceId: Scalars["String"];
};
export type MutationrefreshTokenArgs = {
deviceId: Scalars["String"];
};
export type MutationcreateRoomArgs = {
@ -260,7 +294,10 @@ export type ResolversTypes = {
Room: ResolverTypeWrapper<Room>;
Boolean: ResolverTypeWrapper<Scalars["Boolean"]>;
Message: ResolverTypeWrapper<Message>;
RefreshTokenPayload: ResolverTypeWrapper<RefreshTokenPayload>;
AuthPayload: ResolverTypeWrapper<AuthPayload>;
PresignedUrl: ResolverTypeWrapper<PresignedUrl>;
Int: ResolverTypeWrapper<Scalars["Int"]>;
Query: ResolverTypeWrapper<{}>;
Mutation: ResolverTypeWrapper<{}>;
Subscription: ResolverTypeWrapper<{}>;
@ -275,7 +312,10 @@ export type ResolversParentTypes = {
Room: Room;
Boolean: Scalars["Boolean"];
Message: Message;
RefreshTokenPayload: RefreshTokenPayload;
AuthPayload: AuthPayload;
PresignedUrl: PresignedUrl;
Int: Scalars["Int"];
Query: {};
Mutation: {};
Subscription: {};
@ -294,6 +334,11 @@ export type UserResolvers<
id?: Resolver<ResolversTypes["ID"], ParentType, ContextType>;
email?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
username?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
s3ProfilePicObjectKey?: Resolver<
Maybe<ResolversTypes["String"]>,
ParentType,
ContextType
>;
createdAt?: Resolver<ResolversTypes["DateTime"], ParentType, ContextType>;
updatedAt?: Resolver<
Maybe<ResolversTypes["DateTime"]>,
@ -371,13 +416,34 @@ export type MessageResolvers<
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<
ContextType = MercuriusContext,
ParentType extends
ResolversParentTypes["AuthPayload"] = ResolversParentTypes["AuthPayload"],
> = {
token?: Resolver<ResolversTypes["String"], 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>;
};
@ -407,6 +473,17 @@ export type QueryResolvers<
ContextType,
RequireFields<QuerymessagesArgs, "roomId">
>;
getProfilePicUploadUrl?: Resolver<
ResolversTypes["PresignedUrl"],
ParentType,
ContextType,
RequireFields<QuerygetProfilePicUploadUrlArgs, "fileExtension">
>;
getProfilePicUrl?: Resolver<
Maybe<ResolversTypes["String"]>,
ParentType,
ContextType
>;
};
export type MutationResolvers<
@ -418,13 +495,33 @@ export type MutationResolvers<
ResolversTypes["AuthPayload"],
ParentType,
ContextType,
RequireFields<MutationregisterArgs, "email" | "username" | "password">
RequireFields<
MutationregisterArgs,
"email" | "username" | "password" | "deviceId"
>
>;
login?: Resolver<
ResolversTypes["AuthPayload"],
ParentType,
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<
ResolversTypes["Room"],
@ -450,6 +547,11 @@ export type MutationResolvers<
ContextType,
RequireFields<MutationsendMessageArgs, "content" | "roomId">
>;
deleteProfilePic?: Resolver<
ResolversTypes["Boolean"],
ParentType,
ContextType
>;
};
export type SubscriptionResolvers<
@ -483,7 +585,9 @@ export type Resolvers<ContextType = MercuriusContext> = {
User?: UserResolvers<ContextType>;
Room?: RoomResolvers<ContextType>;
Message?: MessageResolvers<ContextType>;
RefreshTokenPayload?: RefreshTokenPayloadResolvers<ContextType>;
AuthPayload?: AuthPayloadResolvers<ContextType>;
PresignedUrl?: PresignedUrlResolvers<ContextType>;
Query?: QueryResolvers<ContextType>;
Mutation?: MutationResolvers<ContextType>;
Subscription?: SubscriptionResolvers<ContextType>;
@ -515,6 +619,12 @@ export interface Loaders<
id?: LoaderResolver<Scalars["ID"], User, {}, TContext>;
email?: LoaderResolver<Scalars["String"], User, {}, TContext>;
username?: LoaderResolver<Scalars["String"], User, {}, TContext>;
s3ProfilePicObjectKey?: LoaderResolver<
Maybe<Scalars["String"]>,
User,
{},
TContext
>;
createdAt?: LoaderResolver<Scalars["DateTime"], User, {}, TContext>;
updatedAt?: LoaderResolver<Maybe<Scalars["DateTime"]>, User, {}, TContext>;
messages?: LoaderResolver<Maybe<Array<Message>>, User, {}, TContext>;
@ -550,9 +660,30 @@ export interface Loaders<
room?: LoaderResolver<Maybe<Room>, Message, {}, TContext>;
};
RefreshTokenPayload?: {
accessToken?: LoaderResolver<
Scalars["String"],
RefreshTokenPayload,
{},
TContext
>;
refreshToken?: LoaderResolver<
Scalars["String"],
RefreshTokenPayload,
{},
TContext
>;
};
AuthPayload?: {
token?: LoaderResolver<Scalars["String"], 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" {

View file

@ -1,58 +1,82 @@
import dotenv from 'dotenv';
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 mercuriusCodegen from 'mercurius-codegen';
import schema from './schema';
import { resolvers } from './resolvers';
import { PrismaClient } from '@prisma/client';
import fastifyCors from '@fastify/cors';
import { z } from 'zod';
import config from './config';
import { resolvers } from './resolvers';
import schema from './schema';
import { MemcService, MinioService, TokenService } from './services';
dotenv.config({ path: '../../.env.local' });
const { allowedOrigins, port, host } = z
.object({
ALLOWED_ORIGINS: z.string(),
API_HOST: z.string(),
API_PORT: z.coerce.number(),
})
.transform((env) => {
return {
allowedOrigins: env.ALLOWED_ORIGINS.split(','),
port: env.API_PORT,
host: env.API_HOST,
};
})
.parse(process.env);
const {
server: { allowedOrigins, ...serverConfig },
minio: minioConfig,
token: tokenConfig,
memc: memcConfig,
cookie: cookieConfig,
} = config;
const app = fastify({
logger: true,
exposeHeadRoutes: true,
});
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) => {
const userId = (req.headers['user-id'] as string) || null;
setInterval(
() => {
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 {
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, {
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) return callback(null, true);
return callback(new Error('Not allowed'), false);
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'user-id'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
app.register(mercurius, {
schema,
subscription: true,
jit: 1,
cache: true,
context,
});
@ -75,10 +99,10 @@ mercuriusCodegen(app, {
},
});
app.listen({ host, port }, (err, address) => {
app.listen({ ...serverConfig }, (err, address) => {
if (err) {
console.error(err);
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 { IResolvers, withFilter } from 'mercurius';
import { MutationResolvers, QueryResolvers } from '../generated/graphql';
import { withAuth } from '../utils';
export const MESSAGE_ADDED = 'MESSAGE_ADDED';
export const messageResolvers: IResolvers = {
Query: {
messages: async (_, { roomId }, { prisma, userId }) => {
if (!userId) {
throw new GraphQLError('You must be logged in to view messages', {
extensions: {
code: 'UNAUTHENTICATED',
},
messages: withAuth<QueryResolvers['messages']>(
async (_, { roomId }, { prisma, jwt }) => {
const room = await prisma.room.findUnique({
where: { id: roomId, members: { some: { id: jwt.sub } } },
});
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: {
sendMessage: async (
_: any,
{ content, roomId }: { content: string; roomId: string },
{ prisma, userId, pubsub }
) => {
if (!userId) {
throw new GraphQLError('You must be logged in to send a message', {
extensions: {
code: 'UNAUTHENTICATED',
sendMessage: withAuth<MutationResolvers['sendMessage']>(
async (_, { content, roomId }, { prisma, jwt, pubsub }) => {
const room = await prisma.room.findUnique({
where: { id: roomId, members: { some: { id: jwt.sub } } },
});
if (!room) {
throw new GraphQLError('Room not found', {
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
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { members: true },
});
if (!room) {
throw new GraphQLError('Room not found', {
extensions: {
code: 'NOT_FOUND',
},
pubsub.publish({
topic: MESSAGE_ADDED,
payload: { messageAdded: message, roomId },
});
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: {
messageAdded: {
@ -119,21 +79,17 @@ export const messageResolvers: IResolvers = {
},
},
Message: {
user: async (parent, _, { prisma }) => {
if (parent.user) {
return parent.user;
}
return prisma.user.findUnique({
where: { id: parent.userId },
});
},
room: async (parent, _, { prisma }) => {
if (parent.room) {
return parent.room;
}
return prisma.room.findUnique({
where: { id: parent.roomId },
});
},
user: async (parent, _, { prisma }) =>
parent.user
? parent.user
: prisma.user.findUnique({
where: { id: parent.userId },
}),
room: async (parent, _, { prisma }) =>
parent.room
? parent.room
: prisma.room.findUnique({
where: { id: parent.roomId },
}),
},
};

View file

@ -1,5 +1,7 @@
import { GraphQLError } from 'graphql';
import { IResolvers } from 'mercurius';
import { withAuth } from '../utils';
import { MutationResolvers } from '../generated/graphql';
export const ROOM_ADDED = 'ROOM_ADDED';
export const ROOM_UPDATED = 'ROOM_UPDATED';
@ -11,176 +13,133 @@ export const roomResolvers: IResolvers = {
where: { isPrivate: false },
});
},
room: async (_: any, { id }: { id: string }, { prisma }) => {
room: async (_, { id }, { prisma }) => {
return prisma.room.findUnique({
where: { id },
});
},
},
Mutation: {
createRoom: async (
_,
{ name, description, isPrivate },
{ prisma, userId, pubsub }
) => {
if (!userId) {
throw new GraphQLError('You must be logged in to create a room', {
extensions: {
code: 'UNAUTHENTICATED',
createRoom: withAuth<MutationResolvers['createRoom']>(
async (_, { name, description, isPrivate }, { prisma, jwt, pubsub }) => {
const room = await prisma.room.create({
data: {
name,
description,
isPrivate: isPrivate ?? false,
owner: {
connect: { id: jwt.sub },
},
members: {
connect: { id: jwt.sub },
},
},
});
}
const room = await prisma.room.create({
data: {
name,
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',
},
pubsub.publish({
topic: ROOM_ADDED,
payload: { roomAdded: room },
});
return 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',
},
),
joinRoom: withAuth<MutationResolvers['joinRoom']>(
async (_, { roomId }, { prisma, jwt, pubsub }) => {
const room = await prisma.room.findUnique({
where: { id: roomId },
include: { members: true },
});
}
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',
{
if (!room) {
throw new GraphQLError('Room not found', {
extensions: {
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: {
code: 'FORBIDDEN',
},
}
);
}
});
}
// Check if user is already a member
const isMember = room.members.some(
(member: { id: string }) => member.id === userId
);
if (isMember) {
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',
const updatedRoom = await prisma.room.update({
where: { id: roomId },
data: {
members: {
disconnect: { id: jwt.sub },
},
},
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',
},
// Publish room updated event
pubsub.publish({
topic: ROOM_UPDATED,
payload: { roomUpdated: updatedRoom },
});
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: {
roomAdded: {

View file

@ -1,48 +1,79 @@
import { GraphQLError } from 'graphql';
import { IResolvers } from 'mercurius';
// In a real application, you would use bcrypt for password hashing
// import bcrypt from 'bcryptjs';
// import jwt from 'jsonwebtoken';
import { MutationResolvers, QueryResolvers } from '../generated/graphql';
import { withAuth, hashPassword, verifyPassword } from '../utils';
export const userResolvers: IResolvers = {
Query: {
me: async (_, __, { prisma, userId }) => {
// In a real application, you would get the user from the context
// which would be set by an authentication middleware
if (!userId) {
return null;
me: withAuth<QueryResolvers['me']>(async (_, __, { prisma, jwt }) =>
prisma.user.findUnique({
where: { id: jwt.sub },
})
),
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 },
});
},
users: async (_, __, { prisma }) => {
return prisma.user.findMany();
},
user: async (_, { id }: { id: string }, { prisma }) => {
return prisma.user.findUnique({
where: { id },
});
},
),
getProfilePicUrl: withAuth<QueryResolvers['getProfilePicUrl']>(
async (_, __, { jwt, minio, prisma }) => {
const user = await prisma.user.findUnique({
where: { id: jwt.sub },
select: { s3ProfilePicObjectKey: true },
});
const { s3ProfilePicObjectKey } = user ?? {};
if (!s3ProfilePicObjectKey) return null;
const exists = await minio.objectExists(s3ProfilePicObjectKey);
if (!exists) return null;
return minio.generateDownloadUrl(s3ProfilePicObjectKey);
}
),
},
Mutation: {
register: async (
_: any,
{
email,
username,
password,
}: { email: string; username: string; password: string },
{ prisma }
_,
{ email, username, password, deviceId },
{ prisma, token, reply }
) => {
// Check if user already exists
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ email }, { username }],
},
});
if (existingUser) {
throw new GraphQLError('User already exists', {
extensions: {
@ -50,63 +81,107 @@ export const userResolvers: IResolvers = {
},
});
}
// In a real application, you would hash the password
// const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
email,
username,
password, // In a real app: hashedPassword
},
});
// In a real application, you would generate a JWT token
// const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
const hashedPassword = hashPassword(password);
const { user, accessToken, refreshToken } = await prisma.$transaction(
async (tx) => {
const user = await tx.user.create({
data: {
email,
username,
password: hashedPassword,
},
});
const tokens = await token.generateTokens({
userId: user.id,
role: 'user',
deviceId,
txn: tx,
});
return { user, ...tokens };
}
);
reply.setCookie('refreshToken', refreshToken);
return {
token: 'dummy-token', // In a real app: token
accessToken,
refreshToken,
user,
};
},
login: async (
_: any,
{ email, password }: { email: string; password: string },
{ prisma }
_,
{ email, password, deviceId },
{ prisma, token, reply }
) => {
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
if (!user || !verifyPassword(password, user.password)) {
throw new GraphQLError('Invalid credentials', {
extensions: {
code: 'INVALID_CREDENTIALS',
},
});
}
// In a real application, you would verify the password
// const valid = await bcrypt.compare(password, user.password);
const valid = password === user.password; // This is just for demo purposes
if (!valid) {
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);
const { accessToken, refreshToken } = await token.generateTokens({
userId: user.id,
role: 'user',
deviceId,
});
reply.setCookie('refreshToken', refreshToken);
return {
token: 'dummy-token', // In a real app: token
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: {
messages: async (user, _, { prisma }) =>

View file

@ -7,6 +7,7 @@ export default gql`
id: ID!
email: String!
username: String!
s3ProfilePicObjectKey: String
createdAt: DateTime!
updatedAt: DateTime
messages: [Message!]
@ -37,9 +38,20 @@ export default gql`
room: Room
}
type RefreshTokenPayload {
accessToken: String!
refreshToken: String!
}
type AuthPayload {
token: String!
user: User!
accessToken: String!
refreshToken: String!
}
type PresignedUrl {
url: String!
expiresIn: Int!
}
type Query {
@ -49,15 +61,26 @@ export default gql`
rooms: [Room!]!
room(id: ID!): Room
messages(roomId: ID!): [Message!]!
getProfilePicUploadUrl(fileExtension: String!): PresignedUrl!
getProfilePicUrl: String
}
type Mutation {
register(email: String!, username: String!, password: String!): AuthPayload!
login(email: String!, password: String!): AuthPayload!
register(
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!
joinRoom(roomId: ID!): Room!
leaveRoom(roomId: ID!): Boolean!
sendMessage(content: String!, roomId: ID!): Message!
deleteProfilePic: Boolean!
}
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' {
interface MercuriusContext {
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
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=your-secret-key
- API_PORT=4000
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
- 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:
- default-network
@ -31,9 +40,7 @@ services:
networks:
- default-network
volumes:
db_data:
networks:
default-network:
external: true
default:
name: default-network
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",
"API_HOST",
"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",
"TOKEN_ACCESS_EXPIRES_IN",
"TOKEN_REFRESH_EXPIRES_IN",
"TOKEN_SECRET",
"VITE_API_URL",
"VITE_WS_URL"
],