
今天客户给卓伊凡提了一个问题,说交付的app要有个功能,用户的登录状态要一直保存,就是没有特殊情况下退出或者切换的情况下类似 抖音,微信,快手,小红书一样一直保持登录,我们默认的蜻蜓Q系统是laravel系统,并且默认了token的自动刷新机制,本文详细讲解需要实现长时间登录的详细功能原理以及介绍,包括前端(uni+vue3)开发要做的内容和后端开发(php+laravel)要做的内容。
下面我将详细阐述其原理、技术方案,并分别给出前端(uni-app + Vue3)和后端(PHP + Laravel)的具体实现步骤。
首先,要理解为什么默认的 Token 机制无法实现“永久登录”。
这个“超长有效期”的凭证,通常就是我们延长了有效期的 Refresh Token,或者一个独立的、专门用于此目的的 “记住我令牌”(Remember Me Token)。
安全考量:为了防止令牌被盗用后永久有效,必须引入令牌轮转和检测到可疑活动时立即使所有令牌失效的机制。
我们将采用经过验证的安全实践:
我们假设使用 Laravel Sanctum(API 令牌认证)或 Laravel Passport(OAuth2 服务器)来实现。两者都支持令牌刷新,但可能需要稍作扩展以实现“记住我”功能。以下以 Sanctum 为例进行概念性说明。
我们需要一张表来管理长效的 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();
});
}// 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'];
}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小时,单位秒
]);
}这个接口用于静默刷新 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,
]);
}注销时,不仅要清除 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.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);
},
};这是实现自动刷新的核心。使用 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);
}
});// 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);
}
});
});
}在应用启动时,检查是否存在令牌,并尝试获取用户信息,以验证令牌是否有效。
// 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' });
// 通常不在这里直接跳转,而是由各个页面的权限守卫处理
}
}在登录页面,当用户成功登录并选择“记住我”后,保存返回的所有令牌。
<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>通过上述后端和前端的配合,卓伊凡团队可以实现一个非常健壮的“永久登录”功能:
这套方案平衡了用户体验和安全性,是业界普遍采用的最佳实践。团队可以根据蜻蜓Q系统的具体架构(是 Sanctum 还是 Passport)进行微调,但核心原理是相通的。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。