DEV Community

Andrej Tlčina
Andrej Tlčina

Posted on • Updated on

Create a fullstack book app: Authentication and DB models

Hello! In this part of the series, we'll dive into authentication and how I designed the database schema. Initially, I wanted to do just authentication, but when adding a user model with Prisma I realized it would be wise to create all models, so I get a bird's eye view of the whole DB.

Building schema

I'll be using Prisma for working with DB. But Andi, what is Prisma? Glad you asked. It is a database ORM, which is a layer of abstraction, that helps you with DB actions, like searching in DB or creating new records. On top of that, it has full type safety.

create-t3-app does a lot of initializing for you. You'll have a folder called prisma at the root of your project, which will have schema.prisma file containing this

generator client {
    provider = "prisma-client-js"
}

datasource db {
    provider = "sqlite"
    url      = "file:./db.sqlite"
    // url      = env("DATABASE_URL")
}
Enter fullscreen mode Exit fullscreen mode

This essentially sets your DB of type sqlite. Now, every time there's change in the schema, to see changes reflected in DB we have to run either

npx prisma db push
Enter fullscreen mode Exit fullscreen mode

or

npx prisma migrate dev
Enter fullscreen mode Exit fullscreen mode

I was using mainly the first one, because I was quickly prototyping. When running push, the DB will be wiped. To keep changes you'll want to run the latter.

So, as I wrote in the last post the DB will consist of users, which will have book-notes assigned to them, and book-notes will have chapters assigned to them. Here are the initial models

model User {
    id String @id @default(uuid())
}

model BookNote {
    id String @id @default(uuid())
    isbn13 String
}

model Chapter {
    id String @id @default(uuid())
}
Enter fullscreen mode Exit fullscreen mode

Each model has to have an identifier. You can set default value with @default(uuid()). I didn't do it for the BookNote model, cause a booknote identifier will be isbn13 code of the book. That will be received via the external endpoint https://api.itbook.store/.
EDIT: yes, the isbn13 code will be received from external endpoint, but we have to set id to BookNote model, as well. Otherwise, we'll have one bookNote for one book.

Next up, I added more attributes that either made sense or I could retrieve from the endpoint mentioned above.

model User {
    id String @id @default(uuid())

    name String @unique
    password String
}

model BookNote {
    isbn13 String @id 

    title String
    subtitle String?
    image String
    price String

    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
}

model Chapter {
    id String @id @default(uuid())

    title String
    text String

    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

Here are some special attributes like @unique which make the column unique (so there are no two names identical). Then, there is @default(now()), which adds time to createdAt column, and @updatedAt which updates time, whenever we make a change.

Now, as I wrote earlier, each user will have multiple book-notes. Each book-note will have multiple chapters. This leads us to using one-to-many relation. If a book-note would be shareable, i.e. a book-note can have multiple users, I would use many-to-many relation.

Looking at the docs of one-to-many relation (sidenote: don't be scared to look at docs, memorizing is a waste of everybody's time), we get final schema

model User {
    id String @id @default(uuid())
    name String @unique
    password String
    bookNotes BookNote[]
}

model BookNote {
    isbn13 String @id 
    title String
    subtitle String?
    image String
    price String
    author User @relation(fields: [authorId], references: [id])
    authorId String
    chapters Chapter[]
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
}

model Chapter {
    id String @id @default(uuid())

    title String
    text String
    bookNote BookNote @relation(fields: [bookNoteId], references: [isbn13])
    bookNoteId String
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

JWT Authentication with trpc

First things first... what is JWT? It's a shortcut for jsonwebtoken. It helps you take some data, like name, email, and essentially hash it and save it to help with identifying user, by unhashing it and checking the data. This token can be saved on the client, which, from what I read, can be dangerous, or can be saved on the server (we'll do that).

Let's install some packages

npm i cookie jose bcrypt --save
npm i @types/cookie @types/jose @types/bcrypt --save-dev
Enter fullscreen mode Exit fullscreen mode

I like to make checklist of everything that has to be done:

  • on the sign-in, set the user's JWT token in the request's cookie
  • verify the given JWT token
  • get hashed values out of the JWT token
  • on the sign-out, expire user's JWT token in requests cookie

I like to first have an interface, so I can easily test stuff. Next.js has file-based routing, which means, when you create a file like example.tsx in the pages directory, you already have a route /example. Let's create sign-up.tsx and login.tsx files. Both will follow structure

function FormPage() {
  create a function that will send auth data to server and redirect to particular page

  return (
    // render a form that calls function mentioned above
  );
}

export default FormPage;

Enter fullscreen mode Exit fullscreen mode

I don't want to go to specifics, the main idea is written above. We can refactor to infinity, but that's not important... right now, I just want an interface.

Register

The whole Backend will be written thanks to tRPC. It is a technology, that lets you define functions on the Backend and call them on the Frontend. For creating such a function one needs a router. Let's create one in server/router directory, it will be called auth.router.ts. Here, we'll create a new router by calling createRouter() and chaining mutation() to it. The tRPC has 2 main types of functions mutation and query. I use mutation, whenever I have to fetch the data on some event, like on click. And I use query, whenever I just have to fetch stuff in the component. We'll use mutation for sign-up, because we're sending data on click.

export const authRouter = createRouter()
  .mutation("signup", {
    input: z.object({
      name: z.string(),
      password: z.string(),
    }),
    async resolve({ input, ctx }) {
      const { prisma } = ctx;

      const { name, password } = input;


      const user = await prisma.user.create({
        data: {
          name: name,
          password: bcrypt.hashSync(password, 10),
        },
      });

      return { name: user.name };
    },
  })
Enter fullscreen mode Exit fullscreen mode

The function expects you to send some input, in above-defined format, then creates a new user with Prisma. On Frontend we'll create mutation via

const mutation = trpc.useMutation(["auth.sign-up"]);
Enter fullscreen mode Exit fullscreen mode

tRPC is a layer above React-Query, so it works like useMutation there.

Login

Let's chain another function login.

.mutation("login", {
    input: z.object({ name: z.string(), password: z.string() }),
    async resolve({ ctx, input }) {
      const { prisma } = ctx;

      const { name, password } = input;

      const user = await prisma.user.findUnique({
        where: {
          name: name,
        },
      });

      if (bcrypt.compareSync(password, user.password)) {
        const token = await setUserCookie(user.name, ctx.res);
        return { name: user.name, token };
      } 
    },
  })
Enter fullscreen mode Exit fullscreen mode

Login takes some input and searches the DB to find the unique name (that's why @unique is in the schema). Finding users is not enough though, we have to compare passwords. If they're the same we take the name and hash it with JWT and set in on the server. Create a new file auth.ts.

// src/lib/auth.s

const SECRET = process.env.JWT_SECRET;

export async function setUserCookie(name: string, res: NextApiResponse) {
  try {
    const token = await new SignJWT({})
      .setProtectedHeader({ alg: "HS256" })
      .setJti(name)
      .setIssuedAt()
      .setExpirationTime("2h")
      .sign(new TextEncoder().encode(SECRET));

    res.setHeader(
      "Set-cookie",
      cookie.serialize("token", token, {
        httpOnly: true,
        path: "/",
        maxAge: 60 * 60 * 2, // 2 hours in seconds,
      })
    );

    return token;
  } catch (e) {
    console.error({ setCookies: e });
  }
}

Enter fullscreen mode Exit fullscreen mode

The signing part is a little scary with all the chaining, but I literally just copied it from their docs. In function, we're setting the header with setHeader (method of Next API Response). Thanks to package cookie we can serialize the hashed token to header cookies, here we're setting httpOnly: true, which makes it so we have cookies on the server and not the client, path these cookies exists on, and maximum age of the cookie.

You can see there's also a SECRET variable. It is a key that hashes the data. We'll be using this value at verifying the given token, speaking of... let's do that

// src/lib/auth.s

interface UserJwtPayload {
  jti: string;
  iat: number;
}

export async function verifyJWT(token: string) {
  const authSession = await jwtVerify(token, new TextEncoder().encode(SECRET));
  return authSession.payload as UserJwtPayload;
}
Enter fullscreen mode Exit fullscreen mode

We'll get some payload from the hashed token. The payload will be of type UserJwtPayload, and the data, we hashed, will be in jti attribute.

Logout

At last, we want to expire token, when user logs out. For that, let's chain new method in auth.router.ts.

 .mutation("logout", {
    async resolve({ ctx }) {
      await expireUserCookie(ctx.res);

      return true;
    },
  })
Enter fullscreen mode Exit fullscreen mode
// src/lib/auth.s

export function expireUserCookie(res: NextApiResponse) {
  res.setHeader(
    "set-cookie",
    cookie.serialize("token", "invalid", {
      httpOnly: true,
      path: "/",
      maxAge: 0,
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

tRPC Context

You may wonder, what is the ctx variable in resolve? The tRPC comes with the handy thing called context, which runs on every request (that's the ctx in router resolve). We can pass the user there, so, we have it at our disposal. You can do that for any other data you feel have to be globally available for each request. If we remove user data from the server cookie, the context will pass null as user data.

const getUserFromCookies = async (req: NextApiRequest) => {
  // get JWT `token` on cookies
  const token = req.cookies["token"] || "";

  try {
    // if token is invalid, `verify` will throw an error
    const payload = await verifyJWT(token).catch((err) => {
      console.error(err.message);
    });

    if (!payload) return null;

    // find user in database
    const user = await prisma.user.findUnique({
      where: {
        name: payload.jti,
      },
    });

    return user;
  } catch (e) {
    return null;
  }
};

export const createContext = async ({
  req,
  res,
}: trpcNext.CreateNextContextOptions) => {
  const user = await getUserFromCookies(req);

  return {
    req,
    res,
    prisma,
    user,
  };
};
Enter fullscreen mode Exit fullscreen mode

Logout can be tested with a simple button. However, it would be nice to know if the user is logged in on the client-side. Having something like context from tRPC on the client, you know... Oh wait, we have just the thing, React Context.

Authentication Context

Context can be a tricky thing with (what sometimes feels like) random re-renders and such. Thanks to this article on developerway, created by Nadia Makarevich, I view context differently and use it with more confidence. I really recommend reading it, but long story short, people put a lot of data into the context's state. What you want to do, is split the state to API part, where you call dispatch type of functions and part with some values, like user name, email, and such. Then you create the context for each of these values, so when one changes, this change does not trigger unnecessary rerenders. It will look like this

const AuthAPIContext = createContext({
  logoutUser: () => {},
  loginUser: () => {},
});

const AuthUserContext = createContext({
  user: initialState.user,
});

const reducer = (state: TState, action: TAction): TState => {
  switch (action.type) {
    case "AUTH/LOGIN":
      return {
        ...state,
        user: action.payload?.name,
      };
    case "AUTH/LOGOUT":
      return {
        ...state,
        user: null,
      };
    default:
      return state;
  }
};

export const AuthProvider = ({ children }) => {
  const [state, dispatch] = React.useReducer(reducer, initialState);

  const api = React.useMemo(
    () => ({
      loginUser: (s: string) => dispatch(loginUser(s)),
      logoutUser: () => dispatch(logoutUser()),
    }),
    []
  );

  return (
    <AuthUserContext.Provider value={{ user: state.user }}>
      <AuthAPIContext.Provider value={api}>{children}</AuthAPIContext.Provider>
    </AuthUserContext.Provider>
  );
};

export const useAuthAPI = () => useContext(AuthAPIContext);
export const useAuthUser = () => useContext(AuthUserContext);
Enter fullscreen mode Exit fullscreen mode

Protecting routes

Cool! We have the login system, we have an auth context, but we're still missing some protection for routes. For example, we don't want an unauthenticated user to be looking at some parts of the website. We can do this multiple ways, however, I found two that I personally like the best, HOC and middleware.

Higher Order Component solution

With this solution, we'll create HOC wrapping getServerSideProps. When there's a user logged in, we can redirect the user by returning

redirect: {
  permanent: false,
  destination: "/login",
}
Enter fullscreen mode Exit fullscreen mode

Here's the definition

export const withAuth =
  (getServerSidePropsFn) =>
  async (ctx) => {
    const token = ctx.req.cookies?.token;

    if (!token) {
      return {
        redirect: {
          permanent: false,
          destination: "/login",
        },
      };
    }

    // if token is invalid, `verify` will throw an error
    const payload = await verifyJWT(token).catch((err) => {
      console.error(err.message);
    });

    if (!payload) {
      return {
        redirect: {
          permanent: false,
          destination: "/login",
        },
      };
    }

    return getServerSidePropsFn(ctx);
  };
Enter fullscreen mode Exit fullscreen mode

And here's how I used it

export const getServerSideProps = withAuth(async (
  ctx
) => {
  return {
    props: {
      ...
    },
  };
});

Enter fullscreen mode Exit fullscreen mode

I like this solution, because redirect happens on the server, instead of the client, therefore no flashes, but we have to do this for each and every protected page. Luckily with the newest version of Next.js we might have a solution in form of middleware.

Middleware solution

In Next 12.2 they released middleware. From Next.js docs:

Middleware allows you to run code before a request is completed, then based on the incoming request, you can modify the response by rewriting, redirecting, adding headers, or setting cookies.

Which is the exact thing we want, we run this middleware, before a request is completed where, we'll check request cookies and if there's something wrong, we redirect the user. It can even run on just particular pages with

export const config = {
  matcher: here you write a path or array of paths you want to match,
};
Enter fullscreen mode Exit fullscreen mode

Here's how I used it

export const config = {
  matcher: ["/my-notes", "/books/:path*"],
};

export async function middleware(req: NextRequest) {
  const verifiedToken = await verifyAuth(req).catch((err) => {
    console.error(err.message);
  });

  // redirect if the token is invalid
  if (!verifiedToken) {
    return NextResponse.redirect(new URL("/login", req.url));
  }

  return NextResponse.next();
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Well, that was a lot. But to conclude, we created the whole schema for our DB. Then, we created a whole login system with tRPC router, JWT tokens, client and server context and we finished up with protecting our routes.

You can look at the project here https://github.com/Attanox/it-notes

Thanks a lot for reading! The next part will be about CRUD operations. See you there! 😉

Top comments (0)