google connection

This commit is contained in:
2026-04-06 07:21:55 +02:00
parent 845ec655d1
commit 204b72bb94
7 changed files with 150 additions and 11 deletions

View File

@@ -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?

View File

@@ -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

View File

@@ -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 })
})
}

View File

@@ -14,4 +14,3 @@ export const LoginSchema = z.object({
})
export type LoginInput = z.infer<typeof LoginSchema>

View File

@@ -0,0 +1,7 @@
import { z } from 'zod'
export const GoogleAuthSchema = z.object({
access_token: z.string().min(1),
})
export type GoogleAuthInput = z.infer<typeof GoogleAuthSchema>

View File

@@ -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

View File

@@ -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<GoogleUserInfo> {
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<GoogleUserInfo>
}
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,
},
}
}