RedwoodJS v7.7 Tutorial Chapter 4

Aug 15, 2024

はじめに

Chapter 4は認証機能を実装する内容となります。

Note

デプロイについても取り上げていましたが、RailwayやNetlifyなど普段私が使わないサービスでしたので今回はスキップします。

前回の記事

管理画面

まずはルーティングを変更してscaffoldで生成したPostリソースの画面群を全て/admin以下に設定します。

// web/src/Routes.tsx
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

セットアップ

次にコマンド実行して、認証のセットアップします。

yarn rw setup auth dbAuth
 YN0000: · Yarn 4.3.0
 YN0000: Resolution step
 YN0085: + @redwoodjs/auth-dbauth-setup@npm:7.7.4, @simplewebauthn/browser@npm:7.4.0, and 1 more.
 YN0000: Completed in 3s 509ms
 YN0000: Post-resolution validation
 YN0086: Some peer dependencies are incorrectly met by dependencies; run yarn explain peer-requirements for details.
 YN0000: Completed
 YN0000: Fetch step
 YN0013: 3 packages were added to the project (+ 120.16 KiB).
 YN0000: Completed in 0s 917ms
 YN0000: Link step
 YN0000: Completed in 0s 308ms
 YN0000: · Done with warnings in 4s 884ms

 Enable WebAuthn support (TouchID/FaceID)? See https://redwoodjs.com/docs/auth/dbAuth#webAuthn … no
 Setting up Auth from scratch
 Generating auth api side files...
 Successfully wrote file `./api/src/functions/auth.ts`
 Successfully wrote file `./api/src/lib/auth.ts`
 Updating web/src/App.{jsx,tsx}
 Creating web/src/auth.ts
 Updating Routes file...
 Adding auth config to GraphQL API...
 Adding required web packages...
 Adding required api packages...
 Installing packages...
 Adding SESSION_SECRET var to .env...
 Create auth decoder function
 One more thing...

   Done! But you have a little more work to do:

   You will need to add a couple of fields to your User table in order
   to store a hashed password and salt:

     model User {
       id                  Int @id @default(autoincrement())
       email               String  @unique
       hashedPassword      String    // <─┐
       salt                String    // <─┼─ add these lines
       resetToken          String?   // <─┤
       resetTokenExpiresAt DateTime? // <─┘
     }

   If you already have existing user records you will need to provide
   a default value for `hashedPassword` and `salt` or Prisma complains, so
   change those to: 

     hashedPassword String @default("")
     salt           String @default("")

   If you expose any of your user data via GraphQL be sure to exclude
   `hashedPassword` and `salt` (or whatever you named them) from the
   SDL file that defines the fields for your user.

   You'll need to let Redwood know what fields you're using for your
   users' `id` and `username` fields. In this case we're using `id` and
   `email`, so update those in the `authFields` config in
   `/api/src/functions/auth.js` (this is also the place to tell Redwood if
   you used a different name for the `hashedPassword`, `salt`,
   `resetToken` or `resetTokenExpiresAt`, fields:`

     authFields: {
       id: 'id',
       username: 'email',
       hashedPassword: 'hashedPassword',
       salt: 'salt',
       resetToken: 'resetToken',
       resetTokenExpiresAt: 'resetTokenExpiresAt',
     },

   To get the actual user that's logged in, take a look at `getCurrentUser()`
   in `/api/src/lib/auth.js`. We default it to something simple, but you may
   use different names for your model or unique ID fields, in which case you
   need to update those calls (instructions are in the comment above the code).

   Finally, we created a SESSION_SECRET environment variable for you in
   /path/to/project/my-first-redwood-app/.env. This value should NOT be checked
   into version control and should be unique for each environment you
   deploy to. If you ever need to log everyone out of your app at once
   change this secret to a new value and deploy. To create a new secret, run:

     yarn rw generate secret

   Need simple Login, Signup and Forgot Password pages? We've got a generator
   for those as well:

     yarn rw generate dbAuth

実行すると、パッケージがインストールされたり、WebAuthnサポートについて聞かれたり、色々ファイルが上書きされたりしました。

実行ログにあるように手動で手を加えないと部分があるので、やっていきます。

Userモデルの作成

まずはUserモデルを作成します。

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

認証に関するフィールドは以下です。

  • hashedPassword: パスワードとソルトの組み合わせをハッシュ化したものを保存します
  • salt: パスワードと混ぜるためのユニークな文字列。Rainbow Table Attack防止のためです
  • resetToken: ユーザーがパスワードを忘れてリセットする際に使用するトークンです
  • resetTokenExpiresAt: パスワードリセットの有効期限です

Userモデルのスキーマ定義ができたらマイグレーションを実行します。

yarn rw prisma migrate dev

これでデータベースのセットアップが完了しました。

プライベートルート

この段階で/admin/postsにアクセスすると「You don't have permission to do that.」とエラーメッセージが表示されます。

これをアクセスできるように修正します。

import { PrivateSet, Router, Route, Set } from '@redwoodjs/router'

import ScaffoldLayout from 'src/layouts/ScaffoldLayout'
import BlogLayout from 'src/layouts/BlogLayout'

import { useAuth } from './auth'

const Routes = () => {
  return (
    <Router useAuth={useAuth}>
      <PrivateSet 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>
        {/* ... */}
      </PrivateSet>
      </Router>
  )
}

PrivateSetコンポーネントを挟みました。 再度/admin/postsへアクセスしてみると、未認証のためhome(/)へリダイレクトされました。

ホーム画面ではPost一覧が見えるようにしたいので、SDLファイルを編集します。

// api/src/graphql/posts.sdl.ts
export const schema = gql`
  # ...
  type Query {
    posts: [Post!]! @skipAuth
    post(id: Int!): Post @skipAuth
  }
  # ...
`

Queryのpostsとpostを@requireAuthから@skipAuthへ変更しました。 これでPost一覧と詳細が認証なしで表示できるようになりました。

ログイン&サインアップページ

RedwoodJSにはログインページとサインアップページを生成してくれるコマンドがあるので実行します。

yarn rw g dbAuth
 Determining UI labels...
 Username label: "Username"
 Password label: "Password"
 Querying WebAuthn addition: WebAuthn addition not included
 Creating pages...
 Successfully wrote file `./web/src/pages/SignupPage/SignupPage.tsx`
 Successfully wrote file `./web/src/pages/ResetPasswordPage/ResetPasswordPage.tsx`
 Successfully wrote file `./web/src/pages/LoginPage/LoginPage.tsx`
 Successfully wrote file `./web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx`
 Adding routes...
 Adding scaffold import...
 One more thing...

   Pages created! But you're not done yet:

   You'll need to tell your pages where to redirect after a user has logged in,
   signed up, or reset their password. Look in LoginPage, SignupPage,
   ForgotPasswordPage and ResetPasswordPage for these lines: 

     if (isAuthenticated) {
       navigate(routes.home())
     }

   and change the route to where you want them to go if the user is already
   logged in. Also take a look in the onSubmit() functions in ForgotPasswordPage
   and ResetPasswordPage to change where the user redirects to after submitting
   those forms.

   Happy authenticating!

/login/signupへアクセスすると以下のような画面ができています。

Login Redwood App

Signup Redwood App

そのままサインアップした後、/admin/postsへアクセスすると無事に見れるようになりました。

ログアウトリンク

ログインできたので次はログアウトリンクを追加しましょう。 BlogLayoutコンポーネントに追加します。

import { Link, routes } from '@redwoodjs/router'

import { useAuth } from 'src/auth'
type BlogLayoutProps = {
  children?: React.ReactNode
}

const BlogLayout = ({ children }: BlogLayoutProps) => {
  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

useAuthフックを使うと、ログインユーザー情報やログイン状況、ログアウト関数が取れます。 ただしこのままだとcurrentUser.emailが取れないのでapi/src/lib/auth.tsを編集します。

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

Home Redwood App

これでログインしているユーザーのEmailが表示できました。

セッションシークレットについて

setupコマンドを実行した際に、SESSION_SECRETという環境変数が.envに追加されたはずです。 これはログイン時にユーザーのブラウザに保存されるクッキーの暗号化キーです。これは漏洩しないように注意してください。リポジトリへのプッシュもするべきではありません。

再度生成したい場合はyarn rw g secretで新しい値を出力できます。 もし本番環境のSESSION_SECRETを変更する場合は全てのユーザーがログアウトしてしまうので注意してください。

これでChapter 4(Authenticationのみ)は終了です。