Skip to main content
Version: 5.3

Authentication

An Admin Section

管理画面を /admin に置くことは、合理的なことです。これを実現するために、URLが /posts で始まる4つのルートを更新し、代わりに /admin/posts で始まるようにしましょう:

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

ブラウザで http://localhost:8910/admin/posts を開くと、生成された scaffold ページ表示されるはずです。名前付きルートのおかげでページの name は変更されなかったので、scaffold によって生成された <Link> を更新する必要はありません!

管理者が別のパスにいることは素晴らしいことですが、誰かがその新しいパスをブラウズして、私たちのブログ記事をいじくることを止めるものは何もないのです。どうすれば、覗き見されないようにできるでしょうか?

Authentication

"Authentication(認証)" とは、ユーザ(しばしばメールアドレスとパスワードで識別される)が、何かにアクセスすることを許可するための包括的な用語です。認証は、技術的観点からも開発者の幸福の観点からも、正しく行うことが famously fickle (継続的な実現が非常に難しいことで有名)であることがよくあります。

"Credentials" (クレデンシャル)とは、ユーザが自分自身を証明するために提供する情報のことです:一般的にはユーザ名(だいたいはメールアドレス)とパスワードがこれにあたります。

Redwoodには2つの認証方法があります:

  • セルフホスト:ユーザ認証情報はユーザ自身のデータベースに保存される
  • サードパーティホスト:ユーザ認証情報はサードパーティに保存される

どちらの場合も、最終的にはWebサイドとAPIサイドの両方でアクセスできる認証済みユーザを得ることができます。

Redwood には、最も人気のあるいくつかのサードパーティの認証プロバイダの integrations が含まれています:

このチュートリアルのブログでは、セルフホストの認証(Redwoodでは dbAuth という名前)を使用することにします。これは最も簡単で、サードパーティの認証サービスへのユーザ登録が不要だからです。

Authentication vs. Authorization

ログインを語る上で必ずと言っていいほど登場する、"A "で始まり "ation "で終わる、文字数の多い2つの用語があります(つまり、韻を踏もうと思えば踏めるということです):

  • Authentication
  • Authorization

Redwoodはこれらの用語を以下のように使用しています:

  • Authentication (認証)は、一般的にメールアドレスとパスワード、またはAuth0のようなサードパーティプロバイダで "logging in" することによって、誰かがその人であるかどうかを決定することを扱う
  • Authorization (認可)は、ユーザ(通常、すでに認証されている)が、彼らがしたい何かをすることを許可されているかどうか。一般的に、URLやサイト機能へのアクセスを許可する前に、ロールと権限チェックを組み合わせて判定する

チュートリアルのこのセクションでは Authentication (認証)のみに焦点を当てています。Redwood の Authorization については チュートリアルの 7 章 を参照してください。

Auth Setup

おそらくお分かりのように、Redwood にはいくつかのジェネレータがあります。一つは dbAuth に必要なバックエンドコンポーネントをインストールするもので、もう一つはログイン、サインアップ、パスワード忘れのページを作成するものです。

このセットアップコマンドを実行すると、アプリに dbAuth が追加されます:

yarn rw setup auth dbAuth

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

まず User モデルにいくつかのフィールドを追加する必要があります。まだ User モデルがないので、必要なフィールドと一緒に作成します。

schema.prisma を開いて追加します:

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?
}

これにより、名前とメールアドレス持つユーザと、dbAuthが制御する4つのフィールドが得られます:

  • hashedPassword : ユーザのパスワードを salt と組み合わせて ハッシュ化 した結果
  • saltレインボーテーブル攻撃を防ぐためにhashedPasswordと結合されるユニークな文字列
  • resetToken : ユーザがパスワードを忘れた場合、dbAuthはここにトークンを挿入するので、ユーザがパスワードをリセットするために戻ってきたときここにデータ存在しなければならない
  • resetTokenExpiresAt : resetToken が期限切れで無効になるタイムスタンプ (ユーザはパスワード忘れフォームに再度記入する必要がある)

データベースに"create user" のような名前をつけたマイグレーションを実行して、 ユーザモデルを作ってみましょう:

yarn rw prisma migrate dev

データベースの設定は以上です!

Private Routes

ブログ記事管理画面を再読み込みしてみれば、50%正しいものが表示されるはずです:

image

adminセクションに行くと、ログインしていないユーザがブログ記事を見ることができなくなりました。素晴らしい!これは api/src/graphql/posts.sdl.ts にある @requireAuth ディレクティブの結果です:あなたは認証されていないので、GraphQLはあなたのデータ要求に応答しません。しかし、理想を言えば、管理ページそのものを見えないようにしたいところです。Routesファイルにある新しいコンポーネント <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'

import { useAuth } from './auth'

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

プライベートなルート(ログインしているときだけアクセスできるルート)を <Private> コンポーネントでラップし、認証されていないルートをどこに送ればよいかをアプリに知らせます。この場合、ルートは home に送られます。

http://localhost:8910/admin/posts に戻ってみると - なんと!

Homepage showing user does not have permission to view

さて、管理画面には行けませんでしたが、ブログ記事も見れなくなってしまいました。なぜ、ブログ記事の管理画面で見たのと同じメッセージがここで表示されているかわかりますか?

posts.sdl.tsposts クエリが、ホームページとブログ記事管理ページの 両方で 使用されるからです。これは @requireAuth ディレクティブを持つので、ログインしているときだけアクセスできるようにロックされています。しかし、ログインしていない人がホームページのブログ記事を見ることができるように したい のです!

管理ページが <Private> ルートの配下になったので、代わりに posts クエリを @skipAuth に設定したらどうでしょうか。試してみましょう:

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
}
`

ホームページを再読み込みすると:

image

戻ってきました!一応それぞれのブログ記事が見られるかもチェックしてみましょう...ぐぬぬ:

image

このページでは単一のブログ記事を表示するのに posts ではなく post クエリを使っています!そのためこちらにも @skipAuth が必要です:

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
}
`

指をくわえてリロード!

image

やったね!アプリに認証を追加すると、このように、デフォルトでロックされているページやクエリへのアクセスを再許可しなければならない状況に何度か遭遇するでしょう。Redwoodはデフォルトでセキュアであることを忘れないでください -- アプリの意図しない公開は 多い より 少ない ほうがいいでしょう!

さて、ページがログイン後にあるため、実際にログインページを作成して、再び見ることができるようにしましょう。

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

ああ、良い目の付け所ですね。ブログ記事は今のところ特に秘密の情報を公開しませんが、いずれ publishStatus のようなフィールドを追加して、ブログ記事を draft (下書き)としてマークし、ホームページには表示されないようにしたらどうでしょう。しかし、もしあなたがGraphQLについて十分に知っていれば、データベース内のすべてのブログ記事を簡単にリクエストでき、すべての下書きを読むことができるようになります!

publicPostspublicPost のような公開されたブログ記事を取得する 新しい エンドポイントを作成するほうが将来有望でしょう。 その場合、新しいエンドポイントは最小限のデータしか返さないようにするためのロジックを組み込み、デフォルトの postspost クエリはブログ記事の全データを返し管理者だけがアクセスできるようにします(逆に postspost を public にして、機密情報を含むことができる adminPostsadminPost というエンドポイントを新たに作成することもできます)。

Login & Signup Pages

今回は、ログイン、サインアップ、パスワード忘れなどのページを作成するもうひとつのジェネレータをご紹介します。

yarn rw g dbAuth

またここでいくつかのページが作成され、インストール後の手順が説明されます。しかし、とりあえず、http://localhost:8910/login にアクセスしてみてください:

Generated login page

簡単でしたね!ログインするユーザがいないので、代わりにサインアップページに行ってみてください(ログインボタンの下にリンクがあります、または http://localhost:8910/signup を開いてください)。

Generated signup page

dbAuthのデフォルトでは、最初のフィールドは一般的な "Username" ですが、私たちの場合、ユーザ名はメールアドレスになります(このラベルはすぐに変更可能です)。メールアドレスメールとパスワードでユーザを作成します:

image

そして "Signup" をクリックすると、ホームページに戻り、すべてが同じように見えるはずです!やったー?しかし、今度は http://localhost:8910/admin/posts に行ってみてください:

Posts admin

すごい!サインアップすると自動的にログインします(この動作は 変更可能 )。 SignupPage のコードを見れば、ホームページへのリダイレクトが行われる場所がわかります(ヒント:21行目を確認してください)。

さて、ログインしたところで、どうやってログアウトするのでしょうか?すべてのページに表示されるように BlogLayout にリンクを追加し、実際に誰としてログインしているかの表示も追加しましょう。

Redwood は hookuseAuth を提供しており、これをコンポーネント内で使用して、ユーザのログイン状態を判断したり、ユーザ情報を取得したりすることができます。 BlogLayout では、 useAuth() から isAuthenticatedcurrentUserlogOut プロパティを再構築したいと思います:

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

import { useAuth } from 'src/auth'

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

たぶん名前でわかると思いますが:

  • isAuthenticated : ユーザがログインしているかどうかのブール値
  • currentUser : アプリが持つそのユーザの詳細(詳細は後述)
  • logOut : ユーザのセッションを削除し、ログアウトさせる

ページの右上には、ユーザのメールアドレス(ログインしている場合)とログアウトのためのリンクを表示しましょう。ログインしていない場合は、ログインのリンクを表示します:

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

import { useAuth } from 'src/auth'

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

まあ、だいたい合ってるんですけどね!メールアドレスはどこでしょ?デフォルトでは、 currentUser に何が入っているかを判断する関数は、セキュリティ上の理由から、そのユーザの id フィールドのみを返します(公開しすぎるよりは、少なすぎる方が良いことを覚えておいてください!)。そのリストにemailを追加するには、 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.")
}
}

getCurrentUser() 関数は、マジックが起こる場所です:この関数が返すのは、Webサイドと APIサイドの両方で currentUser の内容なのです! dbAuth の場合、唯一の引数として渡される session には、ログインしているユーザの id が含まれます。 そして、Prismaでデータベース内のユーザを検索し、 id だけを選択します。ここに email を追加してみましょう:

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

これでメールアドレスがホームページの右上に表示されるはずです:

image

このファイルから離れる前に requireAuth() を見てみましょう。 @requireAuth ディレクティブの話をしたとき、最初に認証をインストールしたときに "You don't have permission to do that" というメッセージが表示されたのを覚えていますか?あのメッセージはここからきています!

Session Secret

最初の setup コマンドで dbAuth をインストールした後、プロジェクトのルートにある .env ファイルが編集されたことにお気づきでしょうか。 setup スクリプトは SESSION_SECRET という新しい環境変数に、数字と文字からなるランダムな長い文字列を追加していました。これは、ユーザがログインしたときにブラウザに保存されるクッキーの暗号化キーです。この秘密は決して共有してはいけませんし、あなたのリポジトリにチェックインしてもいけません。また、デプロイする環境ごとに再作成しなければなりません。

新しい値は yarn rw g secret コマンドで生成できます。このコマンドはターミナルに出力されるだけなので、 .env ファイルにコピー&ペーストする必要があります。本番環境でこのキーを変更すると、すべてのユーザが次のリクエストでログアウトしてしまうことに注意してください!なぜなら、現在持っているクッキーを新しいキーで復号することができないからです。ユーザは新しいキーで暗号化された新しいクッキーで再度ログインする必要があります。

Wrapping Up

信じられないかもしれませんが、これで認証はほぼ完了です! @requireAuth@skipAuth ディレクティブの組み合わせで GraphQL クエリ/ミューテーションへのアクセスをロックしたり、 <Private> コンポーネントを使用してアプリのページ全体へのアクセスを制限したりできます。特定のコンポーネント、またはコンポーネントの特定の部分へのアクセスのみを制限したい場合は、常に useAuth() フックから isAuthenticated を取得して個別にレンダリングしなければなりません。

Redwood のドキュメントを読み込むなら self-hostedthird-party をどうぞ。

One More Thing

Creating a Contact の最後にあるGraphQL Playgroundを覚えていますか?認証が実装された今の状態でもう一度実行してみると、先ほど説明した @requireAuth ディレクティブが原因でエラーが発生するはずです!しかし、新しい お問い合わせの追加は問題なく行えるはずです(このミューテーションでは @skipAuth を使用しているからです)。

しかしながら、GraphQL Playgroundを通じてログインしたユーザをシミュレートすることは、そう楽しいことではありません。しかし、私たちはこの体験を改善するために努力しています!