otp/pages/index/index.js
“xHuPo” 2b8870a40e init
2025-06-09 13:35:15 +08:00

721 lines
No EOL
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// index.js
const {
generateCode,
getRemainingSeconds,
formatTime,
syncTokens
} = require('../../utils/util');
const storage = require('../../utils/storage');
const eventManager = require('../../utils/eventManager');
Page({
data: {
tokens: [],
remainingSeconds: {}, // 存储每个令牌的剩余时间
loading: true,
syncing: false,
error: null
},
onLoad: function() {
// 初始化页面数据
this.loadTokens();
// 监听令牌更新事件
this.tokensUpdatedListener = (tokens) => {
this.loadTokens();
};
eventManager.on('tokensUpdated', this.tokensUpdatedListener);
// 初始化页面路由
this.pageRoutes = {
form: '/pages/form/form',
edit: '/pages/edit/edit'
};
},
onShow: function() {
// 先加载令牌列表
this.loadTokens();
// 然后延迟100ms刷新验证码确保tokens已经加载
setTimeout(() => {
this.refreshTokens();
}, 100);
},
onPullDownRefresh: function() {
this.refreshTokens().then(() => {
wx.stopPullDownRefresh();
});
},
async loadTokens() {
try {
this.setData({ loading: true, error: null });
// 从本地存储加载令牌
const tokens = await wx.getStorageSync('tokens') || [];
// console.log('[DEBUG] Loaded tokens count:', tokens.length);
// 统计令牌类型
const typeCounts = tokens.reduce((acc, token) => {
acc[token.type] = (acc[token.type] || 0) + 1;
return acc;
}, {});
// console.log('[DEBUG] Token types:', typeCounts);
// 初始化剩余时间
const remainingSeconds = {};
tokens.forEach(token => {
if (token.type === 'totp') {
const period = token.period || 30;
const remaining = getRemainingSeconds(period);
// console.log(`[DEBUG] Token ${token.id} period=${period}, remaining=${remaining}`);
remainingSeconds[token.id] = remaining;
}
});
// 获取当前时间戳
const timestamp = Math.floor(Date.now() / 1000);
// 先更新验证码
let tokensToSet = [...tokens]; // 创建tokens的副本
if (tokensToSet.length > 0) {
const updatedTokens = await this.updateTokenCodes(tokensToSet);
if (Array.isArray(updatedTokens)) {
tokensToSet = updatedTokens;
}
}
// 设置状态
this.setData({
tokens: tokensToSet,
remainingSeconds,
loading: false
});
// 设置定时器定期更新TOTP令牌
this.setupRefreshTimer();
} catch (error) {
console.error('Failed to load tokens:', error);
this.setData({
error: '加载令牌失败: ' + error.message,
loading: false
});
}
},
async refreshTokens() {
try {
const currentTokens = [...this.data.tokens]; // 创建tokens的副本
const updatedTokens = await this.updateTokenCodes(currentTokens);
// 更新剩余时间
const remainingSeconds = {};
updatedTokens.forEach(token => {
if (token.type === 'totp') {
remainingSeconds[token.id] = getRemainingSeconds(token.period || 30);
}
});
this.setData({
tokens: updatedTokens,
remainingSeconds
});
} catch (error) {
console.error('Failed to refresh tokens:', error);
this.setData({
error: '刷新令牌失败: ' + error.message
});
}
},
setupRefreshTimer() {
// 清除现有的定时器
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
}
// console.log('[DEBUG] Setting up refresh timer...');
// 设置新的定时器,每秒检查一次
this.refreshTimer = setInterval(() => {
// console.log('[DEBUG] Timer tick - checking tokens...');
const currentTokens = [...this.data.tokens]; // 创建tokens的副本
const currentRemainingSeconds = this.data.remainingSeconds || {};
const remainingSeconds = {};
const tokensToUpdate = [];
currentTokens.forEach(token => {
// 处理所有支持的OTP类型
const supportedTypes = ['totp', 'hotp', 'otpauth'];
if (!supportedTypes.includes(token.type)) {
// console.log(`[DEBUG] Skipping unsupported token type: ${token.type}`);
return;
}
// console.log(`[DEBUG] Processing ${token.type.toUpperCase()} token ${token.id || token.name}`);
// 针对不同类型做特殊处理
switch(token.type) {
case 'totp':
// 标准TOTP处理逻辑
break;
case 'hotp':
// HOTP特定处理
// console.log(`[DEBUG] HOTP token detected, counter: ${token.counter}`);
break;
case 'otpauth':
// 扫描二维码生成的令牌
// console.log(`[DEBUG] OTPAUTH URI token detected`);
break;
}
const period = token.period || 30;
const remaining = getRemainingSeconds(period);
const prevRemaining = currentRemainingSeconds[token.id];
// console.log(`[DEBUG] Token ${token.id}: prev=${prevRemaining}, curr=${remaining}`);
// 更新剩余时间
remainingSeconds[token.id] = remaining;
// 在以下情况下更新token
// 1. 剩余时间为0新的时间窗口开始
// 2. 之前没有剩余时间记录(首次加载)
// 3. 时间窗口发生变化prevRemaining > remaining说明跨越了时间窗口
if (remaining === 0 || prevRemaining === undefined || (prevRemaining > 0 && remaining < prevRemaining)) {
// console.log(`[DEBUG] Token ${token.id} needs update`);
tokensToUpdate.push({...token}); // 创建token的副本
}
});
// 只在有变化时更新剩余时间
if (Object.keys(remainingSeconds).length > 0) {
// console.log('[DEBUG] Updating remainingSeconds:', remainingSeconds);
this.setData({
'remainingSeconds': {
...currentRemainingSeconds,
...remainingSeconds
}
}, () => {
// console.log('[DEBUG] remainingSeconds updated');
});
}
// 只更新需要更新的token并传入当前时间戳
if (tokensToUpdate.length > 0) {
// console.log('[DEBUG] Updating tokens:', tokensToUpdate.length);
const timestamp = Math.floor(Date.now() / 1000);
this.updateSelectedTokens(tokensToUpdate, timestamp);
}
}, 1000);
},
// 只更新选定的token
async updateSelectedTokens(tokensToUpdate, timestamp) {
try {
const currentTokens = this.data.tokens;
// 获取当前时间戳,如果没有传入
const currentTimestamp = timestamp || Math.floor(Date.now() / 1000);
// 为每个token计算正确的时间戳
const updatePromises = tokensToUpdate.map(token => {
if (token.type === 'totp') {
// 计算时间窗口的开始时间
const period = token.period || 30;
const windowStart = Math.floor(currentTimestamp / period) * period;
return this.updateTokenCode(token, windowStart);
} else {
// 对于HOTP类型直接使用当前时间戳
return this.updateTokenCode(token, currentTimestamp);
}
});
const updatedTokens = await Promise.all(updatePromises);
// 创建token ID到更新后token的映射
const updatedTokenMap = {};
updatedTokens.forEach(token => {
updatedTokenMap[token.id] = token;
});
// 更新tokens数组中的特定token
const newTokens = currentTokens.map(token =>
updatedTokenMap[token.id] ? updatedTokenMap[token.id] : token
);
this.setData({ tokens: newTokens });
} catch (error) {
console.error('更新选定token失败:', error);
}
},
async updateTokenCodes(tokens) {
if (!Array.isArray(tokens) || tokens.length === 0) {
return [];
}
try {
// 获取当前时间戳
const currentTimestamp = Math.floor(Date.now() / 1000);
// 并行更新所有令牌的验证码,为每个令牌计算其时间窗口的开始时间
const updatePromises = tokens.map(token => {
if (token.type === 'totp') {
// 计算时间窗口的开始时间
const period = token.period || 30;
const windowStart = Math.floor(currentTimestamp / period) * period;
return this.updateTokenCode(token, windowStart);
} else {
// 对于HOTP类型直接使用当前时间戳
return this.updateTokenCode(token, currentTimestamp);
}
});
const updatedTokens = await Promise.all(updatePromises);
return updatedTokens;
} catch (error) {
console.error('更新验证码失败:', error);
// 出错时返回原始tokens避免undefined
return tokens;
}
},
async updateTokenCode(token, timestamp) {
try {
// 生成新的验证码,使用传入的统一时间戳
const code = await generateCode({
type: token.type,
secret: token.secret,
algorithm: token.algorithm || 'SHA1',
digits: token.digits || 6,
period: token.period || 30,
counter: token.counter,
timestamp: timestamp // 使用传入的统一时间戳
});
// 更新令牌数据
token.code = code;
token.lastUpdate = formatTime(new Date());
return token;
} catch (error) {
console.error(`更新令牌 ${token.id} 失败:`, error);
token.error = error.message;
return token;
}
},
async refreshHotpToken(event) {
const tokenId = event.currentTarget.dataset.tokenId;
const token = this.data.tokens.find(t => t.id === tokenId);
if (!token) return;
try {
// 增加计数器值
const newCounter = (token.counter || 0) + 1;
// 生成新的验证码
const code = await generateCode({
type: 'hotp',
secret: token.secret,
algorithm: token.algorithm || 'SHA1',
counter: newCounter,
digits: token.digits || 6
});
// 更新令牌数据
token.code = code;
token.counter = newCounter;
token.lastUpdate = formatTime(new Date());
// 更新存储和状态
const tokens = this.data.tokens.map(t =>
t.id === token.id ? token : t
);
await wx.setStorageSync('tokens', tokens);
this.setData({ tokens });
// 同步到云端
if (this.data.syncing) {
await syncTokens(tokens);
}
} catch (error) {
console.error(`刷新HOTP令牌 ${token.id} 失败:`, error);
this.setData({
error: '刷新HOTP令牌失败: ' + error.message
});
}
},
async syncWithCloud() {
try {
this.setData({ syncing: true, error: null });
// 获取本地令牌
const localTokens = this.data.tokens;
// 同步令牌
const syncedTokens = await syncTokens(localTokens);
// 更新所有令牌的验证码
await this.updateTokenCodes(syncedTokens);
// 更新存储和状态
await wx.setStorageSync('tokens', syncedTokens);
this.setData({
tokens: syncedTokens,
syncing: false
});
} catch (error) {
console.error('同步失败:', error);
this.setData({
error: '同步失败: ' + error.message,
syncing: false
});
}
},
onUnload: function() {
// 清除定时器
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
}
// 移除令牌更新事件监听
if (this.tokensUpdatedListener) {
eventManager.off('tokensUpdated', this.tokensUpdatedListener);
}
},
// 处理令牌点击事件
handleTokenTap(event) {
const tokenId = event.currentTarget.dataset.tokenId;
const token = this.data.tokens.find(t => t.id === tokenId);
if (!token) return;
// 复制验证码到剪贴板
wx.setClipboardData({
data: token.code,
success: () => {
wx.showToast({
title: '验证码已复制',
icon: 'success',
duration: 1500
});
}
});
},
// 显示添加令牌菜单
showAddTokenMenu() {
wx.showActionSheet({
itemList: ['扫描二维码', '手动添加'],
success: (res) => {
switch (res.tapIndex) {
case 0: // 扫描二维码
wx.scanCode({
onlyFromCamera: false,
scanType: ['qrCode'],
success: (res) => {
try {
// 解析二维码内容
const qrContent = res.result;
// 如果是otpauth://格式的URL
if (qrContent.startsWith('otpauth://')) {
// 小程序兼容的URL解析
const [protocolAndPath, search] = qrContent.split('?');
const [protocol, path] = protocolAndPath.split('://');
const type = protocol.replace('otpauth:', '');
// 解析路径部分
const decodedPath = decodeURIComponent(path.substring(1)); // 移除开头的/
let [issuer, remark] = decodedPath.split(':');
if (!remark) {
remark = issuer;
issuer = '';
}
// 解析查询参数
const params = {};
if (search) {
search.split('&').forEach(pair => {
const [key, value] = pair.split('=');
if (key && value) {
params[key] = decodeURIComponent(value);
}
});
}
// 从参数中获取issuer如果存在
if (params.issuer) {
issuer = params.issuer;
}
// 验证secret参数
if (!params.secret) {
wx.showToast({
title: '无效的二维码缺少secret参数',
icon: 'none',
duration: 2000
});
return;
}
// 将otpauth类型转换为实际类型
let validType = type.toLowerCase();
if (validType === 'otpauth') {
// 从URI路径中提取实际类型
validType = path.split('/')[0].toLowerCase();
}
// 构建表单数据,确保数字类型参数正确转换
const formData = {
type: validType,
issuer,
remark,
secret: params.secret,
algorithm: params.algorithm || 'SHA1',
digits: params.digits ? parseInt(params.digits, 10) : 6,
period: validType === 'totp' ? (params.period ? parseInt(params.period, 10) : 30) : undefined,
counter: validType === 'hotp' ? (params.counter ? parseInt(params.counter, 10) : 0) : undefined
};
// 验证必要参数
if (formData.digits < 6 || formData.digits > 8) {
formData.digits = 6;
console.warn('验证码位数无效已设置为默认值6');
}
if (validType === 'totp' && (formData.period < 15 || formData.period > 60)) {
formData.period = 30;
console.warn('TOTP周期无效已设置为默认值30秒');
}
// 检查必要参数是否完整
if (formData.secret && formData.type) {
// 直接添加令牌
this.addTokenDirectly(formData).then(() => {
wx.showToast({
title: '令牌已添加',
icon: 'success'
});
}).catch(error => {
console.error('添加令牌失败:', error);
// 失败后跳转到表单页面
const queryString = Object.entries(formData)
.filter(([_, value]) => value !== undefined)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
wx.navigateTo({
url: `${this.pageRoutes.form}?${queryString}`
});
});
} else {
// 参数不完整,跳转到表单页面
const queryString = Object.entries(formData)
.filter(([_, value]) => value !== undefined)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
wx.navigateTo({
url: `${this.pageRoutes.form}?${queryString}`
});
}
} else {
wx.showToast({
title: '无效的二维码格式',
icon: 'error'
});
}
} catch (error) {
console.error('解析二维码失败:', error);
wx.showToast({
title: '解析二维码失败',
icon: 'error'
});
}
},
fail: (error) => {
console.error('扫描二维码失败:', error);
if (error.errMsg.includes('cancel')) {
// 用户取消扫码,不显示错误提示
return;
}
wx.showToast({
title: '扫描失败',
icon: 'error'
});
}
});
break;
case 1: // 手动添加
wx.navigateTo({
url: '/pages/form/form',
events: {
// 监听form页面的事件
tokenAdded: () => {
this.loadTokens();
}
},
success: () => {
// 跳转成功后的回调
wx.showToast({
title: '请填写令牌信息',
icon: 'none',
duration: 1000
});
}
});
break;
}
}
});
},
// 编辑令牌
editToken(event) {
const id = event.currentTarget.dataset.tokenId;
wx.navigateTo({
url: `${this.pageRoutes.edit}?token_id=${id}`
});
},
// 删除令牌
deleteToken(event) {
const id = event.currentTarget.dataset.id;
const that = this;
wx.showModal({
title: '确认删除',
content: '确定要删除这个令牌吗?',
success(res) {
if (res.confirm) {
// 从本地存储中删除
wx.getStorage({
key: 'tokens',
success(res) {
const tokens = res.data.filter(token => token.id !== id);
wx.setStorage({
key: 'tokens',
data: tokens,
success() {
// 更新UI
that.setData({ tokens });
wx.showToast({
title: '删除成功',
icon: 'success'
});
// 如果正在同步,更新云端
if (that.data.syncing) {
syncTokens(tokens).catch(err => {
console.error('同步删除失败:', err);
});
}
},
fail() {
wx.showToast({
title: '删除失败',
icon: 'none'
});
}
});
}
});
}
}
});
},
// 直接添加令牌
async addTokenDirectly(tokenData) {
try {
// 获取当前令牌列表
const tokens = await wx.getStorageSync('tokens') || [];
// 生成唯一ID和时间戳
const newToken = {
...tokenData,
id: Date.now().toString(),
createdAt: new Date().toISOString(),
lastUpdate: new Date().toISOString()
};
// 如果是TOTP类型先初始化剩余时间
if ((newToken.type || 'totp').toLowerCase() === 'totp') {
const period = parseInt(newToken.period || '30', 10);
const currentRemainingSeconds = this.data.remainingSeconds || {};
// 先更新剩余时间,避免闪烁
this.setData({
remainingSeconds: {
...currentRemainingSeconds,
[newToken.id]: getRemainingSeconds(period)
}
});
}
// 生成验证码
try {
// 确保类型是字符串且为小写
const type = (newToken.type || 'totp').toLowerCase();
// 确保digits是数字
const digits = parseInt(newToken.digits || '6', 10);
// 确保period是数字
const period = parseInt(newToken.period || '30', 10);
// 确保counter是数字如果存在
const counter = newToken.counter ? parseInt(newToken.counter, 10) : undefined;
const code = await generateCode({
type,
secret: newToken.secret,
algorithm: newToken.algorithm || 'SHA1',
digits,
period,
counter
});
newToken.code = code;
} catch (error) {
console.error('生成验证码失败:', error);
wx.showToast({
title: '生成验证码失败: ' + error.message,
icon: 'none',
duration: 2000
});
throw error;
}
// 添加到列表并保存
const updatedTokens = [...tokens, newToken];
await wx.setStorageSync('tokens', updatedTokens);
// 更新UI使用setData的回调确保UI更新完成
this.setData({ tokens: updatedTokens }, () => {
// 触发更新事件
eventManager.emit('tokensUpdated', updatedTokens);
// 如果正在同步,更新云端
if (this.data.syncing) {
syncTokens(updatedTokens).catch(err => {
console.error('同步新令牌失败:', err);
});
}
});
} catch (error) {
console.error('直接添加令牌失败:', error);
throw error;
}
},
// 阻止触摸移动
catchTouchMove() {
return false;
}
});