Create a fullstack book app: Authentication and DB models
Andrej Tlčina
Posted on July 11, 2022
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")
}
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
or
npx prisma migrate dev
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())
}
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 That will be received via the external endpoint https://api.itbook.store/. isbn13
code of the book.
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
}
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
}
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
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;
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 };
},
})
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"]);
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 };
}
},
})
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 });
}
}
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;
}
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;
},
})
// src/lib/auth.s
export function expireUserCookie(res: NextApiResponse) {
res.setHeader(
"set-cookie",
cookie.serialize("token", "invalid", {
httpOnly: true,
path: "/",
maxAge: 0,
})
);
}
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,
};
};
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);
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",
}
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);
};
And here's how I used it
export const getServerSideProps = withAuth(async (
ctx
) => {
return {
props: {
...
},
};
});
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,
};
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();
}
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! 😉
Posted on July 11, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.