使用 Next.js 进行用户身份验证

2025-05-25

使用 Next.js 进行用户身份验证

注意:这篇文章是我在API 路由发布之前写的。我需要更新这篇文章以使用最新的 Next.js 功能。同时,你应该阅读《Next.js 使用 Auth0 进行身份验证的终极指南》,这是一篇很棒的指南,介绍了你可以在 Next.js 中使用的所有身份验证模式。这篇文章只关注一种方法,并解释了如何构建它。我认为保留这两份指南很有价值,所以我会努力保持更新。

之前在我的博客上发表过

使用 Next.js 进行用户身份验证一直是社区最热门的示例之一。GitHub上的 issue获得了超过 300 个赞和数百条评论,其中包含建议和提案。

该问题要求社区贡献一个具有特定要求的示例:

  • 可跨页面重复使用的身份验证助手
  • 选项卡间的会话同步
  • 简单的无密码电子邮件后端托管于now.sh

此示例的主要目的是为新手提供一个起点。

随着Next.js 8的发布,一个示例终于被接受并合并到示例仓库中。在本文中,我们将从头开始创建该示例。

您可以在Next.js 示例存储库中找到代码,或者使用Now 2 中部署的工作演示。

项目设置

我们将把项目设置为monorepo,并带有推荐的文件夹结构和now.json文件,以便我们可以将其部署到 Now。

$ mkdir project
$ cd project
$ mkdir www api
$ touch now.json

后端

我们将使用它micro来处理我们的传入请求并isomoprhic-unfetch发出我们的传出 API 请求。

$ cd api
$ npm install isomorphic-unfetch micro --save

为了简化示例,我们将使用 GitHub API 作为无密码后端。我们的后端将调用/users/:username端点并检索用户的id,从现在开始,这id将是我们的令牌。

在我们的应用程序中,我们将创建两个作为端点的函数: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/文件夹中,我们需要安装 Next.js 应用程序和依赖项,

$ 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

我们的前端结构已经准备好了。

登录页面和身份验证

登录页面将包含用于验证用户的表单。该表单将向端点发送一个/api/login.js包含用户名的 POST 请求,如果用户名存在,后端将返回一个令牌。

对于这个例子,只要我们在前端保留这个令牌,我们就可以说用户有一个活动会话。

// 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或。https

...
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'
...

host接下来,我们根据是在浏览器还是服务器获取我们的 URL 。这样,无论我们是使用动态生成的 URL,还是在本地开发中使用,我们都能获取正确的 URL http://localhost:3000

...
const apiUrl = process.browser
  ? `${protocol}://${window.location.host}/${endpoint}`
  : `${protocol}://${req.headers.host}/${endpoint}`;
...

其余一切都非常标准,表单提交时会发出 POST 请求。我们还使用本地状态来处理简单的验证错误消息。

如果我们的请求成功,我们将通过使用从 API 获得的令牌保存 cookie 来登录我们的用户,并将用户重定向到我们的个人资料页面。

...
cookie.set("token", token, { expires: 1 });
Router.push("/profile")
...

个人资料页面和授权

对于仅客户端的 SPA,要对用户进行身份验证或授权,我们必须让他们请求页面,加载 JavaScript,然后向服务器发送请求以验证用户的会话。幸运的是,Next.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
}

当用户加载页面时,该函数将尝试使用从 cookie 中获取令牌nextCookie,然后如果会话无效,它将把浏览器重定向到登录页面,否则 Next.js 将正常呈现页面。

// Implementation example
...
Profile.getInitialProps = async ctx => {
  // Check user's session
  const token = auth(ctx);

  return { token }
}
...

对于我们的示例来说,这个辅助函数足够简单,可以在服务器和客户端上运行。理想情况下,我们希望限制服务器端的访问,这样就不会加载不必要的资源。

授权高阶组件

另一种抽象的方法是使用 HOC,我们可以在像个人资料这样的受限页面中使用。我们可以像这样使用它:

import { withAuthSync } from '../utils/auth'

const Profile = props =>
  <div>If you can see this, you are logged in.</div>

export default withAuthSync(Profile)

此外,它稍后对我们的注销功能也很有用。像这样,我们以标准方式编写 HOC,并包含auth辅助函数来处理授权。

auth.js我们也在我们的文件中创建了 HOC 。

// 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中包含我们的 token。这样,我们就能确保 API 获得授权请求所需的信息并返回数据。

注销和会话同步

在前端,要注销用户,我们需要清除 Cookie 并将用户重定向到登录页面。我们在auth.js文件中添加了一个函数来实现这一点。

// www/auth.js

import cookie from "js-cookie";
import Router from "next/router";

export const logout = () => {
  cookie.remove("token");
  Router.push("/login");
};

每次我们需要注销用户时,我们都会调用这个函数,它应该会处理好这件事。但是,其中一个要求是会话同步,这意味着如果我们注销用户,它应该从所有浏览器标签页/窗口执行此操作。为此,我们需要监听一个全局事件监听器,但我们不会设置类似自定义事件的东西,而是使用存储事件 (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");
};

最后,因为我们已经将此功能添加到我们的身份验证/授权 HOC,所以我们不需要在我们的个人资料页面中更改任何内容。

现在,每次我们的用户注销时,会话将在所有窗口/选项卡上同步。

部署至 Now 2

剩下的唯一事情就是将我们的配置写入我们的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" }
  ]
}

配置文件告诉 Now 如何路由我们的请求以及使用哪些构建器。您可以在部署配置 (now.json)页面中阅读更多相关信息。

本地开发

在我们的 API 中,当函数部署在 Now 2 中时,它们profile.js可以作为lambdalogin.js正确工作,但我们现在无法在本地使用它们。

我们可以通过将这些函数导入到使用基本路由的小型服务器中,在本地使用它们。为此,我们创建了第三个文件,名为 ,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 时,服务器将返回函数,这对于路由来说有点不寻常,但对于我们的示例来说却有效。

然后,在前端,我们将为 Next.js 应用使用一个自定义服务器,它将某些请求代理到我们的 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"
},
...

now通过此设置,我们可以将其部署到在根文件夹运行的 Now 2 ,或者在文件夹micro-dev dev.js -p 3001api/和文件夹npm run dev内本地运行使用它www/

结论

此示例是通过查看问题评论、提案、代码示例、博客文章和现有实现并提取每个内容的最佳部分的结果。

该示例最终仅是对使用 Next.js 在前端实现身份验证的简单介绍,并未提及实际实现中可能需要的功能,以及强烈推荐的第三方库,例如 Redux 和 Apollo(配合 GraphQL 使用)。此外,该示例与后端无关,因此可以轻松地在服务器中使用任何语言。

最后,众多讨论之一是是否使用localStorageCookie。本示例使用 Cookie,以便我们可以在服务器和客户端之间共享令牌。

文章来源:https://dev.to/jolvera/user-authentication-with-nextjs-4023
PREV
2020 年必须了解的 11 个前端趋势
NEXT
每个程序员都需要的 100 多个最有用的 Github 存储库 HTML CSS 前端 JavaScript React.js Node.js 编程路线图 算法和数据结构 设计模式和系统设计 通用