server: auth module — register, login, JWT

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:41:39 +02:00
parent 8ad83348f4
commit c792f67e2d
2 changed files with 111 additions and 0 deletions
+54
View File
@@ -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),
};
});
};
+57
View File
@@ -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<UserWithPlayer> {
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<UserWithPlayer> {
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;
}