使用Laravel Sanctum实现SPA无状态认证:Token签发与过期策略的设计

Laravel Sanctum SPA 无状态认证:Token 签发与过期策略设计

大家好,今天我们来深入探讨 Laravel Sanctum 在构建单页应用 (SPA) 时如何实现无状态认证,并重点讨论 Token 的签发和过期策略设计。Sanctum 是 Laravel 官方提供的轻量级认证包,非常适合 API 认证,尤其是在 SPA 场景下,它能很好地解决 Cookie-based 认证在跨域问题上的局限性。

一、Sanctum 认证原理回顾

在开始之前,我们先快速回顾一下 Sanctum 的核心工作原理。Sanctum 的目标是在不依赖 Cookie 的前提下,为 SPA 应用提供安全的认证机制。它主要依赖以下几点:

  1. API Token: Sanctum 使用 API Token 来验证用户的身份。这些 Token 是数据库中存储的字符串,与特定用户关联。
  2. Token 能力 (Abilities): 每个 Token 可以被赋予不同的能力,例如 read, write, admin 等。 这允许你控制用户可以通过该 Token 执行的操作。
  3. 加密: Token 在传输过程中应该通过 HTTPS 加密,以防止被窃取。
  4. Single Page Application (SPA) 认证: Sanctum 专门为 SPA 认证设计了一套流程,允许前端通过发送请求获取 Token,并将 Token 存储在前端(通常是 localStoragesessionStorage)并在后续请求的 Authorization 头部中携带。

二、Sanctum 安装与配置

首先,确保你的 Laravel 项目已经安装了 Sanctum。如果没有,可以通过 Composer 安装:

composer require laravel/sanctum

安装完成后,需要发布 Sanctum 的配置文件和迁移文件,并执行迁移:

php artisan vendor:publish --provider="LaravelSanctumSanctumServiceProvider"
php artisan migrate

这将在你的数据库中创建 personal_access_tokens 表,用于存储 API Token。

最后,在 AppModelsUser 模型中引入 HasApiTokens trait:

<?php

namespace AppModels;

use IlluminateFoundationAuthUser as Authenticatable;
use IlluminateNotificationsNotifiable;
use LaravelSanctumHasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;

    // ...
}

三、Token 签发策略设计

Token 签发是认证流程的核心。我们需要考虑以下几个关键点:

  1. Token 类型:

    • 短期 Token (Short-Lived Token): 用于授权用户访问 API。过期时间较短,例如 1 小时,安全性较高。
    • 刷新 Token (Refresh Token): 用于获取新的短期 Token。过期时间较长,例如 30 天,但必须安全存储。
      Sanctum 默认只支持短期 Token,我们需要手动实现刷新 Token 的逻辑。
  2. Token 存储位置:

    • 前端 (localStorage/sessionStorage): 短期Token存储于此
    • 数据库: 刷新Token存储于此
      选择哪种取决于你的安全需求和用户体验。
  3. Token 签发接口: 提供一个 API 接口,用于用户登录后签发 Token。

3.1 短期Token签发

我们可以创建一个 AuthController 来处理用户登录和 Token 签发:

<?php

namespace AppHttpControllers;

use AppModelsUser;
use IlluminateHttpRequest;
use IlluminateSupportFacadesAuth;
use IlluminateSupportFacadesHash;
use IlluminateSupportStr;

class AuthController extends Controller
{
    public function login(Request $request)
    {
        $request->validate([
            'email' => 'required|email',
            'password' => 'required',
        ]);

        $user = User::where('email', $request->email)->first();

        if (!$user || !Hash::check($request->password, $user->password)) {
            return response()->json(['message' => 'Invalid credentials'], 401);
        }

        $token = $user->createToken('auth_token')->plainTextToken;

        return response()->json([
            'access_token' => $token,
            'token_type' => 'Bearer',
        ]);
    }

    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();

        return response()->json(['message' => 'Logged out']);
    }
}

路由定义:

Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');

3.2 刷新Token签发

为了实现刷新 Token 的功能,我们需要进行以下步骤:

  1. 修改 personal_access_tokens 表: 添加 refresh_tokenexpires_at 字段。

    use IlluminateDatabaseMigrationsMigration;
    use IlluminateDatabaseSchemaBlueprint;
    use IlluminateSupportFacadesSchema;
    
    class AddRefreshTokenToPersonalAccessTokensTable extends Migration
    {
        /**
         * Run the migrations.
         *
         * @return void
         */
        public function up()
        {
            Schema::table('personal_access_tokens', function (Blueprint $table) {
                $table->string('refresh_token')->nullable()->unique();
                $table->timestamp('expires_at')->nullable();
            });
        }
    
        /**
         * Reverse the migrations.
         *
         * @return void
         */
        public function down()
        {
            Schema::table('personal_access_tokens', function (Blueprint $table) {
                $table->dropColumn('refresh_token');
                $table->dropColumn('expires_at');
            });
        }
    }

    运行 migrate

    php artisan migrate
  2. 修改 login 方法: 生成并存储刷新 Token。

public function login(Request $request)
{
    $request->validate([
        'email' => 'required|email',
        'password' => 'required',
    ]);

    $user = User::where('email', $request->email)->first();

    if (!$user || !Hash::check($request->password, $user->password)) {
        return response()->json(['message' => 'Invalid credentials'], 401);
    }

    // 创建短期 Token
    $accessToken = $user->createToken('auth_token', ['*'])->plainTextToken;

    // 生成刷新 Token
    $refreshToken = Str::random(40);

    // 保存刷新 Token 到数据库
    $token = $user->tokens()->where('name', 'auth_token')->first(); // 获取刚才创建的 token 记录
    $token->refresh_token = $refreshToken;
    $token->expires_at = now()->addDays(30); // 设置刷新 Token 的过期时间
    $token->save();

    return response()->json([
        'access_token' => $accessToken,
        'token_type' => 'Bearer',
        'refresh_token' => $refreshToken,
        'expires_in' => now()->addMinutes(60)->timestamp // 短期Token过期时间,例如1小时
    ]);
}
  1. 创建 refresh 方法: 使用刷新 Token 获取新的短期 Token。
public function refresh(Request $request)
{
    $request->validate([
        'refresh_token' => 'required',
    ]);

    $refreshToken = $request->input('refresh_token');

    // 查找有效的刷新 Token
    $token = PersonalAccessToken::where('refresh_token', $refreshToken)
        ->where('expires_at', '>', now())
        ->first();

    if (!$token) {
        return response()->json(['message' => 'Invalid refresh token'], 401);
    }

    $user = $token->tokenable; // 获取关联的用户

    // 删除旧的 Token
    $token->delete();

    // 创建新的短期 Token
    $accessToken = $user->createToken('auth_token', ['*'])->plainTextToken;

    // 生成新的刷新 Token
    $newRefreshToken = Str::random(40);

    // 保存新的刷新 Token 到数据库
    $newToken = $user->tokens()->where('name', 'auth_token')->first(); // 获取刚才创建的 token 记录
    $newToken->refresh_token = $newRefreshToken;
    $newToken->expires_at = now()->addDays(30); // 设置刷新 Token 的过期时间
    $newToken->save();

    return response()->json([
        'access_token' => $accessToken,
        'token_type' => 'Bearer',
        'refresh_token' => $newRefreshToken,
        'expires_in' => now()->addMinutes(60)->timestamp // 短期Token过期时间,例如1小时
    ]);
}
  1. 添加路由:
Route::post('/refresh', [AuthController::class, 'refresh']);

四、Token 过期策略设计

Token 过期策略对于安全性至关重要。

  1. 短期 Token 过期时间: 短期 Token 的过期时间应该设置得相对较短,例如 1 小时或更短。这可以减少 Token 被窃取后造成的潜在损害。

  2. 刷新 Token 过期时间: 刷新 Token 的过期时间可以设置得较长,例如 30 天或更长。但是,必须确保刷新 Token 的安全存储,例如存储在数据库中,并进行加密处理。

  3. Token 撤销: 提供一个 API 接口,允许用户主动撤销 Token。这在用户注销或怀疑 Token 泄露时非常有用。

  4. 定期清理过期 Token: 定期清理数据库中过期的 Token,以减少数据库的存储压力。可以使用 Laravel 的任务调度器来完成这个任务。

4.1 手动设置过期时间

在签发 Token 时,我们可以使用 $token->expires_at 属性来设置过期时间。

$token = $user->createToken('auth_token');
$accessToken = $token->plainTextToken;
$token->accessToken->expires_at = now()->addMinutes(60); // 设置过期时间为 1 小时后
$token->accessToken->save();

4.2 定期清理过期 Token

创建一个 Artisan 命令来清理过期的 Token:

<?php

namespace AppConsoleCommands;

use IlluminateConsoleCommand;
use LaravelSanctumPersonalAccessToken;

class PruneExpiredTokens extends Command
{
    protected $signature = 'sanctum:prune-expired';
    protected $description = 'Prune expired Sanctum tokens';

    public function handle()
    {
        PersonalAccessToken::where('expires_at', '<', now())->delete();
        $this->info('Expired Sanctum tokens pruned successfully.');
    }
}

注册命令到 app/Console/Kernel.php

protected $commands = [
    CommandsPruneExpiredTokens::class,
];

使用 Laravel 的任务调度器来定期运行该命令:

protected function schedule(Schedule $schedule)
{
    $schedule->command('sanctum:prune-expired')->daily(); // 每天运行一次
}

五、SPA 前端集成

在 SPA 前端,需要实现以下功能:

  1. 登录: 调用 login API 获取 Token 和刷新 Token,并将它们存储在 localStoragesessionStorage 中。

  2. 发送 API 请求: 在每个 API 请求的 Authorization 头部中添加 Bearer Token。

  3. Token 刷新: 在 Token 过期或即将过期时,调用 refresh API 获取新的 Token。

  4. 注销: 调用 logout API 撤销 Token,并从 localStoragesessionStorage 中删除 Token。

5.1 前端示例 (JavaScript)

// 登录
async function login(email, password) {
  try {
    const response = await fetch('/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ email, password })
    });

    const data = await response.json();

    if (response.ok) {
      localStorage.setItem('access_token', data.access_token);
      localStorage.setItem('refresh_token', data.refresh_token);
      localStorage.setItem('expires_in', data.expires_in);
      return true;
    } else {
      console.error('Login failed:', data.message);
      return false;
    }
  } catch (error) {
    console.error('Login error:', error);
    return false;
  }
}

// 发送 API 请求
async function fetchData(url) {
  const accessToken = localStorage.getItem('access_token');

  if (!accessToken) {
    // 用户未登录,重定向到登录页面
    window.location.href = '/login';
    return;
  }

  try {
    const response = await fetch(url, {
      headers: {
        'Authorization': `Bearer ${accessToken}`
      }
    });

    if (response.status === 401) {
      // Token 过期,尝试刷新
      const refreshed = await refreshToken();
      if (refreshed) {
        // 重新发送请求
        return fetchData(url);
      } else {
        // 刷新失败,重定向到登录页面
        window.location.href = '/login';
        return;
      }
    }

    const data = await response.json();
    return data;
  } catch (error) {
    console.error('API request error:', error);
  }
}

// 刷新 Token
async function refreshToken() {
  const refreshToken = localStorage.getItem('refresh_token');

  if (!refreshToken) {
    return false;
  }

  try {
    const response = await fetch('/refresh', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ refresh_token: refreshToken })
    });

    const data = await response.json();

    if (response.ok) {
      localStorage.setItem('access_token', data.access_token);
      localStorage.setItem('refresh_token', data.refresh_token);
      localStorage.setItem('expires_in', data.expires_in);
      return true;
    } else {
      console.error('Refresh failed:', data.message);
      return false;
    }
  } catch (error) {
    console.error('Refresh error:', error);
    return false;
  }
}

// 注销
async function logout() {
  const accessToken = localStorage.getItem('access_token');

  try {
    const response = await fetch('/logout', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`
      }
    });

    if (response.ok) {
      localStorage.removeItem('access_token');
      localStorage.removeItem('refresh_token');
      localStorage.removeItem('expires_in');
      window.location.href = '/login';
    } else {
      console.error('Logout failed:', response.status);
    }
  } catch (error) {
    console.error('Logout error:', error);
  }
}

六、安全注意事项

  1. HTTPS: 确保所有 API 请求都通过 HTTPS 进行加密,防止 Token 被窃取。

  2. CORS: 配置 CORS 策略,只允许受信任的域名访问 API。

  3. XSS: 防止 XSS 攻击,避免将 Token 直接渲染到 HTML 页面中。

  4. CSRF: 虽然 Sanctum 主要用于无状态认证,但仍然需要注意 CSRF 攻击。 Laravel 提供了 CSRF 保护机制,可以在 API 请求中包含 CSRF Token。

  5. Token 存储: 安全地存储 Token 在前端。 考虑使用 HttpOnly Cookie 或加密 localStorage 的内容。

  6. 限制 Token 能力: 为 Token 分配最小权限原则的能力。

七、总结

通过以上步骤,我们可以使用 Laravel Sanctum 构建一个安全的 SPA 无状态认证系统。 重点在于合理设计 Token 的签发和过期策略,并采取必要的安全措施来保护 Token 的安全。 记住,安全是一个持续的过程,需要不断地进行监控和改进。

八、核心要点回顾:Token 管理与安全

Sanctum 为 SPA 提供了便捷的 API 认证方案。合理地利用 Token 签发和过期策略,结合安全措施,可以构建一个可靠且安全的无状态认证系统。务必重视 Token 的安全存储和传输,并定期审查和更新你的安全策略。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注