使用 Laravel 作为后端在 Nuxt SPA 中进行安全身份验证

2025-06-10

使用 Laravel 作为后端在 Nuxt SPA 中进行安全身份验证

过去一段时间,我正在做一个项目,包括在 Nuxt 中构建一个单页应用程序(位于一个域名),以及在 Laravel 中构建另一个子域名的 API。API 构建完成后,我就开始着手开发前端,并尝试在考虑安全性的情况下,正确构建身份验证系统。网上有很多关于这个主题的文章,但我找不到任何一篇涉及应用程序安全性的文章。

TL;DR 请不要将您的令牌或任何其他敏感信息存储在 LocalStorage 中,因为页面上的任何 javascript 代码都可以访问它,这会使您容易受到 XSS 攻击。

TL;DR 如果你只想看代码,这里有 GitHub 链接

身份验证流程如下:

  1. 用户输入其用户名和密码。
  2. 如果凭证有效,我们会将刷新令牌保存在httponlycookie 中。
  3. 用户在cookie中设置访问令牌,请注意,这是普通的cookie,其有效期为5分钟。
  4. 访问令牌过期后,如果用户设置了有效的刷新令牌,我们将刷新访问令牌。
  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
...


Enter fullscreen mode Exit fullscreen mode

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}
...


Enter fullscreen mode Exit fullscreen mode

然后我们将“密码客户端” ID 和密钥添加到,config/services.php以便稍后在代码中使用它们:



...
'passport' => [
    'password_client_id' => env('PASSWORD_CLIENT_ID'),
    'password_client_secret' => env('PASSWORD_CLIENT_SECRET'),
],


Enter fullscreen mode Exit fullscreen mode

config/auth.php设置api守护驱动程序作为护照



...

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'passport',
        'provider' => 'users',
        'hash' => false,
    ],
],

...


Enter fullscreen mode Exit fullscreen mode

下一步是向模型添加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;

    ...
}


Enter fullscreen mode Exit fullscreen mode

不要忘记在顶部导入特征。

最后一步是注册护照路线。在方法中AuthServiceProvider添加bootLaravel\Passport\Passport在顶部导入。



public function boot()
{
    $this->registerPolicies();

    Passport::routes(function ($router) {
        $router->forAccessTokens();
        $router->forPersonalAccessTokens();
        $router->forTransientTokens();
    });
}


Enter fullscreen mode Exit fullscreen mode

我们只注册我们需要的路线,如果由于某种原因您想要注册所有护照路线,请不要通过闭包,只需添加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));
}


Enter fullscreen mode Exit fullscreen mode

关于 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);
}


Enter fullscreen mode Exit fullscreen mode

这里我们检查请求来源是否在允许来源的数组中,如果是,我们就设置正确的标头。

现在我们只需要注册这个中间件。在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,
];

...


Enter fullscreen mode Exit fullscreen mode

就是这样,非常简单。

制作 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');
});


Enter fullscreen mode Exit fullscreen mode

我们需要创建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()
    {
    }
}


Enter fullscreen mode Exit fullscreen mode

为了实现这一点,我们需要创建一个代理来向我们自己的 API 发出请求。乍一看可能有点令人困惑,但一旦搞定,就会明白其中的道理。

我们将在应用程序目录中创建新文件夹,名为 Utilities。在app/Utilities新建 php 文件中ProxyRequest.php



<?php

namespace App\Utilities;

class ProxyRequest
{

}


Enter fullscreen mode Exit fullscreen mode

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;
    }

...


Enter fullscreen mode Exit fullscreen mode

我们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
        );
    }
}


Enter fullscreen mode Exit fullscreen mode

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,
];

...


Enter fullscreen mode Exit fullscreen mode

现在来创建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);
}

...


Enter fullscreen mode Exit fullscreen mode

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);
 }

...


Enter fullscreen mode Exit fullscreen mode

方法refreshToken



...

public function refreshToken()
{
    $resp = $this->proxy->refreshAccessToken();

    return response([
        'token' => $resp->access_token,
        'expiresIn' => $resp->expires_in,
        'message' => 'Token has been refreshed.',
    ], 200);
}

...


Enter fullscreen mode Exit fullscreen mode

方法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);
}

...


Enter fullscreen mode Exit fullscreen mode

好的,这就是我们在后端要做的所有事情。我认为里面的方法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,请先安装。

它会询问你一些问题,例如项目名称、描述、包管理器等等。输入并选择你喜欢的内容。只需确保自定义服务器框架设置为“无”,并添加axiosnuxt 模块即可。注意,我将使用 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
  },

  ...
}


Enter fullscreen mode Exit fullscreen mode

接下来我们将激活 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');
  }
};


Enter fullscreen mode Exit fullscreen mode

我将逐一解释这些操作:

  1. setToken- 它在 axios 和 cookie 中设置 token,并调用SET_TOKEN提交
  2. refreshToken- 它向 API 发送 POST 请求来刷新令牌并调度setToken操作
  3. logout- 它从 axios、cookie 和状态中删除令牌

在 pages 文件夹中,添加以下 vue 文件register.vue,,login.vuesecret.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>


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

pages/secret.vue添加这个



<template>
  <h2>THIS IS SOME SECRET PAGE</h2>
</template>

<script>
  export default {
    middleware: 'auth',
  }
</script>


Enter fullscreen mode Exit fullscreen mode

我们必须为身份验证创建路由中间件,在中间件文件夹中添加新auth.js文件,并添加此



export default function ({ store, redirect }) {
  if (! store.state.token) {
    return redirect('/');
  }
}


Enter fullscreen mode Exit fullscreen mode

现在我们来制作导航栏。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>

...


Enter fullscreen mode Exit fullscreen mode

为了刷新访问令牌,我们将添加另一个中间件,并将其应用于每个路由。为此,请nuxt.config.js



export default {
  ...

  router: {
    middleware: 'refreshToken',
  },

  ...
}


Enter fullscreen mode Exit fullscreen mode

然后创建中间件。在中间件文件夹中添加新文件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');
      });
  }
}


Enter fullscreen mode Exit fullscreen mode

在这里我们检查用户是否在 cookies 中拥有令牌,如果没有,我们将尝试刷新他的令牌,并为他分配一个新的访问令牌。

就是这样。现在我们有了安全的身份验证系统,因为即使有人能够窃取某个用户的访问令牌,他也没有太多时间去做任何事情。

这篇文章很长,但我希望这些概念清晰简洁。如果您有任何疑问,或者您认为可以改进的地方,请在下方评论。

鏂囩珷鏉ユ簮锛�https://dev.to/stefant123/secure-authentication-in-nuxt-spa-with-laravel-as-back-end-19a9
PREV
ES6 - 初学者指南 - 生成器
NEXT
我选择框架无关的 3 个原因以及你也应该这样做的原因