diff --git a/src/components/changePassword/index.tsx b/src/components/changePassword/index.tsx new file mode 100644 index 0000000..0b30bd7 --- /dev/null +++ b/src/components/changePassword/index.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import { Button } from "../button"; +import { Alert, SuccessAlert } from "../alert"; +import { PasswordInput } from "../password"; +import { IChangePasswordProps } from "@/pages/changePassword"; + +export default function ChangePassword({ + token, +}: IChangePasswordProps): JSX.Element { + const [error, setError] = useState(null); + const [passHasBeenChanged, setPassHasBeenChanged] = useState(false); + return ( +
+
+ OFMI +
+

Cambiar contraseña

+
+
+
+ {error && } + {passHasBeenChanged ? ( + + ) : ( +
{ + ev.preventDefault(); + setError(null); + const data = new FormData(ev.currentTarget); + const pass = data.get("password")?.toString(); + const passConfirm = data.get("confirmPassword")?.toString(); + if (pass == null || passConfirm == null) { + setError(new Error("Todos los campos son requeridos")); + return; + } + if (pass !== passConfirm) { + setError(new Error("Las contraseñas no coinciden")); + return; + } + if (pass.length < 8) { + setError( + new Error("La contraseña debe tener al menos 8 caracteres"), + ); + return; + } + const response = await fetch("/api/user/changePassword", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token, + password: pass, + }), + }); + const req = await response.json(); + if (response.status !== 200) { + setError(new Error(req.message)); + return; + } + setPassHasBeenChanged(true); + }} + > +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+ )} +
+
+ ); +} diff --git a/src/components/forgotPassword/index.tsx b/src/components/forgotPassword/index.tsx new file mode 100644 index 0000000..1e0cd63 --- /dev/null +++ b/src/components/forgotPassword/index.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { Button } from "../button"; +import { SuccessAlert } from "../alert"; + +export default function ForgotPassword(): JSX.Element { + const [emailHasBeenSent, setEmailHasBeenSet] = useState(false); + return ( +
+
+ OFMI +
+

Recuperar cuenta

+
+
+
+ {emailHasBeenSent ? ( + + ) : ( +
{ + ev.preventDefault(); + const data = new FormData(ev.currentTarget); + const email = data.get("email")?.toString(); + await fetch("/api/user/resetPassword", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email, + }), + }); + setEmailHasBeenSet(true); + }} + > +
+ +
+ +
+
+
+ +
+
+ )} +
+
+ ); +} diff --git a/src/components/login/index.tsx b/src/components/login/index.tsx index 59e1fe1..04d45a0 100644 --- a/src/components/login/index.tsx +++ b/src/components/login/index.tsx @@ -81,7 +81,6 @@ export default function Login({ } async function handleResendEmailVerification(email: string): Promise { - console.log("resend email verification"); const response = await resendEmailVerification({ email }); if (!response.success) { console.log("error", response.error); @@ -141,7 +140,7 @@ export default function Login({
¿Olvidaste tu contraseña? diff --git a/src/lib/emailVerificationToken.ts b/src/lib/emailVerificationToken.ts index 710b400..0881f34 100644 --- a/src/lib/emailVerificationToken.ts +++ b/src/lib/emailVerificationToken.ts @@ -12,7 +12,7 @@ const VERIFICATION_EMAIL_SECRET_KEY = "VERIFICATION_EMAIL_SECRET"; export type verificationEmailToken = Static< typeof verificationEmailTokenSchema >; -const verificationEmailTokenSchema = Type.Object({ +export const verificationEmailTokenSchema = Type.Object({ userAuthId: Type.String(), // User auth id }); diff --git a/src/lib/emailer/index.ts b/src/lib/emailer/index.ts index ffa6d2e..9124450 100644 --- a/src/lib/emailer/index.ts +++ b/src/lib/emailer/index.ts @@ -5,6 +5,8 @@ import { ofmiRegistrationCompleteTemplate, signUpSuccessfulEmailTemplate, OFMI_EMAIL_SMTP_USER_KEY, + passwordRecoveryAttemptTemplate, + successfulPasswordRecoveryTemplate, } from "./template"; import config from "@/config/default"; import { getSecretOrError } from "../secret"; @@ -46,6 +48,17 @@ export class Emailer { ): Promise { await this.sendEmail(ofmiRegistrationCompleteTemplate(email, gDriveFolder)); } + + public async notifyPasswordRecoveryAttempt( + email: string, + url: string, + ): Promise { + await this.sendEmail(passwordRecoveryAttemptTemplate(email, url)); + } + + public async notifySuccessfulPasswordRecovery(email: string): Promise { + await this.sendEmail(successfulPasswordRecoveryTemplate(email)); + } } export const emailer = new Emailer(); diff --git a/src/lib/emailer/template.ts b/src/lib/emailer/template.ts index 06af831..69182d5 100644 --- a/src/lib/emailer/template.ts +++ b/src/lib/emailer/template.ts @@ -82,3 +82,41 @@ export const ofmiRegistrationCompleteTemplate = ( `, }; }; + +export const passwordRecoveryAttemptTemplate = ( + email: string, + url: string, +): MailOptions => { + return { + from: getSecretOrError(OFMI_EMAIL_SMTP_USER_KEY), + to: email, + subject: "Recuperacion de contraseña de la OFMI", + text: "Recuperacion de contraseña de la OFMI", + html: ` +

Estás recibiendo este correo porque se recibió una solicitud de recuperación de tu contraseña de la OFMI.

+

Para continuar, haz click en el siguiente enlace.

+

Si no realizaste esta solicitud o tienes alguna duda, por favor envía un correo a + ofmi@omegaup.com

+
+

Equipo organizador de la OFMI

+ `, + }; +}; + +export const successfulPasswordRecoveryTemplate = ( + email: string, +): MailOptions => { + return { + from: getSecretOrError(OFMI_EMAIL_SMTP_USER_KEY), + to: email, + subject: "Actualizacion de contraseña de la OFMI", + text: "Actualizacion de contraseña de la OFMI", + html: ` +

Estás recibiendo este correo porque la contraseña de tu cuenta de la OFMI ha sido cambiada.

+

Si no realizaste este cambio o tienes alguna duda, por favor envía un correo a + ofmi@omegaup.com

+
+

Equipo organizador de la OFMI

+ `, + }; +}; diff --git a/src/lib/passwordRecoveryToken.ts b/src/lib/passwordRecoveryToken.ts new file mode 100644 index 0000000..8f453ba --- /dev/null +++ b/src/lib/passwordRecoveryToken.ts @@ -0,0 +1,55 @@ +import jwt from "jsonwebtoken"; +import config from "@/config/default"; +import { + verificationEmailToken, + verificationEmailTokenSchema, +} from "./emailVerificationToken"; +import { jwtSign, jwtVerify } from "./jwt"; +import { getSecretOrError } from "./secret"; + +const VERIFICATION_EMAIL_SECRET_KEY = "VERIFICATION_EMAIL_SECRET"; + +export default async function generateRecoveryToken( + userId: string, +): Promise { + const payload: verificationEmailToken = { userAuthId: userId }; + const emailToken = await jwtSign( + payload, + getSecretOrError(VERIFICATION_EMAIL_SECRET_KEY), + { + expiresIn: config.VERIFICATION_TOKEN_EXPIRATION, + }, + ); + return emailToken; +} + +export async function validateRecoveryToken(token: string): Promise< + | { + message: string; + success: false; + } + | { success: true; userId: string } +> { + try { + const result = await jwtVerify( + verificationEmailTokenSchema, + token, + getSecretOrError(VERIFICATION_EMAIL_SECRET_KEY), + ); + return { + success: true, + userId: result.userAuthId, + }; + } catch (e) { + if (e instanceof jwt.TokenExpiredError) { + return { + success: false, + message: "El token ha expirado, por favor solicita uno nuevo", + }; + } + return { + success: false, + message: "El token es invalido", + }; + } +} diff --git a/src/middleware.ts b/src/middleware.ts index c1921fe..0c794a5 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -14,6 +14,8 @@ import { // Prefix routes that requires only to be log in const withAuthPaths = ["/mentorias", "/registro", "/oauth"]; +const unauthenticatedPaths = ["changePassword", "/forgotPassword", "/signup"]; + export type CustomMiddleware = ( request: NextRequest, ) => NextMiddlewareResult | Promise; @@ -38,6 +40,16 @@ function withAuthRoles(roles?: Array): CustomMiddleware { }; } +const unauthenticated: CustomMiddleware = async (request) => { + const user = await getUser(request); + if (!user) { + return NextResponse.next({ request }); + } + const url = request.nextUrl.clone(); + url.pathname = "/"; + return NextResponse.redirect(url); +}; + const withAuth = withAuthRoles(); const asAdmin = withAuthRoles([Role.ADMIN]); @@ -68,6 +80,15 @@ export const middleware: CustomMiddleware = async ( return withAuth(request); } + // Pages that requiresr to be unauthenticated + if ( + unauthenticatedPaths.some((path) => + request.nextUrl.pathname.startsWith(path), + ) + ) { + return unauthenticated(request); + } + // Allow return NextResponse.next({ request }); }; diff --git a/src/pages/api/admin/sendEmail.ts b/src/pages/api/admin/sendEmail.ts index 37ab249..7779280 100644 --- a/src/pages/api/admin/sendEmail.ts +++ b/src/pages/api/admin/sendEmail.ts @@ -15,10 +15,8 @@ async function sendEmailHandler( res: NextApiResponse, ): Promise { const { body } = req; - console.log(body); if (!Value.Check(SendEmailRequestSchema, body)) { const firstError = Value.Errors(SendEmailRequestSchema, body).First(); - console.log(firstError); return res.status(400).json({ message: `${firstError ? parseValueError(firstError) : "Invalid request body."}`, }); diff --git a/src/pages/api/user/changePassword.ts b/src/pages/api/user/changePassword.ts new file mode 100644 index 0000000..4e4d9a4 --- /dev/null +++ b/src/pages/api/user/changePassword.ts @@ -0,0 +1,56 @@ +import { parseValueError } from "@/lib/typebox"; +import { PasswordChangeRequestSchema } from "@/types/auth.schema"; +import { Value } from "@sinclair/typebox/value"; +import { NextApiRequest, NextApiResponse } from "next"; +import { prisma } from "@/lib/prisma"; +import { validateRecoveryToken } from "@/lib/passwordRecoveryToken"; +import { emailer } from "@/lib/emailer"; +import { hashPassword } from "@/lib/hashPassword"; + +async function requestPasswordChangeHandler( + req: NextApiRequest, + res: NextApiResponse, +): Promise { + const { body } = req; + if (!Value.Check(PasswordChangeRequestSchema, body)) { + const firstError = Value.Errors(PasswordChangeRequestSchema, body).First(); + return res.status(400).json({ + message: `${firstError ? parseValueError(firstError) : "Invalid request body."}`, + }); + } + const { token, password } = body; + const validatedToken = await validateRecoveryToken(token); + if (validatedToken.success === false) { + return res.status(400).json({ message: validatedToken.message }); + } + try { + const user = await prisma.userAuth.update({ + where: { + id: validatedToken.userId, + }, + data: { + password: hashPassword(password), + }, + }); + await emailer.notifySuccessfulPasswordRecovery(user.email); + return res + .status(200) + .json({ message: "La contraseña fue actualizada exitosamente" }); + } catch (e) { + console.error("Error requestPasswordChangeHandler", e); + return res + .status(400) + .json({ message: "No se pudo actualizar la contraseña" }); + } +} + +export default async function handle( + req: NextApiRequest, + res: NextApiResponse, +): Promise { + if (req.method === "POST") { + await requestPasswordChangeHandler(req, res); + } else { + return res.status(405).json({ message: "Method Not Allowed" }); + } +} diff --git a/src/pages/api/user/resetPassword.ts b/src/pages/api/user/resetPassword.ts new file mode 100644 index 0000000..5a4de82 --- /dev/null +++ b/src/pages/api/user/resetPassword.ts @@ -0,0 +1,47 @@ +import { parseValueError } from "@/lib/typebox"; +import { PasswordResetRequestSchema } from "@/types/auth.schema"; +import { Value } from "@sinclair/typebox/value"; +import { NextApiRequest, NextApiResponse } from "next"; +import { prisma } from "@/lib/prisma"; +import generateRecoveryToken from "@/lib/passwordRecoveryToken"; +import config from "@/config/default"; +import { emailer } from "@/lib/emailer"; + +async function requestPasswordResetHandler( + req: NextApiRequest, + res: NextApiResponse, +): Promise { + const { body } = req; + if (!Value.Check(PasswordResetRequestSchema, body)) { + const firstError = Value.Errors(PasswordResetRequestSchema, body).First(); + return res.status(400).json({ + message: `${firstError ? parseValueError(firstError) : "Invalid request body."}`, + }); + } + const { email } = req.body; + const user = await prisma.userAuth.findFirst({ + where: { + email: email, + }, + }); + if (user) { + const token = await generateRecoveryToken(user.id); + const url = `${config.BASE_URL}/changePassword?token=${token}`; + await emailer.notifyPasswordRecoveryAttempt(user.email, url); + } + res.status(200).json({ + message: + "Si el usuario existe, se le ha enviado un correo con las instrucciones para cambiar su contraseña", + }); +} + +export default async function handle( + req: NextApiRequest, + res: NextApiResponse, +): Promise { + if (req.method === "POST") { + await requestPasswordResetHandler(req, res); + } else { + return res.status(405).json({ message: "Method Not Allowed" }); + } +} diff --git a/src/pages/changePassword.tsx b/src/pages/changePassword.tsx new file mode 100644 index 0000000..3fa5949 --- /dev/null +++ b/src/pages/changePassword.tsx @@ -0,0 +1,29 @@ +import ChangePassword from "@/components/changePassword"; +import { GetServerSideProps } from "next"; + +export interface IChangePasswordProps { + token: string; +} + +export default function ChangePasswordPage({ + token, +}: IChangePasswordProps): JSX.Element { + return ; +} + +export const getServerSideProps: GetServerSideProps = async ({ query }) => { + const token = query?.token; + if (typeof token !== "string") { + return { + redirect: { + destination: "/forgotPassword", + permanent: false, + }, + }; + } + return { + props: { + token: token, + }, + }; +}; diff --git a/src/pages/forgotPassword.tsx b/src/pages/forgotPassword.tsx new file mode 100644 index 0000000..184491a --- /dev/null +++ b/src/pages/forgotPassword.tsx @@ -0,0 +1,5 @@ +import ForgotPassword from "@/components/forgotPassword"; + +export default function ChangePasswordPage(): JSX.Element { + return ; +} diff --git a/src/pages/signup.tsx b/src/pages/signup.tsx index ce5d156..f39e11b 100644 --- a/src/pages/signup.tsx +++ b/src/pages/signup.tsx @@ -1,25 +1,5 @@ import SignUp from "@/components/signup"; -import { getServerSession } from "next-auth/next"; -import type { GetServerSideProps } from "next/types"; -import { authOptions } from "@/pages/api/auth/[...nextauth]"; export default function LoginPage(): JSX.Element { return ; } - -export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { - const session = await getServerSession(req, res, authOptions); - - if (session) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } - - return { - props: {}, - }; -}; diff --git a/src/types/auth.schema.ts b/src/types/auth.schema.ts index 90c4bae..cea3ba3 100644 --- a/src/types/auth.schema.ts +++ b/src/types/auth.schema.ts @@ -47,3 +47,14 @@ export type ResendEmailVerificationResponse = { email: string; message: string; }; + +export type PasswordResetRequest = Static; +export const PasswordResetRequestSchema = Type.Object({ + email: Type.String({ pattern: emailReg }), +}); + +export type PasswordChangeRequest = Static; +export const PasswordChangeRequestSchema = Type.Object({ + password: Type.String({ minLength: 8 }), + token: Type.String(), +});