实现“永久登录”:针对蜻蜓Q系统的用户体验优化方案(前端uni-app+后端Laravel详解)-优雅草卓伊凡

举报
卓伊凡 发表于 2025/09/23 00:04:37 2025/09/23
【摘要】 实现“永久登录”:针对蜻蜓Q系统的用户体验优化方案(前端uni-app+后端Laravel详解)-优雅草卓伊凡

实现“永久登录”:针对蜻蜓Q系统的用户体验优化方案(前端uni-app+后端Laravel详解)-优雅草卓伊凡

今天客户给卓伊凡提了一个问题,说交付的app要有个功能,用户的登录状态要一直保存,就是没有特殊情况下退出或者切换的情况下类似 抖音,微信,快手,小红书一样一直保持登录,我们默认的蜻蜓Q系统是laravel系统,并且默认了token的自动刷新机制,本文详细讲解需要实现长时间登录的详细功能原理以及介绍,包括前端(uni+vue3)开发要做的内容和后端开发(php+laravel)要做的内容。

下面我将详细阐述其原理、技术方案,并分别给出前端(uni-app + Vue3)和后端(PHP + Laravel)的具体实现步骤。


一、功能原理与核心概念介绍

首先,要理解为什么默认的 Token 机制无法实现“永久登录”。

  1. 默认的 Token 机制(如 Laravel Sanctum)
    • 用户登录后,服务器颁发一个访问令牌(Access Token)和一个刷新令牌(Refresh Token)。
    • Access Token:生命周期较短(例如 2 小时)。每次请求敏感数据时都必须携带,用于身份验证。过期后即失效。
    • Refresh Token:生命周期较长(例如 7 天、30 天甚至更长)。专门用于在 Access Token 过期后,静默地获取一个新的 Access Token,而无需用户重新输入账号密码。
    • 问题:如果用户在 Refresh Token 的有效期内都未打开 App,那么当 Refresh Token 也过期后,用户再次打开 App 时就会被强制退出登录。
  1. “永久登录”或“记住我”的实现原理
    核心在于 “自动刷新令牌” “持久化存储令牌” 的结合。我们要做的就是创建一个机制,即使用户长时间未使用 App,也能在下次打开时,利用一个“超长有效期”的凭证来重新获取有效的登录状态。

这个“超长有效期”的凭证,通常就是我们延长了有效期的 Refresh Token,或者一个独立的、专门用于此目的的 “记住我令牌”(Remember Me Token)

安全考量:为了防止令牌被盗用后永久有效,必须引入令牌轮转检测到可疑活动时立即使所有令牌失效的机制。


二、整体技术方案

我们将采用经过验证的安全实践:

  1. 双令牌机制:Access Token(短效) + Refresh Token(长效)。
  2. “记住我”功能:创建一个与设备绑定的、有效期极长的 Remember Me Token(例如 1 年),并将其安全地存储在客户端(如本地存储)。
  3. 令牌轮转与回收:每次使用 Refresh Token 或 Remember Me Token 换取新的 Access Token 时,使旧的令牌失效并颁发新令牌。这可以检测到令牌是否被盗(如果旧的令牌被再次使用,则说明有风险,立即吊销该用户的所有令牌)。
  4. 流程
    • 首次登录:用户输入账号密码,并选择“记住我”。
    • 后续打开 App
      • 检查是否有有效的 Access Token?有则正常使用。
      • 如果没有(已过期),则尝试使用 Refresh Token 刷新。
      • 如果 Refresh Token 也过期了,则检查是否存在有效的 Remember Me Token。
      • 如果存在,则使用它来获取一套新的 Access Token 和 Refresh Token,实现无感登录。
      • 如果所有令牌都无效,则跳转到登录页面。

三、后端开发(PHP + Laravel)要做的内容

我们假设使用 Laravel Sanctum(API 令牌认证)或 Laravel Passport(OAuth2 服务器)来实现。两者都支持令牌刷新,但可能需要稍作扩展以实现“记住我”功能。以下以 Sanctum 为例进行概念性说明。

1. 扩展数据库(创建数据迁移)

我们需要一张表来管理长效的 Remember Me Tokens。

// database/migrations/2024_06_19_000000_create_remember_tokens_table.php

public function up()
{
    Schema::create('remember_tokens', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->onDelete('cascade');
        $table->string('token', 64)->unique(); // 一个随机的、唯一的令牌
        $table->string('device_info')->nullable(); // 可选的:存储设备信息,用于管理
        $table->timestamp('expires_at');
        $table->timestamps();
    });
}

2. 创建相关模型和关系

// app/Models/User.php
public function rememberTokens()
{
    return $this->hasMany(RememberToken::class);
}

// app/Models/RememberToken.php
class RememberToken extends Model
{
    protected $fillable = ['user_id', 'token', 'device_info', 'expires_at'];
    protected $dates = ['expires_at'];
}

3. 修改登录逻辑(AuthController)

public function login(Request $request)
{
    // 1. 验证邮箱和密码
    $credentials = $request->only('email', 'password');
    if (!Auth::attempt($credentials)) {
        return response()->json(['message' => 'Unauthorized'], 401);
    }

    $user = Auth::user();

    // 2. 撤销用户现有的所有令牌(可选,增强安全性)
    $user->tokens()->delete();

    // 3. 创建标准的 Access Token(短效)
    $accessToken = $user->createToken('api-access-token', ['*'], now()->addHours(2))->plainTextToken;

    // 4. 创建 Refresh Token(中效,例如7天)
    $refreshToken = $user->createToken('api-refresh-token', ['refresh'], now()->addDays(7))->plainTextToken;

    // 5. 如果用户选择了“记住我”,则创建 Remember Me Token(长效,例如1年)
    $rememberToken = null;
    if ($request->remember_me) {
        $rememberTokenValue = hash('sha256', $plainTextToken = Str::random(40));
        $rememberToken = $user->rememberTokens()->create([
            'token' => $rememberTokenValue,
            'device_info' => $request->header('User-Agent'),
            'expires_at' => now()->addYear(),
        ]);
        // 注意:这里只将明文的令牌返回给客户端一次,后续无法再获取
        $rememberToken->plain_text_token = $plainTextToken;
    }

    return response()->json([
        'user' => $user,
        'access_token' => $accessToken,
        'refresh_token' => $refreshToken,
        'remember_token' => $rememberToken ? $rememberToken->plain_text_token : null,
        'token_type' => 'Bearer',
        'expires_in' => 2 * 60 * 60, // 2小时,单位秒
    ]);
}

4. 创建令牌刷新接口(AuthController)

这个接口用于静默刷新 Access Token。

public function refreshToken(Request $request)
{
    // 1. 验证请求中携带的 Refresh Token
    $refreshToken = $request->user()->currentAccessToken();

    // 检查这个 token 是否具有 'refresh' 权限(Scope)
    if (!$refreshToken->can('refresh')) {
        // 如果不是 Refresh Token,尝试用 Remember Me Token 逻辑
        return $this->refreshViaRememberToken($request);
    }

    // 2. 令牌轮转:删除旧的 Refresh Token
    $request->user()->tokens()->where('id', $refreshToken->id)->delete();

    // 3. 创建新的令牌对
    $newAccessToken = $request->user()->createToken('api-access-token', ['*'], now()->addHours(2))->plainTextToken;
    $newRefreshToken = $request->user()->createToken('api-refresh-token', ['refresh'], now()->addDays(7))->plainTextToken;

    return response()->json([
        'access_token' => $newAccessToken,
        'refresh_token' => $newRefreshToken,
        'token_type' => 'Bearer',
        'expires_in' => 2 * 60 * 60,
    ]);
}

// 通过 Remember Me Token 刷新的私有方法
private function refreshViaRememberToken(Request $request)
{
    $rememberTokenValue = $request->bearerToken(); // 假设 Remember Token 放在 Authorization 头中
    if (!$rememberTokenValue) {
        return response()->json(['message' => 'Token not provided.'], 401);
    }

    // 在数据库中查找(使用哈希值比较)
    $tokenRecord = RememberToken::where('token', hash('sha256', $rememberTokenValue))
                                 ->where('expires_at', '>', now())
                                 ->first();

    if (!$tokenRecord) {
        return response()->json(['message' => 'Invalid or expired remember token.'], 401);
    }

    $user = $tokenRecord->user;

    // 安全措施:可选地使这个 Remember Token 失效并生成一个新的(轮转)
    $tokenRecord->delete();
    $newRememberTokenValue = hash('sha256', $newPlainTextToken = Str::random(40));
    $newRememberToken = $user->rememberTokens()->create([
        'token' => $newRememberTokenValue,
        'device_info' => $request->header('User-Agent'),
        'expires_at' => now()->addYear(),
    ]);

    // 创建新的标准令牌对
    $newAccessToken = $user->createToken('api-access-token', ['*'], now()->addHours(2))->plainTextToken;
    $newRefreshToken = $user->createToken('api-refresh-token', ['refresh'], now()->addDays(7))->plainTextToken;

    return response()->json([
        'access_token' => $newAccessToken,
        'refresh_token' => $newRefreshToken,
        'remember_token' => $newPlainTextToken, // 返回新的 Remember Token
        'token_type' => 'Bearer',
        'expires_in' => 2 * 60 * 60,
    ]);
}

5. 注销接口

注销时,不仅要清除 Access Token,最好也清除客户端的 Remember Token。

public function logout(Request $request)
{
    // 可选的:获取客户端的 Remember Token 并使其在服务端失效
    $clientRememberToken = $request->input('remember_token');
    if ($clientRememberToken) {
        RememberToken::where('token', hash('sha256', $clientRememberToken))->delete();
    }

    // 删除用户当前的 Access Token
    $request->user()->currentAccessToken()->delete();

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

四、前端开发(uni-app + Vue3)要做的内容

前端主要负责令牌的存储、管理和自动刷新。

1. 令牌存储

使用 uni.setStorageSync 将令牌安全地存储在本地。

// utils/auth.js

const TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
const REMEMBER_TOKEN_KEY = 'remember_token';

export const auth = {
  // 保存令牌
  setTokens(tokens) {
    uni.setStorageSync(TOKEN_KEY, tokens.access_token);
    uni.setStorageSync(REFRESH_TOKEN_KEY, tokens.refresh_token);
    if (tokens.remember_token) {
      uni.setStorageSync(REMEMBER_TOKEN_KEY, tokens.remember_token);
    }
  },

  // 获取令牌
  getAccessToken() {
    return uni.getStorageSync(TOKEN_KEY);
  },
  getRefreshToken() {
    return uni.getStorageSync(REFRESH_TOKEN_KEY);
  },
  getRememberToken() {
    return uni.getStorageSync(REMEMBER_TOKEN_KEY);
  },

  // 清除令牌(退出登录时调用)
  clearTokens() {
    uni.removeStorageSync(TOKEN_KEY);
    uni.removeStorageSync(REFRESH_TOKEN_KEY);
    uni.removeStorageSync(REMEMBER_TOKEN_KEY);
  },
};

2. 请求拦截器(自动携带 Token 和 处理 401)

这是实现自动刷新的核心。使用 uni.addInterceptor 拦截所有请求。

// utils/request.js
import { auth } from './auth.js';

// 创建并配置一个 request 实例(如果使用 uni-request 或自己封装的请求库)
// 这里以拦截 uni.request 为例

let isRefreshing = false; // 是否正在刷新令牌
let requests = []; // 存储等待刷新完成的请求队列

// 响应拦截器
const responseInterceptor = (response) => {
  const { statusCode, data } = response;
  if (statusCode === 401) {
    // 遇到 401 未授权错误
    if (!isRefreshing) {
      isRefreshing = true;
      return refreshToken().then((newTokens) => {
        // 令牌刷新成功,重试所有挂起的请求
        requests.forEach(cb => cb(newTokens.access_token));
        requests = [];
        isRefreshing = false;
        // 重试当前失败的请求
        const retryConfig = { ...response.config, header: { ...response.config.header, 'Authorization': `Bearer ${newTokens.access_token}` } };
        return uni.request(retryConfig);
      }).catch(error => {
        // 刷新失败,跳转到登录页
        requests = [];
        isRefreshing = false;
        auth.clearTokens();
        uni.navigateTo({ url: '/pages/login/login' });
        return Promise.reject(error);
      });
    } else {
      // 如果正在刷新,将当前请求加入队列
      return new Promise((resolve) => {
        requests.push((newAccessToken) => {
          response.config.header['Authorization'] = `Bearer ${newAccessToken}`;
          resolve(uni.request(response.config));
        });
      });
    }
  }
  return response;
};

// 注册拦截器
uni.addInterceptor('request', {
  invoke(args) {
    // 请求前拦截:自动添加 Token
    const token = auth.getAccessToken();
    if (token) {
      args.header = {
        ...args.header,
        'Authorization': `Bearer ${token}`
      };
    }
  },
  success(response) {
    // 成功回调,可以在这里处理通用成功逻辑
    return responseInterceptor(response);
  },
  fail(error) {
    // 失败回调
    return Promise.reject(error);
  }
});

3. 令牌刷新函数

// utils/refreshToken.js
import { auth } from './auth.js';

export function refreshToken() {
  return new Promise((resolve, reject) => {
    // 优先使用 Refresh Token
    let refreshToken = auth.getRefreshToken();
    let url = '/api/auth/refresh';
    let tokenToUse = refreshToken;

    // 如果没有 Refresh Token,尝试使用 Remember Token
    if (!refreshToken) {
      refreshToken = auth.getRememberToken();
      url = '/api/auth/refresh-remember'; // 假设有另一个专门用于 Remember Token 的端点,或者像后端示例一样统一处理
      tokenToUse = refreshToken;
    }

    if (!tokenToUse) {
      reject(new Error('No available token for refresh.'));
      return;
    }

    uni.request({
      url: url,
      method: 'POST',
      header: {
        'Authorization': `Bearer ${tokenToUse}`
      },
      success: (res) => {
        if (res.statusCode === 200) {
          // 保存新的令牌
          auth.setTokens(res.data);
          resolve(res.data);
        } else {
          reject(new Error('Refresh failed.'));
        }
      },
      fail: (err) => {
        reject(err);
      }
    });
  });
}

4. App 初始化(App.vue 或 main.js)

在应用启动时,检查是否存在令牌,并尝试获取用户信息,以验证令牌是否有效。

// App.vue
import { onLaunch } from '@dcloudio/uni-app';
import { auth } from '@/utils/auth.js';

onLaunch(() => {
  // 应用启动时,检查登录状态
  checkLoginStatus();
});

function checkLoginStatus() {
  const token = auth.getAccessToken() || auth.getRememberToken();
  if (token) {
    // 如果有令牌,尝试获取用户信息来验证其有效性
    uni.request({
      url: '/api/user',
      success: (res) => {
        if (res.statusCode === 200) {
          // 令牌有效,可以将用户信息存储到 Vuex 或 Pinia 中
          console.log('自动登录成功');
        } else {
          // 令牌无效,清除本地存储
          auth.clearTokens();
        }
      },
      fail: () => {
        auth.clearTokens();
      }
    });
  } else {
    // 没有令牌,跳转到登录页
    // uni.redirectTo({ url: '/pages/login/login' });
    // 通常不在这里直接跳转,而是由各个页面的权限守卫处理
  }
}

5. 登录页面

在登录页面,当用户成功登录并选择“记住我”后,保存返回的所有令牌。

<script setup>
import { ref } from 'vue';
import { auth } from '@/utils/auth.js';

const form = ref({
  email: '',
  password: '',
  remember_me: false
});

const onSubmit = async () => {
  try {
    const res = await uni.request({
      url: '/api/login',
      method: 'POST',
      data: form.value
    });

    if (res.statusCode === 200) {
      // 保存令牌
      auth.setTokens(res.data);
      // 跳转到首页
      uni.switchTab({ url: '/pages/index/index' }); // 假设首页是 tabbar 页面
    } else {
      uni.showToast({ title: '登录失败', icon: 'none' });
    }
  } catch (error) {
    uni.showToast({ title: '网络错误', icon: 'none' });
  }
};
</script>

总结

通过上述后端和前端的配合,卓伊凡团队可以实现一个非常健壮的“永久登录”功能:

  • 后端:负责签发、验证、刷新和销毁不同类型的令牌,并通过令牌轮转保障安全。
  • 前端:负责安全地存储令牌,在每次请求时自动携带,并在收到 401 错误时自动尝试刷新令牌,实现无感登录。同时,在 App 启动时自动恢复登录状态。

这套方案平衡了用户体验和安全性,是业界普遍采用的最佳实践。团队可以根据蜻蜓Q系统的具体架构(是 Sanctum 还是 Passport)进行微调,但核心原理是相通的。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。