From f9c6230101f64937520991d10a81a172026c3556 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Montoya Date: Mon, 10 Mar 2025 00:41:39 -0500 Subject: [PATCH] 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 --- .dockerignore | 11 +- apps/api/.dockerignore | 8 +- apps/api/src/config.ts | 6 +- apps/api/src/generated/graphql.ts | 19 +-- apps/api/src/index.ts | 7 +- apps/api/src/resolvers/user.ts | 33 +++-- apps/api/src/schema/index.ts | 8 +- apps/api/src/services/memc.service.ts | 5 +- apps/web/src/App.tsx | 173 ++++++++++++---------- apps/web/src/components/login-form.tsx | 16 +- apps/web/src/components/register-form.tsx | 11 +- apps/web/src/index.tsx | 11 +- apps/web/src/lib/graphql-client.ts | 5 +- docker-compose.yml | 8 +- turbo.json | 17 +-- 15 files changed, 190 insertions(+), 148 deletions(-) diff --git a/.dockerignore b/.dockerignore index 3387bff..b1e4dfa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,9 @@ -.env -node_modules +**/dist dist -.turbo +.env +.env.local +.git +.gitignore +node_modules +**/node_modules +.turbo \ No newline at end of file diff --git a/apps/api/.dockerignore b/apps/api/.dockerignore index 3387bff..faafc23 100644 --- a/apps/api/.dockerignore +++ b/apps/api/.dockerignore @@ -1,4 +1,8 @@ -.env -node_modules +**/dist dist +.env +.env.local +.git +.gitignore +node_modules .turbo diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 0382246..d0ea62d 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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: { diff --git a/apps/api/src/generated/graphql.ts b/apps/api/src/generated/graphql.ts index 94bb6ab..27a3095 100644 --- a/apps/api/src/generated/graphql.ts +++ b/apps/api/src/generated/graphql.ts @@ -140,21 +140,21 @@ export type MutationregisterArgs = { email: Scalars["String"]; username: Scalars["String"]; password: Scalars["String"]; - deviceId: Scalars["String"]; + deviceId?: InputMaybe; }; export type MutationloginArgs = { email: Scalars["String"]; password: Scalars["String"]; - deviceId: Scalars["String"]; + deviceId?: InputMaybe; }; export type MutationlogoutArgs = { - deviceId: Scalars["String"]; + deviceId?: InputMaybe; }; export type MutationrefreshTokenArgs = { - deviceId: Scalars["String"]; + deviceId?: InputMaybe; }; export type MutationcreateRoomArgs = { @@ -495,28 +495,25 @@ export type MutationResolvers< ResolversTypes["AuthPayload"], ParentType, ContextType, - RequireFields< - MutationregisterArgs, - "email" | "username" | "password" | "deviceId" - > + RequireFields >; login?: Resolver< ResolversTypes["AuthPayload"], ParentType, ContextType, - RequireFields + RequireFields >; logout?: Resolver< ResolversTypes["Boolean"], ParentType, ContextType, - RequireFields + Partial >; refreshToken?: Resolver< ResolversTypes["AuthPayload"], ParentType, ContextType, - RequireFields + Partial >; logoutAllDevices?: Resolver< ResolversTypes["Boolean"], diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 18e2a6f..389a42d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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, { diff --git a/apps/api/src/resolvers/user.ts b/apps/api/src/resolvers/user.ts index 8303ae4..4561b09 100644 --- a/apps/api/src/resolvers/user.ts +++ b/apps/api/src/resolvers/user.ts @@ -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( - 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, diff --git a/apps/api/src/schema/index.ts b/apps/api/src/schema/index.ts index e44c593..bc44386 100644 --- a/apps/api/src/schema/index.ts +++ b/apps/api/src/schema/index.ts @@ -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! diff --git a/apps/api/src/services/memc.service.ts b/apps/api/src/services/memc.service.ts index 9f0e129..8fff3e3 100644 --- a/apps/api/src/services/memc.service.ts +++ b/apps/api/src/services/memc.service.ts @@ -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 { 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; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ee33b53..40323d6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,6 @@ -import { createSignal, Show } from 'solid-js'; -import { Provider } from '@urql/solid'; -import { client } from './lib/graphql-client'; +import { createSignal, Show, onMount } from 'solid-js'; +import { gql } from '@urql/core'; +import { createMutation } from '@urql/solid'; import { LoginForm } from './components/login-form'; import { RegisterForm } from './components/register-form'; import { RoomList } from './components/room-list'; @@ -8,11 +8,18 @@ import { ChatRoom } from './components/chat-room'; import { CreateRoom } from './components/create-room'; import './App.css'; +const LOGOUT_MUTATION = gql` + mutation Logout { + logout + } +`; + function App() { const [isAuthenticated, setIsAuthenticated] = createSignal(false); const [userId, setUserId] = createSignal(''); const [selectedRoomId, setSelectedRoomId] = createSignal(null); const [showRegister, setShowRegister] = createSignal(false); + const [, logout] = createMutation(LOGOUT_MUTATION); // Function to generate avatar URL const getUserAvatarUrl = (userId: string, size: number = 40) => @@ -30,19 +37,27 @@ function App() { }; // Call checkAuth on component mount - checkAuth(); + onMount(() => { + checkAuth(); + }); const handleLoginSuccess = (_: string, id: string) => { setIsAuthenticated(true); setUserId(id); }; - const handleLogout = () => { - localStorage.removeItem('token'); - localStorage.removeItem('userId'); - setIsAuthenticated(false); - setUserId(''); - setSelectedRoomId(null); + const handleLogout = async () => { + try { + await logout({}); + } catch (error) { + console.error('Logout failed:', error); + } finally { + localStorage.removeItem('token'); + localStorage.removeItem('userId'); + setIsAuthenticated(false); + setUserId(''); + setSelectedRoomId(null); + } }; const handleSelectRoom = (roomId: string) => { @@ -54,81 +69,79 @@ function App() { }; return ( - -
-
-

Unreal Chat

- {isAuthenticated() && ( - - )} -
+
+
+

Unreal Chat

+ {isAuthenticated() && ( + + )} +
-
- -
- - -
- - } +
+ +
+ +
- } - > -
- -
- - - - -
- Select a room to start chatting or create a new one -
-
-
+ } + > + +
-
-
-
- + } + > +
+ + +
+ + + + +
+ Select a room to start chatting or create a new one +
+
+
+
+ + +
); } diff --git a/apps/web/src/components/login-form.tsx b/apps/web/src/components/login-form.tsx index 385556d..3cd7c91 100644 --- a/apps/web/src/components/login-form.tsx +++ b/apps/web/src/components/login-form.tsx @@ -5,7 +5,8 @@ import { createMutation } from '@urql/solid'; const LOGIN_MUTATION = gql` mutation Login($email: String!, $password: String!) { login(email: $email, password: $password) { - token + accessToken + refreshToken user { id username @@ -35,7 +36,10 @@ export function LoginForm(props: LoginFormProps) { } try { - const result = await login({ email: email(), password: password() }); + const result = await login({ + email: email(), + password: password(), + }); if (result.error) { setError(result.error.message); @@ -43,10 +47,12 @@ export function LoginForm(props: LoginFormProps) { } if (result.data?.login) { - const { token, user } = result.data.login; - localStorage.setItem('token', token); + const { accessToken, user } = result.data.login; + localStorage.setItem('token', accessToken); localStorage.setItem('userId', user.id); - props.onLoginSuccess(token, user.id); + props.onLoginSuccess(accessToken, user.id); + } else { + setError('Login failed: No data received from server'); } } catch (err) { setError(err instanceof Error ? err.message : 'An error occurred'); diff --git a/apps/web/src/components/register-form.tsx b/apps/web/src/components/register-form.tsx index bfcd403..5352a5a 100644 --- a/apps/web/src/components/register-form.tsx +++ b/apps/web/src/components/register-form.tsx @@ -5,7 +5,8 @@ import { createMutation } from '@urql/solid'; const REGISTER_MUTATION = gql` mutation Register($email: String!, $username: String!, $password: String!) { register(email: $email, username: $username, password: $password) { - token + accessToken + refreshToken user { id username @@ -54,10 +55,12 @@ export function RegisterForm(props: RegisterFormProps) { } if (result.data?.register) { - const { token, user } = result.data.register; - localStorage.setItem('token', token); + const { accessToken, user } = result.data.register; + localStorage.setItem('token', accessToken); localStorage.setItem('userId', user.id); - props.onRegisterSuccess(token, user.id); + props.onRegisterSuccess(accessToken, user.id); + } else { + setError('Registration failed: No data received from server'); } } catch (err) { setError(err instanceof Error ? err.message : 'An error occurred'); diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index c06b6be..38c8984 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -1,8 +1,17 @@ /* @refresh reload */ import { render } from 'solid-js/web'; +import { Provider } from '@urql/solid'; +import { client } from './lib/graphql-client'; import './index.css'; import App from './App.tsx'; const root = document.getElementById('root'); -render(() => , root!); +render( + () => ( + + + + ), + root! +); diff --git a/apps/web/src/lib/graphql-client.ts b/apps/web/src/lib/graphql-client.ts index 554c8c7..36fb596 100644 --- a/apps/web/src/lib/graphql-client.ts +++ b/apps/web/src/lib/graphql-client.ts @@ -10,8 +10,6 @@ const envSchema = z WS_URL: env.VITE_WS_URL, })); const { API_URL, WS_URL } = envSchema.parse(import.meta.env); -console.log('Current API_URL', API_URL); -console.log('Current WS_URL', WS_URL); // Create a WebSocket client for GraphQL subscriptions const wsClient = createWsClient({ @@ -40,10 +38,9 @@ export const client = createClient({ // For development, we'll add a simple header-based authentication fetchOptions: () => { const token = localStorage.getItem('token'); - const userId = localStorage.getItem('userId'); return { + credentials: 'include', headers: { - 'user-id': userId || '', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, }; diff --git a/docker-compose.yml b/docker-compose.yml index 10f1004..3292af5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,11 +6,11 @@ services: container_name: unreal-chat-api restart: unless-stopped environment: + - DATABASE_URL=${DATABASE_URL} - 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} @@ -19,10 +19,8 @@ services: - MINIO_PORT=${MINIO_PORT} - MINIO_SECRET_KEY=${MINIO_SECRET_KEY} - MINIO_USE_SSL=${MINIO_USE_SSL} - - NODE_ENV=production + - NODE_ENV=${NODE_ENV} - TOKEN_SECRET=${TOKEN_SECRET} - networks: - - default-network web: build: @@ -37,8 +35,6 @@ services: - NODE_ENV=production - VITE_API_URL=https://chat-api.jusemon.com/graphql - VITE_WS_URL=wss://chat-api.jusemon.com/graphql - networks: - - default-network networks: default: diff --git a/turbo.json b/turbo.json index 1055248..4e97ff0 100644 --- a/turbo.json +++ b/turbo.json @@ -3,23 +3,20 @@ "ui": "tui", "globalDependencies": [".env"], "globalEnv": [ + "DATABASE_URL", "ALLOWED_ORIGINS", "API_HOST", "API_PORT", "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", + "MINIO_ACCESS_KEY", + "MINIO_BUCKET_NAME", + "MINIO_ENDPOINT", + "MINIO_PORT", + "MINIO_SECRET_KEY", + "MINIO_USE_SSL", "NODE_ENV", - "TOKEN_ACCESS_EXPIRES_IN", - "TOKEN_REFRESH_EXPIRES_IN", "TOKEN_SECRET", "VITE_API_URL", "VITE_WS_URL"