chore: sync local changes to Gitea
This commit is contained in:
14
src/worker/constants.ts
Normal file
14
src/worker/constants.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 首次部署前默认应用密码;建议在设置中尽快修改。
|
||||
*/
|
||||
export const DEFAULT_APP_PASSWORD = 'shumengya520';
|
||||
|
||||
export const PASSWORD_META_KEY = 'password_hash';
|
||||
|
||||
export const SESSION_COOKIE_NAME = 'sprout2fa_session';
|
||||
|
||||
/** 会话有效期(毫秒) */
|
||||
export const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Workers 运行时 Web Crypto 要求 PBKDF2 迭代次数 ≤ 100000 */
|
||||
export const PBKDF2_ITERATIONS = 100_000;
|
||||
65
src/worker/crypto/aes.ts
Normal file
65
src/worker/crypto/aes.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* AES-GCM 封装,密文格式: base64( iv(12) | ciphertext+tag )
|
||||
*/
|
||||
|
||||
function b64encode(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
function b64decode(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)!;
|
||||
return out;
|
||||
}
|
||||
|
||||
async function importAesKey(masterKeyB64: string): Promise<CryptoKey> {
|
||||
const raw = b64decode(masterKeyB64.trim());
|
||||
if (raw.length !== 32) {
|
||||
throw new Error('MASTER_KEY 必须为 32 字节的 base64 编码');
|
||||
}
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
raw as BufferSource,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptSecret(
|
||||
masterKeyB64: string,
|
||||
plaintextUtf8: string,
|
||||
): Promise<string> {
|
||||
const key = await importAesKey(masterKeyB64);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const enc = new TextEncoder();
|
||||
const cipher = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
enc.encode(plaintextUtf8),
|
||||
);
|
||||
const combined = new Uint8Array(iv.length + cipher.byteLength);
|
||||
combined.set(iv, 0);
|
||||
combined.set(new Uint8Array(cipher), iv.length);
|
||||
return b64encode(combined);
|
||||
}
|
||||
|
||||
export async function decryptSecret(
|
||||
masterKeyB64: string,
|
||||
storedB64: string,
|
||||
): Promise<string> {
|
||||
const key = await importAesKey(masterKeyB64);
|
||||
const combined = b64decode(storedB64);
|
||||
if (combined.length < 12 + 16) throw new Error('密文损坏');
|
||||
const iv = combined.slice(0, 12);
|
||||
const data = combined.slice(12);
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
data,
|
||||
);
|
||||
return new TextDecoder().decode(plain);
|
||||
}
|
||||
97
src/worker/crypto/password.ts
Normal file
97
src/worker/crypto/password.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { DEFAULT_APP_PASSWORD, PBKDF2_ITERATIONS } from '../constants';
|
||||
|
||||
function bytesToB64(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
function b64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)!;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成存储格式: saltB64:iterations:hashB64
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits'],
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: salt as BufferSource,
|
||||
iterations: PBKDF2_ITERATIONS,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterial,
|
||||
256,
|
||||
);
|
||||
const hash = new Uint8Array(bits);
|
||||
return `${bytesToB64(salt)}:${PBKDF2_ITERATIONS}:${bytesToB64(hash)}`;
|
||||
}
|
||||
|
||||
export async function verifyPasswordHash(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
const parts = stored.split(':');
|
||||
if (parts.length !== 3) return false;
|
||||
const [saltB64, iterStr, hashB64] = parts;
|
||||
const iterations = Number(iterStr);
|
||||
if (!Number.isFinite(iterations) || iterations < 10000) return false;
|
||||
// 旧版曾用 120000,Worker 不支持;需清空 app_meta 中 password_hash 后重新登录
|
||||
if (iterations > 100_000) return false;
|
||||
const salt = b64ToBytes(saltB64!);
|
||||
const expected = b64ToBytes(hashB64!);
|
||||
const enc = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
enc.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits'],
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: salt as BufferSource,
|
||||
iterations,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterial,
|
||||
256,
|
||||
);
|
||||
const actual = new Uint8Array(bits);
|
||||
if (actual.length !== expected.length) return false;
|
||||
let ok = 0;
|
||||
for (let i = 0; i < actual.length; i++) ok |= actual[i]! ^ expected[i]!;
|
||||
return ok === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取数据库中的口令哈希;若无记录则仅当口令为默认口令时视为通过,并返回需写入的哈希。
|
||||
*/
|
||||
export async function checkAppPassword(
|
||||
password: string,
|
||||
storedRow: { value: string } | null,
|
||||
): Promise<{ ok: boolean; newHashToStore?: string }> {
|
||||
if (!storedRow) {
|
||||
if (password !== DEFAULT_APP_PASSWORD) {
|
||||
return { ok: false };
|
||||
}
|
||||
const newHashToStore = await hashPassword(password);
|
||||
return { ok: true, newHashToStore };
|
||||
}
|
||||
const ok = await verifyPasswordHash(password, storedRow.value);
|
||||
return { ok };
|
||||
}
|
||||
90
src/worker/crypto/totp.ts
Normal file
90
src/worker/crypto/totp.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* RFC 6238 TOTP(Worker 侧计算,依赖 Web Crypto HMAC)
|
||||
*/
|
||||
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
export function normalizeBase32Secret(secret: string): string {
|
||||
return secret.replace(/\s+/g, '').toUpperCase().replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function decodeBase32(s: string): Uint8Array {
|
||||
const str = normalizeBase32Secret(s);
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const output: number[] = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const idx = BASE32_ALPHABET.indexOf(str[i]!);
|
||||
if (idx === -1) continue;
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
output.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return new Uint8Array(output);
|
||||
}
|
||||
|
||||
function writeBigEndianCounter(view: DataView, counter: number): void {
|
||||
let c = counter;
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
view.setUint8(i, c & 0xff);
|
||||
c = Math.floor(c / 256);
|
||||
}
|
||||
}
|
||||
|
||||
async function hmacDigest(
|
||||
keyBytes: Uint8Array,
|
||||
message: ArrayBuffer,
|
||||
hash: 'SHA-1' | 'SHA-256' | 'SHA-512',
|
||||
): Promise<Uint8Array> {
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes as BufferSource,
|
||||
{ name: 'HMAC', hash },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
return new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, message));
|
||||
}
|
||||
|
||||
export async function computeTotpCode(
|
||||
secretBase32: string,
|
||||
options: { digits: number; period: number; algorithm: string; nowMs?: number },
|
||||
): Promise<{ code: string; expiresInSeconds: number }> {
|
||||
const now = options.nowMs ?? Date.now();
|
||||
const period = options.period || 30;
|
||||
const digits = Math.min(10, Math.max(6, options.digits || 6));
|
||||
const algo = (options.algorithm || 'SHA1').toUpperCase();
|
||||
const hashName =
|
||||
algo === 'SHA256' || algo === 'SHA-256'
|
||||
? 'SHA-256'
|
||||
: algo === 'SHA512' || algo === 'SHA-512'
|
||||
? 'SHA-512'
|
||||
: 'SHA-1';
|
||||
|
||||
const keyBytes = decodeBase32(secretBase32);
|
||||
if (keyBytes.length === 0) {
|
||||
throw new Error('密钥无效');
|
||||
}
|
||||
|
||||
const counter = Math.floor(now / 1000 / period);
|
||||
const buf = new ArrayBuffer(8);
|
||||
writeBigEndianCounter(new DataView(buf), counter);
|
||||
const hmac = await hmacDigest(keyBytes, buf, hashName);
|
||||
const offset = hmac[hmac.length - 1]! & 0x0f;
|
||||
const bin =
|
||||
((hmac[offset]! & 0x7f) << 24) |
|
||||
((hmac[offset + 1]! & 0xff) << 16) |
|
||||
((hmac[offset + 2]! & 0xff) << 8) |
|
||||
(hmac[offset + 3]! & 0xff);
|
||||
const mod = 10 ** digits;
|
||||
const num = bin % mod;
|
||||
const code = num.toString(10).padStart(digits, '0');
|
||||
|
||||
const elapsed = Math.floor(now / 1000) % period;
|
||||
const expiresInSeconds = period - elapsed;
|
||||
|
||||
return { code, expiresInSeconds };
|
||||
}
|
||||
55
src/worker/index.ts
Normal file
55
src/worker/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Hono } from 'hono';
|
||||
import { verifySessionToken } from './middleware/session';
|
||||
import { registerAuthRoutes } from './routes/auth';
|
||||
import { registerAccountRoutes } from './routes/accounts';
|
||||
import { registerImportExportRoutes } from './routes/importExport';
|
||||
import { registerShareRoutes } from './routes/share';
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.onError((err, c) => {
|
||||
console.error('[sprout2fa] unhandled', err);
|
||||
return c.json(
|
||||
{ error: err instanceof Error ? err.message : '服务器错误' },
|
||||
500,
|
||||
);
|
||||
});
|
||||
|
||||
// 必须先注册更具体的前缀 /api/auth,避免被 /api 子应用吞掉
|
||||
const apiAuth = new Hono<{ Bindings: Env }>();
|
||||
registerAuthRoutes(apiAuth);
|
||||
app.route('/api/auth', apiAuth);
|
||||
|
||||
const apiShare = new Hono<{ Bindings: Env }>();
|
||||
registerShareRoutes(apiShare);
|
||||
app.route('/api/share', apiShare);
|
||||
|
||||
const authed = new Hono<{ Bindings: Env }>();
|
||||
authed.use('*', async (c, next) => {
|
||||
const ok = await verifySessionToken(
|
||||
c.env.SESSION_SECRET,
|
||||
c.req.header('Cookie') ?? null,
|
||||
);
|
||||
if (!ok) {
|
||||
return c.json({ error: '未授权' }, 401);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
registerAccountRoutes(authed);
|
||||
registerImportExportRoutes(authed);
|
||||
app.route('/api', authed);
|
||||
|
||||
export default {
|
||||
async fetch(
|
||||
request: Request,
|
||||
env: Env,
|
||||
ctx: ExecutionContext,
|
||||
): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.startsWith('/api')) {
|
||||
return app.fetch(request, env, ctx);
|
||||
}
|
||||
return env.ASSETS.fetch(request);
|
||||
},
|
||||
};
|
||||
48
src/worker/lib/ensureShareToken.ts
Normal file
48
src/worker/lib/ensureShareToken.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { generateShareToken } from './shareToken';
|
||||
|
||||
/** 账户若无 share_token 则自动生成并持久化 */
|
||||
export async function ensureShareTokenForAccount(
|
||||
db: D1Database,
|
||||
id: string,
|
||||
existing: string | null,
|
||||
): Promise<string> {
|
||||
if (existing) return existing;
|
||||
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const row = await db
|
||||
.prepare('SELECT share_token FROM totp_accounts WHERE id = ?')
|
||||
.bind(id)
|
||||
.first<{ share_token: string | null }>();
|
||||
if (row?.share_token) return row.share_token;
|
||||
|
||||
const token = generateShareToken();
|
||||
try {
|
||||
await db
|
||||
.prepare('UPDATE totp_accounts SET share_token = ? WHERE id = ?')
|
||||
.bind(token, id)
|
||||
.run();
|
||||
return token;
|
||||
} catch {
|
||||
/* 唯一索引冲突时重试 */
|
||||
}
|
||||
}
|
||||
|
||||
const final = await db
|
||||
.prepare('SELECT share_token FROM totp_accounts WHERE id = ?')
|
||||
.bind(id)
|
||||
.first<{ share_token: string | null }>();
|
||||
if (final?.share_token) return final.share_token;
|
||||
throw new Error('无法生成分享密钥');
|
||||
}
|
||||
|
||||
export async function newShareTokenForInsert(db: D1Database): Promise<string> {
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const token = generateShareToken();
|
||||
const dup = await db
|
||||
.prepare('SELECT id FROM totp_accounts WHERE share_token = ?')
|
||||
.bind(token)
|
||||
.first();
|
||||
if (!dup) return token;
|
||||
}
|
||||
throw new Error('无法生成分享密钥');
|
||||
}
|
||||
16
src/worker/lib/shareToken.ts
Normal file
16
src/worker/lib/shareToken.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const TOKEN_RE = /^[a-zA-Z0-9_-]{16,64}$/;
|
||||
|
||||
export function isValidShareToken(token: string): boolean {
|
||||
return TOKEN_RE.test(token);
|
||||
}
|
||||
|
||||
export function generateShareToken(): string {
|
||||
const bytes = new Uint8Array(24);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function shareUrlFromRequest(reqUrl: string, token: string): string {
|
||||
const origin = new URL(reqUrl).origin;
|
||||
return `${origin}/?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
128
src/worker/middleware/session.ts
Normal file
128
src/worker/middleware/session.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { SESSION_COOKIE_NAME, SESSION_MAX_AGE_MS } from '../constants';
|
||||
|
||||
function b64urlEncode(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function b64urlDecodeToString(s: string): string {
|
||||
const pad = 4 - (s.length % 4);
|
||||
const b64 = (pad === 4 ? s : s + '='.repeat(pad)).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const bin = atob(b64);
|
||||
return bin;
|
||||
}
|
||||
|
||||
async function hmacSign(secret: string, message: string): Promise<Uint8Array> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
return new Uint8Array(
|
||||
await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createSessionToken(sessionSecret: string): Promise<string> {
|
||||
const payload = JSON.stringify({
|
||||
exp: Date.now() + SESSION_MAX_AGE_MS,
|
||||
iat: Date.now(),
|
||||
});
|
||||
const sig = await hmacSign(sessionSecret, payload);
|
||||
return `${b64urlEncode(new TextEncoder().encode(payload))}.${b64urlEncode(sig)}`;
|
||||
}
|
||||
|
||||
export async function verifySessionToken(
|
||||
sessionSecret: string | undefined,
|
||||
cookieHeader: string | null,
|
||||
): Promise<boolean> {
|
||||
if (!sessionSecret || sessionSecret.trim().length < 8) return false;
|
||||
if (!cookieHeader) return false;
|
||||
const cookies = parseCookies(cookieHeader);
|
||||
const token = cookies[SESSION_COOKIE_NAME];
|
||||
if (!token) return false;
|
||||
const dot = token.lastIndexOf('.');
|
||||
if (dot === -1) return false;
|
||||
const payloadPart = token.slice(0, dot);
|
||||
const sigPart = token.slice(dot + 1);
|
||||
let payloadStr: string;
|
||||
try {
|
||||
payloadStr = b64urlDecodeToString(payloadPart);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
let sigBytes: Uint8Array;
|
||||
try {
|
||||
const pad = 4 - (sigPart.length % 4);
|
||||
const b64 = (pad === 4 ? sigPart : sigPart + '='.repeat(pad))
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
const bin = atob(b64);
|
||||
sigBytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) sigBytes[i] = bin.charCodeAt(i)!;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const expected = await hmacSign(sessionSecret, payloadStr);
|
||||
if (expected.length !== sigBytes.length) return false;
|
||||
let ok = 0;
|
||||
for (let i = 0; i < expected.length; i++) ok |= expected[i]! ^ sigBytes[i]!;
|
||||
if (ok !== 0) return false;
|
||||
try {
|
||||
const data = JSON.parse(payloadStr) as { exp?: number };
|
||||
if (typeof data.exp !== 'number' || data.exp < Date.now()) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCookies(header: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const part of header.split(';')) {
|
||||
const idx = part.indexOf('=');
|
||||
if (idx === -1) continue;
|
||||
const k = part.slice(0, idx).trim();
|
||||
const v = part.slice(idx + 1).trim();
|
||||
out[k] = decodeURIComponent(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 根据请求 URL 判断是否为 HTTPS,用于设置 Secure Cookie */
|
||||
export function sessionCookieHeaderForRequest(
|
||||
token: string,
|
||||
maxAgeSec: number,
|
||||
requestUrl: string,
|
||||
): string {
|
||||
const url = new URL(requestUrl);
|
||||
const isSecure = url.protocol === 'https:';
|
||||
const parts = [
|
||||
`${SESSION_COOKIE_NAME}=${encodeURIComponent(token)}`,
|
||||
'HttpOnly',
|
||||
'Path=/',
|
||||
'SameSite=Lax',
|
||||
`Max-Age=${maxAgeSec}`,
|
||||
];
|
||||
if (isSecure) parts.push('Secure');
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
export function clearSessionCookieHeaderForRequest(requestUrl: string): string {
|
||||
const url = new URL(requestUrl);
|
||||
const isSecure = url.protocol === 'https:';
|
||||
const parts = [
|
||||
`${SESSION_COOKIE_NAME}=`,
|
||||
'HttpOnly',
|
||||
'Path=/',
|
||||
'SameSite=Lax',
|
||||
'Max-Age=0',
|
||||
];
|
||||
if (isSecure) parts.push('Secure');
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
export { SESSION_COOKIE_NAME };
|
||||
275
src/worker/routes/accounts.ts
Normal file
275
src/worker/routes/accounts.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { Hono } from 'hono';
|
||||
import { decryptSecret, encryptSecret } from '../crypto/aes';
|
||||
import { computeTotpCode, normalizeBase32Secret } from '../crypto/totp';
|
||||
import {
|
||||
ensureShareTokenForAccount,
|
||||
newShareTokenForInsert,
|
||||
} from '../lib/ensureShareToken';
|
||||
|
||||
type AccountRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
issuer: string | null;
|
||||
encrypted_secret: string;
|
||||
algorithm: string;
|
||||
digits: number;
|
||||
period: number;
|
||||
created_at: number;
|
||||
sort_order: number;
|
||||
share_token: string | null;
|
||||
};
|
||||
|
||||
function parseAccountBody(body: unknown): {
|
||||
label: string;
|
||||
issuer: string | null;
|
||||
secret: string;
|
||||
algorithm: string;
|
||||
digits: number;
|
||||
period: number;
|
||||
} | null {
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
const o = body as Record<string, unknown>;
|
||||
const label = typeof o.label === 'string' ? o.label.trim() : '';
|
||||
const issuer =
|
||||
typeof o.issuer === 'string' && o.issuer.trim()
|
||||
? o.issuer.trim()
|
||||
: null;
|
||||
const secretRaw = typeof o.secret === 'string' ? o.secret.trim() : '';
|
||||
const algorithm =
|
||||
typeof o.algorithm === 'string' && o.algorithm.trim()
|
||||
? o.algorithm.trim()
|
||||
: 'SHA1';
|
||||
const digits =
|
||||
typeof o.digits === 'number' && Number.isFinite(o.digits)
|
||||
? Math.min(10, Math.max(6, Math.floor(o.digits)))
|
||||
: 6;
|
||||
const period =
|
||||
typeof o.period === 'number' && Number.isFinite(o.period)
|
||||
? Math.min(120, Math.max(10, Math.floor(o.period)))
|
||||
: 30;
|
||||
if (!label || !secretRaw) return null;
|
||||
const secret = normalizeBase32Secret(secretRaw);
|
||||
if (secret.length < 4) return null;
|
||||
return { label, issuer, secret: secretRaw, algorithm, digits, period };
|
||||
}
|
||||
|
||||
export function registerAccountRoutes(authed: Hono<{ Bindings: Env }>): void {
|
||||
authed.get('/accounts', async (c) => {
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT id, label, issuer, encrypted_secret, algorithm, digits, period, created_at, sort_order, share_token
|
||||
FROM totp_accounts
|
||||
ORDER BY sort_order ASC, created_at ASC`,
|
||||
).all<AccountRow>();
|
||||
|
||||
const list = [];
|
||||
const now = Date.now();
|
||||
for (const row of results ?? []) {
|
||||
let secret: string;
|
||||
try {
|
||||
secret = await decryptSecret(c.env.MASTER_KEY, row.encrypted_secret);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const { code, expiresInSeconds } = await computeTotpCode(
|
||||
normalizeBase32Secret(secret),
|
||||
{
|
||||
digits: row.digits,
|
||||
period: row.period,
|
||||
algorithm: row.algorithm,
|
||||
nowMs: now,
|
||||
},
|
||||
);
|
||||
const shareToken = await ensureShareTokenForAccount(
|
||||
c.env.DB,
|
||||
row.id,
|
||||
row.share_token,
|
||||
);
|
||||
list.push({
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
issuer: row.issuer,
|
||||
algorithm: row.algorithm,
|
||||
digits: row.digits,
|
||||
period: row.period,
|
||||
code,
|
||||
expiresInSeconds,
|
||||
shareToken,
|
||||
});
|
||||
}
|
||||
return c.json({ accounts: list });
|
||||
});
|
||||
|
||||
authed.post('/accounts', async (c) => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: '无效的 JSON' }, 400);
|
||||
}
|
||||
const parsed = parseAccountBody(body);
|
||||
if (!parsed) {
|
||||
return c.json({ error: '缺少 label 或有效的 secret' }, 400);
|
||||
}
|
||||
const normalized = normalizeBase32Secret(parsed.secret);
|
||||
try {
|
||||
await computeTotpCode(normalized, {
|
||||
digits: parsed.digits,
|
||||
period: parsed.period,
|
||||
algorithm: parsed.algorithm,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '密钥无法生成验证码';
|
||||
return c.json({ error: msg }, 400);
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const maxRow = await c.env.DB.prepare(
|
||||
'SELECT COALESCE(MAX(sort_order), -1) as m FROM totp_accounts',
|
||||
).first<{ m: number }>();
|
||||
const sortOrder = (maxRow?.m ?? -1) + 1;
|
||||
|
||||
const enc = await encryptSecret(c.env.MASTER_KEY, normalized);
|
||||
const shareToken = await newShareTokenForInsert(c.env.DB);
|
||||
|
||||
await c.env.DB.prepare(
|
||||
`INSERT INTO totp_accounts (id, label, issuer, encrypted_secret, algorithm, digits, period, created_at, sort_order, share_token)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
parsed.label,
|
||||
parsed.issuer,
|
||||
enc,
|
||||
parsed.algorithm.toUpperCase(),
|
||||
parsed.digits,
|
||||
parsed.period,
|
||||
now,
|
||||
sortOrder,
|
||||
shareToken,
|
||||
)
|
||||
.run();
|
||||
|
||||
return c.json({ id, ok: true });
|
||||
});
|
||||
|
||||
authed.patch('/accounts/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: '无效的 JSON' }, 400);
|
||||
}
|
||||
const row = await c.env.DB.prepare(
|
||||
`SELECT id, label, issuer, encrypted_secret, algorithm, digits, period
|
||||
FROM totp_accounts WHERE id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
.first<AccountRow>();
|
||||
if (!row) return c.json({ error: '未找到' }, 404);
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (typeof body.label === 'string' && body.label.trim()) {
|
||||
updates.push('label = ?');
|
||||
values.push(body.label.trim());
|
||||
}
|
||||
if (body.issuer !== undefined) {
|
||||
updates.push('issuer = ?');
|
||||
values.push(
|
||||
typeof body.issuer === 'string' && body.issuer.trim()
|
||||
? body.issuer.trim()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
if (typeof body.algorithm === 'string' && body.algorithm.trim()) {
|
||||
updates.push('algorithm = ?');
|
||||
values.push(body.algorithm.trim().toUpperCase());
|
||||
}
|
||||
if (typeof body.digits === 'number' && Number.isFinite(body.digits)) {
|
||||
updates.push('digits = ?');
|
||||
values.push(Math.min(10, Math.max(6, Math.floor(body.digits))));
|
||||
}
|
||||
if (typeof body.period === 'number' && Number.isFinite(body.period)) {
|
||||
updates.push('period = ?');
|
||||
values.push(Math.min(120, Math.max(10, Math.floor(body.period))));
|
||||
}
|
||||
if (typeof body.sort_order === 'number' && Number.isFinite(body.sort_order)) {
|
||||
updates.push('sort_order = ?');
|
||||
values.push(Math.floor(body.sort_order));
|
||||
}
|
||||
|
||||
const nextAlgorithm =
|
||||
typeof body.algorithm === 'string' && body.algorithm.trim()
|
||||
? body.algorithm.trim().toUpperCase()
|
||||
: row.algorithm;
|
||||
const nextDigits =
|
||||
typeof body.digits === 'number' && Number.isFinite(body.digits)
|
||||
? Math.min(10, Math.max(6, Math.floor(body.digits)))
|
||||
: row.digits;
|
||||
const nextPeriod =
|
||||
typeof body.period === 'number' && Number.isFinite(body.period)
|
||||
? Math.min(120, Math.max(10, Math.floor(body.period)))
|
||||
: row.period;
|
||||
|
||||
if (typeof body.secret === 'string' && body.secret.trim()) {
|
||||
const normalized = normalizeBase32Secret(body.secret.trim());
|
||||
if (normalized.length < 4) {
|
||||
return c.json({ error: '无效的 secret' }, 400);
|
||||
}
|
||||
try {
|
||||
await computeTotpCode(normalized, {
|
||||
digits: nextDigits,
|
||||
period: nextPeriod,
|
||||
algorithm: nextAlgorithm,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '密钥无法生成验证码';
|
||||
return c.json({ error: msg }, 400);
|
||||
}
|
||||
const enc = await encryptSecret(c.env.MASTER_KEY, normalized);
|
||||
updates.push('encrypted_secret = ?');
|
||||
values.push(enc);
|
||||
} else if (
|
||||
body.algorithm !== undefined ||
|
||||
body.digits !== undefined ||
|
||||
body.period !== undefined
|
||||
) {
|
||||
let secret: string;
|
||||
try {
|
||||
secret = await decryptSecret(c.env.MASTER_KEY, row.encrypted_secret);
|
||||
} catch {
|
||||
return c.json({ error: '无法读取现有密钥' }, 500);
|
||||
}
|
||||
try {
|
||||
await computeTotpCode(normalizeBase32Secret(secret), {
|
||||
digits: nextDigits,
|
||||
period: nextPeriod,
|
||||
algorithm: nextAlgorithm,
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '参数无法生成验证码';
|
||||
return c.json({ error: msg }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) return c.json({ ok: true });
|
||||
|
||||
const sql = `UPDATE totp_accounts SET ${updates.join(', ')} WHERE id = ?`;
|
||||
values.push(id);
|
||||
await c.env.DB.prepare(sql)
|
||||
.bind(...values)
|
||||
.run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
authed.delete('/accounts/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
await c.env.DB.prepare('DELETE FROM totp_accounts WHERE id = ?')
|
||||
.bind(id)
|
||||
.run();
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
80
src/worker/routes/auth.ts
Normal file
80
src/worker/routes/auth.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Hono } from 'hono';
|
||||
import { PASSWORD_META_KEY } from '../constants';
|
||||
import { SESSION_MAX_AGE_MS } from '../constants';
|
||||
import { checkAppPassword } from '../crypto/password';
|
||||
import {
|
||||
clearSessionCookieHeaderForRequest,
|
||||
createSessionToken,
|
||||
sessionCookieHeaderForRequest,
|
||||
} from '../middleware/session';
|
||||
|
||||
const MAX_AGE_SEC = Math.floor(SESSION_MAX_AGE_MS / 1000);
|
||||
|
||||
/** 挂载在 /api/auth 下,路径为 /login、/logout */
|
||||
export function registerAuthRoutes(apiAuth: Hono<{ Bindings: Env }>): void {
|
||||
apiAuth.post('/login', async (c) => {
|
||||
const sec = c.env.SESSION_SECRET;
|
||||
if (typeof sec !== 'string' || sec.trim().length < 8) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'服务器未配置 SESSION_SECRET(本地在项目根目录添加 .dev.vars;生产环境执行 wrangler secret put SESSION_SECRET)',
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
let body: { password?: string };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: '无效的 JSON' }, 400);
|
||||
}
|
||||
const password = typeof body.password === 'string' ? body.password : '';
|
||||
|
||||
try {
|
||||
const row = await c.env.DB.prepare(
|
||||
'SELECT value FROM app_meta WHERE key = ?',
|
||||
)
|
||||
.bind(PASSWORD_META_KEY)
|
||||
.first<{ value: string }>();
|
||||
|
||||
const result = await checkAppPassword(password, row);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: '密码错误' }, 401);
|
||||
}
|
||||
if (result.newHashToStore) {
|
||||
await c.env.DB.prepare(
|
||||
'INSERT OR REPLACE INTO app_meta (key, value) VALUES (?, ?)',
|
||||
)
|
||||
.bind(PASSWORD_META_KEY, result.newHashToStore)
|
||||
.run();
|
||||
}
|
||||
const token = await createSessionToken(sec.trim());
|
||||
c.header(
|
||||
'Set-Cookie',
|
||||
sessionCookieHeaderForRequest(token, MAX_AGE_SEC, c.req.url),
|
||||
);
|
||||
return c.json({ ok: true });
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: typeof e === 'string'
|
||||
? e
|
||||
: '登录失败';
|
||||
console.error('[sprout2fa] login', e);
|
||||
return c.json(
|
||||
{
|
||||
error: `数据库或运行时错误:${msg}。若提示 no such table,请执行:wrangler d1 migrations apply sprout2fa --local(本地)或加 --remote(线上库)。`,
|
||||
},
|
||||
500,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
apiAuth.post('/logout', (c) => {
|
||||
c.header('Set-Cookie', clearSessionCookieHeaderForRequest(c.req.url));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
140
src/worker/routes/importExport.ts
Normal file
140
src/worker/routes/importExport.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Hono } from 'hono';
|
||||
import { decryptSecret, encryptSecret } from '../crypto/aes';
|
||||
import { computeTotpCode, normalizeBase32Secret } from '../crypto/totp';
|
||||
import { newShareTokenForInsert } from '../lib/ensureShareToken';
|
||||
|
||||
type ExportItem = {
|
||||
label: string;
|
||||
issuer: string | null;
|
||||
secret: string;
|
||||
algorithm: string;
|
||||
digits: number;
|
||||
period: number;
|
||||
};
|
||||
|
||||
type AccountRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
issuer: string | null;
|
||||
encrypted_secret: string;
|
||||
algorithm: string;
|
||||
digits: number;
|
||||
period: number;
|
||||
};
|
||||
|
||||
function parseImportItem(raw: unknown): ExportItem | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
const label = typeof o.label === 'string' ? o.label.trim() : '';
|
||||
const issuer =
|
||||
typeof o.issuer === 'string' && o.issuer.trim()
|
||||
? o.issuer.trim()
|
||||
: null;
|
||||
const secretRaw = typeof o.secret === 'string' ? o.secret.trim() : '';
|
||||
if (!label || !secretRaw) return null;
|
||||
const algorithm =
|
||||
typeof o.algorithm === 'string' && o.algorithm.trim()
|
||||
? o.algorithm.trim()
|
||||
: 'SHA1';
|
||||
const digits =
|
||||
typeof o.digits === 'number' && Number.isFinite(o.digits)
|
||||
? Math.min(10, Math.max(6, Math.floor(o.digits)))
|
||||
: 6;
|
||||
const period =
|
||||
typeof o.period === 'number' && Number.isFinite(o.period)
|
||||
? Math.min(120, Math.max(10, Math.floor(o.period)))
|
||||
: 30;
|
||||
return { label, issuer, secret: secretRaw, algorithm, digits, period };
|
||||
}
|
||||
|
||||
export function registerImportExportRoutes(
|
||||
authed: Hono<{ Bindings: Env }>,
|
||||
): void {
|
||||
authed.get('/export', async (c) => {
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT id, label, issuer, encrypted_secret, algorithm, digits, period FROM totp_accounts ORDER BY sort_order ASC, created_at ASC`,
|
||||
).all<AccountRow>();
|
||||
|
||||
const items: ExportItem[] = [];
|
||||
for (const row of results ?? []) {
|
||||
try {
|
||||
const secret = await decryptSecret(
|
||||
c.env.MASTER_KEY,
|
||||
row.encrypted_secret,
|
||||
);
|
||||
items.push({
|
||||
label: row.label,
|
||||
issuer: row.issuer,
|
||||
secret: normalizeBase32Secret(secret),
|
||||
algorithm: row.algorithm,
|
||||
digits: row.digits,
|
||||
period: row.period,
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return c.json({
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
items,
|
||||
});
|
||||
});
|
||||
|
||||
authed.post('/import', async (c) => {
|
||||
let body: { items?: unknown };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: '无效的 JSON' }, 400);
|
||||
}
|
||||
if (!Array.isArray(body.items)) {
|
||||
return c.json({ error: '需要 items 数组' }, 400);
|
||||
}
|
||||
|
||||
const maxRow = await c.env.DB.prepare(
|
||||
'SELECT COALESCE(MAX(sort_order), -1) as m FROM totp_accounts',
|
||||
).first<{ m: number }>();
|
||||
let sortBase = (maxRow?.m ?? -1) + 1;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let imported = 0;
|
||||
|
||||
for (const raw of body.items) {
|
||||
const parsed = parseImportItem(raw);
|
||||
if (!parsed) continue;
|
||||
const normalized = normalizeBase32Secret(parsed.secret);
|
||||
try {
|
||||
await computeTotpCode(normalized, {
|
||||
digits: parsed.digits,
|
||||
period: parsed.period,
|
||||
algorithm: parsed.algorithm,
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const enc = await encryptSecret(c.env.MASTER_KEY, normalized);
|
||||
const shareToken = await newShareTokenForInsert(c.env.DB);
|
||||
await c.env.DB.prepare(
|
||||
`INSERT INTO totp_accounts (id, label, issuer, encrypted_secret, algorithm, digits, period, created_at, sort_order, share_token)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
parsed.label,
|
||||
parsed.issuer,
|
||||
enc,
|
||||
parsed.algorithm.toUpperCase(),
|
||||
parsed.digits,
|
||||
parsed.period,
|
||||
now,
|
||||
sortBase++,
|
||||
shareToken,
|
||||
)
|
||||
.run();
|
||||
imported++;
|
||||
}
|
||||
|
||||
return c.json({ ok: true, imported });
|
||||
});
|
||||
}
|
||||
65
src/worker/routes/share.ts
Normal file
65
src/worker/routes/share.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Hono } from 'hono';
|
||||
import { decryptSecret } from '../crypto/aes';
|
||||
import { computeTotpCode, normalizeBase32Secret } from '../crypto/totp';
|
||||
import { isValidShareToken } from '../lib/shareToken';
|
||||
|
||||
type ShareRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
issuer: string | null;
|
||||
encrypted_secret: string;
|
||||
algorithm: string;
|
||||
digits: number;
|
||||
period: number;
|
||||
};
|
||||
|
||||
/** 公开接口:仅凭 share_token 返回单条验证码,无需登录 */
|
||||
export function registerShareRoutes(api: Hono<{ Bindings: Env }>): void {
|
||||
api.get('/:token', async (c) => {
|
||||
const token = c.req.param('token');
|
||||
if (!isValidShareToken(token)) {
|
||||
return c.json({ error: '无效的分享链接' }, 400);
|
||||
}
|
||||
|
||||
const row = await c.env.DB.prepare(
|
||||
`SELECT id, label, issuer, encrypted_secret, algorithm, digits, period
|
||||
FROM totp_accounts WHERE share_token = ?`,
|
||||
)
|
||||
.bind(token)
|
||||
.first<ShareRow>();
|
||||
|
||||
if (!row) {
|
||||
return c.json({ error: '链接无效或已失效' }, 404);
|
||||
}
|
||||
|
||||
let secret: string;
|
||||
try {
|
||||
secret = await decryptSecret(c.env.MASTER_KEY, row.encrypted_secret);
|
||||
} catch {
|
||||
return c.json({ error: '无法生成验证码' }, 500);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const { code, expiresInSeconds } = await computeTotpCode(
|
||||
normalizeBase32Secret(secret),
|
||||
{
|
||||
digits: row.digits,
|
||||
period: row.period,
|
||||
algorithm: row.algorithm,
|
||||
nowMs: now,
|
||||
},
|
||||
);
|
||||
|
||||
return c.json({
|
||||
account: {
|
||||
label: row.label,
|
||||
issuer: row.issuer,
|
||||
algorithm: row.algorithm,
|
||||
digits: row.digits,
|
||||
period: row.period,
|
||||
code,
|
||||
expiresInSeconds,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user