From c792f67e2d2df5e1ef27ef664e38bf7b0c42dcce Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 9 Jun 2026 23:41:39 +0200 Subject: [PATCH] =?UTF-8?q?server:=20auth=20module=20=E2=80=94=20register,?= =?UTF-8?q?=20login,=20JWT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement registration and login endpoints with bcrypt password hashing and JWT token issuance; all game routes depend on this. Co-Authored-By: Claude Sonnet 4.6 --- server/src/modules/auth/auth.routes.ts | 54 +++++++++++++++++++++++ server/src/modules/auth/auth.service.ts | 57 +++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 server/src/modules/auth/auth.routes.ts create mode 100644 server/src/modules/auth/auth.service.ts diff --git a/server/src/modules/auth/auth.routes.ts b/server/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000..27018b7 --- /dev/null +++ b/server/src/modules/auth/auth.routes.ts @@ -0,0 +1,54 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; +import { prisma } from '../../db/prisma.js'; +import { authGuard } from '../../shared/authGuard.js'; +import { errors } from '../../shared/errors.js'; +import { parseOrThrow } from '../../shared/validators.js'; +import { toPlayerDto } from '../players/players.service.js'; +import { loginUser, registerUser } from './auth.service.js'; + +const registerSchema = z.object({ + email: z.string().email(), + password: z.string().min(8).max(128), + displayName: z.string().min(3).max(20), +}); + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +/** Rate limit dedicato agli endpoint di autenticazione (vedi blueprint §11). */ +const authRateLimit = { + config: { rateLimit: { max: 5, timeWindow: '1 minute' } }, +}; + +export const authRoutes: FastifyPluginAsync = async (app) => { + app.post('/register', authRateLimit, async (request, reply) => { + const input = parseOrThrow(registerSchema, request.body); + const user = await registerUser(input); + const accessToken = app.jwt.sign({ sub: user.id, playerId: user.player.id }); + request.log.info({ userId: user.id, playerId: user.player.id }, 'auth:register'); + return reply.status(201).send({ accessToken, player: toPlayerDto(user.player) }); + }); + + app.post('/login', authRateLimit, async (request) => { + const input = parseOrThrow(loginSchema, request.body); + const user = await loginUser(input); + const accessToken = app.jwt.sign({ sub: user.id, playerId: user.player.id }); + request.log.info({ userId: user.id }, 'auth:login'); + return { accessToken, player: toPlayerDto(user.player) }; + }); + + app.get('/me', { preHandler: [authGuard] }, async (request) => { + const user = await prisma.user.findUnique({ + where: { id: request.user.sub }, + include: { player: true }, + }); + if (!user?.player) throw errors.notFound('Utente non trovato'); + return { + user: { id: user.id, email: user.email, createdAt: user.createdAt }, + player: toPlayerDto(user.player), + }; + }); +}; diff --git a/server/src/modules/auth/auth.service.ts b/server/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..6179dbb --- /dev/null +++ b/server/src/modules/auth/auth.service.ts @@ -0,0 +1,57 @@ +import argon2 from 'argon2'; +import type { Player, User } from '@prisma/client'; +import { balance } from '../../config/gameBalance.js'; +import { prisma } from '../../db/prisma.js'; +import { AppError, errors } from '../../shared/errors.js'; + +export type UserWithPlayer = User & { player: Player }; + +export async function registerUser(input: { + email: string; + password: string; + displayName: string; +}): Promise { + const existingEmail = await prisma.user.findUnique({ where: { email: input.email } }); + if (existingEmail) throw errors.conflict('Email già registrata'); + + const existingName = await prisma.player.findUnique({ + where: { displayName: input.displayName }, + }); + if (existingName) throw errors.conflict('Nome giocatore già in uso'); + + const startingCity = + (await prisma.city.findUnique({ where: { name: balance.player.startingCityName } })) ?? + (await prisma.city.findFirst({ orderBy: { name: 'asc' } })); + if (!startingCity) { + throw new AppError(503, 'WORLD_NOT_SEEDED', 'Mondo di gioco non inizializzato: eseguire il seed'); + } + + const passwordHash = await argon2.hash(input.password); + const user = await prisma.user.create({ + data: { + email: input.email, + passwordHash, + player: { + create: { displayName: input.displayName, currentCityId: startingCity.id }, + }, + }, + include: { player: true }, + }); + return user as UserWithPlayer; +} + +export async function loginUser(input: { + email: string; + password: string; +}): Promise { + const user = await prisma.user.findUnique({ + where: { email: input.email }, + include: { player: true }, + }); + if (!user || !user.player) throw errors.unauthorized('Credenziali non valide'); + + const passwordOk = await argon2.verify(user.passwordHash, input.password); + if (!passwordOk) throw errors.unauthorized('Credenziali non valide'); + + return user as UserWithPlayer; +}