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
node_modules
**/dist
dist
.turbo
.env
.env.local
.git
.gitignore
node_modules
**/node_modules
.turbo

View file

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

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;

View file

@ -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<string | null>(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 (
<Provider value={client}>
<div class='app'>
<header class='app-header'>
<h1>Unreal Chat</h1>
{isAuthenticated() && (
<div class='user-profile'>
<img
src={getUserAvatarUrl(userId())}
alt='User avatar'
class='user-avatar'
/>
<button class='logout-button' onClick={handleLogout}>
Logout
</button>
</div>
)}
</header>
<div class='app'>
<header class='app-header'>
<h1>Unreal Chat</h1>
{isAuthenticated() && (
<div class='user-profile'>
<img
src={getUserAvatarUrl(userId())}
alt='User avatar'
class='user-avatar'
/>
<button class='logout-button' onClick={handleLogout}>
Logout
</button>
</div>
)}
</header>
<main class='app-main'>
<Show
when={isAuthenticated()}
fallback={
<div class='auth-container'>
<div class='auth-tabs'>
<button
class={!showRegister() ? 'active' : ''}
onClick={() => setShowRegister(false)}
>
Login
</button>
<button
class={showRegister() ? 'active' : ''}
onClick={() => setShowRegister(true)}
>
Register
</button>
</div>
<Show
when={showRegister()}
fallback={<LoginForm onLoginSuccess={handleLoginSuccess} />}
<main class='app-main'>
<Show
when={isAuthenticated()}
fallback={
<div class='auth-container'>
<div class='auth-tabs'>
<button
class={!showRegister() ? 'active' : ''}
onClick={() => setShowRegister(false)}
>
<RegisterForm onRegisterSuccess={handleLoginSuccess} />
</Show>
Login
</button>
<button
class={showRegister() ? 'active' : ''}
onClick={() => setShowRegister(true)}
>
Register
</button>
</div>
}
>
<div class='chat-container'>
<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>
<Show
when={showRegister()}
fallback={<LoginForm onLoginSuccess={handleLoginSuccess} />}
>
<RegisterForm onRegisterSuccess={handleLoginSuccess} />
</Show>
</div>
</Show>
</main>
</div>
</Provider>
}
>
<div class='chat-container'>
<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`
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');

View file

@ -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');

View file

@ -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(() => <App />, root!);
render(
() => (
<Provider value={client}>
<App />
</Provider>
),
root!
);

View file

@ -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}` } : {}),
},
};

View file

@ -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:

View file

@ -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"