Skip to main content
Version: Canary

Authentication

An Admin Section

Having the admin screens at /admin is a reasonable thing to do. Let's update the routes to make that happen by updating the four routes where the URL begins with /posts to start with /admin/posts instead:

web/src/Routes.js
import { Router, Route, Set } from '@redwoodjs/router'
import ScaffoldLayout from 'src/layouts/ScaffoldLayout'
import BlogLayout from 'src/layouts/BlogLayout'

const Routes = () => {
return (
<Router>
<Set wrap={ScaffoldLayout} title="Posts" titleTo="posts" buttonLabel="New Post" buttonTo="newPost">
<Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
<Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
<Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
<Route path="/admin/posts" page={PostPostsPage} name="posts" />
</Set>
<Set wrap={BlogLayout}>
<Route path="/article/{id:Int}" page={ArticlePage} name="article" />
<Route path="/contact" page={ContactPage} name="contact" />
<Route path="/about" page={AboutPage} name="about" />
<Route path="/" page={HomePage} name="home" />
</Set>
<Route notfound page={NotFoundPage} />
</Router>
)
}

export default Routes

Head to http://localhost:8910/admin/posts and our generated scaffold page should come up. Thanks to named routes we don't have to update any of the <Link>s that were generated by the scaffolds since the names of the pages didn't change!

Having the admin at a different path is great, but nothing is stopping someone from just browsing to that new path and messing with our blog posts. How do we keep prying eyes away?

Authentication

"Authentication" is a blanket term for all of the stuff that goes into making sure that a user, often identified with an email address and password, is allowed to access something. Authentication can be famously fickle to do right both from a technical and developer-happiness standpoint.

"Credentials" are the pieces of information a user provides to prove they are who they say they are: commonly a username (usually email) and password.

Redwood includes two authentication paths out of the box:

  • Self-hosted, where user credentials are stored in your own database
  • Third-party hosted, where user credentials are stored with the third party

In both cases you end up with an authenticated user that you can access in both the web and api sides of your app.

Redwood includes integrations for several of the most popular third-party auth providers:

As for our blog, we're going to use self-hosted authentication (named dbAuth in Redwood) since it's the simplest to get started with and doesn't involve any third party signups.

Authentication vs. Authorization

There are two terms which contain a lot of letters, starting with an "A" and ending in "ation" (which means you could rhyme them if you wanted to) that become involved in most discussions about login:

  • Authentication
  • Authorization

Here is how Redwood uses these terms:

  • Authentication deals with determining whether someone is who they say they are, generally by "logging in" with an email and password, or a third party provider like Auth0.
  • Authorization is whether a user (who has usually already been authenticated) is allowed to do something they want to do. This generally involves some combination of roles and permission checking before allowing access to a URL or feature of your site.

This section of the tutorial focuses on Authentication only. See chapter 7 of the tutorial to learn about Authorization in Redwood.

Auth Setup

As you probably have guessed, Redwood has a couple of generators to get you going. One installs the backend components needed for dbAuth, the other creates login, signup and forgot password pages.

Run this setup command to get the internals of dbAuth added to our app:

yarn rw setup auth dbAuth

When asked if you want to override the existing file /api/src/lib/auth.ts say yes. The shell auth.ts that's created in a new app makes sure things like the @requireAuth directive work, but now we'll replace it with a real implementation. When prompted to "Enable WebAuthn support", pick no - this is a separate piece of functionality we won't need for the tutorial.

You'll see that the process creates several files and includes some post-install instructions for the last couple of customizations you'll need to make. Let's go through them now.

Create a User Model

First we'll need to add a couple of fields to our User model. We don't even have a User model yet, so we'll create one along with the required fields at the same time.

Open up schema.prisma and add:

api/db/schema.prisma
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}

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

model Post {
id Int @id @default(autoincrement())
title String
body String
createdAt DateTime @default(now())
}

model Contact {
id Int @id @default(autoincrement())
name String
email String
message String
createdAt DateTime @default(now())
}

model User {
id Int @id @default(autoincrement())
name String?
email String @unique
hashedPassword String
salt String
resetToken String?
resetTokenExpiresAt DateTime?
}

This gives us a user with a name and email, as well as four fields that dbAuth will control:

  • hashedPassword: stores the result of combining the user's password with a salt and then hashed
  • salt: a unique string that combines with the hashedPassword to prevent rainbow table attacks
  • resetToken: if the user forgets their password, dbAuth inserts a token in here that must be present when the user returns to reset their password
  • resetTokenExpiresAt: a timestamp after which the resetToken will be considered expired and no longer valid (the user will need to fill out the forgot password form again)

Let's create the user model by migrating the database, naming it something like "create user":

yarn rw prisma migrate dev

That's it for the database setup!

Private Routes

Try reloading the Posts admin and we'll see something that's 50% correct:

image

Going to the admin section now prevents a non-logged in user from seeing posts, great! This is the result of the @requireAuth directive in api/src/graphql/posts.sdl.ts: you're not authenticated so GraphQL will not respond to your request for data. But, ideally they wouldn't be able to see the admin pages themselves. Let's fix that with a new component in the Routes file, <Private>:

web/src/Routes.js
import { Private, Router, Route, Set } from '@redwoodjs/router'
import ScaffoldLayout from 'src/layouts/ScaffoldLayout'
import BlogLayout from 'src/layouts/BlogLayout'

const Routes = () => {
return (
<Router>
<Private unauthenticated="home">
<Set wrap={ScaffoldLayout} title="Posts" titleTo="posts" buttonLabel="New Post" buttonTo="newPost">
<Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
<Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
<Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
<Route path="/admin/posts" page={PostPostsPage} name="posts" />
</Set>
</Private>
<Set wrap={BlogLayout}>
<Route path="/article/{id:Int}" page={ArticlePage} name="article" />
<Route path="/contact" page={ContactPage} name="contact" />
<Route path="/about" page={AboutPage} name="about" />
<Route path="/" page={HomePage} name="home" />
</Set>
<Route notfound page={NotFoundPage} />
</Router>
)
}

export default Routes

We wrap the routes we want to be private (that is, only accessible when logged in) in the <Private> component, and tell our app where to send them if they are unauthenticated. In this case they should go to the home route.

Try going back to http://localhost:8910/admin/posts now and—yikes!

Homepage showing user does not have permission to view

Well, we couldn't get to the admin pages, but we also can't see our blog posts any more. Do you know why we're seeing the same message here that we saw in the posts admin page?

It's because the posts query in posts.sdl.ts is used by both the homepage and the posts admin page. Since it has the @requireAuth directive, it's locked down and can only be accessed when logged in. But we do want people that aren't logged in to be able to view the posts on the homepage!

Now that our admin pages are behind a <Private> route, what if we set the posts query to be @skipAuth instead? Let's try:

api/src/graphql/posts.sdl.js
export const schema = gql`
type Post {
id: Int!
title: String!
body: String!
createdAt: DateTime!
}

type Query {
posts: [Post!]! @skipAuth
post(id: Int!): Post @requireAuth
}

input CreatePostInput {
title: String!
body: String!
}

input UpdatePostInput {
title: String
body: String
}

type Mutation {
createPost(input: CreatePostInput!): Post! @requireAuth
updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth
deletePost(id: Int!): Post! @requireAuth
}
`

Reload the homepage and:

image

They're back! Let's just check that if we click on one of our posts that we can see it...UGH:

image

This page shows a single post, using the post query, not posts! So, we need to @skipAuth on that one as well:

api/src/graphql/posts.sdl.js
export const schema = gql`
type Post {
id: Int!
title: String!
body: String!
createdAt: DateTime!
}

type Query {
posts: [Post!]! @skipAuth
post(id: Int!): Post @skipAuth
}

input CreatePostInput {
title: String!
body: String!
}

input UpdatePostInput {
title: String
body: String
}

type Mutation {
createPost(input: CreatePostInput!): Post! @requireAuth
updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth
deletePost(id: Int!): Post! @requireAuth
}
`

Cross your fingers and reload!

image

We're back in business! Once you add authentication into your app you'll probably run into several situations like this where you need to go back and forth, re-allowing access to some pages or queries that inadvertently got locked down by default. Remember, Redwood is secure by default—we'd rather you accidentally expose too little of your app than too much!

Now that our pages are behind login, let's actually create a login page so that we can see them again.

Skipping auth altogether for posts and post feels bad somehow...

Ahh, good eye. While posts don't currently expose any particularly secret information, what if we eventually add a field like publishStatus where you could mark a post as draft so that it doesn't show on the homepage. But, if you knew enough about GraphQL, you could easily request all posts in the database and be able to read all the drafts!

It would be more future-proof to create a new endpoint for public display of posts, something like publicPosts and publicPost that will have built-in logic to only ever return a minimal amount of data and leave the default posts and post queries returning all the data for a post, something that only the admin will have access to. (Or do the opposite: keep posts and post as public and create new adminPosts and adminPost endpoints that can contain sensitive information.)

Login & Signup Pages

Yet another generator is here for you, this time one that will create pages for login, signup and forgot password pages:

yarn rw g dbAuth

Again several pages will be created and some post-install instructions will describe next steps. But for now, try going to http://localhost:8910/login:

Generated login page

That was easy! We don't have a user to login with, so try going to the signup page instead (there's a link under the Login button, or just head to http://localhost:8910/signup):

Generated signup page

dbAuth defaults to the generic "Username" for the first field, but in our case the username will be an email address (we can change that label in a moment). Create yourself a user with email and password:

image

And after clicking "Signup" you should end up back on the homepage, where everything looks the same! Yay? But now try going to http://localhost:8910/admin/posts:

Posts admin

Awesome! Signing up will automatically log you in (although this behavior can be changed) and if you look in the code for the SignupPage you'll see where the redirect to the homepage takes place (hint: check out line 21).

Now that we're logged in, how do we log out? Let's add a link to the BlogLayout so that it's present on all pages, and also include an indicator of who you're actually logged in as.

Redwood provides a hook useAuth which we can use in our components to determine the state of the user's login-ness, get their user info, and more. In BlogLayout we want to destructure the isAuthenticated, currentUser and logOut properties from useAuth():

web/src/layouts/BlogLayout/BlogLayout.js
import { useAuth } from '@redwoodjs/auth'
import { Link, routes } from '@redwoodjs/router'

const BlogLayout = ({ children }) => {
const { isAuthenticated, currentUser, logOut } = useAuth()

return (
<>
<header>
<h1>
<Link to={routes.home()}>Redwood Blog</Link>
</h1>
<nav>
<ul>
<li>
<Link to={routes.home()}>Home</Link>
</li>
<li>
<Link to={routes.about()}>About</Link>
</li>
<li>
<Link to={routes.contact()}>Contact</Link>
</li>
</ul>
</nav>
</header>
<main>{children}</main>
</>
)
}

export default BlogLayout

As you can probably tell by the names:

  • isAuthenticated: a boolean as to whether or not a user is logged in
  • currentUser: any details the app has on that user (more on this in a moment)
  • logOut: removes the user's session and logs them out

At the top right of the page, let's show the email address of the user (if they're logged in) as well as a link to log out. If they're not logged in, let's show a link to do just that:

web/src/layouts/BlogLayout/BlogLayout.js
import { useAuth } from '@redwoodjs/auth'
import { Link, routes } from '@redwoodjs/router'

const BlogLayout = ({ children }) => {
const { isAuthenticated, currentUser, logOut } = useAuth()

return (
<>
<header>
<div className="flex-between">
<h1>
<Link to={routes.home()}>Redwood Blog</Link>
</h1>
{isAuthenticated ? (
<div>
<span>Logged in as {currentUser.email}</span>{' '}
<button type="button" onClick={logOut}>
Logout
</button>
</div>
) : (
<Link to={routes.login()}>Login</Link>
)}
</div>
<nav>
<ul>
<li>
<Link to={routes.home()}>Home</Link>
</li>
<li>
<Link to={routes.about()}>About</Link>
</li>
<li>
<Link to={routes.contact()}>Contact</Link>
</li>
</ul>
</nav>
</header>
<main>{children}</main>
</>
)
}

export default BlogLayout

image

Well, it's almost right! Where's our email address? By default, the function that determines what's in currentUser only returns that user's id field for security reasons (better to expose too little than too much, remember!). To add email to that list, check out api/src/lib/auth.ts:

api/src/lib/auth.js
import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server'
import { db } from './db'

export const getCurrentUser = async (session) => {
return await db.user.findUnique({
where: { id: session.id },
select: { id: true },
})
}

export const isAuthenticated = () => {
return !!context.currentUser
}

export const hasRole = (roles) => {
if (!isAuthenticated()) {
return false
}

const currentUserRoles = context.currentUser?.roles

if (typeof roles === 'string') {
if (typeof currentUserRoles === 'string') {
// roles to check is a string, currentUser.roles is a string
return currentUserRoles === roles
} else if (Array.isArray(currentUserRoles)) {
// roles to check is a string, currentUser.roles is an array
return currentUserRoles?.some((allowedRole) => roles === allowedRole)
}
}

if (Array.isArray(roles)) {
if (Array.isArray(currentUserRoles)) {
// roles to check is an array, currentUser.roles is an array
return currentUserRoles?.some((allowedRole) =>
roles.includes(allowedRole)
)
} else if (typeof currentUserRoles === 'string') {
// roles to check is an array, currentUser.roles is a string
return roles.some((allowedRole) => currentUserRoles === allowedRole)
}
}

// roles not found
return false
}

export const requireAuth = ({ roles } = {}) => {
if (!isAuthenticated()) {
throw new AuthenticationError("You don't have permission to do that.")
}

if (roles && !hasRole(roles)) {
throw new ForbiddenError("You don't have access to do that.")
}
}

The getCurrentUser() function is where the magic happens: whatever is returned by this function is the content of currentUser, in both the web and api sides! In the case of dbAuth, the single argument passed in, session, contains the id of the user that's logged in. It then looks up the user in the database with Prisma, selecting just the id. Let's add email to this list:

api/src/lib/auth.js
export const getCurrentUser = async (session) => {
return await db.user.findUnique({
where: { id: session.id },
select: { id: true, email: true},
})
}

Now our email should be present at the upper right on the homepage:

image

Before we leave this file, take a look at requireAuth(). Remember when we talked about the @requireAuth directive and how when we first installed authentication we saw the message "You don't have permission to do that."? This is where that came from!

Session Secret

After the initial setup command, which installed dbAuth, you may have noticed that an edit was made to the .env file in the root of your project. The setup script appended a new ENV var called SESSION_SECRET along with a big random string of numbers and letters. This is the encryption key for the cookies that are stored in the user's browser when they log in. This secret should never be shared, never checked into your repo, and should be re-generated for each environment you deploy to.

You can generate a new value with the yarn rw g secret command. It only outputs it to the terminal, you'll need to copy/paste to your .env file. Note that if you change this secret in a production environment, all users will be logged out on their next request because the cookie they currently have cannot be decrypted with the new key! They'll need to log in again to a new cookie encrypted with the new key.

Wrapping Up

Believe it or not, that's pretty much it for authentication! You can use the combination of @requireAuth and @skipAuth directives to lock down access to GraphQL query/mutations, and the <Private> component to restrict access to entire pages of your app. If you only want to restrict access to certain components, or certain parts of a component, you can always get isAuthenticated from the useAuth() hook and then render one thing or another.

Head over to the Redwood docs to read more about self-hosted authentication and third-party authentication.

One More Thing

Remember the GraphQL Playground exercise at the end of Creating a Contact? Try to run that again now that authentication is in place and you should get that error we've been talking about because of the @requireAuth directive! But, creating a new contact should still work just fine (because we're using @skipAuth on that mutation).

However, simulating a logged-in user through the GraphQL Playground is no picnic. But, we're working on improving the experience!