使用 Sanctum 对 React SPA 应用进行身份验证
Sanctum 是 Laravel 的轻量级 API 身份验证扩展包。在本教程中, 我将研究通过 Laravel 后端使用 Sanctum 对基于 React 的单页应用(SPA)进行身份验证。假设应用程序的前端和后端是同一顶级域的子域,我们可以使用 Sanctum 基于 cookie 的身份验证,从而避免了管理 API 令牌的麻烦。为此,我设置了 Homestead 两个域:api.sanctum.test,指向 后端
(我们将创建的新 Laravel 项目)的 public
文件夹,以及 sanctum.test
,它指向一个完全独立的目录:前端
(React)。我还提供了一个 MySQL 数据库 sanctum_backend
。
译者强调:前端使用 localhost:3000,而访问后端 API 的 url 是 127.0.0.1:8000 时,认证不会通过,要使用同一个。另外,Sanctum SPA 认证目前没有像 JWT 那样可以设置 cookie 有效期,而是一直有效。
可以在本文末尾找到指向最终代码的链接。
后端
让我们从 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',
'email' => 'alex@alex.com',
'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 frontend
cd frontend
我们将要使用 react-router-dom
软件包将路由添加到应用程序,以及使用 Axios 进行 HTTP 请求。完成后,启动应用程序:
npm install axios react-router-dom
npm start
现在让我们创建一个简单的 Books
组件,该组件将使用 Axios 调用 books 节点并在无序列表中显示这些书:
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 发挥作用的地方。因此,回到 后端
目录,我们需要 Sanctum 软件包:
composer require laravel/sanctum laravel/ui
我们还需要 laravel/ui
软件包,因为它为我们提供了一些身份验证模版。要创建它并发布 Sanctum 配置,请运行:
php artisan ui:auth
php artisan vendor:publish
…并将 sanctum
中间件添加到路由:
Route::middleware('auth:sanctum')->get('/book', 'BookController@index');
由于我们的目标是让前端 sanctum.test
与后端 api.sanctum.test
进行通信,从现在就使用 npm run build
去构建 SPA。这样,我们可以通过 sanctum.test
(而不是开发服务器默认的 localhost
)访问SPA。另外,要配置 Sanctum 的有效状态域,登录才有效果。
因此,构建前端代码,然后尝试再次点击该 books
页面。如果您在浏览器的开发工具中查看请求,则会看到 401 Unauthenticated
错误:我们需要登录。这是 SPA 的 登录
组件:
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 将电子邮件和密码 post 到后端的 login
路由并记录响应。将其添加到 App
组件中:
import Login from './components/Login';
[...]
<Switch>
<Route path='/books' component={Books} />
<Route path='/login' component={Login} />
</Switch>
现在,访问登录页面,填写用户的详细信息(如上所示),然后单击“登录”。糟糕!看一下控制台:它向我们显示了 「Cross-Origin Request Blocked”」错误。
题外话:CORS
同源策略(same-origin policy)是一种嵌入在浏览器中的安全措施,可防止脚本在一个源上运行(源由其架构【http
,https
,ftp
等】,主机名和端口号定义)来访问存储在另一个源上的数据。在我们的上下文中,这特别适用于 Fetch / XMLHttpRequest 调用。同源策略虽然允许我们向其他域调用(因此使我们容易受到 CSRF 攻击的影响,稍后将对此进行介绍),但它不允许我们读取其他域的响应。
CORS (跨域资源共享) 是针对这类问题的浏览器解决方案: 它允许您发送带有一个 Origin
标头的请求,而服务器会返回一个带有 Access-Control-Allow-Origin
标头的响应。如果两者匹配,则响应就是被认可的并且可以被浏览器接收。
这样固然很好,但是如果您查看 network (注:开发者工具中的「网络」) 标签,您会发现我们甚至还没有发出 POST
请求。事实上,我们所看到的只是一个 OPTIONS
请求。 这是为什么?嗯,原因是我们的请求不属于所谓的 「简单请求」,因为它的 Content-Type
标头是 application/json
。这使其成为「预检请求」:在发送实际请求之前,将「预检」OPTIONS
请求发送到服务器,服务器将以一组标头作为响应,浏览器可以根据这些标头确定是否继续进行实际请求。 由于我们的 Laravel 应用尚未针对 CORS 进行设置,因此它不会发送回任何Access-Control-
标头,所以也就不会发出正确的请求。实际上前端已经为我们考虑到这些了,因为浏览器会在请求时自动发送 Origin
标头。因此我们只需要设置后端。
实际上,从 Laravel 7 开始,该框架带有一个开箱即用的 CORS 中间件。您可以在 cors.config
文件中进行配置。打开该文件,您可以看到默认情况下 allowed_origins
被设置为 *
– 也就是说,所有内容都可以发出读取请求。那它为什么不起作用呢?好吧,请注意在稍远的地方有一个 paths
键,它的设置表示允许在 api
命名空间中进行任何操作。但是默认情况下,我们的登录路由是在根命名空间中:/login
。因此,我们需要将 login
放到 paths
数组中。现在,再次填写登录表单并提交试试。
CSRF
一个新的错误!419
。检查响应:「CSRF 令牌不匹配」。进入下一个 issue ! CSRF 表式「跨站请求伪造」:它是恶意代理在已通过身份验证的环境中执行操作的一种方式。这里有一个来自 OWASP guide 的示例: 假设您已登录到网上银行。通过社会工程,您被诱骗访问了一个网站,然而,表面上您仍然是登录到银行的站点。黑客诱骗您「访问」的 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
方法。而且由于我们尚未发送令牌,因此这也会失败,因此我们就会得到这个异常。
Ok,这样就为我们设置了门禁。但是,我们如何首先获得 CSRF 令牌呢? 如果我们是在服务器端,则可以让 Laravel 在框架内将其传递,例如,从控制器到视图。但是在这里前端视图并没有在我们的框架内,因此框架需要以某种方式将 CSRF 令牌发送给我们。api
身份验证也没有为我们提供开箱即用的功能。这里该 Sanctum 登场了。Sanctum 将允许我们请求 CSRF 令牌,然后可以将其在标头中传递。如果你执行
php artisan route:list
您会在此处看到一条新路由:GET /sanctum/csrf-cookie
。 (框架如何知道这一点?它来自 SanctumServiceProvider 的boot
方法中的 defineRoutes
方法,而该方法又是在我们运行 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
配置文件的 path
列表中:
'paths' => ['api/*', 'login', 'sanctum/csrf-cookie'],
现在,再次点击提交表单之前,请打开浏览器的开发工具,然后查看 “Storage” 标签(Firefox)或 “Application” 标签(Chrome)。希望您不会在其中看到任何 cookie(如果有,则将其删除)。现在提交表格。emmm......仍然没有 cookies!这是怎么了?
在网络选项卡中查看:您对 sanctum/csrf-cookie
的请求获得了 204
响应,这很好。单击请求,然后单击 Cookies
选项卡:您将看到两个 cookie,即 Laravel 会话 cookie 和我们想要的 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 docs ,则会看到以下内容:
来自其他域的
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';
替代 axios
,改为调用 apiClient
,由于我们在 Axios 配置的 baseURL 中定义了主机名,因此省略了该主机名:
apiClient.get('/sanctum/csrf-cookie')
.then(response => {
apiClient.post('/login', {
email: email,
password: password
}).then(response => {
console.log(response)
})
});
现在,再次登录,然后查看浏览器工具中的 cookie :这一次它们应该出现。但是,如果您查看控制台,则会再次看到 「跨域请求被阻止」错误,但这一次是有新原因的:「expected “true” in CORS header “Access-Control-Allow-Credentials”」。为了更加安全,仅当服务器将此标志设置为 true 时,浏览器才会执行此请求。我们可以通过在 cors.php
中将 supports_credentials
设置为 true
来实现此目的。
现在,如果您使用正确的凭据(您之前 seed 过的凭据)登录,您将看到来自登录请求的 204
响应。很好:您已通过身份验证。但是,如果您转到 书籍'
页面,当 Axios 调用 book
节点时,您仍然会收到 401 Unauthenticated
错误。解决此问题的方法是使用 Sanctum 的「有效状态域」。打开 app/Http/Kernel.php
,然后将 EnsureFrontendRequestsAreStateful
中间件添加到 api
组中:
'api' => [
EnsureFrontendRequestsAreStateful::class,
'throttle:60,1',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
让我们看一下 handle
方法的 this class 看它能做什么:
config([
'session.http_only' => true,
'session.same_site' => 'lax',
]);
首先,它会覆盖 session
配置。它将 http_only
设置为 true
,这意味着客户端脚本(例如使用 XSS 攻击您的应用程序的恶意脚本)无法访问令牌。(正如 此OWASP文章 所述,「大多数 XSS 攻击均以 session cookie 的盗用为目标」)。它还将 same_site
设置为「lax」。根据 MDN docs,这将防止针对跨站点请求发送 cookie,除非该请求来自另一个站点到您站点的链接( 此博客文章 很好地解释了为什么这很有用)。
return (new Pipeline(app()))
->send($request)
->through(static::fromFrontend($request) ? [
// Middleware
] : [])
->then(function ($request) use ($next) {
return $next($request);
});
理解了该方法的这一部分,对弄懂 Laravel 的中间件是由 Pipeline 处理是很有帮助的,该管道是 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,修改其内容然后发回,也会被服务器拒绝。AddQueuedCookiesToResponse
:处理已被 Cookie 门面队列化的任何 cookie。StartSession
:设置 Laravel 会话及其会话 cookie,并将其添加到响应中。VerifyCsrfToken
:使用 CSRF 令牌检查一切是否正常。
Authentication
现在,添加此中间件可以解决 cookie 流程。但是实际起作用的身份验证是因为我们在 API 路由中设置了 auth:sanctum
。这意味着:使用「sanctum」guard 进行身份验证。但是,如果我们来看看 Sanctum guard class ,似乎有些奇怪。根据 adding custom guards 文档,自定义 guard 必须实现 Illuminate\Contracts\Auth\Guard
接口。但是此接口中并没有任何方法包含在此 guard 中,甚至在类定义中都没有 implements
关键字。相反地,我们只看到这个 __invoke
魔术方法。
让我们看一下 SanctumServiceProvider
来厘清这个问题。它使用了 docs 中建议的 $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
命令是「创建 guard,然后将其传递给第二个参数的闭包;然后返回 guard」速记法。让我们看一下 createGuard
:
return new RequestGuard(
new Guard($auth, config('sanctum.expiration'), $config['provider']),
$this->app['request'],
$auth->createUserProvider()
);
首先,我们可以看到它返回了 RequestGuard
的实例,由于它实现了Guard
,因此它满足了 extend
方法的参数类型。这个 RequestGuard
的第一个参数是闭包,在我们的例子中是 Sanctum 的 Guard
类。唯一的区别是「closure」( Sanctum 的 Guard
类)是一个带有 __invoke
魔术方法的类:您可以将此类视为带有状态的 closure :它为您提供了一个简单的可调用函数,该函数也可以具有属性。
然后,RequestGuard
使用回调函数返回用户。 这里是 Sanctum guard 的相关内容:
if ($user = $this->auth->guard(config('sanctum.guard', 'web'))->user()) {
return $this->supportsTokens($user)
? $user->withAccessToken(new TransientToken)
: $user;
}
第一行从web
guard 中获取用户(因为我们使用常规的Web身份验证路由登录)。 如果找到用户,则 guard 将其返回; 否则,不返回任何内容。
现在,一旦您设置了 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} />
)} />
然后在检查是否已通过调用登录路径获得预期的 204
响应后,在我们的 handleSubmit
方法中调用此 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 的 auth 脚手架为我们提供了一个POST
登出路由,因此我们可以向 App
组件中添加一个方法:
const logout = () => {
apiClient.post('/logout').then(response => {
if (response.status === 204) {
setLoggedIn(false);
}
})
};
试试这个,然后...哎呀! 另一个 CORS 错误。 将 logout
添加到我们的 cors.php
配置文件中的 paths
数组中:
'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);
}
})
};
后端和前端的最终代码可以在这里找到:
本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
文章长了点,一步步照着试试,谢谢大佬分享