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:
@@ -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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user