- 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
206 lines
5.9 KiB
TypeScript
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(),
|
|
},
|
|
};
|