实现“永久登录”:针对蜻蜓Q系统的用户体验优化方案(前端uni-app+后端Laravel详解)-优雅草卓伊凡
实现“永久登录”:针对蜻蜓Q系统的用户体验优化方案(前端uni-app+后端Laravel详解)-优雅草卓伊凡
今天客户给卓伊凡提了一个问题,说交付的app要有个功能,用户的登录状态要一直保存,就是没有特殊情况下退出或者切换的情况下类似 抖音,微信,快手,小红书一样一直保持登录,我们默认的蜻蜓Q系统是laravel系统,并且默认了token的自动刷新机制,本文详细讲解需要实现长时间登录的详细功能原理以及介绍,包括前端(uni+vue3)开发要做的内容和后端开发(php+laravel)要做的内容。
下面我将详细阐述其原理、技术方案,并分别给出前端(uni-app + Vue3)和后端(PHP + Laravel)的具体实现步骤。
一、功能原理与核心概念介绍
首先,要理解为什么默认的 Token 机制无法实现“永久登录”。
- 默认的 Token 机制(如 Laravel Sanctum):
- 用户登录后,服务器颁发一个访问令牌(Access Token)和一个刷新令牌(Refresh Token)。
- Access Token:生命周期较短(例如 2 小时)。每次请求敏感数据时都必须携带,用于身份验证。过期后即失效。
- Refresh Token:生命周期较长(例如 7 天、30 天甚至更长)。专门用于在 Access Token 过期后,静默地获取一个新的 Access Token,而无需用户重新输入账号密码。
- 问题:如果用户在 Refresh Token 的有效期内都未打开 App,那么当 Refresh Token 也过期后,用户再次打开 App 时就会被强制退出登录。
- “永久登录”或“记住我”的实现原理:
核心在于 “自动刷新令牌” 和 “持久化存储令牌” 的结合。我们要做的就是创建一个机制,即使用户长时间未使用 App,也能在下次打开时,利用一个“超长有效期”的凭证来重新获取有效的登录状态。
这个“超长有效期”的凭证,通常就是我们延长了有效期的 Refresh Token,或者一个独立的、专门用于此目的的 “记住我令牌”(Remember Me Token)。
安全考量:为了防止令牌被盗用后永久有效,必须引入令牌轮转和检测到可疑活动时立即使所有令牌失效的机制。
二、整体技术方案
我们将采用经过验证的安全实践:
- 双令牌机制:Access Token(短效) + Refresh Token(长效)。
- “记住我”功能:创建一个与设备绑定的、有效期极长的 Remember Me Token(例如 1 年),并将其安全地存储在客户端(如本地存储)。
- 令牌轮转与回收:每次使用 Refresh Token 或 Remember Me Token 换取新的 Access Token 时,使旧的令牌失效并颁发新令牌。这可以检测到令牌是否被盗(如果旧的令牌被再次使用,则说明有风险,立即吊销该用户的所有令牌)。
- 流程:
- 首次登录:用户输入账号密码,并选择“记住我”。
- 后续打开 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)进行微调,但核心原理是相通的。
- 点赞
- 收藏
- 关注作者
评论(0)