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; +}