feat(auth): implementa logica de recuperacao e mudanca de senha
This commit is contained in:
@@ -4,6 +4,11 @@ import { createError, 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'
|
||||
|
||||
@@ -21,6 +26,15 @@ interface RegisterBody {
|
||||
password?: unknown
|
||||
}
|
||||
|
||||
interface ForgotPasswordBody {
|
||||
email?: unknown
|
||||
}
|
||||
|
||||
interface ResetPasswordBody {
|
||||
token?: unknown
|
||||
new_password?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida e normaliza email/senha enviados no login.
|
||||
*
|
||||
@@ -59,6 +73,51 @@ function parseRegisterBody(body: RegisterBody) {
|
||||
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.
|
||||
*
|
||||
@@ -127,6 +186,130 @@ export async function handleRegister(event: H3Event) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ForgotPasswordBody>(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 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(config.issuer, 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<ResetPasswordBody>(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.
|
||||
|
||||
Reference in New Issue
Block a user