chore: sync local changes to Gitea

This commit is contained in:
shumengya
2026-06-24 22:10:27 +08:00
commit 0812a531e2
44 changed files with 11212 additions and 0 deletions

14
src/worker/constants.ts Normal file
View 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
View 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);
}

View 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;
// 旧版曾用 120000Worker 不支持;需清空 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
View File

@@ -0,0 +1,90 @@
/**
* RFC 6238 TOTPWorker 侧计算,依赖 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
View 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);
},
};

View 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('无法生成分享密钥');
}

View 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)}`;
}

View 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 };

View 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
View 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 });
});
}

View 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 });
});
}

View 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,
},
});
});
}