otp/utils/format.js
2025-06-17 14:44:48 +08:00

162 lines
No EOL
4.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 格式化相关工具函数
*/
/**
* 格式化数字为两位数字符串
* @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;
}
// 解析类型和路径
const protocolAndRest = url.split('://');
if (protocolAndRest.length !== 2) return null;
const typeAndRest = protocolAndRest[1].split('/', 2);
if (typeAndRest.length !== 2) return null;
const type = typeAndRest[0].toLowerCase();
// 分离路径和查询参数
const pathAndQuery = typeAndRest[1].split('?');
if (pathAndQuery.length !== 2) return null;
// 解析路径部分issuer和account
const pathParts = decodeURIComponent(pathAndQuery[0]).split(':');
let issuer = pathParts[0];
const account = pathParts.length > 1 ? pathParts[1] : '';
// 解析查询参数
const params = {};
const queryParts = pathAndQuery[1].split('&');
for (const part of queryParts) {
const [key, value] = part.split('=');
if (key && value) {
params[key.toLowerCase()] = decodeURIComponent(value);
}
}
// 如果params中有issuer参数优先使用它
if (params.issuer) {
issuer = params.issuer;
}
// 验证必要参数
if (!params.secret) {
throw new Error('缺少必要参数: secret');
}
// 验证并规范化参数
const digits = parseInt(params.digits) || 6;
if (digits < 6 || digits > 8) {
throw new Error('验证码位数必须在6-8之间');
}
const period = parseInt(params.period) || (type === 'totp' ? 30 : undefined);
if (type === 'totp' && (period < 15 || period > 60)) {
throw new Error('TOTP周期必须在15-60秒之间');
}
const counter = parseInt(params.counter) || (type === 'hotp' ? 0 : undefined);
if (type === 'hotp' && counter < 0) {
throw new Error('HOTP计数器不能为负数');
}
// 构建返回对象
return {
type,
issuer,
account,
secret: params.secret.toUpperCase(), // 统一转换为大写
algorithm: (params.algorithm || 'SHA1').toUpperCase(),
digits,
period,
counter
};
} catch (error) {
console.error('解析OTP URL失败:', error);
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 validAlgorithms = ['SHA1', 'SHA256', 'SHA512'];
if (!validAlgorithms.includes(tokenData.algorithm)) {
errors.push(`不支持的算法: ${tokenData.algorithm}`);
}
// 验证位数
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
};