Email Password Credentials Authentication Setup in Astro.js

Last updated on

In this step by step guide we are going to implement credentials based authentication via email and password with email verification, password resetting and authentication persistence in Astro.js

Here is the demonstration of what we are going to build

This is on-going series of implementing Authentication in Astro. This is the second part. In first part I have implemented Google and Github authentication in Astro.js without any external library. In future I am going to extend this to implement magic link or passwordless login and in final part I will implement multi factor auth. All the post of the series can be found by following this link.

What are we going to achieve?

So let’s understand and clear the expectation first that after following this what you will be able to achieve.

  • Set up Email and Password based Authentication
  • Hash Password
  • Verify users’s email
  • Persist user login for 14 days using database session
  • Protect pages
  • Protect routes
  • Implement route to reset password

Tools Used

  • Astro.js
  • Solid.js for Form UI
  • Zod for validation
  • Upstash Redis for OTP codes
  • Resend for sending mail

Setup

Download the starter code. You can also clone the repo and select the credentials starter branch. It does contain the github and google auth already setup with components and utility functions.

Download it and extract it and npm install command to install. Also make sure to set up DB, create an account on Resend and Upstash

So let’s start

DB Schema Changes

Please make sure you have dataset setup. If not please refer to previous article.

In the previous article we added 4 tables. Now let’s add a new table for storing passwords.

db/schema.ts

export const passwords = sqliteTable("passwords", {
  userId: text("user_id")
    .references(() => users.id, {
      onDelete: "cascade",
    })
    .primaryKey(),
  password: text("password").notNull(),
});

export const passwordRelations = relations(passwords, ({ one }) => ({
  user: one(users, {
    fields: [passwords.userId],
    references: [users.id],
  }),
}));

// also make changes to this

export const usersRelations = relations(users, ({ many, one }) => ({
  oauthTokens: many(oauthTokens),
  sessions: many(sessions),
  loginLogs: many(loginLogs),
  passwords: one(passwords),
}));

Now push the changes to the database. You can do so by running the command npm run drizzle-kit push:sqlite. Make sure you have all the database credetials setup.

Signup

In the signup page you can see the signup form which contains all the validation logic. I have used Solid.js and Zod for it. The related validation file is src/validations/signup.ts

Before coding part let’s see what we need to do

  • Get data from frontend
  • Parse data using zod schema and return error if any
  • check if user with that email already exist. If there exist user then return error
  • Add a new user entry
  • Hash the password and add new entry in password table
  • Send Verification mail

Let’s add route for Sign up process

Signup route

pages/api/auth/signup.ts
import type { APIContext } from "astro";
import { eq } from "drizzle-orm";
import { db } from "../../../db";
import { users } from "../../../db/schema";
import SignupSchema from "../../../validations/signup";
import {
  createPassword,
  createUser,
  sendVerificationMail,
} from "../../../lib/auth";

export async function POST({ request }: APIContext) {
  try {
    const {
      name,
      email,
      password,
    }: { name: string; email: string; password: string } = await request.json();

    const parsedData = SignupSchema.safeParse({
      name,
      email,
      password,
    });

    if (!parsedData.success) {
      return Response.json(
        {
          error: "validation_error",
          message: parsedData.error.format(),
        },
        { status: 400 }
      );
    }

    const userExists = await db.query.users.findFirst({
      where: eq(users.email, email),
    });

    if (userExists) {
      return Response.json(
        {
          error: "existing_user",
          message: "User with this email already exists",
        },
        { status: 400 }
      );
    }

    const newUser = await createUser({
      email,
      fullName: name,
      profilePhoto: "",
      userName: email.split("@")[0],
      emailVerified: false,
    });

    await createPassword({ userId: newUser.userId, password });

    const verificationResponse = await sendVerificationMail({ email });
    if (verificationResponse) {
      return Response.json(
        { data: { id: verificationResponse.verificationId } },
        { status: 201 }
      );
    } else {
      console.log("error while sending the mail");
      await db.delete(users).where(eq(users.email, email));
      return Response.json(
        { error: "email_error", message: "Error while sending email" },
        { status: 500 }
      );
    }
  } catch (error) {
    console.log("Error while signup", error);
    return Response.json(
      { error: "server_error", message: "Server Error" },
      { status: 500 }
    );
  }
}

Now let’s define these function in lib/auth.ts. If you have downloaded starter code then you must have createUser function. Let’s define the rest 2 functions.

Please note that we will store the hashed version for the password. I am using bcryptjs library (not bcrypt library, both are based on same function but bcrypt causes some issue with Astro).

Install the bcryptjs first

terminal
  npm i bcryptjs

  npm i --save-dev @types/bcryptjs
src/lib/auth.ts
export const createPassword = async ({
  password,
  userId,
}: {
  password: string;
  userId: string;
}) => {
  try {
    const hashedPassword = await bcrypt.hash(password, 10);
    await db.insert(passwords).values({
      userId,
      password: hashedPassword,
    });
  } catch (error) {
    console.log("Error while creating password ", error);
    throw new Error("Error while creating password");
  }
};

For additional bot security you can add captcha such as hCaptcha or Cloudflare turnstile. In future I will add a guide on how to handle it. If you know then you can add it.

Email Verification

After the signup is successful we will send the verification mail and redirect the user to the verification page. Let’s understand and code the logic for sending verification mail functionality first.

sendVerificationMail functionality

Now to send verification email functionality we will use resend and redis. Resend for sending the mail and redis for storing the verification code.

Let’s setup redis first. I am using Upstash for the Redis service. Please note that some of the steps will change if you choose to use local redis or any other provider because I am going to use package provided by redis to manage the data.

Make sure you have project setup on upstash. Get the URL and the token and add it to you .env file.

Lets make a separate file to intiate redis so that it can be used in any other file. In the lib folder make a new file named redis.ts. Also make sure to install @upstash/redis package

terminal
npm i @upstash/redis
lib/redis.ts
import { Redis } from "@upstash/redis";

const redis = new Redis({
  url: import.meta.env.REDIS_URL,
  token: import.meta.env.REDIS_TOKEN,
});

export default redis;

Now back to auth.ts where we will define the sendVerification mail functionality.

Let’s add some constraints. We will rate limit user from sending multiple mail. This is very basic form of rate limiting. You can change it and add more constraints and check according to your business needs.

Constraints

  • Verification code only valid for 1 hour
  • User can only send 1 mail per 10 minutes
  • User can send total of 5 mail in 24 hr

So basically this is the way data will be stored in redis. For a user with email [email protected]

  1. Kn1ZS4lytX5iP10FNJ9qpQtw: “43385261:[email protected]” (verificationId)

  2. [email protected]:count: 4

  3. [email protected]:sent: 1709135503827 (timestamp)

So here is the code for sending verification code. Add it to auth file under lib directory.

src/lib/auth.ts
const generateTokenId = customAlphabet("0123456789", 6);
const generateVerificationId = customAlphabet(
  "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",24
);

export const sendVerificationMail = async ({ email }: { email: string }) => {
  const token = generateTokenId();
  const verificationId = generateVerificationId();

  try {
    const lastEmailSentTime: number | null = await redis.get(`${email}:sent`);

    if (lastEmailSentTime) {
      return {
        waitTime:
          10 - Math.floor((new Date().getTime() - lastEmailSentTime) / 60000),
      };
    }

    const emailSentCount: number | null = await redis.get(`${email}:count`);

    if (emailSentCount == null || emailSentCount > 0) {
      const res = await fetch("https://api.resend.com/emails", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${import.meta.env.RESEND_API_KEY}`,
        },
        body: JSON.stringify({
          from: import.meta.env.FROM_EMAIL,
          to: email,
          subject: `${token} is your email verification code`,
          html: `<div>The code for verification is ${token} </div>
          <div>The code is valid for only 1 hour</div>
          <div>You have received this email because you or someone tried to signup on the website </div>
          <div>If you didn't signup, kindly ignore this email.</div>
          <div>For support contact us at contact[at]example.com</div>
          `,
        }),
      });

      if (res.ok) {
        const verificationIdPromise = redis.set(
          verificationId,
          `${token}:${email}`,
          {
            ex: 3600,
          }
        );

        let emailCountPromise;

        if (emailSentCount === null) {
          emailCountPromise = redis.set(`${email}:count`, 4, {
            ex: 86400,
          });
        } else {
          emailCountPromise = redis.decr(`${email}:count`);
        }

        const emailSentPromise = redis.set(
          `${email}:sent`,
          new Date().getTime(),
          {
            ex: 600,
          }
        );

        const [res1, res2, res3] = await Promise.all([
          verificationIdPromise,
          emailCountPromise,
          emailSentPromise,
        ]);

        if (res1 && res2 && res3) {
          return { verificationId };
        } else {
          throw new Error("Error while sending mail");
        }
      } else {
        throw new Error("Error while sending mail");
      }
    } else {
      return { emailSendLimit: true };
    }
  } catch (error) {
    console.log("error while sending mail", error);
    throw new Error("Error while sending mail");
  }
};

So on success we are returning the verification id which will be used by the client to redirect to the verification page.

Verification Page

For the verification page create a new directory named verify in the src/pages folder and create a new file name [verificationId].astro. Use the VerifyEmailCode component.

src/pages/verify/[verificationId].astro
---
import VerifyEmailCode from "../../components/verify-email-code";
import MainLayout from "../../layout/main-layout.astro";
---

<MainLayout title="Verify" description="Verify">
  <VerifyEmailCode client:load id={Astro.params.verificationId!} />
</MainLayout>

Now this component calls the /api/auth/verify-email endpoint. So let’s define this endpoint to verify the email.

We will firstly fetch the code associated with the verificationId. If it doesn’t exist then we will pass response with appropiate message.

If code exists then match it, if it matches then update the user.

Also we will implement rate limiting so that user is not brute forcing the code. Here we are allowing only 10 verification attempts within an hour. Please change it according to the requirement.

src/pages/api/auth/verify-email
import type { APIContext } from "astro";
import redis from "../../../lib/redis";
import { db } from "../../../db";
import { users } from "../../../db/schema";
import { eq } from "drizzle-orm";

export async function POST({clientAddress, request }: APIContext) {
  const { id, code } = await request.json();
  try {

    const emailVerAttemptCount = await redis.get(
      `${clientAddress}_email_ver_attempt`
    );

    if (emailVerAttemptCount === null) {
      await redis.set(`${clientAddress}_email_ver_attempt`, 9, { ex: 3600 });
    } else {
      if (Number(emailVerAttemptCount) < 1) {
        return Response.json(
          {
            error: "rate_limit",
            message: "Too many requests. Please try again later.",
          },
          { status: 429 }
        );
      } else {
        await redis.decr(`${clientAddress}_email_ver_attempt`);
      }
    }
    const data: string | null = await redis.get(id);

    if (!data) {
      return Response.json(
        {
          error: "code_expired",
          message:
            "Verification code expired. Please generate a new verification code.",
        },
        { status: 400 }
      );
    }

    const [otp, email] = data.split(":");

    if (otp !== code) {
      return Response.json(
        {
          error: "invalid_code",
          message: "Please check your entered code",
        },
        { status: 400 }
      );
    }

    await db
      .update(users)
      .set({
        emailVerified: true,
      })
      .where(eq(users.email, email))
      .returning({ id: users.id });

    await redis.del(id);


    return Response.json({
      data: { emailVerified: true },
      message: "Email Verified",
    });
  } catch (error) {
    console.log("error while verifying email", false);
    return Response.json({ success: false });
  }
}

Now the user will be able to verify their email through the code. After verification success we will redirect user to the login page.

Request verification mail

We will provide user with the ability to generate email verification token again if they missed during the signup phase. We already have the functionality written, we just have to call the function in an API route. Now you can understand why were writing the functionality in separate lib folder instead of writing directly into the API route. We can use it easily and also changes can be made in single file.

Create a new folder in pages folder and name it verify. Inside verify folder create a new file named index.astro (it will be mapped to example.com/verify)

Load the VerificationEmailForm component here here

src/pages/verify/index.astro
---
import MainLayout from "../../layout/main-layout.astro";
import VerificationEmailForm from "../../components/verification-email-form";
---

<MainLayout title="Verify Email" description="Verify Email">
  <VerificationEmailForm client:load />
</MainLayout>

Now VerificationEmailForm calls the verification-mail endpoint so let’s define it in pages/api/auth folder

src/pages/api/auth/verification-mail.ts
import type { APIContext } from "astro";
import { and, eq } from "drizzle-orm";
import { db } from "../../../db";
import { users } from "../../../db/schema";
import { sendVerificationMail } from "../../../lib/auth";
import EmailSchema from "../../../validations/email";

export async function POST({ request }: APIContext) {
  const { email }: { email: string } = await request.json();

  const parsedData = EmailSchema.safeParse(email);

  if (!parsedData.success) {
    return Response.json(
      {
        error: "validation_error",
        message: parsedData.error.format(),
      },
      { status: 400 }
    );
  }

  const userExists = await db.query.users.findFirst({
    where: and(
      eq(users.email, parsedData.data),
      eq(users.isBlocked, false),
      eq(users.isDeleted, false)
    ),
  });

  if (!userExists) {
    return Response.json(
      {
        error: "user_not_exist",
        message: "User with this email doesn't exist",
      },
      { status: 404 }
    );
  }

  if (userExists.emailVerified) {
    return Response.json(
      {
        error: "email_already_verified",
        message:
          "User with this email has been already verified. You can log in",
      },
      { status: 400 }
    );
  }

  try {
    const res = await sendVerificationMail({ email: parsedData.data });

    if (res.emailSendLimit) {
      return Response.json(
        {
          error: "rate_limit",
          message: `Please wait for 24 hrs before sending new mail request`,
        },
        { status: 429 }
      );
    } else if (res.verificationId) {
      return Response.json(
        { data: { verificationId: res.verificationId } },
        { status: 200 }
      );
    } else if (res.waitTime) {
      return Response.json(
        {
          error: "resend_limit",
          message: `Please wait for ${res.waitTime} minutes before generating new request for mail`,
        },
        { status: 429 }
      );
    }
    return Response.json({ error: "server_error" }, { status: 500 });
  } catch (err) {
    console.log("Error while sending mail", err);
    return Response.json({ error: "server_error" }, { status: 500 });
  }
}

Now user will be able to request for verification code at later point.

Now that verification is done let’s add functionality for login.

Login

We already have login form component so let’s start directly with the login api route.

In login we will check if user’s email is verified or not. If not verified then send the error with the message to verify. If user email is verified then move ahead with matching password.

vulnerable login api route

src/pages/api/login.ts
import type { APIContext } from "astro";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { db } from "../../../db";
import { passwords, users } from "../../../db/schema";
import { createLoginLog, createSession } from "../../../lib/auth";
import LoginSchema from "../../../validations/login";

export async function POST({ request }: APIContext) {
  try {
    const { email, password }: { email: string; password: string } =
      await request.json();
    const parsedData = LoginSchema.safeParse({
      email: email,
      password: password,
    });

    if (!parsedData.success) {
      return Response.json(
        {
          error: "validation_error",
          message: parsedData.error.format(),
        },
        { status: 400 }
      );
    }

    const userExists = await db.query.users.findFirst({
      where: eq(users.email, email),
    });

    if (!userExists) {
      return Response.json(
        {
          error: "auth_error",
          message: "Incorrect email or password",
        },
        { status: 401 }
      );
    }

    if (!userExists.emailVerified) {
      return Response.json(
        {
          error: "email_unverified",
          message: "Please verify your email",
        },
        { status: 403 }
      );
    }

    const passwordExists = await db.query.passwords.findFirst({
      where: eq(passwords.userId, userExists.id),
    });

    if (!passwordExists) {
      return Response.json(
        {
          error: "auth_error",
          message: "Incorrect email or password",
        },
        { status: 401 }
      );
    }

    // match password
    const passwordMatch = await bcrypt.compare(
      password,
      passwordExists.password
    );
    if (!passwordMatch) {
      return Response.json(
        {
          error: "auth_error",
          message: "Incorrect email or password",
        },
        { status: 401 }
      );
    }

    const { sessionId, expiresAt } = await createSession({
      userId: userExists.id,
    });

    // log
    await createLoginLog({
      sessionId,
      userAgent: request.headers.get("user-agent"),
      userId: userExists.id,
      ip: clientAddress ?? "dev",
    });

    return Response.json(
      { message: "Logged In Successfully", redirect: "/dashboard" },
      {
        status: 200,
        headers: {
          "Set-Cookie": `app_auth_token=${sessionId}; Path=/; HttpOnly; SameSite=Lax;Expires=${expiresAt.toUTCString()}; Secure=${
            import.meta.env.PROD
          }`,
        },
      }
    );
  } catch (error) {
    console.log("Error while signup", error);
    return Response.json(
      { error: "server_error", message: "Server Error" },
      { status: 500 }
    );
  }
}

But their is a problem with above code. The problem is that possibility of timing attack (kind of). How??

The api will return response very early if user with email doesn’t exist. So this way attacker can know if email exists in the system or not.

enhanced login api route

So what we will do is even if user exists or not we will fetch the password and match the password. Even if password doesn’t exists we will match it against a randomly generated 16 character password hash. My idea here is to prevent the attacker from guessing which one of email or password is incorrect.

I am not a cybersecurity expert so please research more before using it in production. If you find any vulnerabilites or you have any suggestions please let me know via contact form or via opening github issue.

Secondly lets also add the rate limiting logic so that to prevent from brute force attack. I am doing IP level brute force protection. Now much of the sophisticated attackers change their IP frequently. So addtionally you can do is also track number of login attempts on account basis and lock the account if it exceeds certain limit attempt and ask user to contact to support. However currently I am focusing on first case.

src/pages/api/login.ts
import type { APIContext } from "astro";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { db } from "../../../db";
import { passwords, users } from "../../../db/schema";
import { createLoginLog, createSession } from "../../../lib/auth";
import LoginSchema from "../../../validations/login";
import redis from "../../../lib/redis";

export async function POST({ clientAddress, request, cookies }: APIContext) {
  try {
    const loginAttemptCount = await redis.get(`${clientAddress}_login_attempt`);

    if (loginAttemptCount === null) {
      await redis.set(`${clientAddress}_login_attempt`, 9, { ex: 600 });
    } else {
      if (Number(loginAttemptCount) < 1) {
        return Response.json(
          {
            error: "rate_limit",
            message: "Too many requests. Please try again later.",
          },
          { status: 429 }
        );
      } else {
        await redis.decr(`${clientAddress}_login_attempt`);
      }
    }
    const { email, password }: { email: string; password: string } =
      await request.json();
    const parsedData = LoginSchema.safeParse({
      email: email,
      password: password,
    });

    if (!parsedData.success) {
      return Response.json(
        {
          error: "validation_error",
          message: parsedData.error.format(),
        },
        { status: 400 }
      );
    }

    const userExists = await db.query.users.findFirst({
      where: eq(users.email, email),
    });

    const passwordExists = await db.query.passwords.findFirst({
      where: eq(passwords.userId, userExists?.id ?? "SomeThingRandom"),
    });

    // match password
    const passwordMatch = await bcrypt.compare(
      password,
      passwordExists?.password ??
        "$2a$10$mqgl5wfEnNtGQurbRDL.seQZRxY0Dhqc/RVoNtV01wzAMmYRfjvyW"
    );

    if (!passwordMatch || !userExists) {
      return Response.json(
        {
          error: "auth_error",
          message: "Incorrect email or password",
        },
        { status: 401 }
      );
    }

    const { sessionId, expiresAt } = await createSession({
      userId: userExists.id,
    });

    // log
    await createLoginLog({
      sessionId,
      userAgent: request.headers.get("user-agent"),
      userId: userExists.id,
      ip: clientAddress ?? "dev",
    });

    return Response.json(
      { message: "Logged In Successfully", redirect: "/dashboard" },
      {
        status: 200,
        headers: {
          "Set-Cookie": `app_auth_token=${sessionId}; Path=/; HttpOnly; SameSite=Lax;Expires=${expiresAt.toUTCString()}; Secure=${
            import.meta.env.PROD
          }`,
        },
      }
    );
  } catch (error) {
    console.log("Error while signup", error);
    return Response.json(
      { error: "server_error", message: "Server Error" },
      { status: 500 }
    );
  }
}

Note: To save the space we can use various faster and non cryptographic hash to create hash instead of storing complete string like clientAddress_login_attempt. You can use murmurhash3 or xxhash.

Password Reset

To add password reset functionality first create a new folder in the pages folder named forgot-password and add the following code

src/pages/forgot-password/index.astro
---
import MainLayout from "../../layout/main-layout.astro";
import ResetPasswordForm from "../../components/reset-password-form";
---

<MainLayout title="Forgot Password" description="Forgot Password">
  <ResetPasswordForm client:load />
</MainLayout>

Now we have to define the API route. It will be similar to the sendVerificationMail functionality but with some changes.

Let’s define the API route first

API route

src/pages/api/auth/password-reset-mail.ts
import type { APIContext } from "astro";
import { and, eq } from "drizzle-orm";
import { db } from "../../../db";
import { users } from "../../../db/schema";
import { sendPasswordResetMail } from "../../../lib/auth";
import EmailSchema from "../../../validations/email";

export async function POST({ request, url }: APIContext) {
  const { email }: { email: string } = await request.json();

  const parsedData = EmailSchema.safeParse(email);

  if (!parsedData.success) {
    return Response.json(
      {
        error: "validation_error",
        message: parsedData.error.format(),
      },
      { status: 400 }
    );
  }

  const userExists = await db.query.users.findFirst({
    where: and(
      eq(users.email, parsedData.data),
      eq(users.isBlocked, false),
      eq(users.isDeleted, false)
    ),

    with: {
      passwords: true,
    },
  });

  try {
    const res = await sendPasswordResetMail({
      email: parsedData.data,
      url: url.origin,
      userExists: !!userExists?.passwords,
    });

    if (res.emailSendLimit) {
      return Response.json(
        {
          error: "rate_limit",
          message: `Please wait for 24 hrs before sending new mail request`,
        },
        { status: 429 }
      );
    } else if (res.verificationId) {
      return Response.json(
        {
          message:
            "Email sent successfully. Please check your inbox and spam folder",
        },
        { status: 200 }
      );
    } else if (res.waitTime) {
      return Response.json(
        {
          error: "resend_limit",
          message: `Please wait for ${res.waitTime} minutes before generating new request for mail`,
        },
        { status: 429 }
      );
    }
    return Response.json(
      {
        error: "server_error",
        message: "Internal server error Please try again later",
      },
      { status: 500 }
    );
  } catch (err) {
    console.log("Error while sending mail", err);
    return Response.json(
      {
        error: "server_error",
        message: "Internal server error Please try again later",
      },
      { status: 500 }
    );
  }
}

Now we have to define the sendPasswordResetMail functionality. This will be same as sendVerificationMail functionality with little changes

sendPasswordResetMail()

We will define this in the auth.ts file. Here is the code.

export const sendPasswordResetMail = async ({
  email,
  url,
  userExists,
}: {
  email: string;
  url: string;
  userExists: boolean;
}) => {
  const verificationId = generateVerificationId();

  try {
    const lastEmailSentTime: number | null = await redis.get(
      `${email}:pwd_reset_sent`
    );

    if (lastEmailSentTime) {
      return {
        waitTime:
          10 - Math.floor((new Date().getTime() - lastEmailSentTime) / 60000),
      };
    }

    const emailSentCount: number | null = await redis.get(
      `${email}:pwd_reset_count`
    );

    if (emailSentCount == null || emailSentCount > 0) {
      let res;

      if (userExists) {
        res = (await fetch("https://api.resend.com/emails", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${import.meta.env.RESEND_API_KEY}`,
          },
          body: JSON.stringify({
            from: import.meta.env.FROM_EMAIL,
            to: email,
            subject: `Password Reset Request`,
            html: `<div>Reset your password </div>
            <a href=${url}/forgot-password/${verificationId}>Reset Password</a>
            <div>The link is valid for only 1 hour</div>
            <div>You have received this email because you or someone tried to reset the password. </div>
            <div>If you didn't send this, firstly reset your password and contact support.</div>
            <div>For support contact us at contact[at]example.com</div>
            `,
          }),
        })) as Response;
      } else {
        setTimeout(() => {}, 200);
        res = {
          ok: true,
        };
      }

      if (res.ok) {
        const verificationIdPromise = redis.set(verificationId, email, {
          ex: 3600,
        });

        let emailCountPromise;

        if (emailSentCount === null) {
          emailCountPromise = redis.set(`${email}:pwd_reset_sent`, 4, {
            ex: 86400,
          });
        } else {
          emailCountPromise = redis.decr(`${email}:pwd_reset_sent`);
        }

        const emailSentPromise = redis.set(
          `${email}:pwd_reset_sent`,
          new Date().getTime(),
          {
            ex: 600,
          }
        );

        const [res1, res2, res3] = await Promise.all([
          verificationIdPromise,
          emailCountPromise,
          emailSentPromise,
        ]);

        if (res1 && res2 && res3) {
          return { verificationId };
        } else {
          throw new Error("Error while sending mail");
        }
      } else {
        throw new Error("Error while sending mail");
      }
    } else {
      return { emailSendLimit: true };
    }
  } catch (error) {
    console.log("error while sending mail", error);
    throw new Error("Error while sending mail");
  }
};

The mail will send the password reset link. Let’s define the page

Create a [passwordResetId].astro page.

/src/pages/forgot-password/[passwordVerificationId].astro
---
import SetNewPassword from "../../components/set-new-password";
import MainLayout from "../../layout/main-layout.astro";
---

<MainLayout title="Verify" description="Verify">
  <SetNewPassword client:load id={Astro.params.passwordVerificationId!} />
</MainLayout>

The set new password calls the reset-password endpoint. So let’s define the endpoint in api/auth directory,

The route will check for ID if it exists. If their exist an ID then it will update the password and will delete all the sessions associated with the user and then redirect the user to the login page.

/src/pages/api/reset-password.ts
import type { APIContext } from "astro";
import bcrypt from "bcryptjs";
import { and, eq } from "drizzle-orm";
import { db } from "../../../db";
import { passwords, users } from "../../../db/schema";
import redis from "../../../lib/redis";
import PasswordSchema from "../../../validations/password";

export async function POST({ request }: APIContext) {
  const { id, password }: { id: string; password: string } =
    await request.json();

  const parsedData = PasswordSchema.safeParse(password);

  if (!parsedData.success) {
    return Response.json(
      {
        error: "validation_error",
        message: parsedData.error.format(),
      },
      { status: 400 }
    );
  }

  if (!id) {
    return Response.json(
      {
        error: "id_error",
        message: "Please pass a valid ID",
      },
      { status: 400 }
    );
  }

  try {
    const userEmail: string | null = await redis.get(id);

    if (!userEmail) {
      return Response.json(
        {
          error: "token_error",
          message: "Token expired. Please regenerate",
        },
        { status: 400 }
      );
    }

    const userExists = await db.query.users.findFirst({
      where: and(
        eq(users.email, userEmail),
        eq(users.isBlocked, false),
        eq(users.isDeleted, false)
      ),
      columns: {
        id: true,
        email: true,
      },
    });

    if (!userExists) {
      return Response.json(
        {
          error: "token_error",
          message: "Token expired. Please regenerate",
        },
        { status: 400 }
      );
    }

    const hashedPassword = await bcrypt.hash(parsedData.data, 10);
    const res = await db
      .update(passwords)
      .set({
        password: hashedPassword,
      })
      .where(eq(passwords.userId, userExists.id));

    await db.delete(sessions).where(eq(sessions.userId, userExists.id));
    
    if (res.rowsAffected > 0) {
      return Response.json({ success: true }, { status: 200 });
    } else {
      return Response.json({ error: "server_error" }, { status: 500 });
    }
  } catch (err) {
    console.log("Error while reset password", err);
    return Response.json({ error: "server_error" }, { status: 500 });
  }
}

Conclusion

So in this post we have implemented credential based authentication. This is the part 2 of the Astro Authentication Series. In the next part we will implement (passwordless) magic link based authentication. Stay tuned for that.

The final code can be found in the credentials-final branch. You can join EverythingCS discord server if you have any further query and for future post updates.