init
This commit is contained in:
commit
2b8870a40e
51 changed files with 5845 additions and 0 deletions
89
app.js
Normal file
89
app.js
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
})
|
38
app.json
Normal file
38
app.json
Normal file
|
@ -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"
|
||||||
|
}
|
4
app.wxss
Normal file
4
app.wxss
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
/**app.wxss**/
|
||||||
|
page {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
BIN
images/default-avatar.png
Normal file
BIN
images/default-avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 599 KiB |
BIN
images/index.png
Normal file
BIN
images/index.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.3 KiB |
BIN
images/indexoff.png
Normal file
BIN
images/indexoff.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.4 KiB |
BIN
images/mine.png
Normal file
BIN
images/mine.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8 KiB |
BIN
images/mineoff.png
Normal file
BIN
images/mineoff.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8 KiB |
BIN
images/share.png
Normal file
BIN
images/share.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.7 KiB |
233
pages/edit/edit.js
Normal file
233
pages/edit/edit.js
Normal file
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
1
pages/edit/edit.json
Normal file
1
pages/edit/edit.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{}
|
33
pages/edit/edit.wxml
Normal file
33
pages/edit/edit.wxml
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
<!--pages/edit/edit.wxml-->
|
||||||
|
<form bindsubmit="keySubmit">
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title">基本信息</view>
|
||||||
|
<view class="input-box">
|
||||||
|
<text>Service</text>
|
||||||
|
<input name="issuer" placeholder="服务名称" placeholder-style="color: #666; font-size: 1rem;" value="{{issuer}}"/>
|
||||||
|
</view>
|
||||||
|
<view class="input-box">
|
||||||
|
<text>Account</text>
|
||||||
|
<input name="remark" placeholder="帐号备注" placeholder-style="color: #666; font-size: 1rem;" value="{{remark}}"/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<block wx:if="{{isHotp}}">
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title">HOTP设置</view>
|
||||||
|
<view class="input-box">
|
||||||
|
<text>当前计数器值</text>
|
||||||
|
<input type="number" name="counter" value="{{counter}}" placeholder-style="color: #666; font-size: 1rem;" />
|
||||||
|
<view class="tip-text">修改计数器值会影响验证码生成,请谨慎操作</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<view class="button-group">
|
||||||
|
<view class="add-btn">
|
||||||
|
<button form-type="submit" disabled="{{isSubmitting}}">
|
||||||
|
{{isSubmitting ? '保存中...' : '保存修改'}}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</form>
|
104
pages/edit/edit.wxss
Normal file
104
pages/edit/edit.wxss
Normal file
|
@ -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;
|
||||||
|
}
|
196
pages/form/form.js
Normal file
196
pages/form/form.js
Normal file
|
@ -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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 扫描功能已移至主页面
|
||||||
|
})
|
1
pages/form/form.json
Normal file
1
pages/form/form.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{}
|
82
pages/form/form.wxml
Normal file
82
pages/form/form.wxml
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
<!--pages/form/form.wxml-->
|
||||||
|
<view class="container {{pageReady ? 'page-loaded' : ''}}">
|
||||||
|
<form bindsubmit="keySubmit">
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title">基本信息</view>
|
||||||
|
<view class="input-box">
|
||||||
|
<text>Service</text>
|
||||||
|
<input name="issuer" placeholder="服务名称" value="{{formData.issuer}}" placeholder-style="color: #666; font-size: 1rem;" />
|
||||||
|
</view>
|
||||||
|
<view class="input-box">
|
||||||
|
<text>Account</text>
|
||||||
|
<input name="remark" placeholder="帐号备注" value="{{formData.remark}}" placeholder-style="color: #666; font-size: 1rem;" />
|
||||||
|
</view>
|
||||||
|
<view class="input-box">
|
||||||
|
<text>KEY</text>
|
||||||
|
<input name="secret" placeholder="Secret" value="{{formData.secret}}" placeholder-style="color: #666; font-size: 1rem;" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 令牌类型 -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title">令牌类型</view>
|
||||||
|
<view class="input-box">
|
||||||
|
<text>类型</text>
|
||||||
|
<radio-group name="type" bindchange="onTypeChange">
|
||||||
|
<radio value="totp" checked="{{type === 'totp'}}">TOTP (基于时间)</radio>
|
||||||
|
<radio value="hotp" checked="{{type === 'hotp'}}">HOTP (基于计数器)</radio>
|
||||||
|
</radio-group>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 高级设置 -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title">高级设置</view>
|
||||||
|
<view class="input-box">
|
||||||
|
<text>算法</text>
|
||||||
|
<radio-group name="algo">
|
||||||
|
<radio value="SHA1" checked="{{!formData.algo || formData.algo === 'SHA1'}}">SHA1</radio>
|
||||||
|
<radio value="SHA256" checked="{{formData.algo === 'SHA256'}}">SHA256</radio>
|
||||||
|
<radio value="SHA512" checked="{{formData.algo === 'SHA512'}}">SHA512</radio>
|
||||||
|
</radio-group>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-box">
|
||||||
|
<text>验证码位数</text>
|
||||||
|
<radio-group name="digits">
|
||||||
|
<radio value="6" checked="{{!formData.digits || formData.digits === '6'}}">6位</radio>
|
||||||
|
<radio value="7" checked="{{formData.digits === '7'}}">7位</radio>
|
||||||
|
<radio value="8" checked="{{formData.digits === '8'}}">8位</radio>
|
||||||
|
</radio-group>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- TOTP特定设置 -->
|
||||||
|
<block wx:if="{{type === 'totp'}}">
|
||||||
|
<view class="input-box">
|
||||||
|
<text>更新周期(秒)</text>
|
||||||
|
<input type="number" name="period" value="{{formData.period || '30'}}" />
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<!-- HOTP特定设置 -->
|
||||||
|
<block wx:if="{{type === 'hotp'}}">
|
||||||
|
<view class="input-box">
|
||||||
|
<text>初始计数器值</text>
|
||||||
|
<input type="number" name="counter" value="{{formData.counter || '0'}}" />
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<view class="button-group">
|
||||||
|
<button
|
||||||
|
form-type="submit"
|
||||||
|
type="primary"
|
||||||
|
disabled="{{isSubmitting}}"
|
||||||
|
class="submit-button">
|
||||||
|
{{isSubmitting ? '添加中...' : '确认添加'}}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</form>
|
||||||
|
</view>
|
142
pages/form/form.wxss
Normal file
142
pages/form/form.wxss
Normal file
|
@ -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;
|
||||||
|
}
|
721
pages/index/index.js
Normal file
721
pages/index/index.js
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
1
pages/index/index.json
Normal file
1
pages/index/index.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{}
|
108
pages/index/index.wxml
Normal file
108
pages/index/index.wxml
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
<view class="container {{loading ? 'is-loading' : ''}}">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view class="loading-container" wx:if="{{loading}}">
|
||||||
|
<view class="loading"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 错误提示 -->
|
||||||
|
<view class="error-message" wx:if="{{error}}">{{error}}</view>
|
||||||
|
|
||||||
|
<!-- 令牌列表 -->
|
||||||
|
<scroll-view
|
||||||
|
class="scroll-view"
|
||||||
|
scroll-y="true"
|
||||||
|
enable-flex="true"
|
||||||
|
catch:touchmove="{{loading ? 'catchTouchMove' : ''}}"
|
||||||
|
>
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view class="empty-state" wx:if="{{!loading && tokens.length === 0}}">
|
||||||
|
<text class="empty-icon">🔐</text>
|
||||||
|
<text class="empty-title">暂无验证器</text>
|
||||||
|
<text class="empty-desc">点击下方按钮添加新的验证器</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 令牌列表 -->
|
||||||
|
<view class="token-list" wx:else>
|
||||||
|
<view
|
||||||
|
class="token-item {{token.type === 'totp' && remainingSeconds[token.id] <= 5 ? 'warn' : ''}}"
|
||||||
|
wx:for="{{tokens}}"
|
||||||
|
wx:key="id"
|
||||||
|
wx:for-item="token"
|
||||||
|
bindtap="handleTokenTap"
|
||||||
|
data-token-id="{{token.id}}"
|
||||||
|
>
|
||||||
|
<view class="token-content">
|
||||||
|
<!-- 令牌头部 -->
|
||||||
|
<view class="token-header">
|
||||||
|
<text class="token-type {{token.type}}">{{token.type === 'totp' ? 'TOTP' : 'HOTP'}}</text>
|
||||||
|
<text class="token-issuer">{{token.issuer || '未知服务'}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 令牌主体 -->
|
||||||
|
<view class="token-body">
|
||||||
|
<view class="code-container">
|
||||||
|
<text class="code">{{token.code || '------'}}</text>
|
||||||
|
<view class="code-actions">
|
||||||
|
<!-- HOTP刷新按钮 -->
|
||||||
|
<view
|
||||||
|
wx:if="{{token.type === 'hotp'}}"
|
||||||
|
class="refresh-btn {{token.refreshing ? 'refreshing' : ''}}"
|
||||||
|
catch:tap="refreshHotpToken"
|
||||||
|
data-token-id="{{token.id}}"
|
||||||
|
hover-class="button-hover"
|
||||||
|
>
|
||||||
|
<text class="refresh-icon {{token.refreshing ? 'spin' : ''}}">🔄</text>
|
||||||
|
</view>
|
||||||
|
<!-- 编辑按钮 -->
|
||||||
|
<view
|
||||||
|
class="edit-btn"
|
||||||
|
catch:tap="editToken"
|
||||||
|
data-token-id="{{token.id}}"
|
||||||
|
hover-class="button-hover"
|
||||||
|
>
|
||||||
|
<text class="edit-icon">✏️</text>
|
||||||
|
</view>
|
||||||
|
<!-- 删除按钮 -->
|
||||||
|
<view
|
||||||
|
class="delete-btn"
|
||||||
|
catch:tap="deleteToken"
|
||||||
|
data-id="{{token.id}}"
|
||||||
|
hover-class="button-hover"
|
||||||
|
>
|
||||||
|
<text class="delete-icon">🗑️</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 令牌信息 -->
|
||||||
|
<view class="token-info">
|
||||||
|
<!-- TOTP信息 -->
|
||||||
|
<view class="totp-info {{remainingSeconds[token.id] <= 5 ? 'warn' : ''}}" wx:if="{{token.type === 'totp'}}">
|
||||||
|
<text class="remaining-time">剩余 {{remainingSeconds[token.id]}} 秒</text>
|
||||||
|
<view class="progress-bar {{remainingSeconds[token.id] <= 5 ? 'warn' : ''}}">
|
||||||
|
<view class="progress" style="width: {{(remainingSeconds[token.id] / (token.period || 30)) * 100}}%"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- HOTP信息 -->
|
||||||
|
<view class="counter-info" wx:if="{{token.type === 'hotp'}}">
|
||||||
|
<text>计数器: {{token.counter || 0}}</text>
|
||||||
|
<text class="hint">点击刷新按钮生成新的验证码</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 广告容器 -->
|
||||||
|
<view class="ad-container" wx:if="{{!loading && tokens.length > 0}}">
|
||||||
|
<!-- 这里可以放广告组件 -->
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 添加按钮 -->
|
||||||
|
<view class="add-button" bindtap="showAddTokenMenu" hover-class="button-hover">
|
||||||
|
<text class="add-icon">+</text>
|
||||||
|
<text class="add-text">添加验证器</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
301
pages/index/index.wxss
Normal file
301
pages/index/index.wxss
Normal file
|
@ -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);
|
||||||
|
}
|
66
pages/info/info.js
Normal file
66
pages/info/info.js
Normal file
|
@ -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 () {
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
3
pages/info/info.json
Normal file
3
pages/info/info.json
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"navigationBarTitleText": "介绍"
|
||||||
|
}
|
20
pages/info/info.wxml
Normal file
20
pages/info/info.wxml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<view class="container">
|
||||||
|
<view class="info-card">
|
||||||
|
<view class="info-item">
|
||||||
|
<text class="label">个人主页:</text>
|
||||||
|
<navigator url="https://www.xhupo.com" class="link">xhupo.com</navigator>
|
||||||
|
</view>
|
||||||
|
<view class="info-item">
|
||||||
|
<text class="label">评论博客:</text>
|
||||||
|
<navigator url="https://memos.zeroc.net" class="link">memos.zeroc.net</navigator>
|
||||||
|
</view>
|
||||||
|
<view class="info-item">
|
||||||
|
<text class="label">开发工具:</text>
|
||||||
|
<text>VSCode + 腾讯云CodeBuddy</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-item">
|
||||||
|
<text class="label">项目源码:</text>
|
||||||
|
<navigator url="https://git.xhupo.com/otp" class="link">git.xhupo.com/otp</navigator>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
31
pages/info/info.wxss
Normal file
31
pages/info/info.wxss
Normal file
|
@ -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;
|
||||||
|
}
|
217
pages/mine/mine.js
Normal file
217
pages/mine/mine.js
Normal file
|
@ -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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
5
pages/mine/mine.json
Normal file
5
pages/mine/mine.json
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": false,
|
||||||
|
"component": true
|
||||||
|
}
|
29
pages/mine/mine.wxml
Normal file
29
pages/mine/mine.wxml
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<!--pages/mine/mine.wxml-->
|
||||||
|
<view class='mine' data-weui-theme="dark">
|
||||||
|
<view class="top-bg"></view>
|
||||||
|
<view class="user-bg">
|
||||||
|
<view class="userinfo">
|
||||||
|
<block wx:if="{{isLoggedIn && userInfo}}">
|
||||||
|
<button class="avatar-wrapper" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar">
|
||||||
|
<image class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="aspectFit"></image>
|
||||||
|
</button>
|
||||||
|
<input type="nickname" class="userinfo-nickname" placeholder="请输入昵称" value="{{userInfo.nickName}}" bindchange="onNicknameChange"/>
|
||||||
|
</block>
|
||||||
|
<block wx:else>
|
||||||
|
<button class="login-btn" bindtap="goAuth">
|
||||||
|
<image class="userinfo-avatar" src="/images/default-avatar.png" mode="aspectFit"></image>
|
||||||
|
<text class="userinfo-nickname">点击登录</text>
|
||||||
|
</button>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="btns">
|
||||||
|
<button class="btn" loading="{{uploading}}" disabled="{{uploading}}" bindtap="uploadData">数据备份</button>
|
||||||
|
<button class="btn" loading="{{loading}}" disabled="{{loading}}" bindtap="restoreData">数据恢复</button>
|
||||||
|
</view>
|
||||||
|
<view class="footer">
|
||||||
|
<navigator url="/pages/info/info" hover-class="none">
|
||||||
|
<view>Copyright © {{currentYear}}</view>
|
||||||
|
</navigator>
|
||||||
|
</view>
|
||||||
|
</view>
|
121
pages/mine/mine.wxss
Normal file
121
pages/mine/mine.wxss
Normal file
|
@ -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;
|
||||||
|
}
|
25
project.config.json
Normal file
25
project.config.json
Normal file
|
@ -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": {}
|
||||||
|
}
|
14
project.private.config.json
Normal file
14
project.private.config.json
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
7
sitemap.json
Normal file
7
sitemap.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
|
||||||
|
"rules": [{
|
||||||
|
"action": "allow",
|
||||||
|
"page": "*"
|
||||||
|
}]
|
||||||
|
}
|
1
utils/.gitignore
vendored
Normal file
1
utils/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
__tests__
|
311
utils/auth.js
Normal file
311
utils/auth.js
Normal file
|
@ -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<string|null>} 新的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<Object>} 登录结果,包含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<string|null>} 访问令牌或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
|
||||||
|
};
|
336
utils/base32.js
Normal file
336
utils/base32.js
Normal file
|
@ -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
|
||||||
|
};
|
139
utils/base32.test.js
Normal file
139
utils/base32.test.js
Normal file
|
@ -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 })}`);
|
||||||
|
});
|
298
utils/cloud.js
Normal file
298
utils/cloud.js
Normal file
|
@ -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<void>}
|
||||||
|
*/
|
||||||
|
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<any>} 响应数据
|
||||||
|
*/
|
||||||
|
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<string>} 云端数据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<Object>} 云端数据对象
|
||||||
|
*/
|
||||||
|
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<boolean>} 初始化是否成功
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
};
|
42
utils/config.js
Normal file
42
utils/config.js
Normal file
|
@ -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
|
||||||
|
};
|
165
utils/crypto.js
Normal file
165
utils/crypto.js
Normal file
|
@ -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
|
||||||
|
};
|
24
utils/eventManager.js
Normal file
24
utils/eventManager.js
Normal file
|
@ -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;
|
131
utils/format.js
Normal file
131
utils/format.js
Normal file
|
@ -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
|
||||||
|
};
|
290
utils/hmac.js
Normal file
290
utils/hmac.js
Normal file
|
@ -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<Uint8Array>} 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<Uint8Array>} HMAC 结果
|
||||||
|
*/
|
||||||
|
function sha1(key, data) {
|
||||||
|
return hmac('sha1', key, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 HMAC-SHA256 值
|
||||||
|
*
|
||||||
|
* @param {string|Uint8Array} key - 密钥
|
||||||
|
* @param {string|Uint8Array} data - 数据
|
||||||
|
* @returns {Promise<Uint8Array>} HMAC 结果
|
||||||
|
*/
|
||||||
|
function sha256(key, data) {
|
||||||
|
return hmac('sha256', key, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 HMAC-SHA512 值
|
||||||
|
*
|
||||||
|
* @param {string|Uint8Array} key - 密钥
|
||||||
|
* @param {string|Uint8Array} data - 数据
|
||||||
|
* @returns {Promise<Uint8Array>} 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<Uint8Array>} 派生出的密钥
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
};
|
213
utils/hotp.js
Normal file
213
utils/hotp.js
Normal file
|
@ -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<string>} 生成的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<number|false>} 如果验证通过,返回匹配的计数器值;否则返回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
|
||||||
|
};
|
150
utils/otp.js
Normal file
150
utils/otp.js
Normal file
|
@ -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<string>} 生成的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<boolean>} 验证结果
|
||||||
|
* @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
|
||||||
|
};
|
127
utils/otp.test.js
Normal file
127
utils/otp.test.js
Normal file
|
@ -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);
|
71
utils/sjcl.min.js
vendored
Normal file
71
utils/sjcl.min.js
vendored
Normal file
|
@ -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<m;n++)h=a[e>>>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<<c)-1},concat:function(a,b){if(0===a.length||0===b.length)return a.concat(b);var c=a[a.length-1],d=sjcl.bitArray.getPartial(c);return 32===d?a.concat(b):sjcl.bitArray.aa(b,d,c|0,a.slice(0,a.length-1))},bitLength:function(a){var b=a.length;
|
||||||
|
return 0===b?0:32*(b-1)+sjcl.bitArray.getPartial(a[b-1])},clamp:function(a,b){if(32*a.length<b)return a;a=a.slice(0,Math.ceil(b/32));var c=a.length;b=b&31;0<c&&b&&(a[c-1]=sjcl.bitArray.partial(b,a[c-1]&2147483648>>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<a.length;d++)c|=a[d]^b[d];
|
||||||
|
return 0===c},aa:function(a,b,c,d){var e;e=0;for(void 0===d&&(d=[]);32<=b;b-=32)d.push(c),c=0;if(0===b)return d.concat(a);for(e=0;e<a.length;e++)d.push(c|a[e]>>>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<b+a?c:d.pop(),1));return d},s:function(a,b){return[a[0]^b[0],a[1]^b[1],a[2]^b[2],a[3]^b[3]]},byteswapM:function(a){var b,c;for(b=0;b<a.length;++b)c=a[b],a[b]=c>>>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<c/8;d++)0===(d&3)&&(e=a[d/4]),b+=String.fromCharCode(e>>>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<a.length;c++)d=d<<8|a.charCodeAt(c),3===(c&3)&&(b.push(d),d=0);c&3&&b.push(sjcl.bitArray.partial(8*(c&3),d));return b}};
|
||||||
|
sjcl.codec.hex={fromBits:function(a){var b="",c;for(c=0;c<a.length;c++)b+=((a[c]|0)+0xf00000000000).toString(16).substr(4);return b.substr(0,sjcl.bitArray.bitLength(a)/4)},toBits:function(a){var b,c=[],d;a=a.replace(/\s|0x/g,"");d=a.length;a=a+"00000000";for(b=0;b<a.length;b+=8)c.push(parseInt(a.substr(b,8),16)^0);return sjcl.bitArray.clamp(c,4*d)}};
|
||||||
|
sjcl.codec.base32={F:"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",Z:"0123456789ABCDEFGHIJKLMNOPQRSTUV",BITS:32,BASE:5,REMAINING:27,fromBits:function(a,b,c){var d=sjcl.codec.base32.BASE,e=sjcl.codec.base32.REMAINING,f="",g=0,h=sjcl.codec.base32.F,k=0,l=sjcl.bitArray.bitLength(a);c&&(h=sjcl.codec.base32.Z);for(c=0;f.length*d<l;)f+=h.charAt((k^a[c]>>>g)>>>e),g<d?(k=a[c]<<d-g,g+=e,c++):(k<<=d,g-=d);for(;f.length&7&&!b;)f+="=";return f},toBits:function(a,b){a=a.replace(/\s|=/g,"").toUpperCase();var c=sjcl.codec.base32.BITS,
|
||||||
|
d=sjcl.codec.base32.BASE,e=sjcl.codec.base32.REMAINING,f=[],g,h=0,k=sjcl.codec.base32.F,l=0,m,n="base32";b&&(k=sjcl.codec.base32.Z,n="base32hex");for(g=0;g<a.length;g++){m=k.indexOf(a.charAt(g));if(0>m){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<<c-h):(h+=d,l^=m<<c-h)}h&56&&f.push(sjcl.bitArray.partial(h&56,l,1));return f}};
|
||||||
|
sjcl.codec.base32hex={fromBits:function(a,b){return sjcl.codec.base32.fromBits(a,b,1)},toBits:function(a){return sjcl.codec.base32.toBits(a,1)}};
|
||||||
|
sjcl.codec.base64={F:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",fromBits:function(a,b,c){var d="",e=0,f=sjcl.codec.base64.F,g=0,h=sjcl.bitArray.bitLength(a);c&&(f=f.substr(0,62)+"-_");for(c=0;6*d.length<h;)d+=f.charAt((g^a[c]>>>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;d<a.length;d++){h=f.indexOf(a.charAt(d));
|
||||||
|
if(0>h)throw new sjcl.exception.invalid("this isn't base64!");26<e?(e-=26,c.push(g^h>>>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<c/8;d++)0===(d&3)&&(e=a[d/4]),b.push(e>>>24),e<<=8;return b},toBits:function(a){var b=[],c,d=0;for(c=0;c<a.length;c++)d=d<<8|a[c],3===(c&3)&&(b.push(d),d=0);c&3&&b.push(sjcl.bitArray.partial(8*(c&3),d));return b}};sjcl.hash.sha256=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.sha256.hash=function(a){return(new sjcl.hash.sha256).update(a).finalize()};
|
||||||
|
sjcl.hash.sha256.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(0x1fffffffffffff<a)throw new sjcl.exception.invalid("Cannot hash more than 2^53 - 1 bits");if("undefined"!==typeof Uint32Array){var d=new Uint32Array(c),e=0;for(b=512+b-(512+b&0x1ff);b<=a;b+=512)this.h(d.subarray(16*e,
|
||||||
|
16*(e+1))),e+=1;c.splice(0,16*e)}else for(b=512+b-(512+b&0x1ff);b<=a;b+=512)this.h(c.splice(0,16));return this},finalize:function(){var a,b=this.c,c=this.g,b=sjcl.bitArray.concat(b,[sjcl.bitArray.partial(1,1)]);for(a=b.length+2;a&15;a++)b.push(0);b.push(Math.floor(this.a/0x100000000));for(b.push(this.a|0);b.length;)this.h(b.splice(0,16));this.reset();return c},j:[],b:[],H:function(){function a(a){return 0x100000000*(a-Math.floor(a))|0}for(var b=0,c=2,d,e;64>b;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(0x1fffffffffffff<a)throw new sjcl.exception.invalid("Cannot hash more than 2^53 - 1 bits");if("undefined"!==typeof Uint32Array){var d=new Uint32Array(c),e=0;for(b=1024+b-(1024+b&1023);b<=a;b+=1024)this.h(d.subarray(32*
|
||||||
|
e,32*(e+1))),e+=1;c.splice(0,32*e)}else for(b=1024+b-(1024+b&1023);b<=a;b+=1024)this.h(c.splice(0,32));return this},finalize:function(){var a,b=this.c,c=this.g,b=sjcl.bitArray.concat(b,[sjcl.bitArray.partial(1,1)]);for(a=b.length+4;a&31;a++)b.push(0);b.push(0);b.push(0);b.push(Math.floor(this.a/0x100000000));for(b.push(this.a|0);b.length;)this.h(b.splice(0,32));this.reset();return c},j:[],ma:[12372232,13281083,9762859,1914609,15106769,4090911,4308331,8266105],b:[],oa:[2666018,15689165,5061423,9034684,
|
||||||
|
4764984,380953,1658779,7176472,197186,7368638,14987916,16757986,8096111,1480369,13046325,6891156,15813330,5187043,9229749,11312229,2818677,10937475,4324308,1135541,6741931,11809296,16458047,15666916,11046850,698149,229999,945776,13774844,2541862,12856045,9810911,11494366,7844520,15576806,8533307,15795044,4337665,16291729,5553712,15684120,6662416,7413802,12308920,13816008,4303699,9366425,10176680,13195875,4295371,6546291,11712675,15708924,1519456,15772530,6568428,6495784,8568297,13007125,7492395,2515356,
|
||||||
|
12632583,14740254,7262584,1535930,13146278,16321966,1853211,294276,13051027,13221564,1051980,4080310,6651434,14088940,4675607],H:function(){function a(a){return 0x100000000*(a-Math.floor(a))|0}function b(a){return 0x10000000000*(a-Math.floor(a))&255}for(var c=0,d=2,e,f;80>c;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<z>>>0?1:0);c+=A;b+=q+(c>>>0<A>>>0?1:0);c+=M;b+=Y+(c>>>0<M>>>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<L>>>0?1:0),q=q+fa,z=z+(X+(q>>>0<fa>>>0?1:0)),q=q+ga,z=z+(ka+(q>>>0<ga>>>0?1:0)),q=q+c|0,z=z+(b+(q>>>0<c>>>0?1:0));c=M+ja;b=Y+A+(c>>>0<M>>>0?1:0);W=S;L=K;S=R;K=J;R=y;J=x;x=I+q|0;y=V+z+(x>>>0<I>>>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<q>>>0?1:0)|0}g=d[1]=g+w|0;d[0]=f+u+(g>>>0<w>>>0?1:0)|0;k=d[3]=k+E|0;d[2]=h+G+(k>>>0<E>>>0?1:0)|0;m=d[5]=m+F|0;d[4]=l+H+(m>>>0<F>>>0?1:0)|0;p=d[7]=p+I|0;d[6]=n+V+(p>>>0<I>>>0?1:0)|0;r=d[9]=r+x|0;d[8]=t+y+(r>>>0<x>>>0?1:0)|0;B=d[11]=B+J|
|
||||||
|
0;d[10]=C+R+(B>>>0<J>>>0?1:0)|0;P=d[13]=P+K|0;d[12]=D+S+(P>>>0<K>>>0?1:0)|0;Q=d[15]=Q+L|0;d[14]=ea+W+(Q>>>0<L>>>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(0x1fffffffffffff<a)throw new sjcl.exception.invalid("Cannot hash more than 2^53 - 1 bits");if("undefined"!==typeof Uint32Array){var d=new Uint32Array(c),e=0;for(b=this.blockSize+b-(this.blockSize+b&this.blockSize-1);b<=
|
||||||
|
a;b+=this.blockSize)this.h(d.subarray(16*e,16*(e+1))),e+=1;c.splice(0,16*e)}else for(b=this.blockSize+b-(this.blockSize+b&this.blockSize-1);b<=a;b+=this.blockSize)this.h(c.splice(0,16));return this},finalize:function(){var a,b=this.c,c=this.g,b=sjcl.bitArray.concat(b,[sjcl.bitArray.partial(1,1)]);for(a=b.length+2;a&15;a++)b.push(0);b.push(Math.floor(this.a/0x100000000));for(b.push(this.a|0);b.length;)this.h(b.splice(0,16));this.reset();return c},j:[1732584193,4023233417,2562383102,271733878,3285377520],
|
||||||
|
b:[1518500249,1859775393,2400959708,3395469782],h:function(a){var b,c,d,e,f,g,h=this.g,k;if("undefined"!==typeof Uint32Array)for(k=Array(80),c=0;16>c;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);-1<a&&sjcl.mode.ccm.J.splice(a,1)},ga:function(a){var b=sjcl.mode.ccm.J.slice(),c;for(c=0;c<b.length;c+=1)b[c](a)},encrypt:function(a,b,c,d,e){var f,g=b.slice(0),h=sjcl.bitArray,k=h.bitLength(c)/8,l=h.bitLength(g)/8;e=e||64;d=d||[];if(7>k)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;b<g.length;b+=4)d=a.encrypt(k(d,g.slice(b,b+4).concat([0,0,0])));return d},X:function(a,b,c,d,e,f){var g=sjcl.bitArray,h=g.s;e/=8;if(e%2||4>e||16<e)throw new sjcl.exception.invalid("ccm: invalid tag length");
|
||||||
|
if(0xffffffff<d.length||0xffffffff<b.length)throw new sjcl.exception.bug("ccm: can't deal with 4GiB or more data");c=sjcl.mode.ccm.qa(a,d,c,e,g.bitLength(b)/8,f);for(d=0;d<b.length;d+=4)c=a.encrypt(h(c,b.slice(d,d+4).concat([0,0,0])));return g.clamp(c,8*e)},G:function(a,b,c,d,e,f){var g,h=sjcl.bitArray;g=h.s;var k=b.length,l=h.bitLength(b),m=k/50,n=m;c=h.concat([h.partial(8,f-1)],c).concat([0,0,0]).slice(0,4);d=h.bitSlice(g(d,a.encrypt(c)),0,e);if(!k)return{tag:d,data:[]};for(g=0;g<k;g+=4)g>m&&(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+4<b.length;g+=4)n=b.slice(g,g+4),m=l(m,n),p=p.concat(l(c,a.encrypt(l(c,n)))),c=h(c);n=b.slice(g);b=k.bitLength(n);g=a.encrypt(l(c,[0,0,0,b]));n=k.clamp(l(n.concat([0,0,0]),g),b);m=l(m,l(n.concat([0,0,0]),g));m=a.encrypt(l(m,l(c,h(c))));
|
||||||
|
d.length&&(m=l(m,f?d:sjcl.mode.ocb2.pmac(a,d)));return p.concat(k.concat(n,k.clamp(m,e)))},decrypt:function(a,b,c,d,e,f){if(128!==sjcl.bitArray.bitLength(c))throw new sjcl.exception.invalid("ocb iv must be 128 bits");e=e||64;var g=sjcl.mode.ocb2.U,h=sjcl.bitArray,k=h.s,l=[0,0,0,0],m=g(a.encrypt(c)),n,p,t=sjcl.bitArray.bitLength(b)-e,r=[];d=d||[];for(c=0;c+4<t/32;c+=4)n=k(m,a.decrypt(k(m,b.slice(c,c+4)))),l=k(l,n),r=r.concat(n),m=g(m);p=t-32*c;n=a.encrypt(k(m,[0,0,0,p]));n=k(n,h.clamp(b.slice(c),p).concat([0,
|
||||||
|
0,0]));l=k(l,n);l=a.encrypt(k(l,k(m,g(m))));d.length&&(l=k(l,f?d:sjcl.mode.ocb2.pmac(a,d)));if(!h.equal(h.clamp(l,e),h.bitSlice(b,t)))throw new sjcl.exception.corrupt("ocb: tag doesn't match");return r.concat(h.clamp(n,p))},pmac:function(a,b){var c,d=sjcl.mode.ocb2.U,e=sjcl.bitArray,f=e.s,g=[0,0,0,0],h=a.encrypt([0,0,0,0]),h=f(h,d(d(h)));for(c=0;c+4<b.length;c+=4)h=d(h),g=f(g,a.encrypt(f(h,b.slice(c,c+4))));c=b.slice(c);128>e.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<d;d--)f[d]=f[d]>>>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;d<e;d+=4)b[0]^=0xffffffff&c[d],b[1]^=0xffffffff&c[d+1],b[2]^=0xffffffff&c[d+2],b[3]^=0xffffffff&c[d+3],b=sjcl.mode.gcm.la(b,a);return b},G:function(a,b,c,d,e,f){var g,h,k,l,m,n,p,t,r=sjcl.bitArray;n=c.length;p=r.bitLength(c);t=r.bitLength(d);h=r.bitLength(e);
|
||||||
|
g=b.encrypt([0,0,0,0]);96===h?(e=e.slice(0),e=r.concat(e,[1])):(e=sjcl.mode.gcm.u(g,[0,0,0,0],e),e=sjcl.mode.gcm.u(g,e,[0,0,Math.floor(h/0x100000000),h&0xffffffff]));h=sjcl.mode.gcm.u(g,[0,0,0,0],d);m=e.slice(0);d=h.slice(0);a||(d=sjcl.mode.gcm.u(g,h,c));for(l=0;l<n;l+=4)m[3]++,k=b.encrypt(m),c[l]^=k[0],c[l+1]^=k[1],c[l+2]^=k[2],c[l+3]^=k[3];c=r.clamp(c,p);a&&(d=sjcl.mode.gcm.u(g,h,c));a=[Math.floor(t/0x100000000),t&0xffffffff,Math.floor(p/0x100000000),p&0xffffffff];d=sjcl.mode.gcm.u(g,d,a);k=b.encrypt(e);
|
||||||
|
d[0]^=k[0];d[1]^=k[1];d[2]^=k[2];d[3]^=k[3];return{tag:r.bitSlice(d,0,f),data:c}}};sjcl.misc.hmac=function(a,b){this.Y=b=b||sjcl.hash.sha256;var c=[[],[]],d,e=b.prototype.blockSize/32;this.D=[new b,new b];a.length>e&&(a=b.hash(a));for(d=0;d<e;d++)c[0][d]=a[d]^909522486,c[1][d]=a[d]^1549556828;this.D[0].update(c[0]);this.D[1].update(c[1]);this.T=new b(this.D[0])};
|
||||||
|
sjcl.misc.hmac.prototype.encrypt=sjcl.misc.hmac.prototype.mac=function(a){if(this.ba)throw new sjcl.exception.invalid("encrypt on already updated hmac called!");this.update(a);return this.digest(a)};sjcl.misc.hmac.prototype.reset=function(){this.T=new this.Y(this.D[0]);this.ba=!1};sjcl.misc.hmac.prototype.update=function(a){this.ba=!0;this.T.update(a)};sjcl.misc.hmac.prototype.digest=function(){var a=this.T.finalize(),a=(new this.Y(this.D[1])).update(a).finalize();this.reset();return a};
|
||||||
|
sjcl.misc.pbkdf2=function(a,b,c,d,e){c=c||1E4;if(0>d||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;g<c;g++)for(f=a.encrypt(f),h=0;h<f.length;h++)e[h]^=f[h];l=l.concat(e)}d&&(l=m.clamp(l,d));return l};
|
||||||
|
sjcl.prng=function(a){this.i=[new sjcl.hash.sha256];this.w=[0];this.S=0;this.K={};this.R=0;this.W={};this.$=this.l=this.A=this.ia=0;this.b=[0,0,0,0,0,0,0,0];this.o=[0,0,0,0];this.O=void 0;this.P=a;this.I=!1;this.N={progress:{},seeded:{}};this.C=this.ha=0;this.L=1;this.M=2;this.da=0x10000;this.V=[0,48,64,96,128,192,0x100,384,512,768,1024];this.ea=3E4;this.ca=80};
|
||||||
|
sjcl.prng.prototype={randomWords:function(a,b){var c=[],d;d=this.isReady(b);var e;if(d===this.C)throw new sjcl.exception.notReady("generator isn't seeded");if(d&this.M){d=!(d&this.L);e=[];var f=0,g;this.$=e[0]=(new Date).valueOf()+this.ea;for(g=0;16>g;g++)e.push(0x100000000*Math.random()|0);for(g=0;g<this.i.length&&(e=e.concat(this.i[g].finalize()),f+=this.w[g],this.w[g]=0,d||!(this.S&1<<g));g++);this.S>=1<<this.i.length&&(this.i.push(new sjcl.hash.sha256),this.w.push(0));this.l-=f;f>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<a;d+=4)0===(d+1)%this.da&&ba(this),e=N(this),c.push(e[0],e[1],e[2],e[3]);ba(this);return c.slice(0,a)},setDefaultParanoia:function(a,b){if(0===a&&"Setting paranoia=0 will ruin your security; use it only for testing"!==b)throw new sjcl.exception.invalid("Setting paranoia=0 will ruin your security; use it only for testing");this.P=a},addEntropy:function(a,
|
||||||
|
b,c){c=c||"user";var d,e,f=(new Date).valueOf(),g=this.K[c],h=this.isReady(),k=0;d=this.W[c];void 0===d&&(d=this.W[c]=this.ia++);void 0===g&&(g=this.K[c]=0);this.K[c]=(this.K[c]+1)%this.i.length;switch(typeof a){case "number":void 0===b&&(b=1);this.i[g].update([d,this.R++,1,b,f,1,a|0]);break;case "object":c=Object.prototype.toString.call(a);if("[object Uint32Array]"===c){e=[];for(c=0;c<a.length;c++)e.push(a[c]);a=e}else for("[object Array]"!==c&&(k=1),c=0;c<a.length&&!k;c++)"number"!==typeof a[c]&&
|
||||||
|
(k=1);if(!k){if(void 0===b)for(c=b=0;c<a.length;c++)for(e=a[c];0<e;)b++,e=e>>>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;c<f.length;c++)d=f[c],delete e[d]},na:function(){T(this,1)},ra:function(a){var b,c;try{b=a.x||a.clientX||a.offsetX||0,c=a.y||a.clientY||a.offsetY||0}catch(d){c=b=0}0!=b&&0!=c&&this.addEntropy([b,c],2,"mouse");T(this,0)},ta:function(a){a=
|
||||||
|
a.touches[0]||a.changedTouches[0];this.addEntropy([a.pageX||a.clientX,a.pageY||a.clientY],1,"touch");T(this,0)},pa:function(){T(this,2)},fa:function(a){a=a.accelerationIncludingGravity.x||a.accelerationIncludingGravity.y||a.accelerationIncludingGravity.z;if(window.orientation){var b=window.orientation;"number"===typeof b&&this.addEntropy(b,1,"accelerometer")}a&&this.addEntropy(a,2,"accelerometer");T(this,0)}};
|
||||||
|
function ca(a,b){var c,d=sjcl.random.N[a],e=[];for(c in d)d.hasOwnProperty(c)&&e.push(d[c]);for(c=0;c<e.length;c++)e[c](b)}function T(a,b){"undefined"!==typeof window&&window.performance&&"function"===typeof window.performance.now?a.addEntropy(window.performance.now(),b,"loadtime"):a.addEntropy((new Date).valueOf(),b,"loadtime")}function ba(a){a.b=N(a).concat(N(a));a.O=new sjcl.cipher.aes(a.b)}function N(a){for(var b=0;4>b&&(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<f.iv.length)throw new sjcl.exception.invalid("json encrypt: invalid parameters");"string"===typeof a?(g=sjcl.misc.cachedPbkdf2(a,f),a=g.key.slice(0,f.ks/32),f.salt=g.salt):sjcl.ecc&&a instanceof sjcl.ecc.elGamal.publicKey&&(g=a.kem(),f.kemtag=g.tag,a=g.key.slice(0,f.ks/32));"string"===typeof b&&(b=sjcl.codec.utf8String.toBits(b));"string"===typeof c&&(f.adata=c=sjcl.codec.utf8String.toBits(c));g=new sjcl.cipher[f.cipher](a);e.m(d,f);d.key=a;f.ct="ccm"===f.mode&&sjcl.arrayBuffer&&sjcl.arrayBuffer.ccm&&
|
||||||
|
b instanceof ArrayBuffer?sjcl.arrayBuffer.ccm.encrypt(g,b,f.iv,c,f.ts):sjcl.mode[f.mode].encrypt(g,b,f.iv,c,f.ts);return f},encrypt:function(a,b,c,d){var e=sjcl.json,f=e.ka.apply(e,arguments);return e.encode(f)},ja:function(a,b,c,d){c=c||{};d=d||{};var e=sjcl.json;b=e.m(e.m(e.m({},e.defaults),b),c,!0);var f,g;f=b.adata;"string"===typeof b.salt&&(b.salt=sjcl.codec.base64.toBits(b.salt));"string"===typeof b.iv&&(b.iv=sjcl.codec.base64.toBits(b.iv));if(!sjcl.mode[b.mode]||!sjcl.cipher[b.cipher]||"string"===
|
||||||
|
typeof a&&100>=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<b.iv.length)throw new sjcl.exception.invalid("json decrypt: invalid parameters");"string"===typeof a?(g=sjcl.misc.cachedPbkdf2(a,b),a=g.key.slice(0,b.ks/32),b.salt=g.salt):sjcl.ecc&&a instanceof sjcl.ecc.elGamal.secretKey&&(a=a.unkem(sjcl.codec.base64.toBits(b.kemtag)).slice(0,b.ks/32));"string"===typeof f&&(f=sjcl.codec.utf8String.toBits(f));g=new sjcl.cipher[b.cipher](a);f="ccm"===
|
||||||
|
b.mode&&sjcl.arrayBuffer&&sjcl.arrayBuffer.ccm&&b.ct instanceof ArrayBuffer?sjcl.arrayBuffer.ccm.decrypt(g,b.ct,b.iv,b.tag,f,b.ts):sjcl.mode[b.mode].decrypt(g,b.ct,b.iv,f,b.ts);e.m(d,b);d.key=a;return 1===c.raw?f:sjcl.codec.utf8String.fromBits(f)},decrypt:function(a,b,c,d){var e=sjcl.json;return e.ja(a,e.decode(b),c,d)},encode:function(a){var b,c="{",d="";for(b in a)if(a.hasOwnProperty(b)){if(!b.match(/^[a-z0-9]+$/i))throw new sjcl.exception.invalid("json encode: invalid property name");c+=d+'"'+
|
||||||
|
b+'":';d=",";switch(typeof a[b]){case "number":case "boolean":c+=a[b];break;case "string":c+='"'+escape(a[b])+'"';break;case "object":c+='"'+sjcl.codec.base64.fromBits(a[b],0)+'"';break;default:throw new sjcl.exception.bug("json encode: unsupported type");}}return c+"}"},decode:function(a){a=a.replace(/\s/g,"");if(!a.match(/^\{.*\}$/))throw new sjcl.exception.invalid("json decode: this isn't json!");a=a.replace(/^\{|\}$/g,"").split(/,/);var b={},c,d;for(c=0;c<a.length;c++){if(!(d=a[c].match(/^\s*(?:(["']?)([a-z][a-z0-9]*)\1)\s*:\s*(?:(-?\d+)|"([a-z0-9+\/%*_.@=\-]*)"|(true|false))$/i)))throw new sjcl.exception.invalid("json decode: this isn't json!");
|
||||||
|
null!=d[3]?b[d[2]]=parseInt(d[3],10):null!=d[4]?b[d[2]]=d[2].match(/^(ct|adata|salt|iv)$/)?sjcl.codec.base64.toBits(d[4]):unescape(d[4]):null!=d[5]&&(b[d[2]]="true"===d[5])}return b},m:function(a,b,c){void 0===a&&(a={});if(void 0===b)return a;for(var d in b)if(b.hasOwnProperty(d)){if(c&&void 0!==a[d]&&a[d]!==b[d])throw new sjcl.exception.invalid("required parameter overridden");a[d]=b[d]}return a},va:function(a,b){var c={},d;for(d in a)a.hasOwnProperty(d)&&a[d]!==b[d]&&(c[d]=a[d]);return c},ua:function(a,
|
||||||
|
b){var c={},d;for(d=0;d<b.length;d++)void 0!==a[b[d]]&&(c[b[d]]=a[b[d]]);return c}};sjcl.encrypt=sjcl.json.encrypt;sjcl.decrypt=sjcl.json.decrypt;sjcl.misc.sa={};sjcl.misc.cachedPbkdf2=function(a,b){var c=sjcl.misc.sa,d;b=b||{};d=b.iter||1E3;c=c[a]=c[a]||{};d=c[d]=c[d]||{firstSalt:b.salt&&b.salt.length?b.salt.slice(0):sjcl.random.randomWords(2,0)};c=void 0===b.salt?d.firstSalt:b.salt;d[c]=d[c]||sjcl.misc.pbkdf2(a,c,b.iter);return{key:d[c].slice(0),salt:c.slice(0)}};
|
||||||
|
"undefined"!==typeof module&&module.exports&&(module.exports=sjcl);"function"===typeof define&&define([],function(){return sjcl});
|
145
utils/storage.js
Normal file
145
utils/storage.js
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
/**
|
||||||
|
* 存储相关工具函数
|
||||||
|
*/
|
||||||
|
const cloud = require('./cloud');
|
||||||
|
const eventManager = require('./eventManager');
|
||||||
|
|
||||||
|
// 存储键常量
|
||||||
|
const STORAGE_KEY = 'tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从本地存储获取所有令牌
|
||||||
|
* @returns {Promise<Array>} 令牌列表
|
||||||
|
*/
|
||||||
|
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<void>} 异步操作结果
|
||||||
|
*/
|
||||||
|
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<void>} 异步操作结果
|
||||||
|
*/
|
||||||
|
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<void>} 异步操作结果
|
||||||
|
*/
|
||||||
|
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<void>} 异步操作结果
|
||||||
|
*/
|
||||||
|
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<string>} 云端数据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<void>} 异步操作结果
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
};
|
259
utils/totp.js
Normal file
259
utils/totp.js
Normal file
|
@ -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<string>} 生成的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<boolean>} 验证是否通过
|
||||||
|
* @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
|
||||||
|
};
|
132
utils/totp.test.js
Normal file
132
utils/totp.test.js
Normal file
|
@ -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();
|
76
utils/ui.js
Normal file
76
utils/ui.js
Normal file
|
@ -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<boolean>} 用户是否点击了确认
|
||||||
|
*/
|
||||||
|
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<string>} itemList - 菜单项列表
|
||||||
|
* @param {string} [itemColor='#000000'] - 菜单项颜色
|
||||||
|
* @returns {Promise<number>} 用户选择的索引,取消时为-1
|
||||||
|
*/
|
||||||
|
export const showActionSheet = (itemList, itemColor = '#000000') => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
wx.showActionSheet({
|
||||||
|
itemList,
|
||||||
|
itemColor,
|
||||||
|
success: (res) => resolve(res.tapIndex),
|
||||||
|
fail: () => resolve(-1)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
343
utils/util.js
Normal file
343
utils/util.js
Normal file
|
@ -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<string>} 生成的验证码
|
||||||
|
*/
|
||||||
|
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<boolean>} 验证结果
|
||||||
|
*/
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
const addTokenFromUrl = async (url) => {
|
||||||
|
const tokenData = format.parseURL(url);
|
||||||
|
if (!tokenData) {
|
||||||
|
throw new Error('无效的OTP URL');
|
||||||
|
}
|
||||||
|
await addToken(tokenData);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有令牌
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
const getTokens = async () => {
|
||||||
|
return await storage.getTokens();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新令牌
|
||||||
|
* @param {string} tokenId - 令牌ID
|
||||||
|
* @param {Object} updates - 更新的字段
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const updateToken = async (tokenId, updates) => {
|
||||||
|
await storage.updateToken(tokenId, updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除令牌
|
||||||
|
* @param {string} tokenId - 令牌ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
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<Array>} 同步后的令牌列表
|
||||||
|
*/
|
||||||
|
const syncTokens = async (tokens) => {
|
||||||
|
return await cloud.syncTokens(tokens);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从云端获取令牌
|
||||||
|
* @returns {Promise<Array>} 云端的令牌列表
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
};
|
Loading…
Add table
Add a link
Reference in a new issue