使用 Sanctum 认证 React SPA
发布时间 作者 Alex Pestell
Sanctum 是 Laravel 的轻量级 API 身份验证包。在本教程中,我将介绍如何使用 Sanctum 来认证一个基于 React 的单页应用程序 (SPA) 与 Laravel 后端。假设应用程序的前端和后端是同一个顶级域的子域,我们可以使用 Sanctum 的基于 cookie 的身份验证,从而省去了管理 API 令牌的麻烦。为此,我已经设置了 Homestead 为我提供两个域名:api.sanctum.test
,它指向 backend
的 public
文件夹(我们将创建的新 Laravel 项目),以及 sanctum.test
,它指向一个完全独立的目录 frontend
。我还配置了一个 MySQL 数据库 sanctum_backend
。
最终代码的链接可以在本文末尾找到。
后端
让我们从 API 开始
laravel new backend
我们的 API 可以是任何东西 - 让我们假设它是为一个图书馆设计的,我们只有一个资源 books
。我们可以使用一个 artisan 命令创建大部分所需内容
php artisan make:model Book -mr
-m
标志生成一个迁移,而 -r
创建一个资源控制器,包含所有你需要的所有 CRUD 操作方法。在本教程中,我们只需要 index
,但了解这个选项的存在是好的。所以,让我们在迁移中创建几个字段
Schema::create('books', function (Blueprint $table) { $table->id(); $table->string('title'); $table->string('author'); $table->timestamps();});
…然后运行迁移(不要忘记在 .env
文件中更新你的数据库凭证)
php artisan migrate
现在更新 DatabaseSeeder.php
为我们提供一些书籍(以及一个用户供以后使用)
Book::truncate();$faker = \Faker\Factory::create();for ($i = 0; $i < 50; $i++) { Book::create([ 'title' => $faker->sentence, 'author' => $faker->name, ]);}User::truncate();User::create([ 'name' => 'Alex', 'password' => Hash::make('pwdpwd'),]);
现在运行 php artisan db:seed
来播种这些数据。最后,我们需要创建路由和控制器操作。这很简单。将以下代码添加到 routes/api.php
文件中
Route::get('/book', 'BookController@index');
然后在 BookController
的 index
方法中,返回所有书籍
return response()->json(Book::all());
当然,在真实的 API 中,我们可能希望使用像 Laravel 的 API 资源 这样的东西来转换这些对象,但这对我们现在来说已经足够了。现在,如果我们在浏览器或选择的 HTTP 客户端(Postman、Insomnia 等)中访问 api.sanctum.test/api/book
,你应该会看到所有书籍的列表。
前端
为了创建 SPA,我将使用 create-react-app
npx create-react-app frontendcd frontend
我们想要使用 react-router-dom
包来为应用程序添加路由,以及使用 Axios 来进行 HTTP 请求。完成后,启动应用程序
npm install axios react-router-domnpm start
现在让我们创建一个快速的 Books
组件,它将使用 Axios 调用书籍端点并在无序列表中显示书籍
import React from 'react';import axios from 'axios'; const Books = () => { const [books, setBooks] = React.useState([]); React.useEffect(() => { axios.get('https://api.sanctum.test/api/book') .then(response => { setBooks(response.data) }) .catch(error => console.error(error)); }, []); const bookList = books.map((book) => <li key={book.id}>{book.title}</li> ); return ( <ul>{bookList}</ul> );} export default Books;
在 App.js
中引用此组件,我们就可以开始了
import React from 'react';import { BrowserRouter as Router, Switch, Route, NavLink } from 'react-router-dom';import Books from './components/Books'; const App = () => { return ( <Router> <div> <NavLink to='/books'>Books</NavLink> </div> <Switch> <Route path='/books' component={Books} /> </Switch> </Router> );}; export default App;
访问浏览器中的 books
页面,你将看到端点返回的书籍列表。
现在,假设我们想限制谁可以查看这些书籍。或者也许 API 对不同的用户显示不同的书籍。这就是 Sanctum 发挥作用的地方。所以,回到 backend
目录,让我们安装 Sanctum 包
composer require laravel/sanctum laravel/ui
我们也需要安装 laravel/ui
包,因为它提供了一些身份验证的模板代码。要创建它,并发布 Sanctum 配置,运行
php artisan ui:authphp artisan vendor:publish
…并将 sanctum
中间件添加到路由中
Route::middleware('auth:sanctum')->get('/book', 'BookController@index');
由于我们的目标是让前端 - sanctum.test
- 与后端 - api.sanctum.test
- 通信,所以从现在开始,使用 npm run build
来构建我们的 SPA 是有意义的。这样,我们可以访问 sanctum.test
上的 SPA(而不是开发服务器的默认 localhost
)。当我们开始配置 Sanctum 的有状态域时,这将更有意义。
因此,构建前端,并尝试再次访问 books
页面。如果你查看浏览器开发工具中的请求,你应该会看到一个 401 未经授权
错误:我们需要登录。这是 SPA 的 Login
组件
import React from 'react';import axios from 'axios'; const Login = (props) => { const [email, setEmail] = React.useState(''); const [password, setPassword] = React.useState(''); const handleSubmit = (e) => { e.preventDefault(); axios.post('https://api.sanctum.test/login', { email: email, password: password }).then(response => { console.log(response) }); } return ( <div> <h1>Login</h1> <form onSubmit={handleSubmit}> <input type="email" name="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required /> <input type="password" name="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required /> <button type="submit">Login</button> </form> </div> );} export default Login;
这只是一个基本表单,它使用 Axios 将电子邮件和密码发送到后端的 login
路由,并记录响应。将其添加到 App
组件中
import Login from './components/Login';[...]<Switch> <Route path='/books' component={Books} /> <Route path='/login' component={Login} /></Switch>
现在,访问登录页面,填写用户的详细信息(已在上面播种),然后点击“登录”。哦!看看控制台:它给了我们一个“跨域请求被阻止”错误。
关于 CORS 的一个旁白
同源策略是一种嵌入在浏览器中的安全措施,它阻止运行在一个源(其中源由其方案 [http
、https
、ftp
等]、主机名和端口号定义)上的脚本访问存储在另一个源上的数据。在我们的环境中,这特别适用于 Fetch / XMLHttpRequest 调用。同源策略,虽然它允许我们向其他域发出请求(因此使我们容易受到 CSRF 攻击,我们将在后面介绍),但它不允许我们读取来自其他域的响应。
CORS(跨域资源共享)是浏览器解决此问题的方案:它允许你发送一个包含 Origin
标头的请求,而服务器的响应则包含一个 Access-Control-Allow-Origin
标头。如果两者匹配,则响应将被批准并可以被浏览器接收。
一切都很好,但是如果你查看网络选项卡,你会发现我们甚至没有进行 POST
请求。实际上,我们只看到一个 OPTIONS
请求。这是为什么呢?原因是我们的请求不符合所谓的“简单请求”,因为它的 Content-Type
标头是 application/json
。这使得它成为一个“预检请求”:在发送实际请求之前,会发送一个“预检”OPTIONS
请求到服务器,服务器将响应一组标头,浏览器可以从中确定是否继续进行实际请求。由于我们的 Laravel 应用程序尚未为 CORS 设置,因此它不会返回任何 Access-Control-
标头,因此实际请求不会发生。前端侧实际上已经为我们覆盖了,因为浏览器会自动在请求中发送 Origin
标头。所以我们只需要设置后端。
实际上,从 Laravel 7 开始,框架自带了 CORS 中间件。它可以在 cors
配置文件中进行配置。打开它,你会发现 allowed_origins
默认设置为 *
- 也就是说,任何人都可以进行读取请求。那为什么它不起作用呢?好吧,再往上看一点,有一个 paths
键,它允许 api
命名空间中的任何内容。但是我们的登录路由默认位于根命名空间:/login
。所以让我们将 'login'
添加到 paths
数组中。现在再次填写登录表单并提交它。
CSRF
出现了一个新的错误!419
。检查响应:“CSRF token 不匹配”。让我们继续解决下一个问题!CSRF 代表“跨站点请求伪造”:这是一种恶意代理在经过身份验证的环境中执行操作的方式。以下是一个来自OWASP 指南的例子:你登录了你的在线银行网站。通过社会工程学,你在仍然登录银行网站的情况下被诱骗访问一个网站。这个对黑客 URL 的“访问”可能隐藏在电子邮件中的 0x0 图片、诱人的链接或其他任何东西中。无论如何,这个 URL 将访问银行的 API 并在你的帐户上执行一些可怕的操作。因为你已经登录了银行,所以它不需要经过任何身份验证步骤。随之而来的是恐怖。
如何解决这个问题?一种方法,也是我们将在这里采用的方法,是让服务器将一个随机令牌存储在 cookie 中并发送给客户端,然后客户端在每次向服务器发出请求时将令牌作为自定义标头包含在请求中。如果我们运行 php artisan route:list
,我们会看到 login
路由属于 web
中间件组,该组包括 VerifyCsrfToken
中间件。在其 handle
方法中,我们看到了以下条件
if ( $this->isReading($request) || $this->runningUnitTests() || $this->inExceptArray($request) || $this->tokensMatch($request))
如果此条件计算结果为 false
,则会抛出 TokenMismatchException
。现在,由于我们不是在读取(我们正在发送 POST
请求),也不是在运行单元测试,并且没有配置为异常,它将运行 tokensMatch
方法。由于我们没有发送令牌,因此也会失败,因此我们得到异常。
好的,所以这为我们设置了守门员。但是我们如何获得 CSRF 令牌?如果我们留在服务器端,我们可以让 Laravel 在框架内传递它,例如,从控制器传递到视图。但是我们的视图不是由框架提供的,因此框架必须以某种方式将 CSRF 令牌发送给我们。api
身份验证守卫不会默认执行此操作。这就是 Sanctum 的用武之地。Sanctum 将允许我们请求 CSRF 令牌,然后我们可以将其传递到我们的标头中。如果你运行
php artisan route:list
你会在那里看到一条新的路由:GET /sanctum/csrf-cookie
。(框架如何知道这一点?它来自 defineRoutes
方法,该方法位于 SanctumServiceProvider
的 boot
方法中,而该方法是在我们运行 artisan vendor:publish
时触发的。)
所以让我们首次调用 CSRF 路由。回到前端的登录代码中,我将修改在提交登录表单时发出的 Axios 调用。首先它将调用请求 CSRF 令牌;然后它将发出 login
调用
axios.get('https://api.sanctum.test/sanctum/csrf-cookie') .then(response => { axios.post('https://api.sanctum.test/login', { email: email, password: password }).then(response => { console.log(response) }) });
填写表单,按回车键,然后…错误!“跨域请求被阻止”。我以为我们已经处理过这个问题了?我们确实处理了:但现在我们需要将这条新路由添加到 cors
配置文件中的 paths
列表中
'paths' => ['api/*', 'login', 'sanctum/csrf-cookie'],
现在,在你再次提交表单之前,打开浏览器的开发者工具并查看“存储”选项卡(Firefox)或“应用程序”选项卡(Chrome)。希望你不会在那里看到任何 cookie(如果你有,请删除它们)。现在提交表单。然后…仍然没有 cookie!这里出了什么问题?
查看“网络”选项卡:你对 sanctum/csrf-cookie
的调用正在获得 204
响应,这很好。单击请求,然后单击 Cookies
选项卡:你会看到两个 cookie,一个 Laravel 会话 cookie 和我们想要的 XSRF-TOKEN
。但是如果你转到浏览器存储,这些 cookie 不会被保存。为什么?
嗯,这与 cookie 的作用域 有关。正如 MDN 文档所述,Domain
指令将允许你指定 cookie 适用的子域。让我们看一下 XSRF-TOKEN
cookie 的结构,它在对网络请求的响应标头中可见
XSRF-TOKEN=<token>; expires=Sat, 02-May-2020 21:40:15 GMT; Max-Age=7200; path=/; samesite=lax
果然,那里没有 domain
指令。让我们在后端的 .env
文件中添加它
SESSION_DOMAIN=sanctum.test
现在,重新提交登录请求,cookie 仍然没有列出…这是因为我们在前端缺少拼图的最后一部分。如果我们查看 MDN 文档,我们会看到以下内容
XMLHttpRequest
来自不同域的响应无法设置其自身域的 cookie 值,除非在发出请求之前将withCredentials
设置为true
所以我们需要在 Axios 配置中将 withCredentials
设置为 true
。由于我们将在所有请求中都需要这样做,让我们重构 SPA 代码以集中 API 配置。在 src
目录中创建一个名为 services
的新文件夹,并添加一个名为 api.js
的文件,其中包含以下内容
import axios from 'axios'; const apiClient = axios.create({ baseURL: 'https://api.sanctum.test', withCredentials: true,}); export default apiClient;
现在,我们可以在 Book
和 Login
组件中导入它
import apiClient from '../services/api';
并且,我们调用 apiClient
而不是调用 axios
,省略主机名,因为我们已在 Axios 配置的 baseURL
中定义了它
apiClient.get('/sanctum/csrf-cookie') .then(response => { apiClient.post('/login', { email: email, password: password }).then(response => { console.log(response) }) });
现在,再次登录,并在浏览器工具中查看 cookie:这次它们应该会显示出来。但如果你检查控制台,你会看到“跨域请求被阻止”错误再次出现,但这次原因是:“CORS 标头‘Access-Control-Allow-Credentials’ 中预期为‘true’”。为了更加安全,浏览器将仅在服务器将此标志设置为 true 时执行此请求。我们可以通过在 cors.php
中将 supports_credentials
设置为 true
来做到这一点。
现在,如果你使用正确的凭据(你之前播种的凭据)登录,你会从登录请求中看到 204
响应。这很好:你已经通过身份验证。但如果你转到“书籍”页面,当你 Axios 调用 book
端点时,你仍然会收到 401 未经授权
错误。解决这个问题的方法是使用 Sanctum 的“有状态域”。打开 app/Http/Kernel.php
,并将 EnsureFrontendRequestsAreStateful
中间件添加到 api
组
'api' => [ EnsureFrontendRequestsAreStateful::class, 'throttle:60,1', \Illuminate\Routing\Middleware\SubstituteBindings::class,],
让我们看一下 此类的 handle
方法 看看它做了什么
config([ 'session.http_only' => true, 'session.same_site' => 'lax',]);
首先,它覆盖了 session
配置。它将 http_only
设置为 true
,这意味着客户端脚本(例如使用 XSS 尝试攻击你的应用程序的恶意脚本)无法访问令牌。(正如 这篇 OWASP 文章 所说,“大多数 XSS 攻击的目标是窃取会话 cookie”。)它还将 same_site
设置为“lax”。根据 MDN 文档,这将阻止 cookie 被发送到跨站点请求,除非请求来自另一个站点到你的站点的链接(这篇博文很好地解释了为什么这很有用)。
return (new Pipeline(app())) ->send($request) ->through(static::fromFrontend($request) ? [ // Middleware ] : []) ->then(function ($request) use ($next) { return $next($request); });
要理解方法的这一部分,了解 Laravel 的中间件由管道处理是有帮助的,管道是一个 Laravel 实用程序类,它允许你串联一系列管道来发送数据。如果你看一下 Illuminate\Foundation\Http\Kernel.php
的 sendRequestThroughRouter
方法,你会看到与上述类似的代码
return (new Pipeline($this->app)) ->send($request) ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware) ->then($this->dispatchToRouter());
因此,Sanctum 的 EnsureFrontendRequestsAreStateful
中间件所做的事情实际上是插入了更多中间件。但前提是请求来自前端——这就是此检查的目的
static::fromFrontend($request) ? [ // some middleware] : []
如果请求来自前端,则将此中间件排队,否则,只需向管道提供一个空数组。静态方法 fromFrontend
查看 referer
标头:如果它包含你在 Sanctum 配置中设置的字符串,它将知道应该通过特定于 Sanctum 的中间件处理请求。它与 referer
标头进行比较的这个字符串可以通过 .env
中的 SANCTUM_STATEFUL_DOMAINS
变量设置
SANCTUM_STATEFUL_DOMAINS=sanctum.test
那么特定于 Sanctum 的中间件是什么?
[ config('sanctum.middleware.encrypt_cookies', \Illuminate\Cookie\Middleware\EncryptCookies::class), \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, config('sanctum.middleware.verify_csrf_token', \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class),]
这四个中间件管道是标准的:如果你看一下 Kernel.php
中的 web
中间件,你会在那里看到所有四个中间件。
-
EncryptCookies
:加密 cookie 意味着即使攻击者可以访问 cookie,修改其内容也会导致服务器在 cookie 被发送回来时拒绝它。 -
AddQueuedCookiesToResponse
:处理已使用Cookie
门面排队的任何 cookie。 -
StartSession
:设置 Laravel 会话及其会话 cookie,并将其添加到响应中。 -
VerifyCsrfToken
:检查 CSRF 令牌是否一切正常。
身份验证
现在,添加这个中间件解决了 cookie 过程。但是实际的身份验证发生是因为我们在 API 路由中设置了 auth:sanctum
。这意味着:使用“sanctum”守卫进行身份验证。但是如果我们看一下 Sanctum 守卫类,有些事情似乎很奇怪。根据 添加自定义守卫 的文档,自定义守卫必须实现 Illuminate\Contracts\Auth\Guard
接口。但是这个接口的任何方法都没有包含在这个守卫中,而且这个守卫的类定义中也没有 implements
关键字。相反,我们只有一个 __invoke
魔术方法。
让我们看一下 SanctumServiceProvider
来弄清楚这一点。它使用文档中建议的 $auth->extend
方法
$auth->extend('sanctum', function ($app, $name, array $config) use ($auth) { return tap($this->createGuard($auth, $config), function ($guard) { $this->app->refresh('request', $guard, 'setRequest'); });});
这令人困惑,所以让我们将其分解成更小的部分。tap
命令是一种简写方式,表示“创建守卫,然后将其传递给第二个参数中的闭包;然后返回守卫”。让我们看一下 createGuard
return new RequestGuard( new Guard($auth, config('sanctum.expiration'), $config['provider']), $this->app['request'], $auth->createUserProvider());
首先,我们可以看到它返回了一个 RequestGuard
实例,它实现了 Guard
,满足了 extend
方法的参数类型。这个 RequestGuard
将闭包作为其第一个参数,在我们这里就是 Sanctum 的 Guard
类。唯一的区别是“闭包”(Sanctum 的 Guard
类)是一个带有 __invoke
魔术方法的类:你可以将这种类视为一个带有状态的闭包:它为你提供了一个简单的可调用函数,该函数也可以具有属性。
RequestGuard
然后使用回调返回一个用户。 以下是 Sanctum 守卫中的相关行
if ($user = $this->auth->guard(config('sanctum.guard', 'web'))->user()) { return $this->supportsTokens($user) ? $user->withAccessToken(new TransientToken) : $user;}
第一行从 web
守卫中获取用户(因为我们使用的是通常的 web 身份验证路由来登录)。如果找到了用户,守卫会将其返回;否则,将不返回任何内容。
现在,一旦你设置了 SANCTUM_STATEFUL_DOMAINS
环境变量,你应该能够登录并以经过身份验证的用户身份查看书籍页面。
完成 SPA
因此,现在我们有了后端可用的工作身份验证系统,我们可以完成前端。本文的这一部分与 Sanctum 没有直接关系,所以你可以随意忽略它。
首先,我们希望 App
组件中有一些状态来显示用户是否已登录,默认为 false
const [loggedIn, setLoggedIn] = React.useState(false);
让我们添加一个名为 login
的方法,它将此变量设置为 true
const login = () => { setLoggedIn(true);};
现在,我们可以将此方法传递给 Login
组件
<Route path='/login' render={props => ( <Login {...props} login={login} />)} />
然后,在我们的 handleSubmit
方法中,在检查我们是否已从调用登录路由获得预期的 204
响应后,调用此 login
方法
apiClient.get('/sanctum/csrf-cookie') .then(response => { apiClient.post('/login', { email: email, password: password }).then(response => { if (response.status === 204) { props.login(); } }) });
(我还有用于登录后重定向到主页的一些逻辑——查看 最终仓库 了解它。)现在,父 App
组件知道用户何时登录,它可以将其传递给 Books
组件,以便它可以相应地采取行动
<Route path='/books' render={props => ( <Books {...props} loggedIn={loggedIn} />)} />
现在,如果 loggedIn
为 false
,Books
组件知道不要尝试加载书籍,而是向用户显示一条有用的消息
React.useEffect(() => { if (props.loggedIn) { apiClient.get('/api/book') .then(response => { setBooks(response.data) }) .catch(error => console.error(error)); }});// ...if (props.loggedIn) { return ( <ul>{bookList}</ul> );}return ( <div>You are not logged in.</div>);
如何注销?让我们修改当前的登录链接,使其在用户登录时有条件地显示注销按钮,在用户未登录时显示登录页面链接。
const authLink = loggedIn ? <button onClick={logout}>Logout</button> : <NavLink to='/login'>Login</NavLink>;return ( <Router> <div> <NavLink to='/books'>Books</NavLink> {authLink} </div> <Switch> <Route path='/books' component={Books} /> <Route path='/login' render={props => ( <Login {...props} login={login} /> )} /> </Switch> </Router>);
现在我们可以添加一个logout
方法。Laravel 的身份验证脚手架为我们提供了一个POST
路由来注销,因此我们可以向App
组件添加一个方法。
const logout = () => { apiClient.post('/logout').then(response => { if (response.status === 204) { setLoggedIn(false); } })};
试试这个,然后…… 哎哟!另一个 CORS 错误。在我们的cors.php
配置文件中的paths
数组中添加logout
。
'paths' => ['api/*', 'login', 'logout', 'sanctum/csrf-cookie'],
现在登录,再注销,您应该看到菜单项更新了。而且注销后,您将无法访问书籍页面。
最后一步是将loggedIn
布尔值保存到浏览器的存储中。如果不这样做,当用户刷新浏览器时,SPA 将重置用户为未登录状态。我们可以使用浏览器的sessionStorage
API 来实现这一点。
const [loggedIn, setLoggedIn] = React.useState( sessionStorage.getItem('loggedIn') == 'true' || false);const login = () => { setLoggedIn(true); sessionStorage.setItem('loggedIn', true);};const logout = () => { apiClient.post('/logout').then(response => { if (response.status === 204) { setLoggedIn(false); sessionStorage.setItem('loggedIn', false); } })};
后端和前端的最终代码可以在此处找到