commit 2b8870a40efff2ad3cdc5acc77d0ab94dd1fb4a0 Author: “xHuPo” <7513325+vrocwang@users.noreply.github.com> Date: Mon Jun 9 13:35:15 2025 +0800 init diff --git a/app.js b/app.js new file mode 100644 index 0000000..569f0b0 --- /dev/null +++ b/app.js @@ -0,0 +1,89 @@ +//app.js +const { syncTokens, showToast } = require('./utils/util'); +const config = require('./utils/config'); +const cloud = require('./utils/cloud'); + +App({ + onLaunch: function (options) { + // 获取本地OTP信息 + this.getLocalOtpInfo(); + // 不再自动登录,只在用户点击登录按钮时才登录 + }, + + getLocalOtpInfo() { + try { + const otpList = wx.getStorageSync('tokens'); + if (otpList) { + this.globalData.otpList = otpList; + } + } catch (e) { + console.error("获取本地OTP信息失败:", e); + } + }, + + + async userLogin(options) { + try { + // 获取微信登录凭证 + const { code } = await new Promise((resolve, reject) => { + wx.login({ + success: resolve, + fail: reject + }); + }); + + // 调用后端登录接口 + const loginResult = await cloud.request({ + url: config.API_ENDPOINTS.AUTH.LOGIN, + method: 'POST', + data: { code }, + needToken: false + }); + + // 保存登录信息到storage + wx.setStorageSync(config.JWT_CONFIG.storage.access, loginResult.accessToken); + wx.setStorageSync(config.JWT_CONFIG.storage.refresh, loginResult.refreshToken); + + // 初始化基本用户信息 + const userInfo = { + avatarUrl: wx.getStorageSync('userAvatar') || '/images/default-avatar.png', + nickName: wx.getStorageSync('userNickName') || '微信用户' + }; + this.globalData.userInfo = userInfo; + + // 同步OTP数据 + await this.syncOtpData(); + + return userInfo; + } catch (error) { + console.error('登录失败:', error); + showToast('登录失败,请重试', 'none'); + throw error; + } + }, + + async syncOtpData() { + try { + // 获取本地数据 + const localTokens = this.globalData.otpList; + + // 使用util.js中的syncTokens函数进行同步 + // 注意:syncTokens函数已经被更新为使用cloud模块 + const syncedTokens = await syncTokens(localTokens); + + // 更新本地存储和全局数据 + wx.setStorageSync('tokens', syncedTokens); + this.globalData.otpList = syncedTokens; + + } catch (error) { + console.error('同步OTP数据失败:', error); + // 同步失败不显示错误提示,因为这是自动同步过程 + } + }, + + globalData: { + version: 103, + otpList: [], + userInfo: null + } +}) \ No newline at end of file diff --git a/app.json b/app.json new file mode 100644 index 0000000..501e440 --- /dev/null +++ b/app.json @@ -0,0 +1,38 @@ +{ + "pages":[ + "pages/index/index", + "pages/mine/mine", + "pages/edit/edit", + "pages/form/form", + "pages/info/info" + ], + "window": { + "backgroundColor": "#f8f9fa", + "backgroundTextStyle": "dark", + "navigationBarBackgroundColor": "#ffffff", + "navigationBarTitleText": "动态验证码", + "navigationBarTextStyle":"black" + }, + "tabBar": { + "borderStyle":"black", + "backgroundColor":"#ffffff", + "color":"#999999", + "selectedColor": "#007AFF", + "list": [ + { + "pagePath": "pages/index/index", + "text": "验证码", + "iconPath": "images/indexoff.png", + "selectedIconPath": "images/index.png" + }, + { + "pagePath": "pages/mine/mine", + "text": "我的", + "iconPath": "images/mineoff.png", + "selectedIconPath": "images/mine.png" + } + ] + }, + "sitemapLocation": "sitemap.json", + "style":"v2" +} \ No newline at end of file diff --git a/app.wxss b/app.wxss new file mode 100644 index 0000000..7d5a0a4 --- /dev/null +++ b/app.wxss @@ -0,0 +1,4 @@ +/**app.wxss**/ +page { + background: #f8f9fa; +} \ No newline at end of file diff --git a/images/default-avatar.png b/images/default-avatar.png new file mode 100644 index 0000000..bf59892 Binary files /dev/null and b/images/default-avatar.png differ diff --git a/images/index.png b/images/index.png new file mode 100644 index 0000000..a0d2d8d Binary files /dev/null and b/images/index.png differ diff --git a/images/indexoff.png b/images/indexoff.png new file mode 100644 index 0000000..d4bebc2 Binary files /dev/null and b/images/indexoff.png differ diff --git a/images/mine.png b/images/mine.png new file mode 100644 index 0000000..dad8fbe Binary files /dev/null and b/images/mine.png differ diff --git a/images/mineoff.png b/images/mineoff.png new file mode 100644 index 0000000..aed579a Binary files /dev/null and b/images/mineoff.png differ diff --git a/images/share.png b/images/share.png new file mode 100644 index 0000000..f13c3da Binary files /dev/null and b/images/share.png differ diff --git a/pages/edit/edit.js b/pages/edit/edit.js new file mode 100644 index 0000000..a9824af --- /dev/null +++ b/pages/edit/edit.js @@ -0,0 +1,233 @@ +// pages/edit/edit.js +let util = require('../../utils/util') + +Page({ + /** + * 页面的初始数据 + */ + data: { + issuer: '', + remark: '', + secret: '', + type: '', + counter: 0, + isHotp: false, + isSubmitting: false, + token: [], + token_id: null, + hasLoaded: false + }, + + /** + * 生命周期函数--监听页面加载 + */ + onLoad: function (options) { + if (!options.token_id) { + wx.showToast({ + title: '参数错误', + icon: 'error', + duration: 2000 + }) + setTimeout(() => { + wx.navigateBack({ delta: 1 }) + }, 2000) + return + } + + this.setData({ + token_id: options.token_id + }) + + this.loadTokenData() + }, + + /** + * 加载令牌数据 + */ + loadTokenData: function() { + const self = this + wx.getStorage({ + key: 'tokens', + success: function(res) { + const tokens = res.data + const targetToken = tokens.find(token => String(token.id) === String(self.data.token_id)) + + if (!targetToken) { + wx.showToast({ + title: '令牌不存在', + icon: 'error', + duration: 2000 + }) + setTimeout(() => { + wx.navigateBack({ delta: 1 }) + }, 2000) + return + } + + self.setData({ + tokens: tokens, + issuer: targetToken.issuer || '', + remark: targetToken.remark || '', + secret: targetToken.secret || '', + type: targetToken.type || 'totp', + counter: targetToken.counter || 0, + isHotp: targetToken.type === 'hotp', + hasLoaded: true + }) + }, + fail: function(err) { + wx.showToast({ + title: '加载失败', + icon: 'error', + duration: 2000 + }) + setTimeout(() => { + wx.navigateBack({ delta: 1 }) + }, 2000) + } + }) + }, + + // 验证计数器值 + validateCounter: function(counter) { + const num = parseInt(counter) + if (isNaN(num) || num < 0) { + wx.showToast({ + title: '计数器值必须是非负整数', + icon: 'none', + duration: 2000 + }) + return false + } + return true + }, + + // 修改并提交数据 + keySubmit: function (e) { + const self = this + const values = e.detail.value + + // 防止重复提交 + if (this.data.isSubmitting) { + return + } + + // 检查数据是否已加载 + if (!this.data.hasLoaded) { + wx.showToast({ + title: '数据未加载完成', + icon: 'error', + duration: 2000 + }) + return + } + + // 检查必填字段 + if (!values.issuer || !values.issuer.trim()) { + wx.showToast({ + title: '发行方不能为空', + icon: 'none', + duration: 2000 + }) + return + } + + // 如果是HOTP类型,验证计数器值 + if (this.data.isHotp && values.counter !== undefined) { + if (!this.validateCounter(values.counter)) { + return + } + } + + this.setData({ + isSubmitting: true + }) + + // 创建tokens的副本并更新数据 + const updatedTokens = [...this.data.tokens] + const tokenIndex = updatedTokens.findIndex(token => String(token.id) === String(this.data.token_id)) + + if (tokenIndex === -1) { + wx.showToast({ + title: '令牌不存在', + icon: 'error', + duration: 2000 + }) + this.setData({ + isSubmitting: false + }) + return + } + + // 更新令牌数据 + updatedTokens[tokenIndex] = { + ...updatedTokens[tokenIndex], + issuer: values.issuer.trim(), + remark: (values.remark || '').trim(), + secret: this.data.secret, // 使用已加载的secret,而不是从表单获取 + type: this.data.type + } + + // 如果是HOTP类型,更新计数器值 + if (this.data.isHotp && values.counter !== undefined) { + updatedTokens[tokenIndex].counter = parseInt(values.counter) + } + + wx.setStorage({ + key: 'tokens', + data: updatedTokens, + success: function(res) { + // 更新本地数据 + self.setData({ + tokens: updatedTokens + }) + + // 保存到本地存储 + wx.setStorageSync('tokens', updatedTokens) + + wx.showToast({ + title: '更新成功', + icon: 'success', + duration: 1500 + }) + + setTimeout(() => { + wx.navigateBack({ + delta: 1, + }) + }, 1500) + }, + fail: function(err) { + wx.showToast({ + title: '保存失败', + icon: 'error', + duration: 2000 + }) + }, + complete: function() { + self.setData({ + isSubmitting: false + }) + } + }) + }, + + /** + * 生命周期函数--监听页面卸载 + */ + onUnload: function () { + // 清理数据,防止下次加载时出现数据混淆 + this.setData({ + token: [], + token_id: null, + hasLoaded: false, + isSubmitting: false, + issuer: '', + remark: '', + secret: '', + type: '', + counter: 0, + isHotp: false + }) + } +}) \ No newline at end of file diff --git a/pages/edit/edit.json b/pages/edit/edit.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/pages/edit/edit.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/pages/edit/edit.wxml b/pages/edit/edit.wxml new file mode 100644 index 0000000..6d9be71 --- /dev/null +++ b/pages/edit/edit.wxml @@ -0,0 +1,33 @@ + +
+ + 基本信息 + + Service + + + + Account + + + + + + + HOTP设置 + + 当前计数器值 + + 修改计数器值会影响验证码生成,请谨慎操作 + + + + + + + + + +
\ No newline at end of file diff --git a/pages/edit/edit.wxss b/pages/edit/edit.wxss new file mode 100644 index 0000000..bd1b8de --- /dev/null +++ b/pages/edit/edit.wxss @@ -0,0 +1,104 @@ +/* pages/edit/edit.wxss */ +page { + background: #f8f9fa; +} + +.container { + padding: 20rpx; + box-sizing: border-box; + min-height: 100vh; +} + +.section { + margin-bottom: 30rpx; + background: #ffffff; + border-radius: 12rpx; + padding: 24rpx; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05); +} + +.section-title { + color: #333333; + font-size: 32rpx; + font-weight: 500; + margin-bottom: 24rpx; +} + +.input-box { + margin-top: 20rpx; + background: transparent; + border: none; + padding: 0; +} + +.input-box text { + color: #333333; + font-size: 28rpx; + display: block; + margin-bottom: 12rpx; +} + +.input-box input { + width: 100%; + height: 80rpx; + padding: 0 20rpx; + background: #ffffff; + border: 1rpx solid #e0e0e0; + border-radius: 8rpx; + font-size: 28rpx; + color: #333333; + box-sizing: border-box; +} + +.input-box input:focus { + border-color: #007AFF; +} + +.tip-text { + color: #ff9c10; + font-size: 24rpx; + margin-top: 16rpx; + opacity: 0.8; +} + +.button-group { + padding: 0; + margin-top: 40rpx; + display: flex; + justify-content: space-between; +} + +.add-btn { + margin-top: 0; + flex: 1; +} + +.add-btn button { + width: 100%; + height: 80rpx; + border-radius: 8rpx; + background: #007AFF; + color: white; + font-size: 28rpx; + border: none; +} + +.add-btn button[disabled] { + background: rgba(0, 122, 255, 0.5); + color: rgba(255, 255, 255, 0.7); +} + +.edit-ad { + position: fixed; + bottom: 0; + width: 100%; + padding: 20rpx; + box-sizing: border-box; + background: #f8f9fa; +} + +.edit-ad ad { + margin: 0 auto; + border-radius: 8rpx; + overflow: hidden; +} \ No newline at end of file diff --git a/pages/form/form.js b/pages/form/form.js new file mode 100644 index 0000000..be7e276 --- /dev/null +++ b/pages/form/form.js @@ -0,0 +1,196 @@ +// pages/form/form.js +const { + addToken, + parseURL, + validateToken, + showToast +} = require('../../utils/util'); + +Page({ + /** + * 页面的初始数据 + */ + data: { + type: 'totp', // 默认选择TOTP + isSubmitting: false // 防止重复提交 + }, + + /** + * 生命周期函数--监听页面加载 + */ + onLoad: function (options) { + // 准备初始数据 + const initialData = { + type: options.type || 'totp', + formData: { + issuer: '', + remark: '', + secret: '', + algo: 'SHA1', + digits: '6', + period: '30', + counter: '0' + }, + pageReady: true // 直接设置为ready状态 + }; + + // 如果有扫描结果,预处理数据 + if (options.scan) { + try { + const scanData = decodeURIComponent(options.scan); + const parsedToken = parseURL(scanData); + + if (parsedToken) { + initialData.type = parsedToken.type; + initialData.formData = { + issuer: parsedToken.issuer || '', + remark: parsedToken.remark || '', + secret: parsedToken.secret || '', + algo: parsedToken.algo || 'SHA1', + digits: parsedToken.digits || '6', + period: parsedToken.period || '30', + counter: parsedToken.counter || '0' + }; + + // 立即显示成功提示 + wx.showToast({ + title: '二维码解析成功', + icon: 'success', + duration: 1500 + }); + } + } catch (error) { + console.error('解析扫描数据失败:', error); + wx.showToast({ + title: '无效的二维码数据', + icon: 'none', + duration: 1500 + }); + } + } + + // 一次性设置所有数据 + this.setData(initialData); + }, + + /** + * 处理类型切换 + */ + onTypeChange: function(e) { + this.setData({ + type: e.detail.value + }); + }, + + /** + * 验证并格式化表单数据 + */ + validateAndFormatForm: function(values) { + try { + // 基本字段验证 + if (!values.issuer || !values.issuer.trim()) { + throw new Error('请输入服务名称'); + } + if (!values.remark || !values.remark.trim()) { + throw new Error('请输入账号备注'); + } + if (!values.secret || !values.secret.trim()) { + throw new Error('请输入密钥'); + } + + // 格式化数据 + const tokenData = { + type: values.type, + issuer: values.issuer.trim(), + remark: values.remark.trim(), + secret: values.secret.trim().toUpperCase(), + algo: values.algo, + digits: parseInt(values.digits, 10) + }; + + // 类型特定字段 + if (values.type === 'totp') { + const period = parseInt(values.period, 10); + if (isNaN(period) || period < 15 || period > 300) { + throw new Error('更新周期必须在15-300秒之间'); + } + tokenData.period = period; + } else { + const counter = parseInt(values.counter, 10); + if (isNaN(counter) || counter < 0) { + throw new Error('计数器初始值必须大于等于0'); + } + tokenData.counter = counter; + } + + // 使用工具函数验证完整的令牌数据 + const errors = validateToken(tokenData); + if (errors) { + throw new Error(errors.join('\n')); + } + + return tokenData; + } catch (error) { + showToast(error.message, 'none'); + return null; + } + }, + + /** + * 提交数据 + */ + keySubmit: async function (e) { + // 防止重复提交 + if (this.data.isSubmitting) { + return; + } + + this.setData({ isSubmitting: true }); + + try { + const values = e.detail.value; + const tokenData = this.validateAndFormatForm(values); + + if (!tokenData) { + this.setData({ isSubmitting: false }); + return; + } + + // 添加令牌 + await addToken(tokenData); + + // 显示成功提示 + wx.showToast({ + title: '添加成功', + icon: 'success', + mask: true, + duration: 800 + }); + + // 获取页面事件通道 + const eventChannel = this.getOpenerEventChannel(); + + // 通知上一页面刷新数据 + if (eventChannel && typeof eventChannel.emit === 'function') { + eventChannel.emit('tokenAdded'); + } + + // 立即跳转到首页 + wx.switchTab({ + url: '/pages/index/index' + }); + + } catch (error) { + console.error('添加令牌失败:', error); + wx.showToast({ + title: error.message || '添加失败', + icon: 'none', + duration: 2000 + }); + } finally { + this.setData({ isSubmitting: false }); + } + }, + + // 扫描功能已移至主页面 +}) \ No newline at end of file diff --git a/pages/form/form.json b/pages/form/form.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/pages/form/form.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/pages/form/form.wxml b/pages/form/form.wxml new file mode 100644 index 0000000..f13face --- /dev/null +++ b/pages/form/form.wxml @@ -0,0 +1,82 @@ + + +
+ + + 基本信息 + + Service + + + + Account + + + + KEY + + + + + + + 令牌类型 + + 类型 + + TOTP (基于时间) + HOTP (基于计数器) + + + + + + + 高级设置 + + 算法 + + SHA1 + SHA256 + SHA512 + + + + + 验证码位数 + + 6位 + 7位 + 8位 + + + + + + + 更新周期(秒) + + + + + + + + 初始计数器值 + + + + + + + + + +
+
\ No newline at end of file diff --git a/pages/form/form.wxss b/pages/form/form.wxss new file mode 100644 index 0000000..0606dd4 --- /dev/null +++ b/pages/form/form.wxss @@ -0,0 +1,142 @@ +/* pages/form/form.wxss */ +page { + background: #f8f9fa; + padding-bottom: 40rpx; + transition: opacity 0.2s ease; +} + +.container { + padding: 20rpx 0; + opacity: 1; +} + +/* 页面加载动画 - 优化性能 */ +@keyframes quickFadeIn { + from { opacity: 0.8; } + to { opacity: 1; } +} + +.page-loaded { + animation: quickFadeIn 0.15s ease-out; +} + +/* 区块样式 */ +.section { + margin-bottom: 30rpx; + background: #ffffff; + border-radius: 12rpx; + padding: 24rpx; + margin: 0 20rpx 30rpx; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05); +} + +.section-title { + color: #007AFF; + font-size: 32rpx; + font-weight: 500; + margin-bottom: 24rpx; +} + +.input-box { + margin-top: 20rpx; + background: transparent; + border: none; + padding: 0; +} + +.input-box text { + color: #333333; + font-size: 28rpx; + display: block; + margin-bottom: 12rpx; +} + +.input-box input { + width: 100%; + height: 80rpx; + padding: 0 20rpx; + background: #ffffff; + border: 1rpx solid #e0e0e0; + border-radius: 8rpx; + font-size: 28rpx; + color: #333333; + box-sizing: border-box; +} + +.input-box input:focus { + border-color: #007AFF; +} + +/* 单选框组样式 */ +.input-box radio-group { + display: flex; + flex-wrap: wrap; + gap: 20rpx; + margin-top: 20rpx; +} + +.input-box radio { + margin-right: 20rpx; +} + +.input-box radio .wx-radio-input { + background: #ffffff !important; + border-color: #e0e0e0 !important; + width: 36rpx !important; + height: 36rpx !important; +} + +.input-box radio .wx-radio-input.wx-radio-input-checked { + background: #007AFF !important; + border-color: #007AFF !important; +} + +/* 按钮组样式 */ +.button-group { + padding: 30rpx 20rpx; + display: flex; + justify-content: center; +} + +.submit-button { + width: 100% !important; + height: 80rpx !important; + border-radius: 8rpx !important; + background: #007AFF !important; + color: #ffffff !important; + font-weight: 500 !important; + font-size: 28rpx !important; + border: none !important; + box-shadow: 0 4rpx 16rpx rgba(0, 122, 255, 0.3) !important; +} + +.submit-button[disabled] { + background: #e0e0e0 !important; + color: #999999 !important; + opacity: 0.7 !important; + box-shadow: none !important; +} + +/* 广告样式 */ +.form-ad { + position: fixed; + bottom: 0; + width: 100%; + background: #f8f9fa; + padding: 20rpx; + box-sizing: border-box; +} + +.form-ad ad { + margin: 0 auto; + border-radius: 8rpx; + overflow: hidden; +} + +/* 错误提示样式 */ +.error-text { + color: #ff4444; + font-size: 24rpx; + margin-top: 16rpx; + padding: 0 20rpx; +} \ No newline at end of file diff --git a/pages/index/index.js b/pages/index/index.js new file mode 100644 index 0000000..7de06f9 --- /dev/null +++ b/pages/index/index.js @@ -0,0 +1,721 @@ +// 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; + } +}); \ No newline at end of file diff --git a/pages/index/index.json b/pages/index/index.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/pages/index/index.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/pages/index/index.wxml b/pages/index/index.wxml new file mode 100644 index 0000000..b114535 --- /dev/null +++ b/pages/index/index.wxml @@ -0,0 +1,108 @@ + + + + + + + + {{error}} + + + + + + 🔐 + 暂无验证器 + 点击下方按钮添加新的验证器 + + + + + + + + + {{token.type === 'totp' ? 'TOTP' : 'HOTP'}} + {{token.issuer || '未知服务'}} + + + + + + {{token.code || '------'}} + + + + 🔄 + + + + ✏️ + + + + 🗑️ + + + + + + + + + 剩余 {{remainingSeconds[token.id]}} 秒 + + + + + + + 计数器: {{token.counter || 0}} + 点击刷新按钮生成新的验证码 + + + + + + + + + + + + + + + + + + 添加验证器 + + \ No newline at end of file diff --git a/pages/index/index.wxss b/pages/index/index.wxss new file mode 100644 index 0000000..ff28617 --- /dev/null +++ b/pages/index/index.wxss @@ -0,0 +1,301 @@ +/**index.wxss**/ +.container { + padding: 20rpx; + box-sizing: border-box; + min-height: 100vh; + background: #f8f9fa; +} + +.container.is-loading { + overflow: hidden; +} + +/* 加载状态 */ +.loading-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 999; +} + +.loading { + width: 100%; + text-align: center; + padding: 40rpx 0; + color: #666; +} + +/* 错误提示 */ +.error-message { + width: 100%; + padding: 20rpx; + background-color: #ffebee; + color: #c62828; + text-align: center; + margin-bottom: 20rpx; + border-radius: 8rpx; + font-size: 28rpx; +} + +/* 滚动视图 */ +.scroll-view { + width: 100%; + height: calc(100vh - 40rpx); +} + +/* 空状态 */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 120rpx 40rpx; + text-align: center; +} + +.empty-icon { + font-size: 80rpx; + margin-bottom: 20rpx; +} + +.empty-title { + font-size: 36rpx; + color: #333; + margin-bottom: 12rpx; + font-weight: 500; +} + +.empty-desc { + font-size: 28rpx; + color: #999; +} + +/* 令牌列表 */ +.token-list { + width: 100%; + padding-bottom: 180rpx; +} + +.token-item { + background: #fff; + border-radius: 12rpx; + padding: 24rpx; + margin-bottom: 20rpx; + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); + position: relative; + transition: all 0.3s ease; +} + +.token-item.warn { + background: #fff8f8; +} + +.token-content { + width: 100%; +} + +/* 令牌头部 */ +.token-header { + display: flex; + align-items: center; + margin-bottom: 16rpx; +} + +.token-type { + font-size: 24rpx; + padding: 4rpx 12rpx; + border-radius: 4rpx; + margin-right: 12rpx; +} + +.token-type.totp { + background: #e3f2fd; + color: #1976d2; +} + +.token-type.hotp { + background: #f3e5f5; + color: #7b1fa2; +} + +.token-issuer { + font-size: 32rpx; + color: #333; + font-weight: 500; +} + +/* 令牌主体 */ +.token-body { + width: 100%; +} + +.code-container { + display: flex; + align-items: center; + justify-content: space-between; + margin: 20rpx 0; +} + +.code { + font-size: 48rpx; + font-family: monospace; + color: #007AFF; + font-weight: bold; + letter-spacing: 2rpx; +} + +.code-actions { + display: flex; + align-items: center; +} + +/* HOTP刷新按钮 */ +.refresh-btn { + width: 64rpx; + height: 64rpx; + display: flex; + align-items: center; + justify-content: center; + margin-right: 12rpx; + opacity: 0.6; + transition: all 0.3s ease; +} + +.refresh-btn.refreshing { + opacity: 0.3; + pointer-events: none; +} + +.refresh-icon { + font-size: 36rpx; +} + +.refresh-icon.spin { + animation: spin 1s linear infinite; +} + +/* 编辑按钮 */ +.edit-btn { + width: 64rpx; + height: 64rpx; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.6; +} + +.edit-icon { + font-size: 32rpx; +} + +/* 删除按钮 */ +.delete-btn { + width: 64rpx; + height: 64rpx; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.6; + margin-left: 12rpx; +} + +.delete-icon { + font-size: 32rpx; + color: #ff4d4f; +} + +.delete-btn.button-hover { + opacity: 0.8; +} + +/* TOTP进度条 */ +.totp-info { + margin-top: 16rpx; +} + +.remaining-time { + font-size: 24rpx; + color: #666; + margin-bottom: 8rpx; +} + +.progress-bar { + width: 100%; + height: 4rpx; + background: #e0e0e0; + border-radius: 2rpx; + overflow: hidden; +} + +.progress-bar .progress { + height: 100%; + background: #4caf50; + transition: width 1s linear; +} + +.progress-bar.warn .progress { + background: #f44336; +} + +/* HOTP计数器信息 */ +.counter-info { + margin-top: 16rpx; + font-size: 24rpx; + color: #666; +} + +.counter-info .hint { + margin-left: 12rpx; + color: #999; +} + +/* 添加按钮 */ +.add-button { + position: fixed; + bottom: 40rpx; + left: 50%; + transform: translateX(-50%); + background: #007AFF; + border-radius: 40rpx; + padding: 20rpx 32rpx; + display: flex; + align-items: center; + box-shadow: 0 4rpx 12rpx rgba(0, 122, 255, 0.3); + transition: all 0.3s ease; +} + +.add-icon { + color: white; + font-size: 40rpx; + margin-right: 8rpx; +} + +.add-text { + color: white; + font-size: 28rpx; +} + +/* 广告容器 */ +.ad-container { + width: 100%; + margin-top: 20rpx; + margin-bottom: 20rpx; +} + +/* 动画 */ +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* 按钮点击效果 */ +.button-hover { + opacity: 0.7; + transform: scale(0.98); +} \ No newline at end of file diff --git a/pages/info/info.js b/pages/info/info.js new file mode 100644 index 0000000..81027cd --- /dev/null +++ b/pages/info/info.js @@ -0,0 +1,66 @@ +// pages/info/info.js +Page({ + + /** + * 页面的初始数据 + */ + data: { + + }, + + /** + * 生命周期函数--监听页面加载 + */ + onLoad: function (options) { + + }, + + /** + * 生命周期函数--监听页面初次渲染完成 + */ + onReady: function () { + + }, + + /** + * 生命周期函数--监听页面显示 + */ + onShow: function () { + + }, + + /** + * 生命周期函数--监听页面隐藏 + */ + onHide: function () { + + }, + + /** + * 生命周期函数--监听页面卸载 + */ + onUnload: function () { + + }, + + /** + * 页面相关事件处理函数--监听用户下拉动作 + */ + onPullDownRefresh: function () { + + }, + + /** + * 页面上拉触底事件的处理函数 + */ + onReachBottom: function () { + + }, + + /** + * 用户点击右上角分享 + */ + onShareAppMessage: function () { + + } +}) \ No newline at end of file diff --git a/pages/info/info.json b/pages/info/info.json new file mode 100644 index 0000000..a05af9b --- /dev/null +++ b/pages/info/info.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "介绍" + } \ No newline at end of file diff --git a/pages/info/info.wxml b/pages/info/info.wxml new file mode 100644 index 0000000..d27dc6f --- /dev/null +++ b/pages/info/info.wxml @@ -0,0 +1,20 @@ + + + + 个人主页: + xhupo.com + + + 评论博客: + memos.zeroc.net + + + 开发工具: + VSCode + 腾讯云CodeBuddy + + + 项目源码: + git.xhupo.com/otp + + + \ No newline at end of file diff --git a/pages/info/info.wxss b/pages/info/info.wxss new file mode 100644 index 0000000..e6983a9 --- /dev/null +++ b/pages/info/info.wxss @@ -0,0 +1,31 @@ +.container { + padding: 20px; + display: flex; + justify-content: center; +} + +.info-card { + width: 100%; + max-width: 500px; + background: #fff; + border-radius: 10px; + padding: 20px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); +} + +.info-item { + margin-bottom: 15px; + display: flex; + align-items: center; +} + +.label { + font-weight: bold; + width: 80px; + color: #333; +} + +.link { + color: #07C160; + text-decoration: none; +} \ No newline at end of file diff --git a/pages/mine/mine.js b/pages/mine/mine.js new file mode 100644 index 0000000..7f1b5b8 --- /dev/null +++ b/pages/mine/mine.js @@ -0,0 +1,217 @@ +// pages/mine/mine.js +const { + syncTokens, + getCloudTokens, + showToast, + showLoading, + hideLoading +} = require('../../utils/util'); + +Page({ + /** + * 页面的初始数据 + */ + data: { + loading: false, + uploading: false, + isLoggedIn: false, + userInfo: null, + currentYear: new Date().getFullYear() + }, + + onShow: function() { + // 每次显示页面时检查登录状态 + const app = getApp(); + this.setData({ + isLoggedIn: !!app.globalData.token, + userInfo: app.globalData.userInfo + }); + }, + + /** + * 数据备份 + */ + uploadData: async function() { + // 检查登录状态 + if (!this.data.isLoggedIn) { + showToast('请先登录'); + return; + } + + try { + const tokens = wx.getStorageSync('tokens') || []; + + if (!tokens.length) { + showToast('未发现本地数据'); + return; + } + + // 显示确认对话框 + const confirmed = await new Promise(resolve => { + wx.showModal({ + title: '数据备份', + content: '确定备份本地数据到云端?', + confirmText: '确定', + confirmColor: '#ff9c10', + success: res => resolve(res.confirm) + }); + }); + + if (!confirmed) return; + + this.setData({ uploading: true }); + showLoading('正在备份...'); + + // 同步到云端 + await syncTokens(tokens); + + showToast('数据备份成功', 'success'); + } catch (error) { + console.error('数据备份失败:', error); + showToast('数据备份失败,请重试'); + } finally { + this.setData({ uploading: false }); + hideLoading(); + } + }, + + /** + * 数据恢复 + */ + restoreData: async function() { + // 检查登录状态 + if (!this.data.isLoggedIn) { + showToast('请先登录'); + return; + } + + try { + this.setData({ loading: true }); + showLoading('正在获取云端数据...'); + + // 获取云端数据 + const cloudTokens = await getCloudTokens(); + + if (!cloudTokens || !cloudTokens.length) { + showToast('未发现备份数据'); + return; + } + + // 显示确认对话框 + const confirmed = await new Promise(resolve => { + wx.showModal({ + title: '云端数据恢复', + content: `发现${cloudTokens.length}条数据,确定使用云端数据覆盖本地记录?`, + confirmColor: '#ff9c10', + success: res => resolve(res.confirm) + }); + }); + + if (!confirmed) return; + + // 保存到本地 + await wx.setStorageSync('tokens', cloudTokens); + showToast('数据恢复成功', 'success'); + + } catch (error) { + console.error('数据恢复失败:', error); + showToast('数据恢复失败,请重试'); + } finally { + this.setData({ loading: false }); + hideLoading(); + } + }, + + /** + * 用户授权登录 + */ + goAuth: async function() { + if (this.data.isLoggedIn) return; + + try { + showLoading('登录中...'); + const app = getApp(); + await app.userLogin(); + + // 更新登录状态,使用默认值 + this.setData({ + isLoggedIn: !!app.globalData.token, + userInfo: { + avatarUrl: wx.getStorageSync('userAvatar') || '/images/default-avatar.png', + nickName: wx.getStorageSync('userNickName') || '微信用户' + } + }); + + showToast('登录成功', 'success'); + } catch (error) { + console.error('登录失败:', error); + showToast('登录失败,请重试'); + } finally { + hideLoading(); + } + }, + + /** + * 处理头像选择 + */ + onChooseAvatar: async function(e) { + const { avatarUrl } = e.detail; + try { + showLoading('更新头像中...'); + + // 更新头像 + this.setData({ + 'userInfo.avatarUrl': avatarUrl + }); + + // 保存到本地存储 + wx.setStorageSync('userAvatar', avatarUrl); + + showToast('头像更新成功', 'success'); + } catch (error) { + console.error('头像更新失败:', error); + showToast('头像更新失败,请重试'); + } finally { + hideLoading(); + } + }, + + /** + * 处理昵称修改 + */ + onNicknameChange: function(e) { + const nickName = e.detail.value; + if (!nickName) return; + + try { + // 更新昵称 + this.setData({ + 'userInfo.nickName': nickName + }); + + // 保存到本地存储 + wx.setStorageSync('userNickName', nickName); + } catch (error) { + console.error('昵称更新失败:', error); + showToast('昵称更新失败,请重试'); + } + }, + + /** + * 页面相关事件处理函数--监听用户下拉动作 + */ + onPullDownRefresh: function() { + wx.stopPullDownRefresh(); + }, + + /** + * 转发 + */ + onShareAppMessage: function() { + return { + title: '支持云端备份的动态验证码', + path: '/pages/index/index', + imageUrl: '/images/share.png' + }; + } +}); \ No newline at end of file diff --git a/pages/mine/mine.json b/pages/mine/mine.json new file mode 100644 index 0000000..ca06959 --- /dev/null +++ b/pages/mine/mine.json @@ -0,0 +1,5 @@ +{ + "navigationStyle": "custom", + "enablePullDownRefresh": false, + "component": true +} \ No newline at end of file diff --git a/pages/mine/mine.wxml b/pages/mine/mine.wxml new file mode 100644 index 0000000..fa0df1c --- /dev/null +++ b/pages/mine/mine.wxml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + Copyright © {{currentYear}} + + + \ No newline at end of file diff --git a/pages/mine/mine.wxss b/pages/mine/mine.wxss new file mode 100644 index 0000000..d8e327a --- /dev/null +++ b/pages/mine/mine.wxss @@ -0,0 +1,121 @@ +/* pages/info/info.wxss */ +page { + background: #f5f5f5; +} +.mine{ + z-index: 0; +} +.top-bg{ + z-index: -1; + position: fixed; + top:0; + width: 100%; + height: 550rpx; + background: #f8f9fa; + background-image: -webkit-radial-gradient(top, circle cover, #ffffff 0%, #f8f9fa 80%); + background-image: -moz-radial-gradient(top, circle cover, #ffffff 0%, #f8f9fa 80%); + background-image: -o-radial-gradient(top, circle cover, #ffffff 0%, #f8f9fa 80%); + background-image: radial-gradient(top, circle cover, #ffffff 0%, #f8f9fa 80%); +} +.user-bg { + height: 550rpx; + display: flex; + justify-content: center; + padding-top: 40rpx; + overflow: hidden; + position: relative; + flex-direction: column; + align-items: center; + color: #333; + font-weight: 400; + text-shadow: 0 0 3px rgba(255, 255, 255, 0.3); +} +.userinfo { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 20rpx; +} +.userinfo-avatar { + width: 150rpx; + height: 150rpx; + border-radius: 50%; + overflow: hidden; + margin: 20rpx auto; + justify-content: center; +} + +.userinfo-nickname { + color: #666; + text-align: center; + background-color: transparent; +} + +.avatar-wrapper { + padding: 0; + width: 150rpx !important; + height: 150rpx !important; + border-radius: 50%; + background-color: transparent; + margin: 20rpx auto; +} + +.avatar-wrapper::after { + border: none; +} + +.login-btn { + display: flex; + flex-direction: column; + align-items: center; + background-color: transparent; + padding: 0; + margin: 0; + line-height: normal; +} + +.login-btn::after { + border: none; +} + +input.userinfo-nickname { + width: 200rpx; + border-bottom: 1px solid rgba(102, 102, 102, 0.5); + padding: 5rpx 10rpx; + margin-top: 10rpx; +} + +.user-bg text { + opacity: 0.9; +} + +.user-bg image { + width: 400rpx; + height: 200rpx; +} +.btns{ + z-index: 10; + position: relative; +} +.btn{ + z-index: 10; + margin: 1rem; + border-radius: 100px; + background: #ffffff; + color: #ff9c10; + border: 2px solid #e8e8e8; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} +.btn[disabled]:not([type]){ + background: #ffffff; + color: #ff9c10; +} +.footer{ + position: absolute; + bottom: 20px; + margin-top: 10rem; + font-size: 12px; + text-align: center; + width: 100%; + color: #999; +} \ No newline at end of file diff --git a/project.config.json b/project.config.json new file mode 100644 index 0000000..37a0e35 --- /dev/null +++ b/project.config.json @@ -0,0 +1,25 @@ +{ + "setting": { + "es6": true, + "postcss": true, + "minified": true, + "uglifyFileName": false, + "enhance": true, + "packNpmRelationList": [], + "babelSetting": { + "ignore": [], + "disablePlugins": [], + "outputPath": "" + }, + "useCompilerPlugins": false, + "minifyWXML": true + }, + "compileType": "miniprogram", + "simulatorPluginLibVersion": {}, + "packOptions": { + "ignore": [], + "include": [] + }, + "appid": "wxb6599459668b6b55", + "editorSetting": {} +} \ No newline at end of file diff --git a/project.private.config.json b/project.private.config.json new file mode 100644 index 0000000..493b9cf --- /dev/null +++ b/project.private.config.json @@ -0,0 +1,14 @@ +{ + "libVersion": "3.8.6", + "projectname": "%25E5%258A%25A8%25E6%2580%2581%25E4%25BB%25A4%25E7%2589%258C", + "setting": { + "urlCheck": true, + "coverView": true, + "lazyloadPlaceholderEnable": false, + "skylineRenderEnable": false, + "preloadBackgroundData": false, + "autoAudits": false, + "showShadowRootInWxmlPanel": true, + "compileHotReLoad": true + } +} \ No newline at end of file diff --git a/sitemap.json b/sitemap.json new file mode 100644 index 0000000..27b2b26 --- /dev/null +++ b/sitemap.json @@ -0,0 +1,7 @@ +{ + "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html", + "rules": [{ + "action": "allow", + "page": "*" + }] +} \ No newline at end of file diff --git a/utils/.gitignore b/utils/.gitignore new file mode 100644 index 0000000..c912533 --- /dev/null +++ b/utils/.gitignore @@ -0,0 +1 @@ +__tests__ diff --git a/utils/auth.js b/utils/auth.js new file mode 100644 index 0000000..2c6e6f8 --- /dev/null +++ b/utils/auth.js @@ -0,0 +1,311 @@ +/** + * 认证相关工具函数 + * 包含JWT token管理、自动刷新、请求拦截等功能 + */ + +const config = require('./config'); +const eventManager = require('./eventManager'); + +// Token刷新状态 +let isRefreshing = false; +// 等待token刷新的请求队列 +let refreshSubscribers = []; + +/** + * 订阅Token刷新 + * @param {Function} callback - Token刷新后的回调函数 + */ +const subscribeTokenRefresh = (callback) => { + refreshSubscribers.push(callback); +}; + +/** + * 执行Token刷新后的回调 + * @param {string} token - 新的access token + */ +const onTokenRefreshed = (token) => { + refreshSubscribers.forEach(callback => callback(token)); + refreshSubscribers = []; +}; + +/** + * 解析JWT Token + * @param {string} token - JWT token + * @returns {Object|null} 解析后的payload或null + */ +const parseToken = (token) => { + try { + const [, payload] = token.split('.'); + return JSON.parse(atob(payload)); + } catch (error) { + console.error('Token解析失败:', error); + return null; + } +}; + +/** + * 检查Token是否需要刷新 + * @param {string} token - JWT token + * @returns {boolean} 是否需要刷新 + */ +const shouldRefreshToken = (token) => { + const decoded = parseToken(token); + if (!decoded || !decoded.exp) return true; + + const expiresIn = decoded.exp * 1000 - Date.now(); + return expiresIn < config.JWT_CONFIG.refreshThreshold; +}; + +/** + * 刷新Token + * @returns {Promise} 新的access token或null + */ +const refreshToken = async () => { + const refreshToken = wx.getStorageSync(config.JWT_CONFIG.storage.refresh); + if (!refreshToken) return null; + + try { + const response = await wx.request({ + url: `${config.API_BASE_URL}${config.API_ENDPOINTS.AUTH.REFRESH}`, + method: 'POST', + header: { + [config.JWT_CONFIG.headerKey]: `${config.JWT_CONFIG.tokenPrefix}${refreshToken}` + } + }); + + if (response.statusCode === 200 && response.data.access_token) { + const { access_token, refresh_token } = response.data; + wx.setStorageSync(config.JWT_CONFIG.storage.access, access_token); + if (refresh_token) { + wx.setStorageSync(config.JWT_CONFIG.storage.refresh, refresh_token); + } + return access_token; + } + + throw new Error(response.data?.message || '刷新Token失败'); + } catch (error) { + console.error('刷新Token失败:', error); + logout(); // 刷新失败时登出 + return null; + } +}; + +/** + * 用户登录 + * @param {string} username - 用户名 + * @param {string} password - 密码 + * @returns {Promise} 登录结果,包含success和message字段 + */ +const login = async (username, password) => { + try { + const response = await wx.request({ + url: `${config.API_BASE_URL}${config.API_ENDPOINTS.AUTH.LOGIN}`, + method: 'POST', + data: { username, password } + }); + + if (response.statusCode === 200 && response.data.access_token) { + const { access_token, refresh_token } = response.data; + wx.setStorageSync(config.JWT_CONFIG.storage.access, access_token); + wx.setStorageSync(config.JWT_CONFIG.storage.refresh, refresh_token); + + // 触发登录成功事件 + eventManager.emit('auth:login', parseToken(access_token)); + + return { + success: true, + message: '登录成功' + }; + } + + return { + success: false, + message: response.data?.message || '登录失败' + }; + } catch (error) { + console.error('登录失败:', error); + return { + success: false, + message: '网络错误,请稍后重试' + }; + } +}; + +/** + * 用户登出 + */ +const logout = () => { + // 清除所有认证信息 + wx.removeStorageSync(config.JWT_CONFIG.storage.access); + wx.removeStorageSync(config.JWT_CONFIG.storage.refresh); + + // 触发登出事件 + eventManager.emit('auth:logout'); +}; + +/** + * 检查用户是否已登录 + * @returns {boolean} 是否已登录 + */ +const isLoggedIn = () => { + const token = wx.getStorageSync(config.JWT_CONFIG.storage.access); + if (!token) return false; + + try { + // 解析JWT token(不验证签名) + const [, payload] = token.split('.'); + const { exp } = JSON.parse(atob(payload)); + + // 检查token是否已过期 + return Date.now() < exp * 1000; + } catch (error) { + console.error('Token解析失败:', error); + return false; + } +}; + +/** + * 获取当前用户信息 + * @returns {Object|null} 用户信息或null + */ +const getCurrentUser = () => { + const token = wx.getStorageSync(config.JWT_CONFIG.storage.access); + if (!token) return null; + + try { + // 解析JWT token(不验证签名) + const [, payload] = token.split('.'); + const decoded = JSON.parse(atob(payload)); + + return { + id: decoded.sub, + username: decoded.username, + // 其他用户信息... + }; + } catch (error) { + console.error('获取用户信息失败:', error); + return null; + } +}; + +/** + * 获取访问令牌 + * @param {boolean} autoRefresh - 是否自动刷新 + * @returns {Promise} 访问令牌或null + */ +const getAccessToken = async (autoRefresh = true) => { + const token = wx.getStorageSync(config.JWT_CONFIG.storage.access); + if (!token) return null; + + // 检查是否需要刷新token + if (autoRefresh && shouldRefreshToken(token)) { + if (isRefreshing) { + // 如果正在刷新,返回一个Promise等待刷新完成 + return new Promise(resolve => { + subscribeTokenRefresh(newToken => { + resolve(newToken); + }); + }); + } + + isRefreshing = true; + const newToken = await refreshToken(); + isRefreshing = false; + + if (newToken) { + onTokenRefreshed(newToken); + return newToken; + } + + // 刷新失败,清除token + logout(); + return null; + } + + return token; +}; + +/** + * 创建请求拦截器 + * @returns {Function} 请求拦截器函数 + */ +const createRequestInterceptor = () => { + const originalRequest = wx.request; + + // 重写wx.request方法 + wx.request = function(options) { + const { url, header = {}, ...restOptions } = options; + + // 判断是否需要添加认证头 + const isAuthUrl = url.includes(config.API_BASE_URL) && + !url.includes(config.API_ENDPOINTS.AUTH.LOGIN); + + if (isAuthUrl) { + // 创建一个Promise来处理认证 + return new Promise(async (resolve, reject) => { + try { + const token = await getAccessToken(); + if (!token) { + // 没有token且需要认证,触发未授权事件 + eventManager.emit('auth:unauthorized'); + reject(new Error('未授权,请先登录')); + return; + } + + // 添加认证头 + const authHeader = { + ...header, + [config.JWT_CONFIG.headerKey]: `${config.JWT_CONFIG.tokenPrefix}${token}` + }; + + // 发送请求 + originalRequest({ + ...restOptions, + url, + header: authHeader, + success: resolve, + fail: reject + }); + } catch (error) { + reject(error); + } + }); + } + + // 不需要认证的请求直接发送 + return originalRequest(options); + }; + + return () => { + // 恢复原始请求方法 + wx.request = originalRequest; + }; +}; + +/** + * 初始化认证模块 + */ +const initAuth = () => { + createRequestInterceptor(); + + // 监听网络状态变化 + wx.onNetworkStatusChange(function(res) { + if (res.isConnected && isLoggedIn()) { + // 网络恢复且已登录,尝试刷新token + getAccessToken(); + } + }); + + console.log('认证模块初始化完成'); +}; + +module.exports = { + login, + logout, + isLoggedIn, + getCurrentUser, + getAccessToken, + initAuth, + parseToken, + shouldRefreshToken +}; \ No newline at end of file diff --git a/utils/base32.js b/utils/base32.js new file mode 100644 index 0000000..fe93f96 --- /dev/null +++ b/utils/base32.js @@ -0,0 +1,336 @@ +/** + * Enhanced Base32 implementation compatible with RFC 4648 + * Optimized for WeChat Mini Program environment + */ + +// RFC 4648 standard Base32 alphabet and padding +const DEFAULT_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const PADDING = '='; + +// Pre-computed lookup tables +let ENCODE_TABLE = [...DEFAULT_ALPHABET]; +let DECODE_TABLE = new Map(ENCODE_TABLE.map((char, index) => [char, index])); + +// Valid padding lengths for Base32 +const VALID_PADDING_LENGTHS = new Set([0, 1, 3, 4, 6]); + +/** + * Custom error class for Base32 operations + */ +class Base32Error extends Error { + constructor(message, type = 'GENERAL_ERROR') { + super(message); + this.name = 'Base32Error'; + this.type = type; + } +} + +/** + * String to UTF-8 bytes conversion (WeChat Mini Program compatible) + * @private + */ +function stringToUint8Array(str) { + const arr = []; + for (let i = 0; i < str.length; i++) { + let code = str.charCodeAt(i); + + if (code < 0x80) { + arr.push(code); + } else if (code < 0x800) { + arr.push(0xc0 | (code >> 6)); + arr.push(0x80 | (code & 0x3f)); + } else if (code < 0xd800 || code >= 0xe000) { + arr.push(0xe0 | (code >> 12)); + arr.push(0x80 | ((code >> 6) & 0x3f)); + arr.push(0x80 | (code & 0x3f)); + } else { + // Handle surrogate pairs + i++; + code = 0x10000 + (((code & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff)); + arr.push(0xf0 | (code >> 18)); + arr.push(0x80 | ((code >> 12) & 0x3f)); + arr.push(0x80 | ((code >> 6) & 0x3f)); + arr.push(0x80 | (code & 0x3f)); + } + } + return new Uint8Array(arr); +} + +/** + * UTF-8 bytes to string conversion (WeChat Mini Program compatible) + * @private + */ +function uint8ArrayToString(bytes) { + let result = ''; + for (let i = 0; i < bytes.length; i++) { + const byte = bytes[i]; + if (byte < 0x80) { + result += String.fromCharCode(byte); + } else if (byte < 0xe0) { + result += String.fromCharCode( + ((byte & 0x1f) << 6) | + (bytes[++i] & 0x3f) + ); + } else if (byte < 0xf0) { + result += String.fromCharCode( + ((byte & 0x0f) << 12) | + ((bytes[++i] & 0x3f) << 6) | + (bytes[++i] & 0x3f) + ); + } else { + const codePoint = ( + ((byte & 0x07) << 18) | + ((bytes[++i] & 0x3f) << 12) | + ((bytes[++i] & 0x3f) << 6) | + (bytes[++i] & 0x3f) + ); + result += String.fromCodePoint(codePoint); + } + } + return result; +} + +/** + * Sets a custom alphabet for Base32 encoding/decoding + * @param {string} alphabet - The custom Base32 alphabet (32 unique characters) + * @throws {Base32Error} If the alphabet is invalid + */ +function setCustomAlphabet(alphabet) { + if (typeof alphabet !== 'string' || alphabet.length !== 32 || new Set(alphabet).size !== 32) { + throw new Base32Error( + 'Invalid custom alphabet: must be 32 unique characters', + 'INVALID_ALPHABET' + ); + } + + ENCODE_TABLE = [...alphabet]; + DECODE_TABLE = new Map(ENCODE_TABLE.map((char, index) => [char, index])); +} + +/** + * Resets to the default Base32 alphabet + */ +function resetToDefaultAlphabet() { + ENCODE_TABLE = [...DEFAULT_ALPHABET]; + DECODE_TABLE = new Map(ENCODE_TABLE.map((char, index) => [char, index])); +} + +/** + * Validates padding length and position + * @private + */ +function validatePadding(input) { + const paddingMatch = input.match(/=+$/); + if (!paddingMatch) return true; + + const paddingLength = paddingMatch[0].length; + if (!VALID_PADDING_LENGTHS.has(paddingLength)) { + throw new Base32Error( + `Invalid padding length: ${paddingLength}`, + 'INVALID_PADDING' + ); + } + + if (input.indexOf('=') !== input.length - paddingLength) { + throw new Base32Error( + 'Padding character in invalid position', + 'INVALID_PADDING_POSITION' + ); + } + + return true; +} + +/** + * Encodes a string or Uint8Array to Base32 + * @param {string|Uint8Array} input - The input to encode + * @param {Object} [options] - Encoding options + * @param {boolean} [options.noPadding=false] - Whether to omit padding + * @param {boolean} [options.strict=false] - Whether to enable strict mode + * @param {string} [options.alphabet] - Optional custom Base32 alphabet + * @returns {string} The Base32 encoded string + * @throws {Base32Error} If the input is invalid + */ +function encode(input, options = {}) { + try { + if (options.alphabet) { + setCustomAlphabet(options.alphabet); + } + + const data = typeof input === 'string' + ? stringToUint8Array(input) + : input; + + if (!(data instanceof Uint8Array)) { + throw new Base32Error( + 'Input must be a string or Uint8Array', + 'INVALID_INPUT_TYPE' + ); + } + + let bits = 0; + let value = 0; + let output = ''; + + for (let i = 0; i < data.length; i++) { + value = (value << 8) | data[i]; + bits += 8; + + while (bits >= 5) { + output += ENCODE_TABLE[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + + if (bits > 0) { + output += ENCODE_TABLE[(value << (5 - bits)) & 31]; + } + + if (!options.noPadding) { + const pad = 8 - (output.length % 8); + if (pad !== 8) { + output += PADDING.repeat(pad); + } + } + + return output; + } catch (error) { + if (error instanceof Base32Error) throw error; + throw new Base32Error('Encoding failed: ' + error.message, 'ENCODING_ERROR'); + } finally { + if (options.alphabet) { + resetToDefaultAlphabet(); + } + } +} + +/** + * Decodes a Base32 string to Uint8Array + * @param {string} input - The Base32 string to decode + * @param {Object} [options] - Decoding options + * @param {boolean} [options.strict=false] - Whether to enable strict mode + * @param {boolean} [options.returnString=false] - Whether to return a string instead of Uint8Array + * @param {string} [options.alphabet] - Optional custom Base32 alphabet + * @returns {Uint8Array|string} The decoded data + * @throws {Base32Error} If the input is invalid + */ +function decode(input, options = {}) { + try { + if (options.alphabet) { + setCustomAlphabet(options.alphabet); + } + + if (typeof input !== 'string') { + throw new Base32Error('Input must be a string', 'INVALID_INPUT_TYPE'); + } + + const upperInput = input.toUpperCase(); + + if (options.strict) { + validatePadding(upperInput); + } + + const cleanInput = upperInput.replace(/=+$/, ''); + + if (!/^[A-Z2-7]*$/.test(cleanInput)) { + throw new Base32Error('Invalid Base32 character', 'INVALID_CHARACTER'); + } + + let bits = 0; + let value = 0; + const output = []; + + for (let i = 0; i < cleanInput.length; i++) { + const charValue = DECODE_TABLE.get(cleanInput[i]); + value = (value << 5) | charValue; + bits += 5; + + if (bits >= 8) { + output.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + + const result = new Uint8Array(output); + return options.returnString ? uint8ArrayToString(result) : result; + } catch (error) { + if (error instanceof Base32Error) throw error; + throw new Base32Error('Decoding failed: ' + error.message, 'DECODING_ERROR'); + } finally { + if (options.alphabet) { + resetToDefaultAlphabet(); + } + } +} + +/** + * Validates if a string is a valid Base32 encoding + * @param {string} input - The string to validate + * @param {Object} [options] - Validation options + * @param {boolean} [options.strict=false] - Whether to enable strict mode + * @returns {boolean} True if the string is valid Base32 + */ +function isValid(input, options = {}) { + try { + if (typeof input !== 'string') return false; + const upperInput = input.toUpperCase(); + + if (options.strict) { + validatePadding(upperInput); + } + + const cleanInput = upperInput.replace(/=+$/, ''); + return /^[A-Z2-7]*$/.test(cleanInput); + } catch { + return false; + } +} + +/** + * Encodes a string as Base32 without padding + * @param {string|Uint8Array} input - The input to encode + * @returns {string} The Base32 encoded string without padding + */ +function encodeNoPadding(input) { + return encode(input, { noPadding: true }); +} + +/** + * Normalizes a Base32 string + * @param {string} input - The Base32 string to normalize + * @returns {string} The normalized Base32 string + */ +function normalize(input) { + if (!isValid(input)) { + throw new Base32Error('Invalid Base32 string', 'INVALID_INPUT'); + } + + const base = input.toUpperCase().replace(/=+$/, ''); + const padding = 8 - (base.length % 8); + return padding === 8 ? base : base + PADDING.repeat(padding); +} + +/** + * Calculates the length of encoded Base32 string + * @param {number} inputLength - Length of input in bytes + * @param {Object} [options] - Options + * @param {boolean} [options.noPadding=false] - Whether padding will be omitted + * @returns {number} Length of Base32 encoded string + */ +function encodedLength(inputLength, options = {}) { + const baseLength = Math.ceil(inputLength * 8 / 5); + if (options.noPadding) return baseLength; + return Math.ceil(baseLength / 8) * 8; +} + +module.exports = { + encode, + decode, + encodeNoPadding, + isValid, + normalize, + encodedLength, + Base32Error, + setCustomAlphabet, + resetToDefaultAlphabet +}; \ No newline at end of file diff --git a/utils/base32.test.js b/utils/base32.test.js new file mode 100644 index 0000000..fc9d7d1 --- /dev/null +++ b/utils/base32.test.js @@ -0,0 +1,139 @@ +// Base32 Test Suite +const base32 = require('./base32.js'); + +// Test cases +const tests = [ + // 1. Basic encoding/decoding + { + name: '基础编码/解码测试', + input: 'Hello, World!', + expected: 'JBSWY3DPFQQFO33SNRSCC===' + }, + // 2. Empty string + { + name: '空字符串测试', + input: '', + expected: '' + }, + // 3. Unicode characters + { + name: 'Unicode字符测试', + input: '你好,世界!', + expected: '4S4K3DZNEOZTG5TF' + }, + // 4. Special characters + { + name: '特殊字符测试', + input: '!@#$%^&*()', + expected: 'IVLF4UKEJRGUU===' + }, + // 5. Long string + { + name: '长字符串测试', + input: 'A'.repeat(100), + expected: 'IFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQ======' + } +]; + +// Custom alphabet test +const customAlphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUV'; + +// Run tests +console.log('=== Base32 Test Suite ===\n'); + +// 1. Standard Tests +console.log('1. Standard Tests:'); +tests.forEach(test => { + try { + const encoded = base32.encode(test.input); + const decoded = new TextDecoder().decode(base32.decode(encoded)); + const passed = encoded === test.expected && decoded === test.input; + + console.log(`${test.name}:`); + console.log(` Input: ${test.input}`); + console.log(` Encoded: ${encoded}`); + console.log(` Decoded: ${decoded}`); + console.log(` Result: ${passed ? '✓ 通过' : '✗ 失败'}\n`); + } catch (e) { + console.log(` Error: ${e.message}\n`); + } +}); + +// 2. Custom Alphabet Tests +console.log('2. Custom Alphabet Tests:'); +try { + base32.setCustomAlphabet(customAlphabet); + const input = 'Hello'; + const encoded = base32.encode(input); + const decoded = new TextDecoder().decode(base32.decode(encoded)); + console.log(`Custom alphabet encoding:`); + console.log(` Input: ${input}`); + console.log(` Encoded: ${encoded}`); + console.log(` Decoded: ${decoded}`); + console.log(` Result: ${decoded === input ? '✓ 通过' : '✗ 失败'}\n`); +} catch (e) { + console.log(` Error: ${e.message}\n`); +} + +// 3. Error Handling Tests +console.log('3. Error Handling Tests:'); + +// Invalid input type +try { + base32.encode(null); + console.log('Invalid input type test: ✗ 失败 (应该抛出错误)'); +} catch (e) { + console.log(`Invalid input type test: ✓ 通过 (${e.type})`); +} + +// Invalid Base32 string +try { + base32.decode('!@#$'); + console.log('Invalid Base32 string test: ✗ 失败 (应该抛出错误)'); +} catch (e) { + console.log(`Invalid Base32 string test: ✓ 通过 (${e.type})`); +} + +// Invalid padding +try { + base32.decode('JBSWY3DP=', { strict: true }); + console.log('Invalid padding test: ✗ 失败 (应该抛出错误)'); +} catch (e) { + console.log(`Invalid padding test: ✓ 通过 (${e.type})`); +} + +// 4. Utility Function Tests +console.log('\n4. Utility Function Tests:'); + +// isValid +console.log('isValid tests:'); +[ + 'JBSWY3DP', + 'JBSWY3DP======', + 'invalid!', + '' +].forEach(input => { + console.log(` "${input}": ${base32.isValid(input)}`); +}); + +// normalize +console.log('\nnormalize tests:'); +[ + 'jbswy3dp', + 'JBSWY3DP', + 'JBSWY3DP======' +].forEach(input => { + try { + console.log(` "${input}" -> "${base32.normalize(input)}"`); + } catch (e) { + console.log(` "${input}" Error: ${e.message}`); + } +}); + +// encodedLength +console.log('\nencodedLength tests:'); +[5, 10, 15].forEach(len => { + console.log(` Input length ${len}:`); + console.log(` With padding: ${base32.encodedLength(len)}`); + console.log(` Without padding: ${base32.encodedLength(len, { noPadding: true })}`); +}); \ No newline at end of file diff --git a/utils/cloud.js b/utils/cloud.js new file mode 100644 index 0000000..9956fdc --- /dev/null +++ b/utils/cloud.js @@ -0,0 +1,298 @@ +/** + * 云服务相关工具函数 + */ + +// 导入统一配置 +const config = require('./config'); + +/** + * 检查JWT token是否需要刷新 + * @returns {boolean} + */ +const shouldRefreshToken = () => { + try { + const token = wx.getStorageSync(config.JWT_CONFIG.storage.access); + if (!token) return true; + + // 解析JWT token(不验证签名) + const [, payload] = token.split('.'); + const { exp } = JSON.parse(atob(payload)); + const expirationTime = exp * 1000; // 转换为毫秒 + + // 如果token将在5分钟内过期,则刷新 + return Date.now() + config.JWT_CONFIG.refreshThreshold > expirationTime; + } catch (error) { + console.error('Token解析失败:', error); + return true; + } +}; + +/** + * 刷新JWT token + * @returns {Promise} + */ +const refreshToken = async () => { + try { + const refreshToken = wx.getStorageSync(config.JWT_CONFIG.storage.refresh); + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + const response = await wx.request({ + url: `${config.API_BASE_URL}${config.API_ENDPOINTS.AUTH.REFRESH}`, + method: 'POST', + header: { + 'Authorization': `Bearer ${refreshToken}` + } + }); + + if (response.statusCode === 200 && response.data.access_token) { + wx.setStorageSync(config.JWT_CONFIG.storage.access, response.data.access_token); + if (response.data.refresh_token) { + wx.setStorageSync(config.JWT_CONFIG.storage.refresh, response.data.refresh_token); + } + } else { + throw new Error('Token refresh failed'); + } + } catch (error) { + console.error('Token刷新失败:', error); + // 清除所有token,强制用户重新登录 + wx.removeStorageSync(config.JWT_CONFIG.storage.access); + wx.removeStorageSync(config.JWT_CONFIG.storage.refresh); + throw new Error('认证已过期,请重新登录'); + } +}; + +/** + * 发送HTTP请求的通用函数 + * @param {string} url - API URL + * @param {Object} options - 请求选项 + * @returns {Promise} 响应数据 + */ +const request = async (url, options = {}) => { + // 检查并刷新token + if (shouldRefreshToken()) { + await refreshToken(); + } + + const accessToken = wx.getStorageSync(config.JWT_CONFIG.storage.access); + const defaultOptions = { + method: 'GET', + header: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + } + }; + + const requestOptions = { + ...defaultOptions, + ...options, + url: `${config.API_BASE_URL}${url}` + }; + + // 合并headers + requestOptions.header = { + ...defaultOptions.header, + ...options.header + }; + + try { + const response = await new Promise((resolve, reject) => { + wx.request({ + ...requestOptions, + success: resolve, + fail: reject + }); + }); + + // 处理401错误(token无效) + if (response.statusCode === 401) { + // 尝试刷新token并重试请求 + await refreshToken(); + return request(url, options); // 递归调用,使用新token重试 + } + + if (response.statusCode >= 200 && response.statusCode < 300) { + return response.data; + } + + throw new Error(response.data?.message || '请求失败'); + } catch (error) { + console.error('API请求失败:', { + endpoint: url, + method: requestOptions.method, + status: error.statusCode || 'N/A', + error: error.message + }); + throw error; + } +}; + +/** + * 上传令牌数据到云端 + * @param {Array} tokens - 令牌数据数组 + * @returns {Promise} 云端数据ID + */ +const uploadTokens = async (tokens) => { + if (!tokens || tokens.length === 0) { + throw new Error('没有可上传的数据'); + } + + try { + const response = await request(config.API_ENDPOINTS.OTP.SAVE, { + method: 'POST', + data: { + tokens, + timestamp: Date.now() + } + }); + + if (response.success) { + return response.data.id; + } + + throw new Error(response.message || '上传失败'); + } catch (error) { + console.error('上传令牌数据失败:', error); + throw error; + } +}; + +/** + * 从云端获取最新的令牌数据 + * @returns {Promise} 云端数据对象 + */ +const fetchLatestTokens = async () => { + try { + const response = await request(config.API_ENDPOINTS.OTP.RECOVER, { + method: 'POST', + data: { + timestamp: Date.now() + } + }); + + if (response.success && response.data?.tokens) { + return { + tokens: response.data.tokens, + timestamp: response.data.timestamp, + num: response.data.tokens.length + }; + } + + throw new Error(response.message || '未找到云端数据'); + } catch (error) { + console.error('获取云端数据失败:', error); + throw error; + } +}; + +/** + * 初始化云服务 + * 检查认证状态并尝试恢复会话 + * @returns {Promise} 初始化是否成功 + */ +const initCloud = async () => { + try { + // 检查是否有有效的访问令牌 + const accessToken = wx.getStorageSync(config.JWT_CONFIG.storage.access); + if (!accessToken) { + console.log('未找到访问令牌,需要登录'); + return false; + } + + // 验证令牌有效性 + if (shouldRefreshToken()) { + await refreshToken(); + } + return true; + } catch (error) { + console.warn('云服务初始化失败:', error); + // 清除所有认证信息 + wx.removeStorageSync(config.JWT_CONFIG.storage.access); + wx.removeStorageSync(config.JWT_CONFIG.storage.refresh); + return false; + } +}; + +/** + * 比较本地和云端数据 + * @param {Array} localTokens - 本地令牌数组 + * @param {Array} cloudTokens - 云端令牌数组 + * @returns {Object} 比较结果 + */ +const compareTokens = (localTokens, cloudTokens) => { + const result = { + added: [], + removed: [], + modified: [], + unchanged: [] + }; + + // 创建查找映射 + const localMap = new Map(localTokens.map(t => [t.id, t])); + const cloudMap = new Map(cloudTokens.map(t => [t.id, t])); + + // 查找添加和修改的令牌 + cloudTokens.forEach(cloudToken => { + const localToken = localMap.get(cloudToken.id); + if (!localToken) { + result.added.push(cloudToken); + } else if (JSON.stringify(localToken) !== JSON.stringify(cloudToken)) { + result.modified.push(cloudToken); + } else { + result.unchanged.push(cloudToken); + } + }); + + // 查找删除的令牌 + localTokens.forEach(localToken => { + if (!cloudMap.has(localToken.id)) { + result.removed.push(localToken); + } + }); + + return result; +}; + +/** + * 合并本地和云端数据 + * @param {Array} localTokens - 本地令牌数组 + * @param {Array} cloudTokens - 云端令牌数组 + * @param {Object} options - 合并选项 + * @param {boolean} [options.preferCloud=true] - 冲突时是否优先使用云端数据 + * @returns {Array} 合并后的令牌数组 + */ +const mergeTokens = (localTokens, cloudTokens, options = { preferCloud: true }) => { + const comparison = compareTokens(localTokens, cloudTokens); + const result = [...comparison.unchanged]; + + // 添加新令牌 + result.push(...comparison.added); + + // 处理修改的令牌 + comparison.modified.forEach(cloudToken => { + if (options.preferCloud) { + result.push(cloudToken); + } else { + const localToken = localTokens.find(t => t.id === cloudToken.id); + result.push(localToken); + } + }); + + // 如果不优先使用云端数据,保留本地删除的令牌 + if (!options.preferCloud) { + result.push(...comparison.removed); + } + + return result; +}; + +module.exports = { + uploadTokens, + fetchLatestTokens, + compareTokens, + mergeTokens, + initCloud, + shouldRefreshToken, + refreshToken +}; \ No newline at end of file diff --git a/utils/config.js b/utils/config.js new file mode 100644 index 0000000..08814eb --- /dev/null +++ b/utils/config.js @@ -0,0 +1,42 @@ +/** + * 统一配置中心 + * 合并原 config.js 和 configManager.js 的功能 + */ + +// 基础配置 +const baseConfig = { + // 生产环境配置 + API_BASE_URL: 'https://otpm.zeroc.net', + API_VERSION: 'v1', + + // API端点配置 (统一使用微信登录端点) + API_ENDPOINTS: { + AUTH: { + LOGIN: '/auth/login', // 改回原登录端点 + REFRESH: '/auth/refresh' + }, + OTP: { + SAVE: '/otp/save', + RECOVER: '/otp/recover' + } + }, + + // JWT配置 + JWT_CONFIG: { + storage: { + access: 'jwt_access_token', + refresh: 'jwt_refresh_token' + }, + refreshThreshold: 5 * 60 * 1000, // Token过期前5分钟开始刷新 + headerKey: 'Authorization', + tokenPrefix: 'Bearer ' + } +}; + +// 导出合并后的配置 +module.exports = { + ...baseConfig.API_ENDPOINTS, + ...baseConfig.JWT_CONFIG, + API_BASE_URL: baseConfig.API_BASE_URL, + API_VERSION: baseConfig.API_VERSION +}; \ No newline at end of file diff --git a/utils/crypto.js b/utils/crypto.js new file mode 100644 index 0000000..36b4b1a --- /dev/null +++ b/utils/crypto.js @@ -0,0 +1,165 @@ +/** + * 加密工具函数 + * 为微信小程序环境优化的加密工具集 + */ + +const { decode: base32Decode } = require('./base32.js'); + +/** + * 常量时间比较两个字符串 + * 防止时间侧信道攻击 + * + * @param {string} a - 第一个字符串 + * @param {string} b - 第二个字符串 + * @returns {boolean} 是否相等 + */ +function constantTimeEqual(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') { + return false; + } + + if (a.length !== b.length) { + return false; + } + + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return result === 0; +} + +/** + * 安全的整数解析 + * 带有范围检查 + * + * @param {number|string} value - 要解析的值 + * @param {number} min - 最小值(包含) + * @param {number} max - 最大值(包含) + * @returns {number|null} 解析后的整数,如果无效则返回null + */ +function safeIntegerParse(value, min, max) { + let num; + + if (typeof value === 'number') { + num = value; + } else if (typeof value === 'string') { + num = parseInt(value, 10); + } else { + return null; + } + + if (!Number.isInteger(num)) { + return null; + } + + if (num < min || num > max) { + return null; + } + + return num; +} + +/** + * 生成密码学安全的随机字节 + * + * @param {number} length - 需要的字节数 + * @returns {Uint8Array} 随机字节数组 + */ +function getRandomBytes(length) { + if (!Number.isInteger(length) || length <= 0) { + throw new Error('Length must be a positive integer'); + } + + // 优先使用微信小程序的随机数API + if (typeof wx !== 'undefined' && wx.getRandomValues) { + const array = new Uint8Array(length); + wx.getRandomValues({ + length: length, + success: (res) => { + array.set(new Uint8Array(res.randomValues)); + }, + fail: () => { + // 如果原生API失败,回退到Math.random + for (let i = 0; i < length; i++) { + array[i] = Math.floor(Math.random() * 256); + } + } + }); + return array; + } + + // 回退到Math.random(不够安全,但作为降级方案) + const array = new Uint8Array(length); + for (let i = 0; i < length; i++) { + array[i] = Math.floor(Math.random() * 256); + } + return array; +} + +/** + * 生成指定长度的随机Base32密钥 + * + * @param {number} length - 密钥长度(字节) + * @returns {string} Base32编码的密钥 + */ +function generateSecretKey(length = 20) { + if (!Number.isInteger(length) || length < 16 || length > 64) { + throw new Error('Key length must be between 16 and 64 bytes'); + } + + const bytes = getRandomBytes(length); + return require('./base32.js').encode(bytes); +} + +/** + * 验证Base32密钥的有效性和强度 + * + * @param {string} key - Base32编码的密钥 + * @returns {boolean} 密钥是否有效且足够强 + */ +function validateBase32Secret(key) { + try { + // 检查是否是有效的Base32 + if (!require('./base32.js').isValid(key)) { + return false; + } + + // 解码密钥 + const decoded = base32Decode(key); + + // 检查最小长度(128位/16字节) + if (decoded.length < 16) { + return false; + } + + // 检查是否全为0或1(弱密钥) + let allZeros = true; + let allOnes = true; + + for (const byte of decoded) { + if (byte !== 0) allZeros = false; + if (byte !== 255) allOnes = false; + if (!allZeros && !allOnes) break; + } + + if (allZeros || allOnes) { + return false; + } + + return true; + } catch { + return false; + } +} + +const { deriveKeyPBKDF2 } = require('./hmac.js'); + +module.exports = { + constantTimeEqual, + safeIntegerParse, + getRandomBytes, + generateSecretKey, + validateBase32Secret, + deriveKeyPBKDF2 +}; \ No newline at end of file diff --git a/utils/eventManager.js b/utils/eventManager.js new file mode 100644 index 0000000..e0319da --- /dev/null +++ b/utils/eventManager.js @@ -0,0 +1,24 @@ +// 事件管理器模块 +const eventManager = { + listeners: {}, + on(event, callback) { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(callback); + }, + off(event, callback) { + const callbacks = this.listeners[event]; + if (callbacks) { + this.listeners[event] = callbacks.filter(cb => cb !== callback); + } + }, + emit(event, data) { + const callbacks = this.listeners[event]; + if (callbacks) { + callbacks.forEach(callback => callback(data)); + } + } +}; + +module.exports = eventManager; \ No newline at end of file diff --git a/utils/format.js b/utils/format.js new file mode 100644 index 0000000..7981399 --- /dev/null +++ b/utils/format.js @@ -0,0 +1,131 @@ +/** + * 格式化相关工具函数 + */ + +/** + * 格式化数字为两位数字符串 + * @param {number} n 数字 + * @returns {string} 格式化后的字符串 + */ +const formatNumber = n => { + n = n.toString(); + return n[1] ? n : '0' + n; +}; + +/** + * 格式化时间 + * @param {Date} date 日期对象 + * @returns {string} 格式化后的时间字符串 + */ +const formatTime = date => { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hour = date.getHours(); + const minute = date.getMinutes(); + const second = date.getSeconds(); + + return [year, month, day].map(formatNumber).join('/') + ' ' + [hour, minute, second].map(formatNumber).join(':'); +}; + +/** + * 解析OTP URL格式 + * @param {string} url - OTP URL字符串 + * @returns {Object|null} 解析后的令牌数据对象,如果解析失败返回null + */ +const parseURL = (url) => { + try { + // 基本格式验证 + if (!url.startsWith('otpauth://')) { + return null; + } + + // 解析URL + const urlObj = new URL(url); + const type = urlObj.hostname.toLowerCase(); + const pathParts = urlObj.pathname.substring(1).split(':'); + const issuer = decodeURIComponent(pathParts[0]); + const account = pathParts.length > 1 ? decodeURIComponent(pathParts[1]) : ''; + + // 解析查询参数 + const params = {}; + urlObj.searchParams.forEach((value, key) => { + params[key.toLowerCase()] = decodeURIComponent(value); + }); + + // 构建令牌数据 + const tokenData = { + type: type, + issuer: issuer, + remark: account, + secret: params.secret || '', + algo: (params.algorithm || 'SHA1').toUpperCase(), + digits: parseInt(params.digits || '6', 10) + }; + + // 类型特定参数 + if (type === 'totp') { + tokenData.period = parseInt(params.period || '30', 10); + } else if (type === 'hotp') { + tokenData.counter = parseInt(params.counter || '0', 10); + } + + return tokenData; + } catch (error) { + console.error('[Error] Failed to parse OTP URL', { + error: error.message, + url: url.length > 100 ? url.substring(0, 100) + '...' : url + }); + return null; + } +}; + +/** + * 验证令牌数据的有效性 + * @param {Object} tokenData - 令牌数据对象 + * @returns {string[]|null} 错误信息数组,如果验证通过返回null + */ +const validateToken = (tokenData) => { + const errors = []; + + // 验证必填字段 + if (!tokenData.issuer || !tokenData.issuer.trim()) { + errors.push('服务名称不能为空'); + } + if (!tokenData.secret || !tokenData.secret.trim()) { + errors.push('密钥不能为空'); + } + + // 验证算法 + const validAlgos = ['SHA1', 'SHA256', 'SHA512']; + if (!validAlgos.includes(tokenData.algo)) { + errors.push(`不支持的算法: ${tokenData.algo}`); + } + + // 验证位数 + if (tokenData.digits < 6 || tokenData.digits > 8) { + errors.push('位数必须在6-8之间'); + } + + // 类型特定验证 + if (tokenData.type === 'totp') { + if (tokenData.period < 15 || tokenData.period > 300) { + errors.push('更新周期必须在15-300秒之间'); + } + } else if (tokenData.type === 'hotp') { + if (tokenData.counter < 0) { + errors.push('计数器值不能为负数'); + } + } else { + errors.push('无效的令牌类型'); + } + + return errors.length > 0 ? errors : null; +}; + +module.exports = { + formatTime, + formatNumber, + parseURL, + validateToken +}; \ No newline at end of file diff --git a/utils/hmac.js b/utils/hmac.js new file mode 100644 index 0000000..dbec0aa --- /dev/null +++ b/utils/hmac.js @@ -0,0 +1,290 @@ +/** + * HMAC(Hash-based Message Authentication Code)实现 + * 优先使用微信小程序原生API,如果不可用则使用sjcl库作为备用实现 + * + * 依赖: + * - sjcl: Stanford JavaScript Crypto Library + */ + +import sjcl from './sjcl.min.js'; + +// 兼容 sjcl.codec.bytes +if (!sjcl.codec.bytes) { + sjcl.codec.bytes = { + fromBits: function (arr) { + var out = [], bl = sjcl.bitArray.bitLength(arr), i, tmp; + for (i = 0; i < bl / 8; i++) { + if ((i & 3) === 0) { + tmp = arr[i / 4] || 0; + } + out.push(tmp >>> 24); + tmp <<= 8; + } + return out; + }, + toBits: function (bytes) { + var out = [], i, tmp = 0; + for (i = 0; i < bytes.length; i++) { + tmp = tmp << 8 | bytes[i]; + if ((i & 3) === 3) { + out.push(tmp); + tmp = 0; + } + } + if (i & 3) { + out.push(sjcl.bitArray.partial(8 * (i & 3), tmp)); + } + return out; + } + }; +} + +// 微信小程序原生 createHMAC 接口映射表 +const HASH_ALGORITHMS = { + SHA1: 'sha1', + SHA256: 'sha256', + SHA512: 'sha512' +}; + +/** + * 生成 HMAC-SHA1/SHA256/SHA512 值 + * + * @param {string} algorithm - 哈希算法 ('sha1', 'sha256', 'sha512') + * @param {string|Uint8Array} key - 密钥 + * @param {string|Uint8Array} data - 数据 + * @returns {Promise} HMAC 结果 + * @throws {Error} 如果算法不支持或参数错误 + */ +function hmac(algorithm, key, data) { + return new Promise((resolve, reject) => { + try { + // 增强参数校验 + if (!algorithm || typeof algorithm !== 'string') { + throw new Error('Algorithm must be a non-empty string'); + } + + const normalizedAlgorithm = algorithm.toLowerCase(); + if (!['sha1', 'sha256', 'sha512'].includes(normalizedAlgorithm)) { + throw new Error(`Unsupported algorithm: ${algorithm}. Supported: sha1, sha256, sha512`); + } + + if (!key || (typeof key !== 'string' && !(key instanceof Uint8Array))) { + throw new Error('Key must be a non-empty string or Uint8Array'); + } + + if (!data || (typeof data !== 'string' && !(data instanceof Uint8Array))) { + throw new Error('Data must be a non-empty string or Uint8Array'); + } + + // 优先使用微信小程序原生接口 + if (typeof wx !== 'undefined' && wx.createHMAC) { + try { + const crypto = wx.createHMAC(normalizedAlgorithm); + + // 改进数据格式转换 + const keyStr = typeof key === 'string' ? key : + new TextDecoder().decode(key); + const dataStr = typeof data === 'string' ? data : + new TextDecoder().decode(data); + + crypto.update(keyStr); + crypto.update(dataStr); + + const result = crypto.digest(); + if (!result || result.byteLength === 0) { + throw new Error('HMAC digest returned empty result'); + } + resolve(result); + } catch (wxError) { + console.error('WeChat HMAC API failed:', { + algorithm: normalizedAlgorithm, + error: wxError.message + }); + throw new Error(`WeChat HMAC failed: ${wxError.message}`); + } + } else { + // 使用sjcl库作为备用实现 + const keyBytes = typeof key === 'string' ? new TextEncoder().encode(key) : key; + const dataBytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; + + // 根据算法选择对应的哈希函数 + let hashFunction; + switch (normalizedAlgorithm) { + case 'sha1': + hashFunction = sjcl.hash.sha1; + break; + case 'sha256': + hashFunction = sjcl.hash.sha256; + break; + case 'sha512': + hashFunction = sjcl.hash.sha512; + break; + default: + throw new Error(`Unsupported algorithm: ${algorithm}`); + } + + const backupResult = backupHMAC(keyBytes, dataBytes, hashFunction); + resolve(backupResult); + } + } catch (error) { + console.error('HMAC generation failed:', error); + reject(new Error(`HMAC generation failed: ${error.message}`)); + } + }); +} + +/** + * 备用HMAC实现,使用sjcl库 + * + * @param {Uint8Array} key - 密钥 + * @param {Uint8Array} data - 数据 + * @param {Function} hashFunction - 哈希算法构造函数 + * @returns {Uint8Array} HMAC 结果 + */ +function backupHMAC(key, data, hashFunction) { + try { + // 将key和data转换为sjcl的bitArray格式 + const keyBits = sjcl.codec.bytes.toBits(Array.from(key)); + const dataBits = sjcl.codec.bytes.toBits(Array.from(data)); + + // 创建HMAC对象并更新数据 + const hmac = new sjcl.misc.hmac(keyBits, hashFunction); + hmac.update(dataBits); + + // 获取结果并转换回字节数组 + const result = hmac.digest(); + const byteArray = sjcl.codec.bytes.fromBits(result); + return new Uint8Array(byteArray); + } catch (error) { + console.error('Backup HMAC implementation failed:', error); + throw new Error(`Backup HMAC implementation failed: ${error.message}`); + } +} + +/** + * 生成 HMAC-SHA1 值(向后兼容) + * + * @param {string|Uint8Array} key - 密钥 + * @param {string|Uint8Array} data - 数据 + * @returns {Promise} HMAC 结果 + */ +function sha1(key, data) { + return hmac('sha1', key, data); +} + +/** + * 生成 HMAC-SHA256 值 + * + * @param {string|Uint8Array} key - 密钥 + * @param {string|Uint8Array} data - 数据 + * @returns {Promise} HMAC 结果 + */ +function sha256(key, data) { + return hmac('sha256', key, data); +} + +/** + * 生成 HMAC-SHA512 值 + * + * @param {string|Uint8Array} key - 密钥 + * @param {string|Uint8Array} data - 数据 + * @returns {Promise} HMAC 结果 + */ +function sha512(key, data) { + return hmac('sha512', key, data); +} + +/** + * 使用PBKDF2算法派生密钥 + * + * @param {Uint8Array} password - 原始密码 + * @param {Uint8Array} salt - 盐值 + * @param {number} iterations - 迭代次数(建议至少10000次) + * @param {number} keyLength - 生成的密钥长度(字节) + * @param {string} hashAlgorithm - 哈希算法(如'sha256') + * @returns {Promise} 派生出的密钥 + */ +async function deriveKeyPBKDF2(password, salt, iterations, keyLength, hashAlgorithm = 'sha256') { + // 参数验证 + if (!(password instanceof Uint8Array) || !(salt instanceof Uint8Array)) { + throw new Error('Password and salt must be Uint8Array'); + } + + if (typeof iterations !== 'number' || iterations <= 0) { + throw new Error('Iterations must be a positive number'); + } + + if (typeof keyLength !== 'number' || keyLength <= 0) { + throw new Error('Key length must be a positive number'); + } + + // 优先使用微信小程序原生加密API + if (typeof wx !== 'undefined' && wx.derivePBKDF2) { + return new Promise((resolve, reject) => { + wx.derivePBKDF2({ + password: password.buffer, + salt: salt.buffer, + iterations: iterations, + keySize: keyLength, + hashAlgorithm: hashAlgorithm, + success: (res) => { + resolve(new Uint8Array(res.key)); + }, + fail: (err) => { + console.error('PBKDF2 derivation failed:', { + source: 'WeChat API', + error: err.errMsg || 'Unknown error' + }); + reject(new Error(`PBKDF2 derivation failed: ${err.errMsg}`)); + } + }); + }); + } + + // 使用sjcl库的PBKDF2实现 + try { + // 根据算法选择对应的哈希函数 + let hashFunction; + switch (hashAlgorithm.toLowerCase()) { + case 'sha1': + hashFunction = sjcl.hash.sha1; + break; + case 'sha256': + hashFunction = sjcl.hash.sha256; + break; + case 'sha512': + hashFunction = sjcl.hash.sha512; + break; + default: + throw new Error(`Unsupported hash algorithm: ${hashAlgorithm}`); + } + + // 将密码转换为字符串格式 + const passwordStr = typeof password === 'string' ? password : String.fromCharCode.apply(null, password); + // 将盐值转换为字符串格式 + const saltStr = typeof salt === 'string' ? salt : String.fromCharCode.apply(null, salt); + + // 使用sjcl的PBKDF2实现 + const key = sjcl.misc.pbkdf2(passwordStr, saltStr, iterations, keyLength * 8, hashFunction); + + // 将结果转换为Uint8Array + const keyBuffer = new ArrayBuffer(keyLength); + const keyData = new Uint8Array(keyBuffer); + for (let i = 0; i < keyLength; i++) { + keyData[i] = key[i]; + } + + return keyData; + } catch (error) { + throw new Error(`PBKDF2 derivation failed: ${error.message}`); + } +} + +// 导出函数 +export { + hmac, + sha1, + sha256, + sha512, + deriveKeyPBKDF2 +}; \ No newline at end of file diff --git a/utils/hotp.js b/utils/hotp.js new file mode 100644 index 0000000..1037cbd --- /dev/null +++ b/utils/hotp.js @@ -0,0 +1,213 @@ +const { hmac } = require('./hmac.js'); +const { base32Decode } = require('./base32.js'); +const { constantTimeEqual, safeIntegerParse } = require('./crypto.js'); + +// 支持的哈希算法 +const HASH_ALGOS = { + 'SHA1': 'SHA1', + 'SHA256': 'SHA256', + 'SHA512': 'SHA512' +}; + +// 默认配置 +const DEFAULT_CONFIG = { + algorithm: HASH_ALGOS.SHA1, // 默认使用SHA1 + digits: 6, // 默认6位数字 + window: 1 // 默认验证前后1个计数值 +}; + +/** + * 生成HOTP值 + * + * @param {string} secret - Base32编码的密钥 + * @param {number} counter - 计数器值 + * @param {Object} options - 配置选项 + * @param {string} [options.algorithm='SHA1'] - 使用的哈希算法(SHA1/SHA256/SHA512) + * @param {number} [options.digits=6] - 生成的OTP位数 + * @returns {Promise} 生成的HOTP值 + * @throws {Error} 参数无效时抛出错误 + */ +async function generateHOTP(secret, counter, options = {}) { + // 验证密钥 + if (!secret || typeof secret !== 'string') { + throw new Error('Secret must be a non-empty string'); + } + + // 验证计数器 + if (!Number.isInteger(counter) || counter < 0) { + throw new Error('Counter must be a non-negative integer'); + } + + // 合并配置 + const config = { ...DEFAULT_CONFIG, ...options }; + + // 验证算法 + if (!HASH_ALGOS[config.algorithm]) { + throw new Error(`Unsupported algorithm: ${config.algorithm}`); + } + + // 验证位数 + const digits = safeIntegerParse(config.digits, 6, 8); + if (digits === null) { + throw new Error('Digits must be an integer between 6 and 8'); + } + + try { + // 解码密钥 + const key = base32Decode(secret); + + // 生成8字节的计数器缓冲区 + const counterBuffer = new ArrayBuffer(8); + const counterView = new DataView(counterBuffer); + counterView.setBigInt64(0, BigInt(counter), false); // big-endian + + // 计算HMAC + const hash = await hmac(config.algorithm, key, new Uint8Array(counterBuffer)); + + // 根据RFC 4226获取偏移量 + const offset = hash[hash.length - 1] & 0xf; + + // 生成4字节的动态截断数 + const binary = ((hash[offset] & 0x7f) << 24) | + ((hash[offset + 1] & 0xff) << 16) | + ((hash[offset + 2] & 0xff) << 8) | + (hash[offset + 3] & 0xff); + + // 生成指定位数的OTP + const otp = binary % Math.pow(10, digits); + + // 补齐前导零 + return otp.toString().padStart(digits, '0'); + } catch (error) { + throw new Error(`Failed to generate HOTP: ${error.message}`); + } +} + +/** + * 验证HOTP值 + * + * @param {string} token - 要验证的HOTP值 + * @param {string} secret - Base32编码的密钥 + * @param {number} counter - 当前计数器值 + * @param {Object} options - 配置选项 + * @param {string} [options.algorithm='SHA1'] - 使用的哈希算法(SHA1/SHA256/SHA512) + * @param {number} [options.digits=6] - HOTP位数 + * @param {number} [options.window=1] - 验证窗口大小(前后几个计数值) + * @returns {Promise} 如果验证通过,返回匹配的计数器值;否则返回false + * @throws {Error} 参数无效时抛出错误 + */ +async function verifyHOTP(token, secret, counter, options = {}) { + // 验证token + if (!token || typeof token !== 'string') { + throw new Error('Token must be a non-empty string'); + } + + // 验证计数器 + if (!Number.isInteger(counter) || counter < 0) { + throw new Error('Counter must be a non-negative integer'); + } + + // 合并配置 + const config = { ...DEFAULT_CONFIG, ...options }; + + // 验证窗口大小 + const window = safeIntegerParse(config.window, 0, 10); + if (window === null) { + throw new Error('Window must be an integer between 0 and 10'); + } + + // 验证token长度 + if (token.length !== config.digits) { + return false; + } + + // 验证token是否为纯数字 + if (!/^\d+$/.test(token)) { + return false; + } + + try { + // 检查前后window个计数值 + for (let i = -window; i <= window; i++) { + const checkCounter = counter + i; + if (checkCounter < 0) continue; + + const generatedToken = await generateHOTP(secret, checkCounter, config); + + // 使用常量时间比较 + if (constantTimeEqual(token, generatedToken)) { + return checkCounter; + } + } + + return false; + } catch (error) { + throw new Error(`Failed to verify HOTP: ${error.message}`); + } +} + +/** + * 生成HOTP URI + * 遵循 otpauth:// 规范 + * + * @param {string} secret - Base32编码的密钥 + * @param {string} accountName - 账户名称 + * @param {string} issuer - 发行者名称 + * @param {number} counter - 初始计数器值 + * @param {Object} options - 配置选项 + * @param {string} [options.algorithm='SHA1'] - 使用的哈希算法(SHA1/SHA256/SHA512) + * @param {number} [options.digits=6] - HOTP位数 + * @returns {string} HOTP URI + * @throws {Error} 参数无效时抛出错误 + */ +function generateHOTPUri(secret, accountName, issuer, counter, options = {}) { + if (!secret || typeof secret !== 'string') { + throw new Error('Secret must be a non-empty string'); + } + + if (!accountName || typeof accountName !== 'string') { + throw new Error('Account name must be a non-empty string'); + } + + if (!issuer || typeof issuer !== 'string') { + throw new Error('Issuer must be a non-empty string'); + } + + if (!Number.isInteger(counter) || counter < 0) { + throw new Error('Counter must be a non-negative integer'); + } + + const config = { ...DEFAULT_CONFIG, ...options }; + + // 验证算法 + if (!HASH_ALGOS[config.algorithm]) { + throw new Error(`Unsupported algorithm: ${config.algorithm}`); + } + + // 验证位数 + const digits = safeIntegerParse(config.digits, 6, 8); + if (digits === null) { + throw new Error('Digits must be an integer between 6 and 8'); + } + + // 构建参数 + const params = new URLSearchParams({ + secret: secret, + issuer: issuer, + algorithm: config.algorithm, + digits: config.digits.toString(), + counter: counter.toString() + }); + + // 生成URI + // 注意:需要对issuer和accountName进行URI编码 + const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}`; + return `otpauth://hotp/${label}?${params.toString()}`; +} + +module.exports = { + generateHOTP, + verifyHOTP, + generateHOTPUri, + HASH_ALGOS +}; \ No newline at end of file diff --git a/utils/otp.js b/utils/otp.js new file mode 100644 index 0000000..7ffccfd --- /dev/null +++ b/utils/otp.js @@ -0,0 +1,150 @@ +/** + * OTP (One-Time Password) 工具集 + * 包含TOTP和HOTP的实现,以及相关辅助功能 + */ + +const { generateTOTP: baseTOTP, getRemainingSeconds: baseGetRemainingSeconds } = require('./totp'); +const { generateHOTP } = require('./hotp'); +const { parseURL } = require('./format'); + +/** + * 获取当前时间戳(秒) + * 确保在同一时间窗口内使用相同的时间戳 + * @returns {number} 当前时间戳(秒) + */ +function getCurrentTimestamp() { + return Math.floor(Date.now() / 1000); +} + +/** + * 生成OTP值 + * + * @param {string} type - OTP类型('totp'或'hotp') + * @param {string} secret - Base32编码的密钥 + * @param {Object} options - 配置选项 + * @param {number} [options.counter] - 计数器值(仅HOTP需要) + * @param {number} [options.timestamp] - 用于TOTP的时间戳(秒) + * @param {boolean} [options._forceRefresh] - 是否强制刷新,不使用缓存 + * @returns {Promise} 生成的OTP值 + * @throws {Error} 参数无效时抛出错误 + */ +async function generateOTP(type, secret, options = {}) { + // 处理otpauth URI + if (type === 'otpauth') { + const parsed = parseURL(secret); + if (!parsed) { + throw new Error('Invalid otpauth URI format'); + } + // 使用解析出的类型和参数 + return await generateOTP(parsed.type, parsed.secret, { + ...options, + ...(parsed.type === 'totp' ? { period: parsed.period } : {}), + ...(parsed.type === 'hotp' ? { counter: parsed.counter } : {}), + algorithm: parsed.algo, + digits: parsed.digits + }); + } + + if (type === 'totp') { + const totpOptions = { + ...options, + timestamp: options.timestamp || getCurrentTimestamp(), + _forceRefresh: !!options._forceRefresh + }; + return await baseTOTP(secret, totpOptions); + } else if (type === 'hotp') { + if (options.counter === undefined) { + throw new Error('Counter is required for HOTP'); + } + return await generateHOTP(secret, options.counter, options); + } else { + throw new Error(`Unsupported OTP type: ${type}`); + } +} + +/** + * 验证OTP值 + * + * @param {string} token - 要验证的OTP值 + * @param {string} type - OTP类型('totp'或'hotp') + * @param {string} secret - Base32编码的密钥 + * @param {Object} options - 配置选项 + * @param {number} [options.counter] - 当前计数器值(仅HOTP需要) + * @returns {Promise} 验证结果 + * @throws {Error} 参数无效时抛出错误 + */ +async function verifyOTP(token, type, secret, options = {}) { + // 处理otpauth URI + if (type === 'otpauth') { + const parsed = parseURL(secret); + if (!parsed) { + throw new Error('Invalid otpauth URI format'); + } + // 使用解析出的类型和参数 + return await verifyOTP(token, parsed.type, parsed.secret, { + ...options, + ...(parsed.type === 'totp' ? { period: parsed.period } : {}), + ...(parsed.type === 'hotp' ? { counter: parsed.counter } : {}), + algorithm: parsed.algo, + digits: parsed.digits + }); + } + + if (type === 'totp') { + // 始终使用基础版本TOTP,确保一致性 + const generatedToken = await baseTOTP(secret, { + ...options, + timestamp: options.timestamp || getCurrentTimestamp() + }); + return generatedToken === token; + } else if (type === 'hotp') { + if (options.counter === undefined) { + throw new Error('Counter is required for HOTP'); + } + const generatedToken = await generateHOTP(secret, options.counter, options); + return generatedToken === token; + } else { + throw new Error(`Unsupported OTP type: ${type}`); + } +} + +/** + * 获取TOTP剩余秒数 + * @param {Object} options - 配置选项 + * @param {number} [options.period=30] - TOTP周期(秒) + * @param {number} [options.timestamp] - 指定时间戳(秒) + * @returns {number} 剩余秒数 + */ +function getRemainingSeconds(options = {}) { + // 始终使用基础版本,确保一致性 + // 确保传递正确的参数 + const period = options.period || 30; + const timestamp = options.timestamp || getCurrentTimestamp(); + const remaining = baseGetRemainingSeconds({ + ...options, + period, + timestamp + }); + return remaining; +} + +// 导出统一接口 +module.exports = { + now: async (type, secret, counter) => { + if (type === 'totp') { + // 始终使用基础版本TOTP,确保一致性 + return await baseTOTP(secret, { + timestamp: getCurrentTimestamp(), + _forceRefresh: true + }); + } else if (type === 'hotp') { + return await generateHOTP(secret, counter); + } else { + throw new Error(`Unsupported OTP type: ${type}`); + } + }, + generate: generateOTP, + verify: verifyOTP, + getRemainingSeconds, + getCurrentTimestamp +}; \ No newline at end of file diff --git a/utils/otp.test.js b/utils/otp.test.js new file mode 100644 index 0000000..f6f548e --- /dev/null +++ b/utils/otp.test.js @@ -0,0 +1,127 @@ +/** + * OTP功能测试文件 + * 可以在Node.js环境中运行此文件进行测试 + */ + +const otp = require('./otp.js'); + +/** + * 测试TOTP功能 + */ +async function testTOTP() { + console.log('===== 测试TOTP功能 ====='); + + try { + // 生成随机密钥 + const secret = otp.generateSecret(); + console.log(`生成的密钥: ${secret}`); + + // 生成TOTP + const token = await otp.generateTOTP(secret); + console.log(`当前TOTP: ${token}`); + + // 验证TOTP + const isValid = await otp.verifyTOTP(token, secret); + console.log(`验证结果: ${isValid ? '通过' : '失败'}`); + + // 获取剩余时间 + const remaining = otp.getRemainingSeconds(); + console.log(`剩余有效时间: ${remaining}秒`); + + // 测试不同哈希算法 + console.log('\n不同哈希算法测试:'); + for (const algo of Object.keys(otp.HASH_ALGOS)) { + const algoToken = await otp.generateTOTP(secret, { algorithm: algo }); + console.log(`${algo}: ${algoToken}`); + } + + // 测试不同位数 + console.log('\n不同位数测试:'); + for (const digits of [6, 7, 8]) { + const digitToken = await otp.generateTOTP(secret, { digits }); + console.log(`${digits}位: ${digitToken}`); + } + + // 测试不同时间窗口 + console.log('\n不同时间窗口测试:'); + for (const period of [30, 60, 90]) { + const periodToken = await otp.generateTOTP(secret, { period }); + console.log(`${period}秒: ${periodToken}`); + } + + // 生成URI + const uri = otp.generateTOTPUri(secret, 'test@example.com', 'TestApp'); + console.log(`\nTOTP URI: ${uri}`); + + // 解析URI + const parsedUri = otp.parseOTPUri(uri); + console.log('解析URI结果:', parsedUri); + + // 测试Steam令牌 + const steamToken = await otp.generateSteamToken(secret); + console.log(`\nSteam令牌: ${steamToken}`); + + console.log('\nTOTP测试完成'); + } catch (error) { + console.error('TOTP测试失败:', error); + } +} + +/** + * 测试HOTP功能 + */ +async function testHOTP() { + console.log('\n===== 测试HOTP功能 ====='); + + try { + // 生成随机密钥 + const secret = otp.generateSecret(); + console.log(`生成的密钥: ${secret}`); + + // 测试不同计数器值 + console.log('\n不同计数器值测试:'); + for (let counter = 0; counter < 5; counter++) { + const token = await otp.generateHOTP(secret, counter); + console.log(`计数器 ${counter}: ${token}`); + + // 验证HOTP + const result = await otp.verifyHOTP(token, secret, counter); + console.log(`验证结果: ${result !== false ? '通过' : '失败'}`); + } + + // 测试窗口验证 + console.log('\n窗口验证测试:'); + const token = await otp.generateHOTP(secret, 5); + for (let counter = 3; counter <= 7; counter++) { + const result = await otp.verifyHOTP(token, secret, counter, { window: 2 }); + console.log(`计数器 ${counter} 验证结果: ${result !== false ? `通过,匹配计数器 ${result}` : '失败'}`); + } + + // 生成URI + const uri = otp.generateHOTPUri(secret, 'test@example.com', 'TestApp', 0); + console.log(`\nHOTP URI: ${uri}`); + + // 解析URI + const parsedUri = otp.parseOTPUri(uri); + console.log('解析URI结果:', parsedUri); + + console.log('\nHOTP测试完成'); + } catch (error) { + console.error('HOTP测试失败:', error); + } +} + +/** + * 运行所有测试 + */ +async function runTests() { + console.log('开始OTP功能测试...\n'); + + await testTOTP(); + await testHOTP(); + + console.log('\n所有测试完成'); +} + +// 执行测试 +runTests().catch(console.error); \ No newline at end of file diff --git a/utils/sjcl.min.js b/utils/sjcl.min.js new file mode 100644 index 0000000..637466e --- /dev/null +++ b/utils/sjcl.min.js @@ -0,0 +1,71 @@ +"use strict";var sjcl={cipher:{},hash:{},keyexchange:{},mode:{},misc:{},codec:{},exception:{corrupt:function(a){this.toString=function(){return"CORRUPT: "+this.message};this.message=a},invalid:function(a){this.toString=function(){return"INVALID: "+this.message};this.message=a},bug:function(a){this.toString=function(){return"BUG: "+this.message};this.message=a},notReady:function(a){this.toString=function(){return"NOT READY: "+this.message};this.message=a}}}; +sjcl.cipher.aes=function(a){this.B[0][0][0]||this.H();var b,c,d,e,f=this.B[0][4],g=this.B[1];b=a.length;var h=1;if(4!==b&&6!==b&&8!==b)throw new sjcl.exception.invalid("invalid aes key size");this.b=[d=a.slice(0),e=[]];for(a=b;a<4*b+28;a++){c=d[a-1];if(0===a%b||8===b&&4===a%b)c=f[c>>>24]<<24^f[c>>16&255]<<16^f[c>>8&255]<<8^f[c&255],0===a%b&&(c=c<<8^c>>>24^h<<24,h=h<<1^283*(h>>7));d[a]=d[a-b]^c}for(b=0;a;b++,a--)c=d[b&3?a:a-4],e[b]=4>=a||4>b?c:g[0][f[c>>>24]]^g[1][f[c>>16&255]]^g[2][f[c>>8&255]]^g[3][f[c& +255]]}; +sjcl.cipher.aes.prototype={encrypt:function(a){return aa(this,a,0)},decrypt:function(a){return aa(this,a,1)},B:[[[],[],[],[],[]],[[],[],[],[],[]]],H:function(){var a=this.B[0],b=this.B[1],c=a[4],d=b[4],e,f,g,h=[],k=[],l,m,n,p;for(e=0;0x100>e;e++)k[(h[e]=e<<1^283*(e>>7))^e]=e;for(f=g=0;!c[f];f^=l||1,g=k[g]||1)for(n=g^g<<1^g<<2^g<<3^g<<4,n=n>>8^n&255^99,c[f]=n,d[n]=f,m=h[e=h[l=h[f]]],p=0x1010101*m^0x10001*e^0x101*l^0x1010100*f,m=0x101*h[n]^0x1010100*n,e=0;4>e;e++)a[e][f]=m=m<<24^m>>>8,b[e][n]=p=p<<24^p>>>8;for(e= +0;5>e;e++)a[e]=a[e].slice(0),b[e]=b[e].slice(0)}}; +function aa(a,b,c){if(4!==b.length)throw new sjcl.exception.invalid("invalid aes block size");var d=a.b[c],e=b[0]^d[0],f=b[c?3:1]^d[1],g=b[2]^d[2];b=b[c?1:3]^d[3];var h,k,l,m=d.length/4-2,n,p=4,t=[0,0,0,0];h=a.B[c];a=h[0];var r=h[1],C=h[2],B=h[3],D=h[4];for(n=0;n>>24]^r[f>>16&255]^C[g>>8&255]^B[b&255]^d[p],k=a[f>>>24]^r[g>>16&255]^C[b>>8&255]^B[e&255]^d[p+1],l=a[g>>>24]^r[b>>16&255]^C[e>>8&255]^B[f&255]^d[p+2],b=a[b>>>24]^r[e>>16&255]^C[f>>8&255]^B[g&255]^d[p+3],p+=4,e=h,f=k,g=l;for(n= +0;4>n;n++)t[c?3&-n:n]=D[e>>>24]<<24^D[f>>16&255]<<16^D[g>>8&255]<<8^D[b&255]^d[p++],h=e,e=f,f=g,g=b,b=h;return t} +sjcl.bitArray={bitSlice:function(a,b,c){a=sjcl.bitArray.aa(a.slice(b/32),32-(b&31)).slice(1);return void 0===c?a:sjcl.bitArray.clamp(a,c-b)},extract:function(a,b,c){var d=Math.floor(-b-c&31);return((b+c-1^b)&-32?a[b/32|0]<<32-d^a[b/32+1|0]>>>d:a[b/32|0]>>>d)&(1<>b-1,1));return a},partial:function(a,b,c){return 32===a?b:(c?b|0:b<<32-a)+0x10000000000*a},getPartial:function(a){return Math.round(a/0x10000000000)||32},equal:function(a,b){if(sjcl.bitArray.bitLength(a)!==sjcl.bitArray.bitLength(b))return!1;var c=0,d;for(d=0;d>>b),c=a[e]<<32-b;e=a.length?a[a.length-1]:0;a=sjcl.bitArray.getPartial(e);d.push(sjcl.bitArray.partial(b+a&31,32>>24|c>>>8&0xff00|(c&0xff00)<<8|c<<24;return a}}; +sjcl.codec.utf8String={fromBits:function(a){var b="",c=sjcl.bitArray.bitLength(a),d,e;for(d=0;d>>8>>>8>>>8),e<<=8;return decodeURIComponent(escape(b))},toBits:function(a){a=unescape(encodeURIComponent(a));var b=[],c,d=0;for(c=0;c>>g)>>>e),gm){if(!b)try{return sjcl.codec.base32hex.toBits(a)}catch(p){}throw new sjcl.exception.invalid("this isn't "+n+"!");}h>e?(h-=e,f.push(l^m>>>h),l=m<>>e)>>>26),6>e?(g=a[c]<<6-e,e+=26,c++):(g<<=6,e-=6);for(;d.length&3&&!b;)d+="=";return d},toBits:function(a,b){a=a.replace(/\s|=/g,"");var c=[],d,e=0,f=sjcl.codec.base64.F,g=0,h;b&&(f=f.substr(0,62)+"-_");for(d=0;dh)throw new sjcl.exception.invalid("this isn't base64!");26>>e),g=h<<32-e):(e+=6,g^=h<<32-e)}e&56&&c.push(sjcl.bitArray.partial(e&56,g,1));return c}};sjcl.codec.base64url={fromBits:function(a){return sjcl.codec.base64.fromBits(a,1,1)},toBits:function(a){return sjcl.codec.base64.toBits(a,1)}}; +sjcl.codec.bytes={fromBits:function(a){var b=[],c=sjcl.bitArray.bitLength(a),d,e;for(d=0;d>>24),e<<=8;return b},toBits:function(a){var b=[],c,d=0;for(c=0;cb;c++){e=!0;for(d=2;d*d<=c;d++)if(0===c%d){e= +!1;break}e&&(8>b&&(this.j[b]=a(Math.pow(c,.5))),this.b[b]=a(Math.pow(c,1/3)),b++)}},h:function(a){var b,c,d,e=this.g,f=this.b,g=e[0],h=e[1],k=e[2],l=e[3],m=e[4],n=e[5],p=e[6],t=e[7];for(b=0;64>b;b++)16>b?c=a[b]:(c=a[b+1&15],d=a[b+14&15],c=a[b&15]=(c>>>7^c>>>18^c>>>3^c<<25^c<<14)+(d>>>17^d>>>19^d>>>10^d<<15^d<<13)+a[b&15]+a[b+9&15]|0),c=c+t+(m>>>6^m>>>11^m>>>25^m<<26^m<<21^m<<7)+(p^m&(n^p))+f[b],t=p,p=n,n=m,m=l+c|0,l=k,k=h,h=g,g=c+(h&k^l&(h^k))+(h>>>2^h>>>13^h>>>22^h<<30^h<<19^h<<10)|0;e[0]=e[0]+g| +0;e[1]=e[1]+h|0;e[2]=e[2]+k|0;e[3]=e[3]+l|0;e[4]=e[4]+m|0;e[5]=e[5]+n|0;e[6]=e[6]+p|0;e[7]=e[7]+t|0}};sjcl.hash.sha512=function(a){this.b[0]||this.H();a?(this.g=a.g.slice(0),this.c=a.c.slice(0),this.a=a.a):this.reset()};sjcl.hash.sha512.hash=function(a){return(new sjcl.hash.sha512).update(a).finalize()}; +sjcl.hash.sha512.prototype={blockSize:1024,reset:function(){this.g=this.j.slice(0);this.c=[];this.a=0;return this},update:function(a){"string"===typeof a&&(a=sjcl.codec.utf8String.toBits(a));var b,c=this.c=sjcl.bitArray.concat(this.c,a);b=this.a;a=this.a=b+sjcl.bitArray.bitLength(a);if(0x1fffffffffffffc;d++){f=!0;for(e=2;e*e<=d;e++)if(0===d%e){f=!1;break}f&&(8>c&&(this.j[2*c]=a(Math.pow(d,.5)),this.j[2*c+1]=b(Math.pow(d,.5))<<24|this.ma[c]),this.b[2*c]=a(Math.pow(d,1/3)),this.b[2*c+1]=b(Math.pow(d,1/3))<<24|this.oa[c],c++)}},h:function(a){var b, +c,d=this.g,e=this.b,f=d[0],g=d[1],h=d[2],k=d[3],l=d[4],m=d[5],n=d[6],p=d[7],t=d[8],r=d[9],C=d[10],B=d[11],D=d[12],P=d[13],ea=d[14],Q=d[15],v;if("undefined"!==typeof Uint32Array){v=Array(160);for(var u=0;32>u;u++)v[u]=a[u]}else v=a;var u=f,w=g,G=h,E=k,H=l,F=m,V=n,I=p,y=t,x=r,R=C,J=B,S=D,K=P,W=ea,L=Q;for(a=0;80>a;a++){if(16>a)b=v[2*a],c=v[2*a+1];else{c=v[2*(a-15)];var q=v[2*(a-15)+1];b=(q<<31|c>>>1)^(q<<24|c>>>8)^c>>>7;var z=(c<<31|q>>>1)^(c<<24|q>>>8)^(c<<25|q>>>7);c=v[2*(a-2)];var A=v[2*(a-2)+1], +q=(A<<13|c>>>19)^(c<<3|A>>>29)^c>>>6,A=(c<<13|A>>>19)^(A<<3|c>>>29)^(c<<26|A>>>6),X=v[2*(a-7)],Y=v[2*(a-16)],M=v[2*(a-16)+1];c=z+v[2*(a-7)+1];b=b+X+(c>>>0>>0?1:0);c+=A;b+=q+(c>>>0>>0?1:0);c+=M;b+=Y+(c>>>0>>0?1:0)}v[2*a]=b|=0;v[2*a+1]=c|=0;var X=y&R^~y&S,fa=x&J^~x&K,A=u&G^u&H^G&H,ja=w&E^w&F^E&F,Y=(w<<4|u>>>28)^(u<<30|w>>>2)^(u<<25|w>>>7),M=(u<<4|w>>>28)^(w<<30|u>>>2)^(w<<25|u>>>7),ka=e[2*a],ga=e[2*a+1],q=L+((y<<18|x>>>14)^(y<<14|x>>>18)^(x<<23|y>>>9)),z=W+((x<<18|y>>>14)^(x<<14|y>>>18)^(y<< +23|x>>>9))+(q>>>0>>0?1:0),q=q+fa,z=z+(X+(q>>>0>>0?1:0)),q=q+ga,z=z+(ka+(q>>>0>>0?1:0)),q=q+c|0,z=z+(b+(q>>>0>>0?1:0));c=M+ja;b=Y+A+(c>>>0>>0?1:0);W=S;L=K;S=R;K=J;R=y;J=x;x=I+q|0;y=V+z+(x>>>0>>0?1:0)|0;V=H;I=F;H=G;F=E;G=u;E=w;w=q+c|0;u=z+b+(w>>>0>>0?1:0)|0}g=d[1]=g+w|0;d[0]=f+u+(g>>>0>>0?1:0)|0;k=d[3]=k+E|0;d[2]=h+G+(k>>>0>>0?1:0)|0;m=d[5]=m+F|0;d[4]=l+H+(m>>>0>>0?1:0)|0;p=d[7]=p+I|0;d[6]=n+V+(p>>>0>>0?1:0)|0;r=d[9]=r+x|0;d[8]=t+y+(r>>>0>>0?1:0)|0;B=d[11]=B+J| +0;d[10]=C+R+(B>>>0>>0?1:0)|0;P=d[13]=P+K|0;d[12]=D+S+(P>>>0>>0?1:0)|0;Q=d[15]=Q+L|0;d[14]=ea+W+(Q>>>0>>0?1:0)|0}};sjcl.hash.sha1=function(a){a?(this.g=a.g.slice(0),this.c=a.c.slice(0),this.a=a.a):this.reset()};sjcl.hash.sha1.hash=function(a){return(new sjcl.hash.sha1).update(a).finalize()}; +sjcl.hash.sha1.prototype={blockSize:512,reset:function(){this.g=this.j.slice(0);this.c=[];this.a=0;return this},update:function(a){"string"===typeof a&&(a=sjcl.codec.utf8String.toBits(a));var b,c=this.c=sjcl.bitArray.concat(this.c,a);b=this.a;a=this.a=b+sjcl.bitArray.bitLength(a);if(0x1fffffffffffffc;c++)k[c]=a[c];else k=a;c=h[0];d=h[1];e=h[2];f=h[3];g=h[4];for(a=0;79>=a;a++)16<=a&&(b=k[a-3]^k[a-8]^k[a-14]^k[a-16],k[a]=b<<1|b>>>31),b=19>=a?d&e|~d&f:39>=a?d^e^f:59>=a?d&e|d&f|e&f:79>=a?d^e^f:void 0,b=(c<<5|c>>>27)+b+g+k[a]+this.b[Math.floor(a/20)]|0,g=f,f=e,e=d<<30|d>>>2,d=c,c=b;h[0]=h[0]+c|0;h[1]=h[1]+d|0;h[2]=h[2]+e|0;h[3]=h[3]+f|0;h[4]=h[4]+g|0}}; +sjcl.mode.ccm={name:"ccm",J:[],listenProgress:function(a){sjcl.mode.ccm.J.push(a)},unListenProgress:function(a){a=sjcl.mode.ccm.J.indexOf(a);-1k)throw new sjcl.exception.invalid("ccm: iv must be at least 7 bytes");for(f=2;4>f&&l>>>8*f;f++);f<15-k&&(f=15-k);c=h.clamp(c, +8*(15-f));b=sjcl.mode.ccm.X(a,b,c,d,e,f);g=sjcl.mode.ccm.G(a,g,c,b,e,f);return h.concat(g.data,g.tag)},decrypt:function(a,b,c,d,e){e=e||64;d=d||[];var f=sjcl.bitArray,g=f.bitLength(c)/8,h=f.bitLength(b),k=f.clamp(b,h-e),l=f.bitSlice(b,h-e),h=(h-e)/8;if(7>g)throw new sjcl.exception.invalid("ccm: iv must be at least 7 bytes");for(b=2;4>b&&h>>>8*b;b++);b<15-g&&(b=15-g);c=f.clamp(c,8*(15-b));k=sjcl.mode.ccm.G(a,k,c,l,e,b);a=sjcl.mode.ccm.X(a,k.data,c,d,e,b);if(!f.equal(k.tag,a))throw new sjcl.exception.corrupt("ccm: tag doesn't match"); +return k.data},qa:function(a,b,c,d,e,f){var g=[],h=sjcl.bitArray,k=h.s;d=[h.partial(8,(b.length?64:0)|d-2<<2|f-1)];d=h.concat(d,c);d[3]|=e;d=a.encrypt(d);if(b.length)for(c=h.bitLength(b)/8,65279>=c?g=[h.partial(16,c)]:0xffffffff>=c&&(g=h.concat([h.partial(16,65534)],[c])),g=h.concat(g,b),b=0;be||16m&&(sjcl.mode.ccm.ga(g/ +k),m+=n),c[3]++,e=a.encrypt(c),b[g]^=e[0],b[g+1]^=e[1],b[g+2]^=e[2],b[g+3]^=e[3];return{tag:d,data:h.clamp(b,l)}}}; +sjcl.mode.ocb2={name:"ocb2",encrypt:function(a,b,c,d,e,f){if(128!==sjcl.bitArray.bitLength(c))throw new sjcl.exception.invalid("ocb iv must be 128 bits");var g,h=sjcl.mode.ocb2.U,k=sjcl.bitArray,l=k.s,m=[0,0,0,0];c=h(a.encrypt(c));var n,p=[];d=d||[];e=e||64;for(g=0;g+4e.bitLength(c)&&(h=f(h,d(h)),c=e.concat(c,[-2147483648,0,0,0]));g=f(g,c); +return a.encrypt(f(d(f(h,d(h))),g))},U:function(a){return[a[0]<<1^a[1]>>>31,a[1]<<1^a[2]>>>31,a[2]<<1^a[3]>>>31,a[3]<<1^135*(a[0]>>>31)]}}; +sjcl.mode.gcm={name:"gcm",encrypt:function(a,b,c,d,e){var f=b.slice(0);b=sjcl.bitArray;d=d||[];a=sjcl.mode.gcm.G(!0,a,f,d,c,e||128);return b.concat(a.data,a.tag)},decrypt:function(a,b,c,d,e){var f=b.slice(0),g=sjcl.bitArray,h=g.bitLength(f);e=e||128;d=d||[];e<=h?(b=g.bitSlice(f,h-e),f=g.bitSlice(f,0,h-e)):(b=f,f=[]);a=sjcl.mode.gcm.G(!1,a,f,d,c,e);if(!g.equal(a.tag,b))throw new sjcl.exception.corrupt("gcm: tag doesn't match");return a.data},la:function(a,b){var c,d,e,f,g,h=sjcl.bitArray.s;e=[0,0, +0,0];f=b.slice(0);for(c=0;128>c;c++){(d=0!==(a[Math.floor(c/32)]&1<<31-c%32))&&(e=h(e,f));g=0!==(f[3]&1);for(d=3;0>>1|(f[d-1]&1)<<31;f[0]>>>=1;g&&(f[0]^=-0x1f000000)}return e},u:function(a,b,c){var d,e=c.length;b=b.slice(0);for(d=0;de&&(a=b.hash(a));for(d=0;dd||0>c)throw new sjcl.exception.invalid("invalid params to pbkdf2");"string"===typeof a&&(a=sjcl.codec.utf8String.toBits(a));"string"===typeof b&&(b=sjcl.codec.utf8String.toBits(b));e=e||sjcl.misc.hmac;a=new e(a);var f,g,h,k,l=[],m=sjcl.bitArray;for(k=1;32*l.length<(d||1);k++){e=f=a.encrypt(m.concat(b,[k]));for(g=1;gg;g++)e.push(0x100000000*Math.random()|0);for(g=0;g=1<this.A&&(this.A= +f);this.S++;this.b=sjcl.hash.sha256.hash(this.b.concat(e));this.O=new sjcl.cipher.aes(this.b);for(d=0;4>d&&(this.o[d]=this.o[d]+1|0,!this.o[d]);d++);}for(d=0;d>>1;this.i[g].update([d,this.R++,2,b,f,a.length].concat(a))}break;case "string":void 0===b&&(b=a.length);this.i[g].update([d,this.R++,3,b,f,a.length]);this.i[g].update(a);break;default:k=1}if(k)throw new sjcl.exception.bug("random: addEntropy only supports number, array of numbers or string");this.w[g]+=b;this.l+=b;h===this.C&&(this.isReady()!==this.C&&ca("seeded",Math.max(this.A,this.l)),ca("progress",this.getProgress()))}, +isReady:function(a){a=this.V[void 0!==a?a:this.P];return this.A&&this.A>=a?this.w[0]>this.ca&&(new Date).valueOf()>this.$?this.M|this.L:this.L:this.l>=a?this.M|this.C:this.C},getProgress:function(a){a=this.V[a?a:this.P];return this.A>=a?1:this.l>a?1:this.l/a},startCollectors:function(){if(!this.I){this.f={loadTimeCollector:O(this,this.pa),mouseCollector:O(this,this.ra),keyboardCollector:O(this,this.na),accelerometerCollector:O(this,this.fa),touchCollector:O(this,this.ta)};if(window.addEventListener)window.addEventListener("load", +this.f.loadTimeCollector,!1),window.addEventListener("mousemove",this.f.mouseCollector,!1),window.addEventListener("keypress",this.f.keyboardCollector,!1),window.addEventListener("devicemotion",this.f.accelerometerCollector,!1),window.addEventListener("touchmove",this.f.touchCollector,!1);else if(document.attachEvent)document.attachEvent("onload",this.f.loadTimeCollector),document.attachEvent("onmousemove",this.f.mouseCollector),document.attachEvent("keypress",this.f.keyboardCollector);else throw new sjcl.exception.bug("can't attach event"); +this.I=!0}},stopCollectors:function(){this.I&&(window.removeEventListener?(window.removeEventListener("load",this.f.loadTimeCollector,!1),window.removeEventListener("mousemove",this.f.mouseCollector,!1),window.removeEventListener("keypress",this.f.keyboardCollector,!1),window.removeEventListener("devicemotion",this.f.accelerometerCollector,!1),window.removeEventListener("touchmove",this.f.touchCollector,!1)):document.detachEvent&&(document.detachEvent("onload",this.f.loadTimeCollector),document.detachEvent("onmousemove", +this.f.mouseCollector),document.detachEvent("keypress",this.f.keyboardCollector)),this.I=!1)},addEventListener:function(a,b){this.N[a][this.ha++]=b},removeEventListener:function(a,b){var c,d,e=this.N[a],f=[];for(d in e)e.hasOwnProperty(d)&&e[d]===b&&f.push(d);for(c=0;cb&&(a.o[b]=a.o[b]+1|0,!a.o[b]);b++);return a.O.encrypt(a.o)} +function O(a,b){return function(){b.apply(a,arguments)}}sjcl.random=new sjcl.prng(6); +a:try{var U,da,Z,ha;if(ha="undefined"!==typeof module&&module.exports){var ia;try{ia=require("crypto")}catch(a){ia=null}ha=da=ia}if(ha&&da.randomBytes)U=da.randomBytes(128),U=new Uint32Array((new Uint8Array(U)).buffer),sjcl.random.addEntropy(U,1024,"crypto['randomBytes']");else if("undefined"!==typeof window&&"undefined"!==typeof Uint32Array){Z=new Uint32Array(32);if(window.crypto&&window.crypto.getRandomValues)window.crypto.getRandomValues(Z);else if(window.msCrypto&&window.msCrypto.getRandomValues)window.msCrypto.getRandomValues(Z); +else break a;sjcl.random.addEntropy(Z,1024,"crypto['getRandomValues']")}}catch(a){"undefined"!==typeof window&&window.console&&(console.log("There was an error collecting entropy from the browser:"),console.log(a))} +sjcl.json={defaults:{v:1,iter:1E4,ks:128,ts:64,mode:"ccm",adata:"",cipher:"aes"},ka:function(a,b,c,d){c=c||{};d=d||{};var e=sjcl.json,f=e.m({iv:sjcl.random.randomWords(4,0)},e.defaults),g;e.m(f,c);c=f.adata;"string"===typeof f.salt&&(f.salt=sjcl.codec.base64.toBits(f.salt));"string"===typeof f.iv&&(f.iv=sjcl.codec.base64.toBits(f.iv));if(!sjcl.mode[f.mode]||!sjcl.cipher[f.cipher]||"string"===typeof a&&100>=f.iter||64!==f.ts&&96!==f.ts&&128!==f.ts||128!==f.ks&&192!==f.ks&&0x100!==f.ks||2>f.iv.length|| +4=b.iter||64!==b.ts&&96!==b.ts&&128!==b.ts||128!==b.ks&&192!==b.ks&&0x100!==b.ks||!b.iv||2>b.iv.length||4} 令牌列表 + */ +const getTokens = () => { + return new Promise((resolve, reject) => { + wx.getStorage({ + key: STORAGE_KEY, + success: (res) => resolve(res.data || []), + fail: (err) => { + if (err.errMsg.includes('data not found')) { + resolve([]); + } else { + reject(err); + } + } + }); + }); +}; + +/** + * 保存令牌数组到本地存储 + * @param {Array} tokens - 令牌数组 + * @returns {Promise} 异步操作结果 + */ +const saveTokens = (tokens) => { + return new Promise((resolve, reject) => { + wx.setStorage({ + key: STORAGE_KEY, + data: tokens, + success: () => resolve(), + fail: (err) => reject(err) + }); + }); +}; + +/** + * 添加令牌到本地存储 + * @param {Object} tokenData - 令牌数据对象 + * @returns {Promise} 异步操作结果 + */ +const addToken = async (tokenData) => { + try { + const tokens = await getTokens(); + tokens.push(tokenData); + await saveTokens(tokens); + eventManager.emit('tokensUpdated', tokens); + } catch (error) { + throw error; + } +}; + +/** + * 更新令牌信息 + * @param {string} tokenId - 令牌ID + * @param {Object} updates - 要更新的字段 + * @returns {Promise} 异步操作结果 + */ +const updateToken = async (tokenId, updates) => { + try { + const tokens = await getTokens(); + const index = tokens.findIndex(t => t.id === tokenId); + + if (index === -1) { + throw new Error('令牌不存在'); + } + + tokens[index] = { ...tokens[index], ...updates }; + await saveTokens(tokens); + } catch (error) { + throw error; + } +}; + +/** + * 删除令牌 + * @param {string} tokenId - 令牌ID + * @returns {Promise} 异步操作结果 + */ +const deleteToken = async (tokenId) => { + try { + const tokens = await getTokens(); + const newTokens = tokens.filter(t => t.id !== tokenId); + await saveTokens(newTokens); + } catch (error) { + throw error; + } +}; + +/** + * 备份令牌到云端 + * @returns {Promise} 云端数据ID + */ +const backupToCloud = async () => { + try { + const tokens = await getTokens(); + return await cloud.uploadTokens(tokens); + } catch (error) { + throw error; + } +}; + +/** + * 从云端恢复令牌 + * @param {Object} options - 恢复选项 + * @param {boolean} [options.merge=false] - 是否合并数据而不是覆盖 + * @param {boolean} [options.preferCloud=true] - 合并时是否优先使用云端数据 + * @returns {Promise} 异步操作结果 + */ +const restoreFromCloud = async (options = { merge: false, preferCloud: true }) => { + try { + const cloudData = await cloud.fetchLatestTokens(); + const cloudTokens = cloudData.tokens; + + if (options.merge) { + const localTokens = await getTokens(); + const mergedTokens = cloud.mergeTokens(localTokens, cloudTokens, { + preferCloud: options.preferCloud + }); + await saveTokens(mergedTokens); + } else { + await saveTokens(cloudTokens); + } + } catch (error) { + throw error; + } +}; + +module.exports = { + getTokens, + addToken, + updateToken, + deleteToken, + backupToCloud, + restoreFromCloud +}; \ No newline at end of file diff --git a/utils/totp.js b/utils/totp.js new file mode 100644 index 0000000..2d62fc8 --- /dev/null +++ b/utils/totp.js @@ -0,0 +1,259 @@ +const { hmac } = require('./hmac.js'); +const { decode: base32Decode } = require('./base32.js'); +const { constantTimeEqual, safeIntegerParse } = require('./crypto.js'); + +// 支持的哈希算法 +const HASH_ALGOS = { + 'SHA1': 'SHA1', + 'SHA256': 'SHA256', + 'SHA512': 'SHA512' +}; + +// 默认配置 +const DEFAULT_CONFIG = { + algorithm: HASH_ALGOS.SHA1, // 默认使用SHA1 + period: 30, // 默认30秒时间窗口 + digits: 6, // 默认6位数字 + timestamp: null, // 默认使用当前时间 + window: 1 // 默认验证前后1个时间窗口 +}; + +/** + * 生成TOTP值 + * + * @param {string} secret - Base32编码的密钥 + * @param {Object} options - 配置选项 + * @param {string} [options.algorithm='SHA1'] - 使用的哈希算法(SHA1/SHA256/SHA512) + * @param {number} [options.period=30] - 时间窗口大小(秒) + * @param {number} [options.digits=6] - 生成的OTP位数 + * @param {number} [options.timestamp=null] - 指定时间戳(秒),null表示使用当前时间 + * @returns {Promise} 生成的TOTP值 + * @throws {Error} 参数无效时抛出错误 + */ +async function generateTOTP(secret, options = {}) { + // 验证密钥 + if (!secret || typeof secret !== 'string') { + throw new Error('Secret must be a non-empty string'); + } + + // 合并配置 + const config = { ...DEFAULT_CONFIG, ...options }; + + // 验证算法 + if (!HASH_ALGOS[config.algorithm]) { + throw new Error(`Unsupported algorithm: ${config.algorithm}`); + } + + // 验证时间窗口 + const period = safeIntegerParse(config.period, 1, 300); + if (period === null) { + throw new Error('Period must be an integer between 1 and 300'); + } + + // 验证位数 + const digits = safeIntegerParse(config.digits, 6, 8); + if (digits === null) { + throw new Error('Digits must be an integer between 6 and 8'); + } + + // 获取时间戳 + const timestamp = config.timestamp === null + ? Math.floor(Date.now() / 1000) + : config.timestamp; + + if (!Number.isInteger(timestamp) || timestamp < 0) { + throw new Error('Invalid timestamp'); + } + + try { + // 解码密钥 + const key = base32Decode(secret); + // 计算时间计数器 + const counter = Math.floor(timestamp / period); + // 生成8字节的计数器缓冲区 + const counterBuffer = new ArrayBuffer(8); + const counterView = new DataView(counterBuffer); + counterView.setBigInt64(0, BigInt(counter), false); // big-endian + + // 计算HMAC + const hmacInput = new Uint8Array(counterBuffer); + const hash = await hmac(config.algorithm, key, hmacInput); + + // 根据RFC 6238获取偏移量 + const offset = hash[hash.length - 1] & 0xf; + // 生成4字节的动态截断数 + const binary = ((hash[offset] & 0x7f) << 24) | + ((hash[offset + 1] & 0xff) << 16) | + ((hash[offset + 2] & 0xff) << 8) | + (hash[offset + 3] & 0xff); + // 生成指定位数的OTP + const otp = binary % Math.pow(10, digits); + // 补齐前导零 + return otp.toString().padStart(digits, '0'); + } catch (error) { + throw new Error(`Failed to generate TOTP: ${error.message}`); + } +} + +/** + * 验证TOTP值 + * + * @param {string} token - 要验证的TOTP值 + * @param {string} secret - Base32编码的密钥 + * @param {Object} options - 配置选项 + * @param {string} [options.algorithm='SHA1'] - 使用的哈希算法(SHA1/SHA256/SHA512) + * @param {number} [options.period=30] - 时间窗口大小(秒) + * @param {number} [options.digits=6] - TOTP位数 + * @param {number} [options.timestamp=null] - 指定时间戳(秒),null表示使用当前时间 + * @param {number} [options.window=1] - 验证窗口大小(前后几个时间周期) + * @returns {Promise} 验证是否通过 + * @throws {Error} 参数无效时抛出错误 + */ +async function verifyTOTP(token, secret, options = {}) { + // 验证token + if (!token || typeof token !== 'string') { + throw new Error('Token must be a non-empty string'); + } + + // 合并配置 + const config = { ...DEFAULT_CONFIG, ...options }; + + // 验证窗口大小 + const window = safeIntegerParse(config.window, 0, 10); + if (window === null) { + throw new Error('Window must be an integer between 0 and 10'); + } + + // 验证token长度 + if (token.length !== config.digits) { + return false; + } + + // 验证token是否为纯数字 + if (!/^\d+$/.test(token)) { + return false; + } + + try { + const timestamp = config.timestamp === null + ? Math.floor(Date.now() / 1000) + : config.timestamp; + + // 检查前后window个时间窗口 + for (let i = -window; i <= window; i++) { + const checkTime = timestamp + (i * config.period); + const generatedToken = await generateTOTP(secret, { + ...config, + timestamp: checkTime + }); + + // 使用常量时间比较 + if (constantTimeEqual(token, generatedToken)) { + return true; + } + } + + return false; + } catch (error) { + throw new Error(`Failed to verify TOTP: ${error.message}`); + } +} + +/** + * 获取当前TOTP的剩余有效时间(秒) + * + * @param {Object} options - 配置选项 + * @param {number} [options.period=30] - 时间窗口大小(秒) + * @param {number} [options.timestamp=null] - 指定时间戳(秒),null表示使用当前时间 + * @returns {number} 剩余秒数 + * @throws {Error} 参数无效时抛出错误 + */ +function getRemainingSeconds(options = {}) { + const config = { ...DEFAULT_CONFIG, ...options }; + + // 验证时间窗口 + const period = safeIntegerParse(config.period, 1, 300); + if (period === null) { + throw new Error('Period must be an integer between 1 and 300'); + } + + const timestamp = config.timestamp === null + ? Math.floor(Date.now() / 1000) + : config.timestamp; + + if (!Number.isInteger(timestamp) || timestamp < 0) { + throw new Error('Invalid timestamp'); + } + + // 返回从(period-1)到0的倒计时,而不是从period到1 + return (period - (timestamp % period) - 1 + period) % period; +} + +/** + * 生成TOTP URI + * 遵循 otpauth:// 规范 + * + * @param {string} secret - Base32编码的密钥 + * @param {string} accountName - 账户名称 + * @param {string} issuer - 发行者名称 + * @param {Object} options - 配置选项 + * @param {string} [options.algorithm='SHA1'] - 使用的哈希算法(SHA1/SHA256/SHA512) + * @param {number} [options.period=30] - 时间窗口大小(秒) + * @param {number} [options.digits=6] - TOTP位数 + * @returns {string} TOTP URI + * @throws {Error} 参数无效时抛出错误 + */ +function generateTOTPUri(secret, accountName, issuer, options = {}) { + if (!secret || typeof secret !== 'string') { + throw new Error('Secret must be a non-empty string'); + } + + if (!accountName || typeof accountName !== 'string') { + throw new Error('Account name must be a non-empty string'); + } + + if (!issuer || typeof issuer !== 'string') { + throw new Error('Issuer must be a non-empty string'); + } + + const config = { ...DEFAULT_CONFIG, ...options }; + + // 验证算法 + if (!HASH_ALGOS[config.algorithm]) { + throw new Error(`Unsupported algorithm: ${config.algorithm}`); + } + + // 验证时间窗口 + const period = safeIntegerParse(config.period, 1, 300); + if (period === null) { + throw new Error('Period must be an integer between 1 and 300'); + } + + // 验证位数 + const digits = safeIntegerParse(config.digits, 6, 8); + if (digits === null) { + throw new Error('Digits must be an integer between 6 and 8'); + } + + // 构建参数 + const params = new URLSearchParams({ + secret: secret, + issuer: issuer, + algorithm: config.algorithm, + digits: config.digits.toString(), + period: config.period.toString() + }); + + // 生成URI + // 注意:需要对issuer和accountName进行URI编码 + const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}`; + return `otpauth://totp/${label}?${params.toString()}`; +} + +module.exports = { + generateTOTP, + verifyTOTP, + getRemainingSeconds, + generateTOTPUri, + HASH_ALGOS +}; \ No newline at end of file diff --git a/utils/totp.test.js b/utils/totp.test.js new file mode 100644 index 0000000..505cf31 --- /dev/null +++ b/utils/totp.test.js @@ -0,0 +1,132 @@ +const totp = require('./totp'); + +console.log('=== TOTP Implementation Test Suite ===\n'); + +function runTests() { + // 1. Basic Functionality Tests + console.log('1. Basic Functionality Tests:'); + + const testSecret = 'JBSWY3DPEHPK3PXP'; + const testTime = 1623456789000; // Fixed timestamp for consistent testing + + try { + const code = totp.generate(testTime, testSecret); + console.log('Generate TOTP code:'); + console.log(` Secret: ${testSecret}`); + console.log(` Time: ${new Date(testTime).toISOString()}`); + console.log(` Code: ${code}`); + console.log(` Length: ${code.length} digits`); + console.log(' Result: ✓ (Generated successfully)\n'); + } catch (error) { + console.log(` Result: ✗ (${error.message})\n`); + } + + // 2. Verification Tests + console.log('2. Verification Tests:'); + + const currentCode = totp.now(testSecret); + const verifyResults = [ + totp.verify(currentCode, testSecret), + totp.verify('000000', testSecret), + totp.verify(currentCode, 'WRONGSECRET') + ]; + + console.log('Verify current code:'); + console.log(` Code: ${currentCode}`); + console.log(` Valid code: ${verifyResults[0] ? '✓' : '✗'}`); + console.log(` Invalid code: ${!verifyResults[1] ? '✓' : '✗'}`); + console.log(` Wrong secret: ${!verifyResults[2] ? '✓' : '✗'}\n`); + + // 3. Configuration Tests + console.log('3. Configuration Tests:'); + + const configs = [ + { digits: 8 }, + { interval: 60 }, + { algorithm: 'SHA-256' } + ]; + + configs.forEach(config => { + try { + const code = totp.generate(testTime, testSecret, config); + console.log(`Custom config (${Object.keys(config)[0]}=${Object.values(config)[0]}):`); + console.log(` Code: ${code}`); + console.log(' Result: ✓\n'); + } catch (error) { + console.log(`Custom config (${Object.keys(config)[0]}=${Object.values(config)[0]}):`); + console.log(` Result: ✗ (${error.message})\n`); + } + }); + + // 4. Error Handling Tests + console.log('4. Error Handling Tests:'); + + const errorTests = [ + ['Invalid secret', () => totp.generate(testTime, '')], + ['Invalid digits', () => totp.generate(testTime, testSecret, { digits: 4 })], + ['Invalid interval', () => totp.generate(testTime, testSecret, { interval: -1 })], + ['Invalid algorithm', () => totp.generate(testTime, testSecret, { algorithm: 'INVALID' })] + ]; + + errorTests.forEach(([name, test]) => { + try { + test(); + console.log(`${name}: ✗ (Should have thrown)`); + } catch (error) { + console.log(`${name}: ✓ (${error.type})`); + } + }); + console.log(''); + + // 5. URI Generation Test + console.log('5. URI Generation Test:'); + + try { + const uri = totp.generateUri( + testSecret, + 'test@example.com', + 'TestApp', + { digits: 8, interval: 60 } + ); + console.log('Generated URI:'); + console.log(` ${uri}`); + console.log(' Result: ✓\n'); + } catch (error) { + console.log(` Result: ✗ (${error.message})\n`); + } + + // 6. Time Window Tests + console.log('6. Time Window Tests:'); + + const remaining = totp.timeRemaining(); + console.log(`Time until next code: ${remaining} seconds`); + + // Test window verification + const currentTime = Date.now(); + const pastCode = totp.generate(currentTime - 30000, testSecret); + const futureCode = totp.generate(currentTime + 30000, testSecret); + + console.log('Time window verification:'); + console.log(` Past code valid: ${totp.verify(pastCode, testSecret, { window: 1 }) ? '✓' : '✗'}`); + console.log(` Future code valid: ${totp.verify(futureCode, testSecret, { window: 1 }) ? '✓' : '✗'}`); + console.log(` Outside window invalid: ${!totp.verify(pastCode, testSecret, { window: 0 }) ? '✓' : '✗'}\n`); + + // 7. Security Tests + console.log('7. Security Tests:'); + + // Test timing attack resistance + const start = Date.now(); + for (let i = 0; i < 1000; i++) { + totp.verify('000000', testSecret); + totp.verify('999999', testSecret); + } + const end = Date.now(); + const timeDiff = Math.abs((end - start) / 2000); + + console.log('Timing attack resistance:'); + console.log(` Average verification time: ${timeDiff.toFixed(3)}ms`); + console.log(` Time consistency: ${timeDiff < 1 ? '✓' : '✗'}`); +} + +// Run all tests +runTests(); \ No newline at end of file diff --git a/utils/ui.js b/utils/ui.js new file mode 100644 index 0000000..ef97fee --- /dev/null +++ b/utils/ui.js @@ -0,0 +1,76 @@ +/** + * 显示加载提示 + * @param {string} [title='加载中'] - 提示文本 + * @param {boolean} [mask=true] - 是否显示透明蒙层 + */ +export const showLoading = (title = '加载中', mask = true) => { + wx.showLoading({ + title, + mask + }); +}; + +/** + * 隐藏加载提示 + */ +export const hideLoading = () => { + wx.hideLoading(); +}; + +/** + * 显示Toast提示 + * @param {string} title - 提示内容 + * @param {string} [icon='none'] - 图标类型 + * @param {number} [duration=1500] - 显示时长(毫秒) + */ +export const showToast = (title, icon = 'none', duration = 1500) => { + return new Promise((resolve) => { + wx.showToast({ + title, + icon, + duration, + success: resolve + }); + }); +}; + +/** + * 显示确认对话框 + * @param {string} content - 对话框内容 + * @param {Object} [options] - 配置选项 + * @param {string} [options.title='提示'] - 对话框标题 + * @param {string} [options.confirmText='确定'] - 确认按钮文本 + * @param {string} [options.cancelText='取消'] - 取消按钮文本 + * @param {boolean} [options.showCancel=true] - 是否显示取消按钮 + * @returns {Promise} 用户是否点击了确认 + */ +export const showConfirmModal = (content, options = {}) => { + return new Promise((resolve) => { + wx.showModal({ + title: options.title || '提示', + content, + confirmText: options.confirmText || '确定', + cancelText: options.cancelText || '取消', + showCancel: options.showCancel !== false, + success: (res) => resolve(res.confirm), + fail: () => resolve(false) + }); + }); +}; + +/** + * 显示操作菜单 + * @param {Array} itemList - 菜单项列表 + * @param {string} [itemColor='#000000'] - 菜单项颜色 + * @returns {Promise} 用户选择的索引,取消时为-1 + */ +export const showActionSheet = (itemList, itemColor = '#000000') => { + return new Promise((resolve) => { + wx.showActionSheet({ + itemList, + itemColor, + success: (res) => resolve(res.tapIndex), + fail: () => resolve(-1) + }); + }); +}; \ No newline at end of file diff --git a/utils/util.js b/utils/util.js new file mode 100644 index 0000000..640b23f --- /dev/null +++ b/utils/util.js @@ -0,0 +1,343 @@ +/** + * 统一工具接口 + * 所有外部模块只能通过此文件访问功能 + */ +const otp = require('./otp'); +const storage = require('./storage'); +const format = require('./format'); +const cloud = require('./cloud'); +const ui = require('./ui'); +const base32 = require('./base32'); +const crypto = require('./crypto'); + +const eventManager = require('./eventManager'); + +/** + * 验证token是否在当前时间窗口内 + * @param {Object} token - 令牌对象 + * @param {number} timestamp - 当前时间戳(秒) + * @param {number} period - TOTP周期(秒) + * @returns {boolean} 是否在同一时间窗口 + */ +function verifyTokenWindow(token, timestamp, period) { + if (!token || !token.timestamp) return false; + const currentCounter = Math.floor(timestamp / period); + const tokenCounter = Math.floor(token.timestamp / period); + return currentCounter === tokenCounter; +} + +// ============ OTP相关功能 ============ + +/** + * 生成当前时间的验证码 + * @param {Object} config - 配置对象 + * @param {string} config.type - OTP类型 ('totp' 或 'hotp') + * @param {string} config.secret - Base32编码的密钥 + * @param {string} [config.algorithm='SHA1'] - 使用的哈希算法 + * @param {number} [config.period=30] - TOTP的时间周期(秒) + * @param {number} [config.digits=6] - OTP的位数 + * @param {number} [config.counter] - HOTP的计数器值 + * @param {boolean} [config._forceRefresh=false] - 是否强制刷新 + * @returns {Promise} 生成的验证码 + */ +const generateCode = async (config) => { + try { + const options = { + algorithm: config.algorithm || config.algo || 'SHA1', + digits: Number(config.digits) || 6, + _forceRefresh: !!config._forceRefresh, + timestamp: config.timestamp || otp.getCurrentTimestamp() + }; + + if (config.type === 'otpauth') { + // 智能处理otpauth类型 + if (!config.secret) { + throw new Error('otpauth类型必须提供secret参数'); + } + if (!config.secret.startsWith('otpauth://')) { + // 如果不是otpauth URI格式,自动转换为TOTP处理 + config.type = 'totp'; + } + } + + if (config.type === 'totp') { + options.period = Number(config.period) || 30; + + // 如果提供了token对象且不需要强制刷新,验证时间窗口 + if (config.token && !options._forceRefresh) { + if (verifyTokenWindow(config.token, options.timestamp, options.period)) { + return config.token.code; // 仍在同一窗口,返回缓存的code + } + } + } else if (config.type === 'hotp') { + if (config.counter === undefined) { + throw new Error('HOTP需要计数器值'); + } + options.counter = Number(config.counter); + } else { + throw new Error('不支持的OTP类型'); + } + + return await otp.generate(config.type, config.secret, options); + } catch (error) { + console.error('[Error] Failed to generate code:', error); + throw error; + } +}; + +/** + * 验证OTP码 + * @param {string} token - 要验证的OTP码 + * @param {Object} config - 配置对象(与generateCode相同) + * @returns {Promise} 验证结果 + */ +const verifyCode = async (token, config) => { + try { + const options = { + algorithm: config.algorithm || config.algo || 'SHA1', + digits: Number(config.digits) || 6, + timestamp: otp.getCurrentTimestamp() // 使用统一的时间戳获取方法 + }; + + if (config.type === 'otpauth') { + // otpauth URI由底层otp.verify处理 + } else if (config.type === 'totp') { + options.period = Number(config.period) || 30; + } else if (config.type === 'hotp') { + if (config.counter === undefined) { + throw new Error('HOTP需要计数器值'); + } + options.counter = Number(config.counter); + } else { + throw new Error('不支持的OTP类型'); + } + + return await otp.verify(token, config.type, config.secret, options); + } catch (error) { + console.error('[Error] Failed to verify code:', error); + throw error; + } +}; + +/** + * 获取TOTP剩余秒数 + * @param {number} [period=30] - TOTP周期(秒) + * @returns {number} 剩余秒数 + */ +const getRemainingSeconds = (period = 30) => { + try { + // 使用otp模块的getRemainingSeconds,它已经使用了getCurrentTimestamp + return otp.getRemainingSeconds({ period }); + } catch (error) { + console.error('[Error] Failed to get remaining seconds:', error); + throw error; + } +}; + +// ============ 令牌管理相关功能 ============ + +/** + * 添加新令牌 + * @param {Object} tokenData - 令牌数据 + * @returns {Promise} + */ +const addToken = async (tokenData) => { + // 验证令牌数据 + const errors = format.validateToken(tokenData); + if (errors) { + throw new Error(errors.join('; ')); + } + + // 生成唯一ID + const id = `token_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const token = { + ...tokenData, + id, + createTime: formatTime(new Date()) + }; + + // 验证是否可以生成代码 + try { + await generateCode(token); + } catch (error) { + throw new Error('无效的令牌配置: ' + error.message); + } + + // 保存令牌 + await storage.addToken(token); +}; + +/** + * 从URL添加令牌 + * @param {string} url - OTP URL + * @returns {Promise} + */ +const addTokenFromUrl = async (url) => { + const tokenData = format.parseURL(url); + if (!tokenData) { + throw new Error('无效的OTP URL'); + } + await addToken(tokenData); +}; + +/** + * 获取所有令牌 + * @returns {Promise} + */ +const getTokens = async () => { + return await storage.getTokens(); +}; + +/** + * 更新令牌 + * @param {string} tokenId - 令牌ID + * @param {Object} updates - 更新的字段 + * @returns {Promise} + */ +const updateToken = async (tokenId, updates) => { + await storage.updateToken(tokenId, updates); +}; + +/** + * 删除令牌 + * @param {string} tokenId - 令牌ID + * @returns {Promise} + */ +const deleteToken = async (tokenId) => { + await storage.deleteToken(tokenId); +}; + +// ============ 格式化相关功能 ============ + +/** + * 格式化时间 + * @param {Date} date - 日期对象 + * @returns {string} 格式化后的时间字符串 + */ +const formatTime = (date) => { + return format.formatTime(date); +}; + +/** + * 格式化日期 + * @param {Date} date - 日期对象 + * @returns {string} 格式化后的日期字符串 + */ +const formatDate = (date) => { + return format.formatDate(date); +}; + +// ============ 云同步相关功能 ============ + +/** + * 同步令牌到云端 + * @param {Array} tokens - 令牌列表 + * @returns {Promise} 同步后的令牌列表 + */ +const syncTokens = async (tokens) => { + return await cloud.syncTokens(tokens); +}; + +/** + * 从云端获取令牌 + * @returns {Promise} 云端的令牌列表 + */ +const getCloudTokens = async () => { + return await cloud.getTokens(); +}; + +// ============ UI相关功能 ============ + +/** + * 显示加载提示 + * @param {string} [title='加载中'] - 提示文字 + */ +const showLoading = (title = '加载中') => { + ui.showLoading(title); +}; + +/** + * 隐藏加载提示 + */ +const hideLoading = () => { + ui.hideLoading(); +}; + +/** + * 显示提示信息 + * @param {string} title - 提示文字 + * @param {string} [icon='none'] - 图标类型 + */ +const showToast = (title, icon = 'none') => { + ui.showToast(title, icon); +}; + +// ============ 加密相关功能 ============ + +/** + * Base32编码 + * @param {string|Uint8Array} input - 输入数据 + * @returns {string} Base32编码字符串 + */ +const encodeBase32 = (input) => { + return base32.encode(input); +}; + +/** + * Base32解码 + * @param {string} input - Base32编码字符串 + * @returns {Uint8Array} 解码后的数据 + */ +const decodeBase32 = (input) => { + return base32.decode(input); +}; + +/** + * 生成随机密钥 + * @param {number} [length=20] - 密钥长度(字节) + * @returns {string} Base32编码的随机密钥 + */ +const generateSecret = (length = 20) => { + return crypto.generateSecret(length); +}; + +// 导出所有功能 +// 从format模块重新导出验证和解析函数 +const { validateToken, parseURL } = require('./format'); + +module.exports = { + // OTP相关 + generateCode, + verifyCode, + getRemainingSeconds, + + // 令牌管理 + addToken, + addTokenFromUrl, + getTokens, + updateToken, + deleteToken, + + // 格式化 + validateToken, // 添加验证函数 + parseURL, // 添加解析函数 + formatTime, + formatDate, + + // 云同步 + syncTokens, + getCloudTokens, + + // UI + showLoading, + hideLoading, + showToast, + + // 加密 + encodeBase32, + decodeBase32, + generateSecret, + + // 事件管理 + eventManager +}; \ No newline at end of file