使用 Laravel 作为后端在 Nuxt SPA 中进行安全身份验证
过去一段时间,我正在做一个项目,包括在 Nuxt 中构建一个单页应用程序(位于一个域名),以及在 Laravel 中构建另一个子域名的 API。API 构建完成后,我就开始着手开发前端,并尝试在考虑安全性的情况下,正确构建身份验证系统。网上有很多关于这个主题的文章,但我找不到任何一篇涉及应用程序安全性的文章。
TL;DR 请不要将您的令牌或任何其他敏感信息存储在 LocalStorage 中,因为页面上的任何 javascript 代码都可以访问它,这会使您容易受到 XSS 攻击。
TL;DR 如果你只想看代码,这里有 GitHub 链接
身份验证流程如下:
- 用户输入其用户名和密码。
- 如果凭证有效,我们会将刷新令牌保存在
httponly
cookie 中。 - 用户在cookie中设置访问令牌,请注意,这是普通的cookie,其有效期为5分钟。
- 访问令牌过期后,如果用户设置了有效的刷新令牌,我们将刷新访问令牌。
- 访问令牌刷新,并将新的访问令牌和刷新令牌分配给用户。
在这篇文章中,我将为您提供有关如何为单页应用程序创建安全身份验证系统的完整指导。
制作 Laravel 后端
我假设您的机器上安装了 composer 和 laravel,如果没有,请按照他们的文档进行操作。
设置 Laravel 护照
创建新的 laravel 项目并进入该项目laravel new auth-api && cd auth-api
。
我们将使用 Laravel Passport,它为您的 Laravel 应用程序提供了完整的 OAuth2 服务器实现。我知道 Passport 对于一些中小型应用程序来说可能有点过头,但我认为它是值得的。
接下来我们将使用 composer 安装 Passport composer require laravel/passport
。
设置.env
数据库变量。本例中我使用 sqlite。
如果您继续操作,请将DB_CONNECTION
变量更改为使用 sqlite,.env
如下所示:
...
DB_CONNECTION=sqlite
...
database.sqlite
使用制作文件touch database/database.sqlite
。
使用 运行迁移php artisan migrate
。Passport 迁移将创建应用程序存储客户端和访问令牌所需的表。
接下来,运行该php artisan passport:install
命令。此命令将创建生成安全访问令牌所需的加密密钥。运行此命令后,您将看到“个人访问”和“密码授予”客户端已创建,并且您可以看到它们的客户端 ID 和客户端密钥,我们将这些信息存储在.env
文件中。在本文中,我们将仅使用密码授予客户端,但为了方便起见,我们将同时存储这两个客户端。
...
PERSONAL_CLIENT_ID=1
PERSONAL_CLIENT_SECRET={your secret}
PASSWORD_CLIENT_ID=2
PASSWORD_CLIENT_SECRET={your secret}
...
然后我们将“密码客户端” ID 和密钥添加到,config/services.php
以便稍后在代码中使用它们:
...
'passport' => [
'password_client_id' => env('PASSWORD_CLIENT_ID'),
'password_client_secret' => env('PASSWORD_CLIENT_SECRET'),
],
在config/auth.php
设置api守护驱动程序作为护照
...
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'passport',
'provider' => 'users',
'hash' => false,
],
],
...
下一步是向模型添加Laravel\Passport\HasApiTokens
特征App\User
<?php
namespace App;
use Laravel\Passport\HasApiTokens;
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
use Notifiable, HasApiTokens;
...
}
不要忘记在顶部导入特征。
最后一步是注册护照路线。在方法中AuthServiceProvider
添加boot
并Laravel\Passport\Passport
在顶部导入。
public function boot()
{
$this->registerPolicies();
Passport::routes(function ($router) {
$router->forAccessTokens();
$router->forPersonalAccessTokens();
$router->forTransientTokens();
});
}
我们只注册我们需要的路线,如果由于某种原因您想要注册所有护照路线,请不要通过闭包,只需添加Passport::routes()
。
如果你运行,php artisan route:list | grep oauth
你应该会看到 oauth 路由。它应该看起来像这样
现在非常重要,我们要设置令牌的过期时间。为了更好地保护我们的应用,我们将访问令牌的过期时间设置为 5 分钟,刷新令牌的过期时间设置为 10 天。
AuthServiceProvider
在in方法中,boot
我们添加了过期时间。现在该boot
方法应该如下所示:
public function boot()
{
$this->registerPolicies();
Passport::routes(function ($router) {
$router->forAccessTokens();
$router->forPersonalAccessTokens();
$router->forTransientTokens();
});
Passport::tokensExpireIn(now()->addMinutes(5));
Passport::refreshTokensExpireIn(now()->addDays(10));
}
关于 Passport,我们只需要做这些。接下来我们要设置 API。
设置 CORS
为了从位于不同域的前端访问我们的 API,我们需要设置 CORS 中间件。
跑步php artisan make:middleware Cors
。
然后像这样app/Http/Middleware/Cors.php
改变方法handle
public function handle($request, Closure $next)
{
$allowedOrigins = [
'http://localhost:3000',
];
$requestOrigin = $request->headers->get('origin');
if (in_array($requestOrigin, $allowedOrigins)) {
return $next($request)
->header('Access-Control-Allow-Origin', $requestOrigin)
->header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
->header('Access-Control-Allow-Credentials', 'true')
->header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
return $next($request);
}
这里我们检查请求来源是否在允许来源的数组中,如果是,我们就设置正确的标头。
现在我们只需要注册这个中间件。在app/Http/Kernel.php
添加中间件
...
protected $middleware = [
\App\Http\Middleware\TrustProxies::class,
\App\Http\Middleware\CheckForMaintenanceMode::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\Cors::class,
];
...
就是这样,非常简单。
制作 API
在这个文件中,routes/api.php
我们将注册要使用的路由。删除其中的所有内容,然后添加以下内容:
<?php
Route::middleware('guest')->group(function () {
Route::post('register', 'AuthController@register')->name('register');
Route::post('login', 'AuthController@login')->name('login');
Route::post('refresh-token', 'AuthController@refreshToken')->name('refreshToken');
});
Route::middleware('auth:api')->group(function () {
Route::post('logout', 'AuthController@logout')->name('logout');
});
我们需要创建AuthController
运行php artisan make:controller AuthController
。
我们将在其中App\Http\Controllers\AuthController
添加所需的方法。它应该如下所示:
<?php
namespace App\Http\Controllers;
class AuthController extends Controller
{
public function register()
{
}
public function login()
{
}
public function refreshTo()
{
}
public function logout()
{
}
}
为了实现这一点,我们需要创建一个代理来向我们自己的 API 发出请求。乍一看可能有点令人困惑,但一旦搞定,就会明白其中的道理。
我们将在应用程序目录中创建新文件夹,名为 Utilities。在app/Utilities
新建 php 文件中ProxyRequest.php
<?php
namespace App\Utilities;
class ProxyRequest
{
}
App\Utilities\ProxyRequest
现在我们需要在构造函数中注入App\Http\Controllers\AuthController
<?php
namespace App\Http\Controllers;
use App\Utilities\ProxyRequest;
class AuthController extends Controller
{
protected $proxy;
public function __construct(ProxyRequest $proxy)
{
$this->proxy = $proxy;
}
...
我们App\Utilities\ProxyRequest
将添加一些用于授予令牌和刷新令牌的方法。添加以下内容,然后我会解释每个方法的作用。
<?php
namespace App\Utilities;
class ProxyRequest
{
public function grantPasswordToken(string $email, string $password)
{
$params = [
'grant_type' => 'password',
'username' => $email,
'password' => $password,
];
return $this->makePostRequest($params);
}
public function refreshAccessToken()
{
$refreshToken = request()->cookie('refresh_token');
abort_unless($refreshToken, 403, 'Your refresh token is expired.');
$params = [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
];
return $this->makePostRequest($params);
}
protected function makePostRequest(array $params)
{
$params = array_merge([
'client_id' => config('services.passport.password_client_id'),
'client_secret' => config('services.passport.password_client_secret'),
'scope' => '*',
], $params);
$proxy = \Request::create('oauth/token', 'post', $params);
$resp = json_decode(app()->handle($proxy)->getContent());
$this->setHttpOnlyCookie($resp->refresh_token);
return $resp;
}
protected function setHttpOnlyCookie(string $refreshToken)
{
cookie()->queue(
'refresh_token',
$refreshToken,
14400, // 10 days
null,
null,
false,
true // httponly
);
}
}
ProxyRequest
方法:
grantPasswordToken
- 此方法中没有发生太多事情,我们只是设置 Passport“密码授予”所需的参数并发出 POST 请求。refreshAccessToken
- 我们正在检查请求是否包含 refresh_token,如果包含,我们将设置刷新令牌的参数并发出 POST 请求,如果 refresh_token 不存在,我们将以 403 状态中止。makePostRequest
- 这是此类的关键方法。- 我们从配置中设置 client_id 和 client_secret,并合并作为参数传递的其他参数
- 然后我们向 Passport 路由发出内部 POST 请求,并附带所需的参数
- 我们正在对响应进行 JSON 解码
httponly
使用 refresh_token设置cookie- 返回响应
setHttpOnlyCookie
-httponly
在响应中使用 refresh_token 设置 cookie。
为了将 Cookie 排队以进行响应,我们需要添加中间件。像这样app/Http/Kernel.php
添加\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class
...
protected $middleware = [
\App\Http\Middleware\CheckForMaintenanceMode::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\App\Http\Middleware\TrustProxies::class,
\App\Http\Middleware\Cors::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
];
...
现在来创建App\Http\Controllers\AuthController
方法。别忘了导入App\User
。
在register
方法中添加
...
public function register()
{
$this->validate(request(), [
'name' => 'required',
'email' => 'required|email',
'password' => 'required',
]);
$user = User::create([
'name' => request('name'),
'email' => request('email'),
'password' => bcrypt(request('password')),
]);
$resp = $this->proxy->grantPasswordToken(
$user->email,
request('password')
);
return response([
'token' => $resp->access_token,
'expiresIn' => $resp->expires_in,
'message' => 'Your account has been created',
], 201);
}
...
在login
方法中添加
...
public function login()
{
$user = User::where('email', request('email'))->first();
abort_unless($user, 404, 'This combination does not exists.');
abort_unless(
\Hash::check(request('password'), $user->password),
403,
'This combination does not exists.'
);
$resp = $this->proxy
->grantPasswordToken(request('email'), request('password'));
return response([
'token' => $resp->access_token,
'expiresIn' => $resp->expires_in,
'message' => 'You have been logged in',
], 200);
}
...
方法refreshToken
...
public function refreshToken()
{
$resp = $this->proxy->refreshAccessToken();
return response([
'token' => $resp->access_token,
'expiresIn' => $resp->expires_in,
'message' => 'Token has been refreshed.',
], 200);
}
...
方法logout
...
public function logout()
{
$token = request()->user()->token();
$token->delete();
// remove the httponly cookie
cookie()->queue(cookie()->forget('refresh_token'));
return response([
'message' => 'You have been successfully logged out',
], 200);
}
...
好的,这就是我们在后端要做的所有事情。我认为里面的方法AuthController
都是不言自明的。
制作 Nuxt 前端
Nuxt 官方文档介绍,它是一个基于 Vue.js 的渐进式框架,用于创建现代 Web 应用程序。它基于 Vue.js 官方库(vue、vue-router 和 vuex)以及强大的开发工具(webpack、Babel 和 PostCSS)。Nuxt 的目标是在提供卓越开发者体验的同时,增强 Web 开发的功能和性能。
要创建 nuxt 项目,请运行npx create-nuxt-app auth-spa-frontend
。如果您还没有安装npm
,请先安装。
它会询问你一些问题,例如项目名称、描述、包管理器等等。输入并选择你喜欢的内容。只需确保自定义服务器框架设置为“无”,并添加axios
nuxt 模块即可。注意,我将使用 bootstrap-vue。
我们还将安装附加包js-cookie
,运行npm install js-cookie
。
我不会费心设计前端的架构和外观。前端会很简单,但功能齐全。
在nuxt.config.js
集合中baseUrl
export default {
...
axios: {
baseURL: 'http://auth-api.web/api/',
credentials: true, // this says that in the request the httponly cookie should be sent
},
...
}
接下来我们将激活 Vue 状态管理库vuex
。为此,我们只需要在 store 文件夹中创建新的 js 文件。
如果您不熟悉vuex
其工作原理,我建议您阅读文档,它非常简单。
index.js
在store文件夹添加文件,并添加以下内容
import cookies from 'js-cookie';
export const state = () => ({
token: null,
});
export const mutations = {
SET_TOKEN(state, token) {
state.token = token;
},
REMOVE_TOKEN(state) {
state.token = null;
}
};
export const actions = {
setToken({commit}, {token, expiresIn}) {
this.$axios.setToken(token, 'Bearer');
const expiryTime = new Date(new Date().getTime() + expiresIn * 1000);
cookies.set('x-access-token', token, {expires: expiryTime});
commit('SET_TOKEN', token);
},
async refreshToken({dispatch}) {
const {token, expiresIn} = await this.$axios.$post('refresh-token');
dispatch('setToken', {token, expiresIn});
},
logout({commit}) {
this.$axios.setToken(false);
cookies.remove('x-access-token');
commit('REMOVE_TOKEN');
}
};
我将逐一解释这些操作:
setToken
- 它在 axios 和 cookie 中设置 token,并调用SET_TOKEN
提交refreshToken
- 它向 API 发送 POST 请求来刷新令牌并调度setToken
操作logout
- 它从 axios、cookie 和状态中删除令牌
在 pages 文件夹中,添加以下 vue 文件:register.vue
,,login.vue
。secret.vue
然后添加pages/register.vue
这个
<template>
<div class="container">
<b-form @submit.prevent="register">
<b-form-group
id="input-group-1"
label="Email address:"
label-for="input-1"
>
<b-form-input
id="input-1"
v-model="form.email"
type="email"
required
placeholder="Enter email"
></b-form-input>
</b-form-group>
<b-form-group id="input-group-2" label="Your Name:" label-for="input-2">
<b-form-input
id="input-2"
v-model="form.name"
required
placeholder="Enter name"
></b-form-input>
</b-form-group>
<b-form-group id="input-group-3" label="Password:" label-for="input-3">
<b-form-input
id="input-3"
type="password"
v-model="form.password"
required
placeholder="Enter password"
></b-form-input>
</b-form-group>
<b-button type="submit" variant="primary">Submit</b-button>
</b-form>
</div>
</template>
<script>
export default {
data() {
return {
form: {
email: '',
name: '',
},
}
},
methods: {
register() {
this.$axios.$post('register', this.form)
.then(({token, expiresIn}) => {
this.$store.dispatch('setToken', {token, expiresIn});
this.$router.push({name: 'secret'});
})
.catch(errors => {
console.dir(errors);
});
},
}
}
</script>
pages/login.vue
与注册非常相似,我们只需要做一些细微的改变
<template>
<div class="container">
<b-form @submit.prevent="login">
<b-form-group
id="input-group-1"
label="Email address:"
label-for="input-1"
>
<b-form-input
id="input-1"
v-model="form.email"
type="email"
required
placeholder="Enter email"
></b-form-input>
</b-form-group>
<b-form-group id="input-group-3" label="Password:" label-for="input-3">
<b-form-input
id="input-3"
type="password"
v-model="form.password"
required
placeholder="Enter password"
></b-form-input>
</b-form-group>
<b-button type="submit" variant="primary">Submit</b-button>
</b-form>
</div>
</template>
<script>
export default {
data() {
return {
form: {
email: '',
name: '',
},
}
},
methods: {
login() {
this.$axios.$post('login', this.form)
.then(({token, expiresIn}) => {
this.$store.dispatch('setToken', {token, expiresIn});
this.$router.push({name: 'secret'});
})
.catch(errors => {
console.dir(errors);
});
},
}
}
</script>
在pages/secret.vue
添加这个
<template>
<h2>THIS IS SOME SECRET PAGE</h2>
</template>
<script>
export default {
middleware: 'auth',
}
</script>
我们必须为身份验证创建路由中间件,在中间件文件夹中添加新auth.js
文件,并添加此
export default function ({ store, redirect }) {
if (! store.state.token) {
return redirect('/');
}
}
现在我们来制作导航栏。layouts/deafult.vue
像这样修改
<template>
<div>
<div>
<b-navbar toggleable="lg" type="dark" variant="info">
<b-navbar-brand href="#">NavBar</b-navbar-brand>
<b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
<b-collapse id="nav-collapse" is-nav>
<b-navbar-nav class="ml-auto" v-if="isLoggedIn">
<b-nav-item :to="{name: 'secret'}">Secret Page</b-nav-item>
<b-nav-item href="#" right @click="logout">Logout</b-nav-item>
</b-navbar-nav>
<b-navbar-nav class="ml-auto" v-else>
<b-nav-item :to="{name: 'login'}">Login</b-nav-item>
</b-navbar-nav>
</b-collapse>
</b-navbar>
</div>
<nuxt />
</div>
</template>
<script>
export default {
computed: {
isLoggedIn() {
return this.$store.state.token;
}
},
methods: {
logout() {
this.$axios.$post('logout')
.then(resp => {
this.$store.dispatch('logout');
this.$router.push('/');
})
.catch(errors => {
console.dir(errors);
});
}
}
}
</script>
...
为了刷新访问令牌,我们将添加另一个中间件,并将其应用于每个路由。为此,请nuxt.config.js
在
export default {
...
router: {
middleware: 'refreshToken',
},
...
}
然后创建中间件。在中间件文件夹中添加新文件refreshToken.js
,并添加以下内容
import cookies from 'js-cookie';
export default function ({ store, redirect }) {
const token = cookies.get('x-access-token');
if (! token) {
store.dispatch('refreshToken')
.catch(errors => {
console.dir(errors);
store.dispatch('logout');
});
}
}
在这里我们检查用户是否在 cookies 中拥有令牌,如果没有,我们将尝试刷新他的令牌,并为他分配一个新的访问令牌。
就是这样。现在我们有了安全的身份验证系统,因为即使有人能够窃取某个用户的访问令牌,他也没有太多时间去做任何事情。
这篇文章很长,但我希望这些概念清晰简洁。如果您有任何疑问,或者您认为可以改进的地方,请在下方评论。
鏂囩珷鏉ユ簮锛�https://dev.to/stefant123/secure-authentication-in-nuxt-spa-with-laravel-as-back-end-19a9