import { Prisma } from '@prisma/client' import { createError, getRequestURL, readBody, setResponseStatus, type H3Event } from 'h3' import { signAccessToken } from './jwt' import { getAuthRuntimeConfig } from './auth-config' import { hashPassword, verifyPassword } from './password' import { buildPasswordResetPreviewUrl, generateRawPasswordResetToken, hashPasswordResetToken } from './password-reset-token' import { prisma } from './prisma' import { issueRefreshToken, rotateRefreshToken } from './refresh-token' interface LoginBody { email?: unknown password?: unknown } interface RefreshBody { refresh_token?: unknown } interface RegisterBody { email?: unknown password?: unknown } interface ForgotPasswordBody { email?: unknown } interface ResetPasswordBody { token?: unknown new_password?: unknown } /** * Valida e normaliza email/senha enviados no login. * * @param body Corpo bruto da requisição de login. * @returns Email normalizado e senha pronta para validação. * @throws {H3Error} Quando email ou senha não forem informados. */ function parseCredentialBody(body: LoginBody) { const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '' const password = typeof body.password === 'string' ? body.password : '' if (!email || !password) { throw createError({ statusCode: 400, statusMessage: 'email and password are required' }) } return { email, password } } /** * Valida os dados de cadastro e aplica regra mínima de senha. * * @param body Corpo bruto da requisição de cadastro. * @returns Email normalizado e senha pronta para persistência. * @throws {H3Error} Quando os dados forem inválidos. */ function parseRegisterBody(body: RegisterBody) { const { email, password } = parseCredentialBody(body) if (password.length < 6) { throw createError({ statusCode: 400, statusMessage: 'password must have at least 6 characters' }) } return { email, password } } /** * Valida o payload de solicitação da recuperação de senha. * * @param body Corpo bruto da requisição de forgot password. * @returns Email normalizado para busca do usuário. * @throws {H3Error} Quando o email não for informado. */ function parseForgotPasswordBody(body: ForgotPasswordBody): string { const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '' if (!email) { throw createError({ statusCode: 400, statusMessage: 'email is required' }) } return email } /** * Valida token e nova senha no fluxo de redefinição. * * @param body Corpo bruto da requisição de reset. * @returns Token de recuperação e nova senha. * @throws {H3Error} Quando dados obrigatórios faltarem ou senha for curta. */ function parseResetPasswordBody(body: ResetPasswordBody) { const token = typeof body.token === 'string' ? body.token.trim() : '' const newPassword = typeof body.new_password === 'string' ? body.new_password : '' if (!token || !newPassword) { throw createError({ statusCode: 400, statusMessage: 'token and new_password are required' }) } if (newPassword.length < 6) { throw createError({ statusCode: 400, statusMessage: 'password must have at least 6 characters' }) } return { token, newPassword } } /** * Valida o refresh token enviado no corpo da requisição. * * @param body Corpo bruto da requisição de refresh. * @returns Refresh token em formato de string. * @throws {H3Error} Quando `refresh_token` não for informado. */ function parseRefreshBody(body: RefreshBody): string { const refreshToken = typeof body.refresh_token === 'string' ? body.refresh_token.trim() : '' if (!refreshToken) { throw createError({ statusCode: 400, statusMessage: 'refresh_token is required' }) } return refreshToken } /** * Executa o fluxo de cadastro: * valida entrada, cria usuário e retorna dados básicos sem autenticar. * * @param event Evento HTTP da requisição. * @returns Usuário criado no formato público. * @throws {H3Error} Quando houver dados inválidos ou email já cadastrado. */ export async function handleRegister(event: H3Event) { const body = await readBody(event) const { email, password } = parseRegisterBody(body ?? {}) try { const createdUser = await prisma.user.create({ data: { email, passwordHash: hashPassword(password) }, select: { id: true, email: true, createdAt: true, updatedAt: true } }) setResponseStatus(event, 201) return { id: createdUser.id, email: createdUser.email, created_at: createdUser.createdAt.toISOString(), updated_at: createdUser.updatedAt.toISOString() } } catch (error) { if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' && Array.isArray(error.meta?.target) && error.meta.target.includes('email') ) { throw createError({ statusCode: 409, statusMessage: 'Email já cadastrado' }) } throw error } } /** * Inicia a recuperação de senha e retorna preview didático do token/link. * A resposta é sempre genérica para não expor se o email existe. * * @param event Evento HTTP da requisição. * @returns Mensagem genérica e dados de recuperação para testes. * @throws {H3Error} Quando o email não for informado. */ export async function handleForgotPassword(event: H3Event) { const config = getAuthRuntimeConfig(event) const body = await readBody(event) const email = parseForgotPasswordBody(body ?? {}) const now = new Date() const expiresAt = new Date(now.getTime() + config.passwordResetTtlSec * 1000) const rawResetToken = generateRawPasswordResetToken() const tokenHash = hashPasswordResetToken(rawResetToken, config.passwordResetTokenPepper) const resetBaseUrl = config.passwordResetBaseUrl || getRequestURL(event).origin const user = await prisma.user.findUnique({ where: { email }, select: { id: true } }) if (user) { await prisma.$transaction([ prisma.passwordResetToken.updateMany({ where: { userId: user.id, usedAt: null }, data: { usedAt: now } }), prisma.passwordResetToken.create({ data: { userId: user.id, tokenHash, expiresAt } }) ]) } return { message: 'If the email exists, recovery instructions were generated', recovery: { reset_token: rawResetToken, reset_url: buildPasswordResetPreviewUrl(resetBaseUrl, rawResetToken), expires_in: config.passwordResetTtlSec } } } /** * Finaliza a recuperação de senha com token de uso único. * Também revoga todos os refresh tokens ativos do usuário. * * @param event Evento HTTP da requisição. * @returns Mensagem de sucesso após redefinir senha. * @throws {H3Error} Quando token for inválido/expirado/usado ou payload inválido. */ export async function handleResetPassword(event: H3Event) { const config = getAuthRuntimeConfig(event) const body = await readBody(event) const { token, newPassword } = parseResetPasswordBody(body ?? {}) const tokenHash = hashPasswordResetToken(token, config.passwordResetTokenPepper) const existingToken = await prisma.passwordResetToken.findUnique({ where: { tokenHash }, select: { id: true, userId: true, expiresAt: true, usedAt: true } }) if (!existingToken || existingToken.usedAt || existingToken.expiresAt.getTime() <= Date.now()) { throw createError({ statusCode: 401, statusMessage: 'Invalid or expired reset token' }) } const now = new Date() await prisma.$transaction(async (tx) => { const consumeResult = await tx.passwordResetToken.updateMany({ where: { id: existingToken.id, usedAt: null, expiresAt: { gt: now } }, data: { usedAt: now } }) if (consumeResult.count !== 1) { throw createError({ statusCode: 401, statusMessage: 'Invalid or expired reset token' }) } await tx.user.update({ where: { id: existingToken.userId }, data: { passwordHash: hashPassword(newPassword) } }) await tx.refreshToken.updateMany({ where: { userId: existingToken.userId, revokedAt: null }, data: { revokedAt: now } }) }) return { message: 'Password updated successfully' } } /** * Executa o fluxo de login: * valida credenciais, gera access token e emite refresh token. * * @param event Evento HTTP da requisição. * @returns Resposta padrão de autenticação para o cliente. * @throws {H3Error} Quando as credenciais forem inválidas ou faltarem dados. */ export async function handleLogin(event: H3Event) { const config = getAuthRuntimeConfig(event) const body = await readBody(event) const { email, password } = parseCredentialBody(body ?? {}) const user = await prisma.user.findUnique({ where: { email } }) if (!user || !verifyPassword(password, user.passwordHash)) { throw createError({ statusCode: 401, statusMessage: 'Credenciais inválidas!' }) } const accessToken = await signAccessToken(event, { sub: user.id }) const refreshToken = await issueRefreshToken(event, user.id) return { access_token: accessToken, refresh_token: refreshToken.token, token_type: 'Bearer', expires_in: config.accessTtlSec } } /** * Executa o fluxo de refresh: * valida o refresh token, rotaciona e retorna novo access token. * * @param event Evento HTTP da requisição. * @returns Novo par de tokens para continuar a sessão. * @throws {H3Error} Quando o refresh token for inválido ou ausente. */ export async function handleRefresh(event: H3Event) { const config = getAuthRuntimeConfig(event) const body = await readBody(event) const incomingRefreshToken = parseRefreshBody(body ?? {}) const rotated = await rotateRefreshToken(event, incomingRefreshToken) const accessToken = await signAccessToken(event, { sub: rotated.user.id }) return { access_token: accessToken, refresh_token: rotated.token, token_type: 'Bearer', expires_in: config.accessTtlSec } }