From 204b72bb94c4d38f23244b0a561ad0eb8aa94f95 Mon Sep 17 00:00:00 2001 From: Raffi Date: Mon, 6 Apr 2026 07:21:55 +0200 Subject: [PATCH] google connection --- prisma/schema.prisma | 4 +- src/errors/AppError.ts | 5 +- src/routes/auth.ts | 31 ++++++--- src/schemas/auth.schema.ts | 1 - src/schemas/google.schema.ts | 7 +++ src/services/auth.service.ts | 1 + src/services/google.service.ts | 112 +++++++++++++++++++++++++++++++++ 7 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 src/schemas/google.schema.ts create mode 100644 src/services/google.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8d25dd3..4c594bf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,11 +21,11 @@ model ActionToken { model User { id String @id email String @unique - passwordHash String + passwordHash String? displayName String tokenVersion Int @default(0) isConfirmed Boolean @default(false) - isGoogleUser Boolean @default(false) + googleId String? @unique avatar String publicKey String? encryptedPrivateKey String? diff --git a/src/errors/AppError.ts b/src/errors/AppError.ts index f15a177..19bdce2 100644 --- a/src/errors/AppError.ts +++ b/src/errors/AppError.ts @@ -18,6 +18,9 @@ export const Errors = { INVALID_CREDENTIALS: new AppError('INVALID_CREDENTIALS', 401, 'Email ou mot de passe incorrect.'), VALIDATION_ERROR: (message: string) => new AppError('VALIDATION_ERROR', 400, message), + // Google auth errors + INVALID_GOOGLE_TOKEN: new AppError('INVALID_GOOGLE_TOKEN', 401, 'Google token invalide ou expiré.'), + //Action token errors INVALID_TOKEN: new AppError('INVALID_TOKEN', 400, 'Invalid or already used token'), TOKEN_EXPIRED: new AppError('TOKEN_EXPIRED', 400, 'Token has expired'), @@ -26,7 +29,7 @@ export const Errors = { // Email change errors EMAIL_ALREADY_YOURS: new AppError('EMAIL_ALREADY_YOURS', 409, 'This email is already yours'), EMAIL_ALREADY_USED: new AppError('EMAIL_ALREADY_USED', 409, 'This email is already in use'), - //acount deletion errors + // Account deletion errors ACCOUNT_PENDING_DELETION: new AppError('ACCOUNT_PENDING_DELETION', 403, 'Account is scheduled for deletion. Check your emails to cancel.'), ACCOUNT_ALREADY_PENDING_DELETION: new AppError('ACCOUNT_ALREADY_PENDING_DELETION', 409, 'Account is already scheduled for deletion.'), //Auth errors diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 2490e2b..d25aebb 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,44 +1,41 @@ import { FastifyInstance } from 'fastify' import { RegisterSchema, LoginSchema } from '../schemas/auth.schema.js' import { registerUser, loginUser, logoutUser } from '../services/auth.service.js' +import { googleLoginOrRegister } from '../services/google.service.js' +import { GoogleAuthSchema } from '../schemas/google.schema.js' import { signAuthToken } from '../services/authToken.service.js' import { verifyAuth } from '../middleware/verifyAuth.js' +import { Errors } from '../errors/AppError.js' export default async function authRoutes(fastify: FastifyInstance) { + fastify.post('/auth/register', async (request, reply) => { const body = RegisterSchema.parse(request.body) const lang = request.headers['accept-language'] ?.split(',')[0] .split('-')[0] as 'fr' | 'en' const validLang = ['fr', 'en'].includes(lang) ? lang : 'fr' - const { user } = await registerUser(fastify.prisma, fastify.mailer, body, validLang) - const token = signAuthToken(fastify, { userId: user.id, tokenVersion: 0 }) - reply.setCookie('authToken', token, { httpOnly: true, sameSite: 'strict', maxAge: 60 * 60 * 24 * 7, path: '/', }) - return reply.status(201).send({ user }) }) fastify.post('/auth/login', async (request, reply) => { const body = LoginSchema.parse(request.body) const { user } = await loginUser(fastify.prisma, body) - const token = signAuthToken(fastify, { userId: user.id, tokenVersion: user.tokenVersion }) - reply.setCookie('authToken', token, { httpOnly: true, sameSite: 'strict', maxAge: 60 * 60 * 24 * 7, path: '/', }) - return reply.status(200).send({ user }) }) @@ -47,4 +44,24 @@ export default async function authRoutes(fastify: FastifyInstance) { reply.clearCookie('authToken', { path: '/' }) return reply.status(200).send({ message: 'Déconnecté avec succès' }) }) + + fastify.post('/auth/google', async (request, reply) => { + const { access_token } = GoogleAuthSchema.parse(request.body) + const lang = request.headers['accept-language'] + ?.split(',')[0] + .split('-')[0] as 'fr' | 'en' + const validLang = ['fr', 'en'].includes(lang) ? lang : 'fr' + const { user } = await googleLoginOrRegister(fastify.prisma, fastify.mailer, access_token, validLang) + + if (!user) throw Errors.USER_NOT_FOUND + const token = signAuthToken(fastify, { userId: user.id, tokenVersion: user.tokenVersion }) + reply.setCookie('authToken', token, { + httpOnly: true, + sameSite: 'strict', + maxAge: 60 * 60 * 24 * 7, + path: '/', + }) + return reply.status(200).send({ user }) + }) + } \ No newline at end of file diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts index 4b4aed3..d12b5ef 100644 --- a/src/schemas/auth.schema.ts +++ b/src/schemas/auth.schema.ts @@ -14,4 +14,3 @@ export const LoginSchema = z.object({ }) export type LoginInput = z.infer - diff --git a/src/schemas/google.schema.ts b/src/schemas/google.schema.ts new file mode 100644 index 0000000..8dbfb0a --- /dev/null +++ b/src/schemas/google.schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const GoogleAuthSchema = z.object({ + access_token: z.string().min(1), +}) + +export type GoogleAuthInput = z.infer \ No newline at end of file diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 2e1edb9..26747d2 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -56,6 +56,7 @@ export async function registerUser( export async function loginUser(prisma: PrismaClient, input: LoginInput) { const user = await prisma.user.findUnique({ where: { email: input.email } }) if (!user) throw Errors.INVALID_CREDENTIALS + if (!user.passwordHash) throw Errors.INVALID_CREDENTIALS const valid = await argon2.verify(user.passwordHash, input.password) if (!valid) throw Errors.INVALID_CREDENTIALS diff --git a/src/services/google.service.ts b/src/services/google.service.ts new file mode 100644 index 0000000..afeff89 --- /dev/null +++ b/src/services/google.service.ts @@ -0,0 +1,112 @@ +import crypto from 'crypto' +import { PrismaClient } from '../generated/prisma/client.js' +import { Transporter } from 'nodemailer' +import { Errors } from '../errors/AppError.js' +import { generateGravatarUrl } from './avatar.service.js' +import { sendConfirmationMail } from './mailing.service.js' +import { createActionToken } from './actionToken.service.js' + +interface GoogleUserInfo { + sub: string + email: string + name: string + picture: string + email_verified: boolean +} + +async function verifyGoogleToken(accessToken: string): Promise { + const res = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!res.ok) throw Errors.INVALID_GOOGLE_TOKEN + + return res.json() as Promise +} + +export async function googleLoginOrRegister( + prisma: PrismaClient, + mailer: Transporter, + accessToken: string, + lang: 'fr' | 'en' = 'fr' +) { + const googleUser = await verifyGoogleToken(accessToken) + + const existing = await prisma.user.findFirst({ + where: { + OR: [ + { googleId: googleUser.sub }, + { email: googleUser.email }, + ], + }, + }) + + // Email en DB mais compte classique => merge requis, on ne fait rien + if (existing && !existing.googleId) { + return { mergeRequired: true as const } + } + + // Compte google existant + if (existing && existing.googleId) { + if (existing.scheduledDeletionAt) throw Errors.ACCOUNT_PENDING_DELETION + + return { + mergeRequired: false as const, + user: { + id: existing.id, + email: existing.email, + displayName: existing.displayName, + avatar: existing.avatar, + isConfirmed: existing.isConfirmed, + createdAt: existing.createdAt, + tokenVersion: existing.tokenVersion, + }, + } + } + + // Nouveau user Google + const isConfirmed = googleUser.email_verified + + const newUser = await prisma.user.create({ + data: { + id: crypto.randomUUID(), + email: googleUser.email, + googleId: googleUser.sub, + displayName: googleUser.name, + avatar: googleUser.picture, + passwordHash: null, + isConfirmed, + }, + select: { + id: true, + email: true, + displayName: true, + avatar: true, + isConfirmed: true, + createdAt: true, + tokenVersion: true, + }, + }) + + await prisma.userPreference.create({ + data: { + id: crypto.randomUUID(), + userId: newUser.id, + language: lang, + theme: 'light', + }, + }) + + if (!isConfirmed) { + const confirmToken = await createActionToken(prisma, newUser.id, 'email-confirm', 1440) + await sendConfirmationMail(mailer, newUser.email, confirmToken, lang) + } + + return { + mergeRequired: false as const, + user: { + ...newUser, + tokenVersion: 0, + }, + } +} \ No newline at end of file