NextJS 以及使用 OAuth2 和 JWT 的身份验证
我们将使用 OAuth2 流程通过 Laravel API(感谢 Passport)对 NextJS 应用用户进行身份验证。如果您不使用 Laravel,该流程的概念应该与 Twitter 等第三方 API 非常相似。
本指南假设您了解一些 React、什么是 HOC 以及如何使用它,以及 NextJS、Express 或 OAuth2 等基础知识。
工作原理
假设您需要一个应用程序,用户可以登录并访问仅限注册用户的私人页面。您可以创建一个接受用户名和密码的登录表单,并将其发送给 API。但是,对于每个需要身份验证的小应用程序来说,这都需要编写大量代码。这时,OAuth2 就派上用场了。
使用 OAuth2,我们可以让用户点击登录链接,例如“使用 Twitter 登录”按钮。这会将用户重定向到 Twitter,用户可以使用其 Twitter 帐户登录并接受您的应用。然后,Twitter 会向您发送一个特殊的“访问令牌”,让您代表用户使用 Twitter API。
现在,想象一下,与其使用 Twitter,不如将其作为您自己的 API。用户会被发送到您创建的登录服务器,然后您会获得一个适用于您自己的 API 的访问令牌。这样,您就可以允许开发者创建以用户身份使用您的 API 的应用,而不是直接访问。这样,您就可以根据您设置的用户权限来限制 API 的访问。
身份验证 API
首先,我们需要一个身份验证 API,以便在前端 NextJS 应用中使用它。该 API 将使用 OAuth2 协议,并为每个授权用户返回一个 JSON Web Token(或 JWT)。
OAuth2 流程
OAuth2 乍一看可能让人望而生畏,但由于各大平台(Twitter、Facebook、Google 等等)都使用它来访问其 API 中经过身份验证的部分,因此你很快就能掌握它。通过你的应用登录用户的过程非常简单:
- 您登录到要用于身份验证的 API,并创建一个带有回调 URL 的新“客户端”。此回调 URL 是 API 在用户登录后重定向到您应用上的页面。在 Twitter 上,此 URL 指的是“应用”页面。
创建“客户端”会生成一个 API 客户端 ID 和密钥,您可以使用它们来验证您自己的应用是否通过 API 进行身份验证。请保存它们以备后用。ID 是公开的(任何人都可以看到),而密钥则是完全保密的。您永远不会让用户看到或访问您的密钥,它保留在服务器端代码中。
- 您可以在应用中创建一个按钮,或将用户重定向到特定的 URL,该 URL 包含查询参数,包括客户端 ID、回调 URL 和响应类型(默认为“code”)。它应该如下所示:
有时会将范围添加到 URL 的查询参数中,这些参数是赋予您额外 API 权限(例如访问用户电子邮件)的关键字符串。
-
用户会被重定向到 API,如果未登录,他们会看到一个登录界面。登录后,他们会看到一个针对您应用的授权请求。该请求会显示一个窗口,其中会显示您之前输入的应用名称,并询问用户是否允许该应用代表他们访问 API。如果您曾经使用 Twitter/Facebook 等平台登录过网站,您肯定见过类似的情况。
-
如果用户同意,他们会被重定向回您之前创建应用时使用的回调 URL。重定向后,用户会收到一个特殊代码。在应用的回调页面上,您可以获取此代码,并向 API 发出另一个包含该代码和密钥的请求。
-
如果 API 批准了你的请求,它会返回一个 JSON 响应,其中包含用户的 token 以及其他相关信息。通常情况下,你会从响应中获取 token,并将其存储在 session 或 cookie 中,以便稍后使用(因为你会经常使用它!)。
创建 API
现在您了解了我们正在创建什么类型的 API,我们就可以真正地实现它了。
我有一个使用 Laravel 和 Passport 包创建快速身份验证 API 的指南。你可以尝试启动这个项目,并与这个项目一起使用。它会创建一个单独的服务器,其中包含一个 API,你可以访问该 API 来登录用户并获取令牌。
API 服务器启动并运行后,运行以下命令来创建新应用程序:
php artisan passport:client
如果您在 Docker 容器中(就像我的项目使用的一样),请确保使用 docker-compose
exec
命令:docker-compose exec workspace php artisan
CLI 将询问应用程序名称并为您生成客户端 ID 和密钥,请存储这些信息以供日后使用。
理想情况下,你应该创建一个前端 UI 来生成这些 API 凭据,Laravel 也提供了 Vue 的示例版本(如果你需要的话)。但对于测试或更简单的用途,CLI 也足够了。
Laravel 的替代品
我知道 PHP 或 Laravel 并不是每个人都喜欢的,或者你没有时间启动服务器并将其托管在 Heroku 上——你只是想要一个可以运行的 API。
您可以利用 Twitter 或 Slack 等免费平台并使用其“使用…登录”服务。但是,如果您想接入自己的 API,可能仍然需要使用自己的身份验证服务器。否则,使用“使用 Twitter 登录”的唯一原因是通过您的应用代表用户访问 Twitter API。
除了自行创建或使用社交媒体平台之外,您还可以使用Auth0等第三方服务来利用其基于云的身份验证 API。Netlify 等平台也为您的项目提供 JWT 支持。
NextJS 应用程序
这里没什么特别的,只是npm init
一个新项目,包含npm install
几个模块。在使用过程中,我会更深入地解释它们:
npm install react react-dom next express cookie-parser csurf dotenv isomorphic-unfetch js-cookie --save
应用程序结构
该应用程序的结构非常简单,我将向您展示几种不同的技术来授权您的应用程序(服务器端和客户端)。
- 将用户重定向到 API 登录的登录链接或页面。
- 回调页面接受 API 的响应代码,发送包含我们的秘密的请求,接收访问令牌(JWT),并将其存储在我们的应用程序中。
- 用于私有页面的 React 页面包装器(或 HOC)将检查 cookie 中的令牌,如果未检查,则将其重定向到公共页面(通常是登录)。
登录链接
我们需要在应用程序的任意位置创建一个链接,作为应用程序的“登录”按钮。它将是一个简单的<a>
元素,包含指向 Laravel API 和 /oauth/authorize/ 端点(假设是 localhost)的链接:
http://localhost/oauth/authorize/?client_id=4&redirect_uri=http://localhost:3000/token&response_type=code
此链接包含我们的client_id、redirect_uri或回调 URL,以及应设置为 'code' 的respond_type 。客户端 ID和重定向 URL会在我们使用 Laravel API 创建新的 OAuth 应用时提供给我们。通常,你会设置一个前端表单,例如 Twitter 的开发者版块,以及用于创建新“应用”并获取 API 密钥的表单。
如果您想使用 JS 甚至 PHP Artisan CLI 创建应用程序,请查看 Laravel Passport 文档以获取更多信息。
创建回调 URL
使用 Express 或为 NextJS 自定义服务器设置,我们需要设置一个回调路由,以便 API 可以查询该路由来验证我们的凭据。这是一个相当简单的过程:
- 创建一个 GET 路由并随意命名。
fetch()
使用我们的凭证和 API 服务器发送的请求代码向 API发出请求。- 使用 cookie-parser 和 Express () 将令牌存储在 cookie 中
res.cookies
。
这是最终的代码server.js
:
// Callback for OAuth2 API
server.get('/token', (req, res) => {
const callback = {
grant_type: 'authorization_code',
client_id: process.env.API_CLIENT_ID,
client_secret: process.env.API_CLIENT_SECRET,
redirect_uri: process.env.API_REDIRECT_URI,
code: req.query.code
}
// Query API for token
fetch('http://localhost/oauth/token', {
method: 'post',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(callback)
})
.then(r => r.json())
.then(data => jsonErrorCheck(data))
.then(data => {
// Store JWT from response in cookies
res.cookie('kushyFToken', data.access_token, {
maxAge: 900000,
httpOnly: true
});
// store object in session (with express-session)
// req.session.token = data.access_token
return res.redirect('/dashboard')
});
不要忘记使用 的同构版本
fetch()
,因为 Node 没有随附安装。
保护路由 + 从 cookie 中获取令牌
为了在每个路由中访问令牌,我们将创建一个多用途 HOC。这个 HOC 将包装我们的组件,并且仅在获得令牌时返回它们,否则将重定向用户。HOC 的工作原理是从 cookie(服务器或浏览器 cookie)中获取令牌,如果检索到令牌,则我们包装的组件将加载。
在生产环境中,每次需要授权页面时,都应该验证令牌。您永远无法确定用户是否伪造了 Cookie,因此仅仅接受令牌是不够的——令牌必须能够正常工作。
fetch()
使用令牌向我们的 API 发出快速请求(通常在用户配置文件端点上,这样我们就不会浪费 API 调用并获取相关数据)。如果请求有效,则用户获得批准。验证完成后,您可以设置一个服务器端 Cookie(用户 ID + 当前时间 + 应用密钥的加密哈希值),让应用知道用户最近已获得授权。
我们还将利用 React 的 Context API,将授权页面组件与包含令牌的 Provider 包装在一起。这样,你就可以将任何需要访问已验证 API 路由的组件与 Consumer 包装在一起。Consumer 会将令牌通过其 props 传递给你的组件。
但生产情况如何?
在生产环境中,最佳实践是永远不要向任何人显示访问令牌 (JWT)。但由于 OAuth2 本身的安全性,以及需要运行经过身份验证的客户端请求,开发人员经常会在 Cookie 中放入未加密的 JWT,甚至更糟的是localStorage
。
理想情况下,您应该将 JWT 存储在服务器端会话中,其中加密的 ID 保存在 Cookie 中,而 JWT 则存储在服务器端数据存储(例如 Redis 或 Memcached)中。每当您需要令牌时,您都需要向应用的 Express 服务器发送一个 POST 请求,其中包含加密的 Cookie ID 和 CSRF 令牌(由服务器生成,以确保它来自<form>
服务器自身——而不是随机的 POST 请求)。Express 服务器会检查数据存储中的 Cookie ID,获取令牌,并使用它发出 API 请求,然后返回 API 的响应。
您可以看到,应用服务器充当“中间人”,以确保客户端和 API 之间的安全传输。这样,就没有人能够通过使用令牌创建虚假 Cookie 来尝试登录之类的操作。令牌必须存储在服务器端,并且需要相应的密钥才能访问当前数据存储中的任何数据。如果您感兴趣,我将在下文介绍这种方法。
授权 HOC
对于 HOC,我们将包含令牌的 props 加载到 中getInitialProps()
,该令牌是从 cookie 中获取的。然后,当组件挂载时 ( componentDidMount()
),我们会检查 props 中是否存在令牌。如果没有,我们会使用 Next 的 重定向到登录页面Router
。如果有令牌,我们会将加载状态设置为 false,这将激活受保护路由的组件(即我们用 auth HOC 包裹的组件)。
请随意用真正的加载组件或带有加载器的页面替换“LOADING”文本。
现在我们通常会使用 cookie 来获取令牌,但我将向您展示如何在服务器端执行此操作,以便您了解为什么它不是最佳选择。
服务器端
创建一个中间件来获取 cookie 并将其存储在 中res.local.cookies
,然后在渲染每个路由时将其传递到应用程序中:return app.render(req, res, '/dashboard', { token: res.locals.token })
。
服务器.js
// A JSON error checking function since fetch()
// won't let us know if it failed or not
function jsonErrorCheck(data) {
if('error' in data)
{
return error
}
return data
}
// The middleware
function getUser(req, res, next) {
if (req.cookies['kushyFToken']) {
const credentials = {
method: 'get',
headers: {
'Authorization': 'Bearer ' + req.cookies['kushyFToken'],
'Content-Type': 'application/x-www-form-urlencoded'
}
}
const fetchUser = async () => {
await fetch('http://localhost/api/user/', credentials)
.then(r => r.json())
.then(data => jsonErrorCheck(data))
.then(data => {
res.locals.token = req.cookies['kushyFToken']
res.locals.user = data
next()
});
}
fetchUser();
} else {
next()
}
}
// .... some other code
// Apply middleware to your route
// You basically stack middleware after the page string
// and before your page render
// otherwise the middleware would never get called
server.get('/dashboard', getUser, (req, res) => {
return app.render(req, res, '/dashboard', { token: res.locals.token, user: res.locals.user })
})
import React, {Component} from 'react'
import Router from 'next/router'
export default function withAuth(AuthComponent) {
return class Authenticated extends Component {
static async getInitialProps(ctx) {
// Check if Page has a `getInitialProps`; if so, call it.
const pageProps = AuthComponent.getInitialProps && await AuthComponent.getInitialProps(ctx);
// Return props.
return {
...pageProps,
token: ctx.query.token
user: ctx.query.user
}
}
constructor(props) {
super(props)
this.state = {
isLoading: true,
};
}
componentDidMount () {
// Console logs for convenience
// console.log('protected page, did we get token?:');
// console.log(this.props.token);
if (!this.props.token) {
Router.push('/login')
}
this.setState({ isLoading: false })
}
render() {
return (
<div>
{this.state.isLoading ? (
<div>LOADING....</div>
) : (
// We don't need to explicitly pass token or user as props here
// because {...this.props} includes them (ES6 destructuring ftw)
<AuthComponent {...this.props} auth={Auth} />
)}
</div>
)
}
}
}
这很繁琐,因为它要求你在每个请求中都放置 token。而且,如果页面在客户端加载,而没有发出服务器请求,我们就无法访问ctx
包含 token 的变量。
因此,我们被迫使用客户端数据存储,最好是像cookies或 IndexDB这样的安全数据存储。
使用 Cookie
由于我们在 Express 中使用了cookie-parser中间件,因此我们可以使用state:req
中的变量在服务器端访问 cookie 。这将返回一个包含所有 cookie 的长字符串,这些 cookie 由 & 符号分隔(类似于查询参数)。如果我们解析该字符串并按键名找到 cookie,就能得到我们想要的 cookie。getInitialProps
ctx.req.headers.cookie
或者如果我们在浏览器中(例如,如果用户单击<Link />
应用程序中的内部),我们可以使用js-cookie库从客户端获取 cookie(cookie.get('token')
)。
我使用辅助函数来简化 Cookie 的访问(代码来自 Github 上的 @carlos-peru)。它本质上是 js-cookie 的包装器,用于检查用户是服务器端还是客户端。如果是服务器端,它会在
req
变量中搜索我们的 Cookie 名称。如果是客户端,则使用 js-cookie。
实用程序/Cookies.js
import cookie from "js-cookie";
export const setCookie = (key, value) => {
if (process.browser) {
cookie.set(key, value, {
expires: 1,
path: "/"
});
}
};
export const removeCookie = key => {
if (process.browser) {
cookie.remove(key, {
expires: 1
});
}
};
export const getCookie = (key, req) => {
return process.browser ?
getCookieFromBrowser(key) :
getCookieFromServer(key, req);
};
const getCookieFromBrowser = key => {
console.log('grabbing key from browser')
return cookie.get(key);
};
const getCookieFromServer = (key, req) => {
console.log('grabbing key from server')
if (!req.headers.cookie) {
return undefined;
}
const rawCookie = req.headers.cookie
.split(";")
.find(c => c.trim().startsWith(`${key}=`));
if (!rawCookie) {
return undefined;
}
return rawCookie.split("=")[1];
};
withAuth.js
import React, {Component} from 'react'
import Router from 'next/router'
import TokenContext from '../context/TokenContext'
import { getCookie } from '../utils/Cookies'
export default function withAuth(AuthComponent) {
return class Authenticated extends Component {
static async getInitialProps(ctx) {
const token = getCookie('kushyFToken', ctx.req)
// Check if Page has a `getInitialProps`; if so, call it.
const pageProps = AuthComponent.getInitialProps && await AuthComponent.getInitialProps(ctx);
// Return props.
return { ...pageProps, token }
}
constructor(props) {
super(props)
this.state = {
isLoading: true
};
}
componentDidMount () {
console.log('checking auth')
if (!this.props.token) {
Router.push('/')
}
this.setState({ isLoading: false })
}
render() {
return (
<div>
{this.state.isLoading ? (
<div>LOADING....</div>
) : (
<TokenContext.Provider value={this.props.token}>
<AuthComponent {...this.props} />
</TokenContext.Provider>
)}
</div>
)
}
}
}
这是一种更好的方法,因为您可以获得服务器和客户端的 cookie 安全性。
高级提示:你或许想利用 Context API,将 Authorized 组件包装到包含 token 数据的 Provider 中,这样应用程序的任何部分都可以轻松访问 token,而无需进行任何 props 钻取!另一个选择是使用 Cookies 辅助/实用函数,并在任何需要 cookie 的组件中再次获取 cookie,就像上面提到的那样。或者,你知道的,Redux。
秘密的第三个选项:API 端点
由于我们使用 Express 创建路由,并且能够访问服务器运行 JavaScript,因此我们完全可以创建一个动态路由来充当外部 API 的中间人。您可以从动态路由 ( app.render('/api/:endpoint', callback)
) 获取 API 端点,然后使用它 ( req.params.endpoint
) 发送经过身份验证的 API 请求,其中包含我们之前获取的 cookie ( req.cookies['seshToken']
)。
// Route for sending POST requests
server.post('/api/:endpoint', (req, res) => {
// Query protected API endpoint with token
fetch(`http://localhost/api/${req.params.endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Authorization': `Bearer ${req.cookies['seshToken']}`
},
// Grabs request body (ideally Form data), converts to JSON, and sends it as POST
body: JSON.stringify(req.query)
})
});
// Route for sending authenticated GET requests
server.get('/api/:endpoint', (req, res) => {
// Query protected API endpoint with token
fetch(`http://localhost/api/${req.params.endpoint}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
'Authorization': `Bearer ${req.cookies['seshToken']}`
}
})
});
这样,客户端就永远不会在源代码中暴露 token(除非他们检查 cookie)。当与会话(例如 express-session)结合使用时,这是最佳方案。尽管它限制您只能在服务器端使用 NextJS,并且要求您在生产环境中拥有数据存储(例如 Redis)。
HOC 与函数式编程
HOC 看起来是不是有点多此一举?你想要更实用的东西吗?与其像我们之前那样把组件包装在 auth HOC 中,不如在页面中添加一种“中间件”,getInitialProps()
用来检查 cookie 中是否有 token,并在 false 时重定向。
您的页面组件:
static async getInitialProps(ctx) {
// If it does not exist session, it gets redirected
if (redirectIfNotAuthenticated(ctx)) {
return {};
}
“中间件”背后的魔力是:
export const getJwt = ctx => {
return getCookie("jwt", ctx.req);
};
export const isAuthenticated = ctx => !!getJwt(ctx);
export const redirectIfAuthenticated = ctx => {
if (isAuthenticated(ctx)) {
redirect("/user", ctx);
return true;
}
return false;
};
这基本上就是我们在 HOC 中做的事情,只不过被分解成了单独的函数。更像是对同一解决方案的一种函数式方法。
是否使用 Redux?
从技术上讲,你并不需要 Redux,因为你可以使用 js-cookie 从客户端访问 cookie,或者将你的应用包装在 token 提供程序中。但我发现,当我需要在应用中管理更复杂的状态,尤其是持久化状态时,我就开始使用 Redux 了。你可以整天使用 Context API,但如果你不编写一个回退和检索系统,你的应用状态就会在每次刷新时被清除。这就需要一遍又一遍地运行localStorage
动态数据请求。
数据持久性
使用 Redux 获取数据并将结果存储在应用程序的状态中,可以实现更高的数据持久性,尤其是在像 NextJS 这样频繁运行服务器和客户端请求的通用应用中。如果您的应用只是客户端应用(例如 SPA),那么 Redux 可能并不难用,因为用户很少会“硬”重新加载页面。但是,由于某些 NextJS 路由会在服务器端加载,它会刷新应用的状态,从而需要您的上下文提供程序再次访问 API。
但就像我说过的,您始终可以使用上下文提供程序将您的状态保存到localStorage
或其他本地数据存储中,并在再次需要时使用消费者来获取它。
复杂数据管理
想象一下,一个应用中,你的组件需要从 Providers 中访问 3 个(或更多)不同的值。每次创建组件时,你都需要用 3 个以上的 Consumers 包裹它,并相应地向下钻取 props (或者创建一个 HOC 将它们组合成 props)。
// Impractical code that doesn't work
const SomeComponent = () => (
<ThemeContext.Consumer>
{ theme =>
<TokenContext.Consumer theme={theme}>
{ token =>
...etc
}
</TokenContext.Consumer>
}
</ThemeContext.Consumer>
)
使用 Redux,您需要为组件创建一个容器组件。该容器将组件连接到 Redux Store,并将您需要的任何状态值作为 props 发送下去。连接成功后,组件就可以访问您定义的 Store 中的任何内容。
那么...Redux?
你会惊讶地发现,即使没有Redux,你也能走得这么远。不过,归根结底,这一切都是为了让旅程更顺畅。做任何你认为对你的应用最有利的事情。Redux创始人的必读链接,说你可能不需要它。
就这么简单
由于 NextJS 能够执行服务器端任务,甚至可以利用 Express 等其他框架,因此可以非常轻松地扩展前端应用程序,使其包含必要的路由,以便通过 OAuth2 进行用户身份验证。本教程以 Laravel API 为例,但实现其他基于 OAuth2 的 API(例如 Twitter)的代码非常相似。
如果我的指南让您感到困惑,我建议您观看NextJS 会议的这个视频,该视频在 15 分钟内解释了 NextJS 用户身份验证流程。
干平🍻
亮
参考:
- Prosper Otemuyiwa:Next.js 中的身份验证和授权
- 加密你的 cookies
- js-cookie
- Cookies 辅助函数
- 为 getInitialProps 创建身份验证中间件
- OAuth 2.0 - 官方