unreal-chat/apps/api/src/resolvers/user.ts
Juan Sebastian Montoya d29d116214 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
2025-03-09 22:34:57 -05:00

206 lines
5.9 KiB
TypeScript

import { GraphQLError } from 'graphql';
import { IResolvers } from 'mercurius';
import { MutationResolvers, QueryResolvers } from '../generated/graphql';
import { withAuth, hashPassword, verifyPassword } from '../utils';
export const userResolvers: IResolvers = {
Query: {
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
};
}
),
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 (
_,
{ email, username, password, deviceId },
{ prisma, token, reply }
) => {
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ email }, { username }],
},
});
if (existingUser) {
throw new GraphQLError('User already exists', {
extensions: {
code: 'USER_ALREADY_EXISTS',
},
});
}
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 {
accessToken,
refreshToken,
user,
};
},
login: async (
_,
{ email, password, deviceId },
{ prisma, token, reply }
) => {
const user = await prisma.user.findUnique({
where: { email },
});
if (!user || !verifyPassword(password, user.password)) {
throw new GraphQLError('Invalid credentials', {
extensions: {
code: 'INVALID_CREDENTIALS',
},
});
}
const { accessToken, refreshToken } = await token.generateTokens({
userId: user.id,
role: 'user',
deviceId,
});
reply.setCookie('refreshToken', refreshToken);
return {
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 }) =>
prisma.user
.findUnique({
where: { id: user.id },
})
.messages(),
rooms: async (user, _, { prisma }) =>
prisma.room.findMany({
where: {
OR: [{ ownerId: user.id }, { members: { some: { id: user.id } } }],
},
}),
ownedRooms: async (user, _, { prisma }) =>
prisma.user
.findUnique({
where: { id: user.id },
})
.rooms(),
},
};