feat: improve authentication and cookie management

- Updated Docker and Turbo configuration to include more environment variables
- Modified API configuration to support dynamic cookie and CORS settings
- Enhanced user authentication flow with optional device ID and automatic generation
- Refactored login, register, and logout resolvers to handle device management
- Updated GraphQL schema to make device ID optional
- Improved web application logout and authentication handling
- Simplified client-side GraphQL mutations for login and registration
This commit is contained in:
Juan Sebastián Montoya 2025-03-10 00:41:39 -05:00
parent d29d116214
commit f9c6230101
15 changed files with 190 additions and 148 deletions

View file

@ -11,6 +11,7 @@ const schema = z
API_HOST: z.string(),
API_PORT: z.coerce.number(),
COOKIE_SECRET: z.string(),
DATABASE_URL: z.string(),
MINIO_ENDPOINT: z.string(),
MINIO_PORT: z.coerce.number(),
MINIO_ACCESS_KEY: z.string(),
@ -34,6 +35,7 @@ const schema = z
allowedOrigins: env.ALLOWED_ORIGINS.split(','),
port: env.API_PORT,
host: env.API_HOST,
databaseUrl: env.DATABASE_URL,
},
minio: {
endPoint: env.MINIO_ENDPOINT,
@ -45,8 +47,8 @@ const schema = z
region: env.MINIO_REGION,
},
token: {
accessTokenExpiresIn: env.TOKEN_ACCESS_EXPIRES_IN,
refreshTokenExpiresIn: env.TOKEN_REFRESH_EXPIRES_IN,
accessTokenExpiresIn: env.TOKEN_ACCESS_EXPIRES_IN * 1000,
refreshTokenExpiresIn: env.TOKEN_REFRESH_EXPIRES_IN * 1000,
secret: env.TOKEN_SECRET,
},
memc: {

View file

@ -140,21 +140,21 @@ export type MutationregisterArgs = {
email: Scalars["String"];
username: Scalars["String"];
password: Scalars["String"];
deviceId: Scalars["String"];
deviceId?: InputMaybe<Scalars["String"]>;
};
export type MutationloginArgs = {
email: Scalars["String"];
password: Scalars["String"];
deviceId: Scalars["String"];
deviceId?: InputMaybe<Scalars["String"]>;
};
export type MutationlogoutArgs = {
deviceId: Scalars["String"];
deviceId?: InputMaybe<Scalars["String"]>;
};
export type MutationrefreshTokenArgs = {
deviceId: Scalars["String"];
deviceId?: InputMaybe<Scalars["String"]>;
};
export type MutationcreateRoomArgs = {
@ -495,28 +495,25 @@ export type MutationResolvers<
ResolversTypes["AuthPayload"],
ParentType,
ContextType,
RequireFields<
MutationregisterArgs,
"email" | "username" | "password" | "deviceId"
>
RequireFields<MutationregisterArgs, "email" | "username" | "password">
>;
login?: Resolver<
ResolversTypes["AuthPayload"],
ParentType,
ContextType,
RequireFields<MutationloginArgs, "email" | "password" | "deviceId">
RequireFields<MutationloginArgs, "email" | "password">
>;
logout?: Resolver<
ResolversTypes["Boolean"],
ParentType,
ContextType,
RequireFields<MutationlogoutArgs, "deviceId">
Partial<MutationlogoutArgs>
>;
refreshToken?: Resolver<
ResolversTypes["AuthPayload"],
ParentType,
ContextType,
RequireFields<MutationrefreshTokenArgs, "deviceId">
Partial<MutationrefreshTokenArgs>
>;
logoutAllDevices?: Resolver<
ResolversTypes["Boolean"],

View file

@ -56,8 +56,9 @@ app.register(fastifyCookie, {
secret: cookieConfig.secret,
parseOptions: {
secure: config.isProduction,
httpOnly: config.isProduction,
sameSite: 'lax',
httpOnly: true,
sameSite: config.isProduction ? 'none' : 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 days
signed: true,
},
@ -69,7 +70,7 @@ app.register(fastifyCors, {
return callback(new Error('Not allowed'), false);
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
});
app.register(mercurius, {

View file

@ -1,6 +1,6 @@
import { GraphQLError } from 'graphql';
import { IResolvers } from 'mercurius';
import { v4 as uuidv4 } from 'uuid';
import { MutationResolvers, QueryResolvers } from '../generated/graphql';
import { withAuth, hashPassword, verifyPassword } from '../utils';
@ -66,9 +66,10 @@ export const userResolvers: IResolvers = {
Mutation: {
register: async (
_,
{ email, username, password, deviceId },
{ prisma, token, reply }
{ email, username, password, deviceId: reqDeviceId },
{ prisma, token, reply, req }
) => {
const deviceId = req.cookies?.deviceId ?? reqDeviceId ?? uuidv4();
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ email }, { username }],
@ -101,6 +102,7 @@ export const userResolvers: IResolvers = {
}
);
reply.setCookie('refreshToken', refreshToken);
reply.setCookie('deviceId', deviceId);
return {
accessToken,
refreshToken,
@ -109,9 +111,10 @@ export const userResolvers: IResolvers = {
},
login: async (
_,
{ email, password, deviceId },
{ prisma, token, reply }
{ email, password, deviceId: reqDeviceId },
{ prisma, token, reply, req }
) => {
const deviceId = req.cookies?.deviceId ?? reqDeviceId ?? uuidv4();
const user = await prisma.user.findUnique({
where: { email },
});
@ -128,19 +131,27 @@ export const userResolvers: IResolvers = {
role: 'user',
deviceId,
});
reply.setCookie('refreshToken', refreshToken);
reply.setCookie('deviceId', deviceId);
return {
user,
accessToken,
refreshToken,
};
},
logout: async (_, { deviceId }, { token, jwt, reply }) => {
jwt &&
(await token.revokeTokensByDevice({
logout: async (
_,
{ deviceId: reqDeviceId },
{ token, jwt, reply, req }
) => {
const deviceId = req.cookies?.deviceId ?? reqDeviceId ?? uuidv4();
if (jwt) {
await token.revokeTokensByDevice({
userId: jwt.sub,
deviceId,
}));
});
}
reply.clearCookie('refreshToken');
return true;
},
@ -152,13 +163,15 @@ export const userResolvers: IResolvers = {
}
),
refreshToken: withAuth<MutationResolvers['refreshToken']>(
async (_, { deviceId }, { token, jwt, reply }) => {
async (_, { deviceId: reqDeviceId }, { token, jwt, reply }) => {
const deviceId = reply.cookies?.['deviceId'] ?? reqDeviceId ?? uuidv4();
const { accessToken, refreshToken } = await token.rotateRefreshToken({
userId: jwt.sub,
oldToken: jwt.jti,
deviceId,
});
reply.setCookie('refreshToken', refreshToken);
reply.setCookie('deviceId', deviceId);
return {
accessToken,
refreshToken,

View file

@ -70,11 +70,11 @@ export default gql`
email: String!
username: String!
password: String!
deviceId: String!
deviceId: String
): AuthPayload!
login(email: String!, password: String!, deviceId: String!): AuthPayload!
logout(deviceId: String!): Boolean!
refreshToken(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!

View file

@ -38,12 +38,11 @@ export class MemcService {
* 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)
* @param ttl - Optional time-to-live in milliseconds (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);
return await this.keyv.set(key, value, ttl);
} catch (error) {
console.error('Error setting value in Memcached:', error);
return false;