ユーザー認証.js


注:前にこの投稿を書いたAPI routes がリリースされました.私は、次の最新の使用するポストを更新する必要があります.js機能.一方、あなたが読む必要がありますThe Ultimate Guide to Next.js Authentication with Auth0 これは、次の使用できるすべての認証パターンを記述する素晴らしいガイドです.jsこのポストは1つの方法だけに焦点を当てて、それをどのように構築するかを説明します.私は両方のガイドを維持する価値があると思うので、私はそれを最新の状態に保つ作業をします.

Previously published on my blog


ユーザー認証.JSは、コミュニティによって最も要求された例の1つでした.The GitHub issue 300以上の好意と提言や提案とのコメントの何百もの.
この問題は、コミュニティに特定の要件の例を提供するよう求めました.
  • ページ間の再利用可能な認証ヘルパー
  • タブ間のセッション同期
  • シンプルなpasserdlessメールバックエンドをホストnow.sh
  • この例の主な目的は初心者の出発点を持つことでした.
    のリリースでNext.js 8 最後に、例を受け入れて、examples repository . このポストでは、我々はゼロからの例を作成します.
    あなたはそのコードを見つけることができますNext.js examples repository またはworking demo deployed in Now 2 .
  • Project Setup
  • Backend

  • Frontend
  • Login Page and Authentication
  • Profile Page and Authorization
  • Authorization Helper Function
  • Authorization High Order Component
  • Page Component with Authorized requests
  • Logout and Session Synchronization
  • Deploy to Now 2
  • Local Development
  • Conclusion
  • プロジェクト設定


    プロジェクトをAとして設定しますmonorepo と一緒に推奨フォルダ構造でnow.json ファイルを展開します.
    $ mkdir project
    $ cd project
    $ mkdir www api
    $ touch now.json
    

    バックエンド


    私たちはmicro 受信要求とisomoprhic-unfetch 我々のoutoing API要求をするために.
    $ cd api
    $ npm install isomorphic-unfetch micro --save
    
    この例を簡単にするために、Github APIをpasswordless backendとして使用します.我々のバックエンドは/users/:username エンドポイントとユーザーの取得id , ではこれからid 私たちのトークンになります.
    我々のアプリでは、エンドポイントとして機能する2つの関数を作成します.login.js を返します.profile.js 指定したトークンからユーザ情報を返す.
    // api/login.js
    
    const { json, send, createError, run } = require('micro')
    const fetch = require('isomorphic-unfetch')
    
    const login = async (req, res) => {
      const { username } = await json(req)
      const url = `https://api.github.com/users/${username}`
    
      try {
        const response = await fetch(url)
        if (response.ok) {
          const { id } = await response.json()
          send(res, 200, { token: id })
        } else {
          send(res, response.status, response.statusText)
        }
      } catch (error) {
        throw createError(error.statusCode, error.statusText)
      }
    }
    
    module.exports = (req, res) => run(req, res, login);
    
    // api/profile.js
    
    const { send, createError, run } = require('micro')
    const fetch = require('isomorphic-unfetch')
    
    const profile = async (req, res) => {
      if (!('authorization' in req.headers)) {
        throw createError(401, 'Authorization header missing')
      }
    
      const auth = await req.headers.authorization
      const { token } = JSON.parse(auth)
      const url = `https://api.github.com/user/${token}`
    
      try {
        const response = await fetch(url)
    
        if (response.ok) {
          const js = await response.json()
          // Need camelcase in the frontend
          const data = Object.assign({}, { avatarUrl: js.avatar_url }, js)
          send(res, 200, { data })
        } else {
          send(res, response.status, response.statusText)
        }
      } catch (error) {
        throw createError(error.statusCode, error.statusText)
      }
    }
    
    module.exports = (req, res) => run(req, res, profile)
    
    これにより、バックエンドでの認証/認証戦略を簡単に処理する必要があります.

    フロントエンド


    今、我々の中www/ フォルダは、我々は次のインストールする必要があります.アプリケーションと依存関係
    $ cd www/
    $ npm create-next-app .
    $ npm install
    $ npm install isomorphic-unfetch next-cookies js-cookie --save
    
    ページを作成します.
    $ touch pages/index.js
    $ touch pages/profile.js
    
    認証ヘルパーを含むファイル.
    $ mkdir utils
    $ touch utils/auth.js
    
    とローカル開発用のカスタムサーバーが含まれます.我々は、ローカルでmonorepoセットアップを複製するために、これを後で必要とします.
    $ touch server.js
    
    この時点でwww/ フォルダ構造はこのようになります.
    .
    ├── components
    │   ├── header.js
    │   └── layout.js
    ├── package-lock.json
    ├── package.json
    ├── pages
    │   ├── index.js
    │   ├── login.js
    │   └── profile.js
    ├── server.js
    └── utils
        └── auth.js
    
    フロントエンドの構造は準備ができています.

    ログインページと認証


    ログインページには、ユーザーを認証するフォームが含まれます.フォームはPOSTリクエストを/api/login.js ユーザ名でエンドポイントし、ユーザ名が存在する場合、バックエンドはトークンを返します.
    この例では、このトークンをフロントエンドに保つ限り、ユーザはアクティブなセッションを持つことができます.
    // www/pages/login.js
    
    import { Component } from 'react'
    import fetch from 'isomorphic-unfetch'
    import Layout from '../components/layout'
    import { login } from '../utils/auth'
    
    class Login extends Component {
      static getInitialProps ({ req }) {
        const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'
    
        const apiUrl = process.browser
          ? `${protocol}://${window.location.host}/api/login.js`
          : `${protocol}://${req.headers.host}/api/login.js`
    
        return { apiUrl }
      }
    
      constructor (props) {
        super(props)
    
        this.state = { username: '', error: '' }
        this.handleChange = this.handleChange.bind(this)
        this.handleSubmit = this.handleSubmit.bind(this)
      }
    
      handleChange (event) {
        this.setState({ username: event.target.value })
      }
    
      async handleSubmit (event) {
        event.preventDefault()
        const username = this.state.username
        const url = this.props.apiUrl
    
        try {
          const response = await fetch(url, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ username })
          })
          if (response.ok) {
            const { token } = await response.json()
            login({ token })
          } else {
            console.log('Login failed.')
            // https://github.com/developit/unfetch#caveats
            let error = new Error(response.statusText)
            error.response = response
            return Promise.reject(error)
          }
        } catch (error) {
          console.error(
            'You have an error in your code or there are Network issues.',
            error
          )
          throw new Error(error)
        }
      }
    
      render () {
        return (
          <Layout>
            <div className='login'>
              <form onSubmit={this.handleSubmit}>
                <label htmlFor='username'>GitHub username</label>
    
                <input
                  type='text'
                  id='username'
                  name='username'
                  value={this.state.username}
                  onChange={this.handleChange}
                />
    
                <button type='submit'>Login</button>
    
                <p className={`error ${this.state.error && 'show'}`}>
                  {this.state.error && `Error: ${this.state.error}`}
                </p>
              </form>
            </div>
            <style jsx>{`
              .login {
                max-width: 340px;
                margin: 0 auto;
                padding: 1rem;
                border: 1px solid #ccc;
                border-radius: 4px;
              }
              form {
                display: flex;
                flex-flow: column;
              }
              label {
                font-weight: 600;
              }
              input {
                padding: 8px;
                margin: 0.3rem 0 1rem;
                border: 1px solid #ccc;
                border-radius: 4px;
              }
              .error {
                margin: 0.5rem 0 0;
                display: none;
                color: brown;
              }
              .error.show {
                display: block;
              }
            `}</style>
          </Layout>
        )
      }
    }
    
    export default Login
    
    我々getInitialProps() は、環境に基づいたURLを生成し、ブラウザやサーバにあるかどうかをチェックします.
    最初の行はプロトコルをhttps or https 環境によって.
    ...
    const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'
    ...
    
    次は、我々を得るhost ブラウザかサーバにあるかどうかによって異なります.この方法では、我々は現在、動的に生成されたURLを使用している場合でも、ローカルの開発では、右のURLを取得しますhttp://localhost:3000 .
    ...
    const apiUrl = process.browser
      ? `${protocol}://${window.location.host}/${endpoint}`
      : `${protocol}://${req.headers.host}/${endpoint}`;
    ...
    
    他のすべては、提出に関するポスト要求をするフォームでかなり標準です.また、ローカルの状態を使用して、簡単な検証エラーメッセージを処理します.
    我々の要求が成功するならば、我々は我々がAPIから得たトークンでクッキーを保存することによって我々のユーザーにログインして、我々のプロフィールページにユーザーをリダイレクトします.
    ...
    cookie.set("token", token, { expires: 1 });
    Router.push("/profile")
    ...
    

    プロフィールページと承認


    クライアントのみのSPAで、ユーザーを認証するか、認可するために、我々は彼らにページを要求させなければならなくて、JavaScriptをロードして、それからユーザーのセッションを確かめるためにサーバーに要求を送る必要があります.幸運にも、次に.JSは私たちにSSRを与え、サーバー上のユーザのセッションをチェックすることができますgetInitialProps(); .

    認証ヘルパー機能


    プロフィールページを作成する前にwww/utils/auth.js これは許可されたユーザへのアクセスを制限します.
    // www/utils/auth.js
    
    import Router from 'next/router'
    import nextCookie from 'next-cookies'
    
    export const auth = ctx => {
      const { token } = nextCookie(ctx)
    
      if (ctx.req && !token) {
        ctx.res.writeHead(302, { Location: '/login' })
        ctx.res.end()
        return
      }
    
      if (!token) {
        Router.push('/login')
      }
    
      return token
    }
    
    ユーザーがページを読み込むと、この関数はnextCookie , それから、セッションが無効であるならば、それはそうでなければ次に、ログインページにブラウザをリダイレクトします.JSは通常ページをレンダリングします.
    // Implementation example
    ...
    Profile.getInitialProps = async ctx => {
      // Check user's session
      const token = auth(ctx);
    
      return { token }
    }
    ...
    
    このヘルパーは私たちの例に十分シンプルで、サーバーとクライアントで動作します.最適に、我々はサーバーのアクセスを制限したいので、我々は不必要な資源をロードしません.

    認証高次コンポーネント


    これを抽象化するもう一つの方法は、プロファイルのような制限されたページで使用することができます.このように使えます.
    import { withAuthSync } from '../utils/auth'
    
    const Profile = props =>
      <div>If you can see this, you are logged in.</div>
    
    export default withAuthSync(Profile)
    
    また、私たちのloggout機能のために後で役に立つでしょう.このように、我々は標準的な方法を書いて、我々のものを含めますauth 認可の世話をするヘルパー機能.
    我々は、我々の中で我々の基盤をつくりますauth.js ファイルも.
    // Gets the display name of a JSX component for dev tools
    const getDisplayName = Component =>
      Component.displayName || Component.name || 'Component'
    
    export const withAuthSync = WrappedComponent =>
      class extends Component {
        static displayName = `withAuthSync(${getDisplayName(WrappedComponent)})`
    
        static async getInitialProps (ctx) {
          const token = auth(ctx)
    
          const componentProps =
            WrappedComponent.getInitialProps &&
            (await WrappedComponent.getInitialProps(ctx))
    
          return { ...componentProps, token }
        }
    
        render () {
          return <WrappedComponent {...this.props} />
        }
    }
    

    権限のあるページコンポーネント


    我々のプロフィールページは、我々のgithubアバター、名前と生物を示します.我々のAPIからこのデータを引くために、我々は認可された要求を送る必要があります.セッションが無効であるなら、我々のAPIはエラーを投げます、そして、もしそうならば、我々はログインページに我々のユーザーをリダイレクトします.
    これにより、我々の制限されたプロファイルページを承認API呼び出しで作成します.
    // www/pages/profile.js
    
    import Router from 'next/router'
    import fetch from 'isomorphic-unfetch'
    import nextCookie from 'next-cookies'
    import Layout from '../components/layout'
    import { withAuthSync } from '../utils/auth'
    
    const Profile = props => {
      const { name, login, bio, avatarUrl } = props.data
    
      return (
        <Layout>
          <img src={avatarUrl} alt='Avatar' />
          <h1>{name}</h1>
          <p className='lead'>{login}</p>
          <p>{bio}</p>
    
          <style jsx>{`
            img {
              max-width: 200px;
              border-radius: 0.5rem;
            }
            h1 {
              margin-bottom: 0;
            }
            .lead {
              margin-top: 0;
              font-size: 1.5rem;
              font-weight: 300;
              color: #666;
            }
            p {
              color: #6a737d;
            }
          `}</style>
        </Layout>
      )
    }
    
    Profile.getInitialProps = async ctx => {
      // We use `nextCookie` to get the cookie and pass the token to the
      // frontend in the `props`.
      const { token } = nextCookie(ctx)
      const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'
    
      const apiUrl = process.browser
        ? `${protocol}://${window.location.host}/api/profile.js`
        : `${protocol}://${ctx.req.headers.host}/api/profile.js`
    
      const redirectOnError = () =>
        process.browser
          ? Router.push('/login')
          : ctx.res.writeHead(301, { Location: '/login' })
    
      try {
        const response = await fetch(apiUrl, {
          credentials: 'include',
          headers: {
            'Content-Type': 'application/json',
            Authorization: JSON.stringify({ token })
          }
        })
    
        if (response.ok) {
          return await response.json()
        } else {
          // https://github.com/developit/unfetch#caveats
          return redirectOnError()
        }
      } catch (error) {
        // Implementation or Network error
        return redirectOnError()
      }
    }
    
    export default withAuthSync(Profile)
    
    我々は、我々を送りますGET とのAPIへのリクエストcredentials: "include" 私たちのヘッダーを確認するオプションAuthorization 私たちのトークンでそれを送ります.これにより、APIは、要求を承認してデータを返す必要があるものを取得します.

    ログアウトとセッション同期


    フロントエンドでは、ユーザーをログアウトするには、クッキーをクリアし、ログインページにユーザーをリダイレクトする必要があります.我々は、我々の機能を加えますauth.js そうするファイル.
    // www/auth.js
    
    import cookie from "js-cookie";
    import Router from "next/router";
    
    export const logout = () => {
      cookie.remove("token");
      Router.push("/login");
    };
    
    我々は我々がこの機能を呼び出すユーザーをログアウトする必要があるたびに、それはそれの世話をする必要があります.しかし、要件の1つはセッション同期であった.つまり、ユーザをログアウトした場合、すべてのブラウザのタブ/ウィンドウから行うべきである.これを行うには、グローバルイベントリスナーに耳を傾ける必要がありますが、カスタムイベントのようなものを設定するのではなく、storage event .
    それを働かせるために、我々はイベントリスナーをすべての制限されたページに加えなければならないでしょうcomponentDidMount メソッドは、手動で行う代わりに、我々はそれを含めるでしょうwithAuthSync HOC .
    // www/utils/auth.js
    
    // Gets the display name of a JSX component for dev tools
    const getDisplayName = Component =>
      Component.displayName || Component.name || 'Component'
    
    export const withAuthSync = WrappedComponent =>
      class extends Component {
        static displayName = `withAuthSync(${getDisplayName(WrappedComponent)})`
    
        static async getInitialProps (ctx) {
          const token = auth(ctx)
    
          const componentProps =
            WrappedComponent.getInitialProps &&
            (await WrappedComponent.getInitialProps(ctx))
    
          return { ...componentProps, token }
        }
    
        // New: We bind our methods
        constructor (props) {
          super(props)
    
          this.syncLogout = this.syncLogout.bind(this)
        }
    
        // New: Add event listener when a restricted Page Component mounts
        componentDidMount () {
          window.addEventListener('storage', this.syncLogout)
        }
    
        // New: Remove event listener when the Component unmount and
        // delete all data
        componentWillUnmount () {
          window.removeEventListener('storage', this.syncLogout)
          window.localStorage.removeItem('logout')
        }
    
        // New: Method to redirect the user when the event is called
        syncLogout (event) {
          if (event.key === 'logout') {
            console.log('logged out from storage!')
            Router.push('/login')
          }
        }
    
        render () {
          return <WrappedComponent {...this.props} />
        }
    }
    
    それから、我々は我々の我々にすべてのウインドウでログアウトを引き起こすイベントを加えますlogout 関数.
    // www/utils/auth.js
    
    import cookie from "js-cookie";
    import Router from "next/router";
    
    export const logout = () => {
      cookie.remove("token");
      // To trigger the event listener we save some random data into the `logout` key
      window.localStorage.setItem("logout", Date.now()); // new
      Router.push("/login");
    };
    
    最後に、私たちの認証/認可のこの機能を追加したので、我々はプロファイルページで何かを変更する必要はありません.
    今、私たちのユーザーがログインするたびに、セッションはすべてのウィンドウ/タブ間で同期されます.

    今すぐ配備


    唯一のことは我々の設定を書くことですnow.json ファイル.
    // now.json
    
    {
      "version": 2,
      "name": "cookie-auth-nextjs", //
      "builds": [
        { "src": "www/package.json", "use": "@now/next" },
        { "src": "api/*.js", "use": "@now/node" }
      ],
      "routes": [
        { "src": "/api/(.*)", "dest": "/api/$1" },
        { "src": "/(.*)", "dest": "/www/$1" }
      ]
    }
    
    設定ファイルはどのようにルートを私たちの要求とどのようなビルダーを使用するように指示します.あなたはそれについての詳細を読むことができますDeployment Configuration (now.json) ページ.

    地方開発


    APIでは、関数profile.js and login.js 正しく動作するlambdas 彼らが現在2で配備されるとき、しかし、我々が彼らが現在そうであるように、彼らとローカルで働くことができません.
    我々は、基本的なルーティングを使用して、小さなサーバーに機能をインポートすることによって、それらをローカルで使用することができます.これを達成するために、3番目のファイルをdev.js ローカル開発にのみ使用し、インストールするmicro-dev 開発依存.
    $ cd api
    $ touch dev.js
    $ npm install micro-dev --save-dev
    
    // api/dev.js
    
    const { run, send } = require("micro");
    const login = require("./login");
    const profile = require("./profile");
    
    const dev = async (req, res) => {
      switch (req.url) {
        case "/api/profile.js":
          await profile(req, res);
          break;
        case "/api/login.js":
          await login(req, res);
          break;
    
        default:
          send(res, 404, "404. Not found.");
          break;
      }
    };
    
    exports.default = (req, res) => run(req, res, dev);
    
    特定のURLが要求されるとき、サーバは関数を返します、これはルーティングのために少し型破りです、しかし、それは我々の例のために働きます.
    それから、私たちのフロントエンドでは、カスタムサーバーを使用します.私たちのAPIサーバーにプロキシの特定の要求されます.このために、我々は使用しますhttp-proxy 開発依存として
    $ cd www
    $ npm install http-proxy --save-dev
    
    // www/server.js
    
    const { createServer } = require("http");
    const httpProxy = require("http-proxy");
    const { parse } = require("url");
    const next = require("next");
    
    const dev = process.env.NODE_ENV !== "production";
    const app = next({ dev });
    const handle = app.getRequestHandler();
    
    const proxy = httpProxy.createProxyServer();
    const target = "http://localhost:3001";
    
    app.prepare().then(() => {
      createServer((req, res) => {
        const parsedUrl = parse(req.url, true);
        const { pathname, query } = parsedUrl;
    
        switch (pathname) {
          case "/":
            app.render(req, res, "/", query);
            break;
    
          case "/login":
            app.render(req, res, "/login", query);
            break;
    
          case "/api/login.js":
            proxy.web(req, res, { target }, error => {
              console.log("Error!", error);
            });
            break;
    
          case "/profile":
            app.render(req, res, "/profile", query);
            break;
    
          case "/api/profile.js":
            proxy.web(req, res, { target }, error => console.log("Error!", error));
            break;
    
          default:
            handle(req, res, parsedUrl);
            break;
        }
      }).listen(3000, err => {
        if (err) throw err;
        console.log("> Ready on http://localhost:3000");
      });
    });
    
    そして最後のステップはpackage.json カスタムサーバーを実行するにはnpm run dev .
    // www/package.json
    
    ...
     "scripts": {
        "dev": "node server.js",
        "build": "next build",
        "start": "next start"
    },
    ...
    
    このセットアップでは、現在実行中に2を展開することができますnow ルートフォルダで使用するか、ローカルで実行しますmicro-dev dev.js -p 3001 インサイドapi/ フォルダとnpm run dev インサイドwww/ フォルダ.

    結論


    この例は、問題コメント、提案、コード例、ブログ記事、および既存の実装を通過し、それぞれの最良の部分を抽出した結果です.
    次の例では、フロントエンドで認証がどのように機能するかを最小限に表現しています.JSは、現実世界の実装とRedUxとアポロのように強く推奨されているサードパーティ製のライブラリに必要な機能を残して(Graphicsで).また、例はバックエンドの不可知論です.そして、それをサーバーのどんな言語ででも使いやすくします.
    最後に、多くの議論の一つはlocalStorage またはクッキー.この例では、クッキーを使用して、サーバーとクライアント間でトークンを共有できます.