码桶

发现社区成员的开源项目

main
verify-totp.js7.8 KB
// Public endpoint: verify TOTP code after login when 2FA is required.
// Re-authenticates password + TOTP, then issues JWT + refresh token.

import { SignJWT } from 'jose';
import { sha256, hashPassword, verifyPassword, isLegacyHash } from './_crypto.js';
import { verifyTOTP } from './_totp.js';
import { logAudit } from './_audit.js';

const MAX_ATTEMPTS = 5;
const LOCKOUT_SEC = 900; // 15 minutes

async function checkLockout(kv, username) {
    if (!kv) return null;
    const raw = await kv.get(`LOGIN_ATTEMPTS:${username}`);
    if (!raw) return null;
    const data = JSON.parse(raw);
    if (data.count >= MAX_ATTEMPTS) {
        const elapsed = (Date.now() - data.lastAttempt) / 1000;
        if (elapsed < LOCKOUT_SEC) {
            return Math.ceil(LOCKOUT_SEC - elapsed);
        }
        await kv.delete(`LOGIN_ATTEMPTS:${username}`);
        return null;
    }
    return null;
}

async function recordFailedAttempt(kv, username) {
    if (!kv) return;
    const key = `LOGIN_ATTEMPTS:${username}`;
    const raw = await kv.get(key);
    let data = raw ? JSON.parse(raw) : { count: 0 };
    data.count++;
    data.lastAttempt = Date.now();
    await kv.put(key, JSON.stringify(data), { expirationTtl: LOCKOUT_SEC * 2 });
}

async function resetAttempts(kv, username) {
    if (!kv) return;
    await kv.delete(`LOGIN_ATTEMPTS:${username}`);
}

export async function onRequestPost(context) {
    const { request, env } = context;
    const body = await request.json();
    const { username, password, code } = body;

    const serverPassword = env.APP_PASSWORD;
    if (!serverPassword) {
        return new Response(JSON.stringify({ error: 'Server is not configured for password login.' }), {
            status: 400,
            headers: { 'Content-Type': 'application/json' }
        });
    }

    const loginUsername = username || 'admin';
    const kv = env.CF_DNS_KV;

    if (!code) {
        return new Response(JSON.stringify({ error: 'TOTP code is required.' }), {
            status: 400,
            headers: { 'Content-Type': 'application/json' }
        });
    }

    // Check account lockout
    const lockoutRemaining = await checkLockout(kv, loginUsername);
    if (lockoutRemaining) {
        return new Response(JSON.stringify({
            error: `Account temporarily locked. Try again in ${Math.ceil(lockoutRemaining / 60)} minute(s).`,
            lockedUntil: lockoutRemaining
        }), {
            status: 429,
            headers: { 'Content-Type': 'application/json', 'Retry-After': String(lockoutRemaining) }
        });
    }

    // ── Re-authenticate password ────────────────────────────────────────────

    let authenticated = false;
    let role = 'user';
    let totpSecret = null;

    if (loginUsername === 'admin') {
        const serverPasswordHash = await sha256(serverPassword);
        if (password === serverPasswordHash) {
            authenticated = true;
            role = 'admin';
        }
        // Admin TOTP secret is stored in a dedicated key
        if (kv) {
            totpSecret = await kv.get('TOTP_SECRET:admin');
        }
    } else {
        if (kv) {
            const userData = await kv.get(`USER:${loginUsername}`);
            if (userData) {
                const user = JSON.parse(userData);
                if (user.status === 'pending') {
                    return new Response(JSON.stringify({ error: 'Account not set up yet.' }), {
                        status: 403,
                        headers: { 'Content-Type': 'application/json' }
                    });
                }

                if (isLegacyHash(user.passwordHash)) {
                    if (password === user.passwordHash) {
                        authenticated = true;
                        role = user.role || 'user';
                        // Migrate to PBKDF2
                        user.passwordHash = await hashPassword(password);
                        await kv.put(`USER:${loginUsername}`, JSON.stringify(user));
                    }
                } else {
                    if (await verifyPassword(password, user.passwordHash)) {
                        authenticated = true;
                        role = user.role || 'user';
                    }
                }

                totpSecret = user.totpSecret || null;
            }
        }
    }

    if (!authenticated) {
        await recordFailedAttempt(kv, loginUsername);
        return new Response(JSON.stringify({ error: 'Invalid username or password.' }), {
            status: 401,
            headers: { 'Content-Type': 'application/json' }
        });
    }

    // ── Verify TOTP ─────────────────────────────────────────────────────────

    if (!totpSecret) {
        // TOTP is not enabled — this endpoint should not have been called.
        return new Response(JSON.stringify({ error: 'TOTP is not enabled for this account.' }), {
            status: 400,
            headers: { 'Content-Type': 'application/json' }
        });
    }

    const totpValid = await verifyTOTP(totpSecret, String(code));
    if (!totpValid) {
        await recordFailedAttempt(kv, loginUsername);
        return new Response(JSON.stringify({ error: 'Invalid TOTP code.' }), {
            status: 401,
            headers: { 'Content-Type': 'application/json' }
        });
    }

    // ── Issue tokens (same logic as login.js success path) ──────────────────

    await resetAttempts(kv, loginUsername);

    const secret = new TextEncoder().encode(serverPassword);
    const jti = crypto.randomUUID();

    const jwt = await new SignJWT({ sub: loginUsername, role })
        .setProtectedHeader({ alg: 'HS256' })
        .setIssuedAt()
        .setExpirationTime('15m')
        .sign(secret);

    const refreshToken = await new SignJWT({ sub: loginUsername, role, type: 'refresh', jti })
        .setProtectedHeader({ alg: 'HS256' })
        .setIssuedAt()
        .setExpirationTime('7d')
        .sign(secret);

    // Load user's accounts
    let accounts = [];

    if (loginUsername === 'admin') {
        // Inline the same migration/fallback logic from login.js
        if (kv) {
            const tokensJson = await kv.get('USER_TOKENS:admin');
            if (tokensJson) {
                const tokens = JSON.parse(tokensJson);
                accounts = tokens.map(t => { const tp = t.type || (t.email ? 'global_key' : 'api_token'); return { id: t.id, name: t.name, type: tp, hint: tp === 'global_key' ? (t.email || '') : (t.token ? '…' + t.token.slice(-4) : '') }; });
            }
        }
        if (accounts.length === 0) {
            // Fallback: env-based tokens
            const tokens = [];
            if (env.CF_API_TOKEN) {
                tokens.push({ id: 0, name: 'Default Account', type: 'api_token' });
            }
            let i = 1;
            while (env[`CF_API_TOKEN${i}`]) {
                tokens.push({ id: i, name: `Account ${i}`, type: 'api_token' });
                i++;
            }
            accounts = tokens;
        }
    } else if (kv) {
        const tokensJson = await kv.get(`USER_TOKENS:${loginUsername}`);
        if (tokensJson) {
            const tokens = JSON.parse(tokensJson);
            accounts = tokens.map(t => { const tp = t.type || (t.email ? 'global_key' : 'api_token'); return { id: t.id, name: t.name, type: tp, hint: tp === 'global_key' ? (t.email || '') : (t.token ? '…' + t.token.slice(-4) : '') }; });
        }
    }

    await logAudit(kv, loginUsername, 'auth.login', 'Logged in successfully (with TOTP)');

    return new Response(JSON.stringify({ token: jwt, refreshToken, accounts, role, username: loginUsername }), {
        headers: { 'Content-Type': 'application/json' }
    });
}