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

@ -1,4 +1,9 @@
.env **/dist
node_modules
dist dist
.turbo .env
.env.local
.git
.gitignore
node_modules
**/node_modules
.turbo

View file

@ -1,4 +1,8 @@
.env **/dist
node_modules
dist dist
.env
.env.local
.git
.gitignore
node_modules
.turbo .turbo

View file

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

View file

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

View file

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

View file

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

View file

@ -70,11 +70,11 @@ export default gql`
email: String! email: String!
username: String! username: String!
password: String! password: String!
deviceId: String! deviceId: String
): AuthPayload! ): AuthPayload!
login(email: String!, password: String!, deviceId: String!): AuthPayload! login(email: String!, password: String!, deviceId: String): AuthPayload!
logout(deviceId: String!): Boolean! logout(deviceId: String): Boolean!
refreshToken(deviceId: String!): AuthPayload! refreshToken(deviceId: String): AuthPayload!
logoutAllDevices: Boolean! logoutAllDevices: Boolean!
createRoom(name: String!, description: String, isPrivate: Boolean): Room! createRoom(name: String!, description: String, isPrivate: Boolean): Room!
joinRoom(roomId: ID!): Room! joinRoom(roomId: ID!): Room!

View file

@ -38,12 +38,11 @@ export class MemcService {
* Stores a value in Memcached * Stores a value in Memcached
* @param key - The key to store the value under * @param key - The key to store the value under
* @param value - The value to store * @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> { public async set(key: string, value: string, ttl?: number): Promise<boolean> {
try { try {
const ttlMilliseconds = ttl ? ttl * 1000 : undefined; return await this.keyv.set(key, value, ttl);
return await this.keyv.set(key, value, ttlMilliseconds);
} catch (error) { } catch (error) {
console.error('Error setting value in Memcached:', error); console.error('Error setting value in Memcached:', error);
return false; return false;

View file

@ -1,6 +1,6 @@
import { createSignal, Show } from 'solid-js'; import { createSignal, Show, onMount } from 'solid-js';
import { Provider } from '@urql/solid'; import { gql } from '@urql/core';
import { client } from './lib/graphql-client'; import { createMutation } from '@urql/solid';
import { LoginForm } from './components/login-form'; import { LoginForm } from './components/login-form';
import { RegisterForm } from './components/register-form'; import { RegisterForm } from './components/register-form';
import { RoomList } from './components/room-list'; import { RoomList } from './components/room-list';
@ -8,11 +8,18 @@ import { ChatRoom } from './components/chat-room';
import { CreateRoom } from './components/create-room'; import { CreateRoom } from './components/create-room';
import './App.css'; import './App.css';
const LOGOUT_MUTATION = gql`
mutation Logout {
logout
}
`;
function App() { function App() {
const [isAuthenticated, setIsAuthenticated] = createSignal(false); const [isAuthenticated, setIsAuthenticated] = createSignal(false);
const [userId, setUserId] = createSignal(''); const [userId, setUserId] = createSignal('');
const [selectedRoomId, setSelectedRoomId] = createSignal<string | null>(null); const [selectedRoomId, setSelectedRoomId] = createSignal<string | null>(null);
const [showRegister, setShowRegister] = createSignal(false); const [showRegister, setShowRegister] = createSignal(false);
const [, logout] = createMutation(LOGOUT_MUTATION);
// Function to generate avatar URL // Function to generate avatar URL
const getUserAvatarUrl = (userId: string, size: number = 40) => const getUserAvatarUrl = (userId: string, size: number = 40) =>
@ -30,19 +37,27 @@ function App() {
}; };
// Call checkAuth on component mount // Call checkAuth on component mount
checkAuth(); onMount(() => {
checkAuth();
});
const handleLoginSuccess = (_: string, id: string) => { const handleLoginSuccess = (_: string, id: string) => {
setIsAuthenticated(true); setIsAuthenticated(true);
setUserId(id); setUserId(id);
}; };
const handleLogout = () => { const handleLogout = async () => {
localStorage.removeItem('token'); try {
localStorage.removeItem('userId'); await logout({});
setIsAuthenticated(false); } catch (error) {
setUserId(''); console.error('Logout failed:', error);
setSelectedRoomId(null); } finally {
localStorage.removeItem('token');
localStorage.removeItem('userId');
setIsAuthenticated(false);
setUserId('');
setSelectedRoomId(null);
}
}; };
const handleSelectRoom = (roomId: string) => { const handleSelectRoom = (roomId: string) => {
@ -54,81 +69,79 @@ function App() {
}; };
return ( return (
<Provider value={client}> <div class='app'>
<div class='app'> <header class='app-header'>
<header class='app-header'> <h1>Unreal Chat</h1>
<h1>Unreal Chat</h1> {isAuthenticated() && (
{isAuthenticated() && ( <div class='user-profile'>
<div class='user-profile'> <img
<img src={getUserAvatarUrl(userId())}
src={getUserAvatarUrl(userId())} alt='User avatar'
alt='User avatar' class='user-avatar'
class='user-avatar' />
/> <button class='logout-button' onClick={handleLogout}>
<button class='logout-button' onClick={handleLogout}> Logout
Logout </button>
</button> </div>
</div> )}
)} </header>
</header>
<main class='app-main'> <main class='app-main'>
<Show <Show
when={isAuthenticated()} when={isAuthenticated()}
fallback={ fallback={
<div class='auth-container'> <div class='auth-container'>
<div class='auth-tabs'> <div class='auth-tabs'>
<button <button
class={!showRegister() ? 'active' : ''} class={!showRegister() ? 'active' : ''}
onClick={() => setShowRegister(false)} onClick={() => setShowRegister(false)}
>
Login
</button>
<button
class={showRegister() ? 'active' : ''}
onClick={() => setShowRegister(true)}
>
Register
</button>
</div>
<Show
when={showRegister()}
fallback={<LoginForm onLoginSuccess={handleLoginSuccess} />}
> >
<RegisterForm onRegisterSuccess={handleLoginSuccess} /> Login
</Show> </button>
<button
class={showRegister() ? 'active' : ''}
onClick={() => setShowRegister(true)}
>
Register
</button>
</div> </div>
}
>
<div class='chat-container'>
<aside class='sidebar'>
<CreateRoom onRoomCreated={handleSelectRoom} />
<RoomList
onSelectRoom={handleSelectRoom}
selectedRoomId={selectedRoomId() || undefined}
/>
</aside>
<main class='chat-main'> <Show
<Show when={selectedRoomId()}> when={showRegister()}
<ChatRoom fallback={<LoginForm onLoginSuccess={handleLoginSuccess} />}
roomId={selectedRoomId() || ''} >
userId={userId() || ''} <RegisterForm onRegisterSuccess={handleLoginSuccess} />
onLeaveRoom={handleLeaveRoom} </Show>
/>
</Show>
<Show when={!selectedRoomId()}>
<div class='select-room-message'>
Select a room to start chatting or create a new one
</div>
</Show>
</main>
</div> </div>
</Show> }
</main> >
</div> <div class='chat-container'>
</Provider> <aside class='sidebar'>
<CreateRoom onRoomCreated={handleSelectRoom} />
<RoomList
onSelectRoom={handleSelectRoom}
selectedRoomId={selectedRoomId() || undefined}
/>
</aside>
<main class='chat-main'>
<Show when={selectedRoomId()}>
<ChatRoom
roomId={selectedRoomId() || ''}
userId={userId() || ''}
onLeaveRoom={handleLeaveRoom}
/>
</Show>
<Show when={!selectedRoomId()}>
<div class='select-room-message'>
Select a room to start chatting or create a new one
</div>
</Show>
</main>
</div>
</Show>
</main>
</div>
); );
} }

View file

@ -5,7 +5,8 @@ import { createMutation } from '@urql/solid';
const LOGIN_MUTATION = gql` const LOGIN_MUTATION = gql`
mutation Login($email: String!, $password: String!) { mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) { login(email: $email, password: $password) {
token accessToken
refreshToken
user { user {
id id
username username
@ -35,7 +36,10 @@ export function LoginForm(props: LoginFormProps) {
} }
try { try {
const result = await login({ email: email(), password: password() }); const result = await login({
email: email(),
password: password(),
});
if (result.error) { if (result.error) {
setError(result.error.message); setError(result.error.message);
@ -43,10 +47,12 @@ export function LoginForm(props: LoginFormProps) {
} }
if (result.data?.login) { if (result.data?.login) {
const { token, user } = result.data.login; const { accessToken, user } = result.data.login;
localStorage.setItem('token', token); localStorage.setItem('token', accessToken);
localStorage.setItem('userId', user.id); 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) { } catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred'); setError(err instanceof Error ? err.message : 'An error occurred');

View file

@ -5,7 +5,8 @@ import { createMutation } from '@urql/solid';
const REGISTER_MUTATION = gql` const REGISTER_MUTATION = gql`
mutation Register($email: String!, $username: String!, $password: String!) { mutation Register($email: String!, $username: String!, $password: String!) {
register(email: $email, username: $username, password: $password) { register(email: $email, username: $username, password: $password) {
token accessToken
refreshToken
user { user {
id id
username username
@ -54,10 +55,12 @@ export function RegisterForm(props: RegisterFormProps) {
} }
if (result.data?.register) { if (result.data?.register) {
const { token, user } = result.data.register; const { accessToken, user } = result.data.register;
localStorage.setItem('token', token); localStorage.setItem('token', accessToken);
localStorage.setItem('userId', user.id); 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) { } catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred'); setError(err instanceof Error ? err.message : 'An error occurred');

View file

@ -1,8 +1,17 @@
/* @refresh reload */ /* @refresh reload */
import { render } from 'solid-js/web'; import { render } from 'solid-js/web';
import { Provider } from '@urql/solid';
import { client } from './lib/graphql-client';
import './index.css'; import './index.css';
import App from './App.tsx'; import App from './App.tsx';
const root = document.getElementById('root'); const root = document.getElementById('root');
render(() => <App />, root!); render(
() => (
<Provider value={client}>
<App />
</Provider>
),
root!
);

View file

@ -10,8 +10,6 @@ const envSchema = z
WS_URL: env.VITE_WS_URL, WS_URL: env.VITE_WS_URL,
})); }));
const { API_URL, WS_URL } = envSchema.parse(import.meta.env); 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 // Create a WebSocket client for GraphQL subscriptions
const wsClient = createWsClient({ const wsClient = createWsClient({
@ -40,10 +38,9 @@ export const client = createClient({
// For development, we'll add a simple header-based authentication // For development, we'll add a simple header-based authentication
fetchOptions: () => { fetchOptions: () => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const userId = localStorage.getItem('userId');
return { return {
credentials: 'include',
headers: { headers: {
'user-id': userId || '',
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
}; };

View file

@ -6,11 +6,11 @@ services:
container_name: unreal-chat-api container_name: unreal-chat-api
restart: unless-stopped restart: unless-stopped
environment: environment:
- DATABASE_URL=${DATABASE_URL}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
- API_HOST=${API_HOST} - API_HOST=${API_HOST}
- API_PORT=${API_PORT} - API_PORT=${API_PORT}
- COOKIE_SECRET=${COOKIE_SECRET} - COOKIE_SECRET=${COOKIE_SECRET}
- DATABASE_URL=${DATABASE_URL}
- MEMC_HOST=${MEMC_HOST} - MEMC_HOST=${MEMC_HOST}
- MEMC_PORT=${MEMC_PORT} - MEMC_PORT=${MEMC_PORT}
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY} - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
@ -19,10 +19,8 @@ services:
- MINIO_PORT=${MINIO_PORT} - MINIO_PORT=${MINIO_PORT}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY} - MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- MINIO_USE_SSL=${MINIO_USE_SSL} - MINIO_USE_SSL=${MINIO_USE_SSL}
- NODE_ENV=production - NODE_ENV=${NODE_ENV}
- TOKEN_SECRET=${TOKEN_SECRET} - TOKEN_SECRET=${TOKEN_SECRET}
networks:
- default-network
web: web:
build: build:
@ -37,8 +35,6 @@ services:
- NODE_ENV=production - NODE_ENV=production
- VITE_API_URL=https://chat-api.jusemon.com/graphql - VITE_API_URL=https://chat-api.jusemon.com/graphql
- VITE_WS_URL=wss://chat-api.jusemon.com/graphql - VITE_WS_URL=wss://chat-api.jusemon.com/graphql
networks:
- default-network
networks: networks:
default: default:

View file

@ -3,23 +3,20 @@
"ui": "tui", "ui": "tui",
"globalDependencies": [".env"], "globalDependencies": [".env"],
"globalEnv": [ "globalEnv": [
"DATABASE_URL",
"ALLOWED_ORIGINS", "ALLOWED_ORIGINS",
"API_HOST", "API_HOST",
"API_PORT", "API_PORT",
"COOKIE_SECRET", "COOKIE_SECRET",
"MINIO_ENDPOINT",
"MINIO_PORT",
"MINIO_ACCESS_KEY",
"MINIO_SECRET_KEY",
"MINIO_BUCKET_NAME",
"MINIO_USE_SSL",
"MEMC_HOST", "MEMC_HOST",
"MEMC_PORT", "MEMC_PORT",
"MEMC_TTL", "MINIO_ACCESS_KEY",
"MEMC_NAMESPACE", "MINIO_BUCKET_NAME",
"MINIO_ENDPOINT",
"MINIO_PORT",
"MINIO_SECRET_KEY",
"MINIO_USE_SSL",
"NODE_ENV", "NODE_ENV",
"TOKEN_ACCESS_EXPIRES_IN",
"TOKEN_REFRESH_EXPIRES_IN",
"TOKEN_SECRET", "TOKEN_SECRET",
"VITE_API_URL", "VITE_API_URL",
"VITE_WS_URL" "VITE_WS_URL"