码桶

发现社区成员的开源项目

login.js7.6 KB
import { SignJWT } from 'jose';
import { sha256, hashPassword, verifyPassword, isLegacyHash } from './_crypto.js';
import { logAudit } from './_audit.js';
import { fireWebhook } from './_webhook.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);
        }
        // Lockout expired, reset
        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}`);
}

// Lazy migrate old CF_API_TOKEN* keys into USER_TOKENS:admin
async function migrateAdminTokens(env) {
    const kv = env.CF_DNS_KV;
    if (!kv) return [];

    const existing = await kv.get('USER_TOKENS:admin');
    if (existing) return JSON.parse(existing);

    const tokens = [];
    if (env.CF_API_TOKEN) {
        tokens.push({ id: 0, name: 'Default Account', token: env.CF_API_TOKEN });
    }
    let i = 1;
    while (env[`CF_API_TOKEN${i}`]) {
        tokens.push({ id: i, name: `Account ${i}`, token: env[`CF_API_TOKEN${i}`] });
        i++;
    }

    const seen = new Set(tokens.map(t => t.id));
    const kvToken = await kv.get('CF_API_TOKEN');
    if (kvToken && !seen.has(0)) {
        const name = await kv.get('CF_API_TOKEN_NAME') || 'Default Account';
        tokens.push({ id: 0, name, token: kvToken });
    }
    let k = 1;
    while (true) {
        const t = await kv.get(`CF_API_TOKEN${k}`);
        if (!t) break;
        if (!seen.has(k)) {
            const name = await kv.get(`CF_API_TOKEN_NAME${k}`) || `Account ${k}`;
            tokens.push({ id: k, name, token: t });
        }
        k++;
    }

    if (tokens.length > 0) {
        await kv.put('USER_TOKENS:admin', JSON.stringify(tokens));
    }
    return tokens;
}

export async function onRequestPost(context) {
    const { request, env } = context;
    const body = await request.json();
    const { username, password } = 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;

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

    let authenticated = false;
    let role = 'user';
    let hasTOTP = false;

    if (loginUsername === 'admin') {
        const serverPasswordHash = await sha256(serverPassword);
        if (password === serverPasswordHash) {
            authenticated = true;
            role = 'admin';
        }
        // Check if admin has TOTP enabled (stored in dedicated key)
        if (kv) {
            const adminTotp = await kv.get('TOTP_SECRET:admin');
            if (adminTotp) hasTOTP = true;
        }
    } 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. Please use your setup token to set a password first.', needsSetup: true }), {
                        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';
                    }
                }

                if (user.totpSecret) hasTOTP = true;
            }
        }
    }

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

    // Reset failed attempts on success
    await resetAttempts(kv, loginUsername);

    // If user has TOTP enabled, require a second step — do not issue JWT yet
    if (hasTOTP) {
        return new Response(JSON.stringify({ requiresTOTP: true, username: loginUsername }), {
            headers: { 'Content-Type': 'application/json' }
        });
    }

    // Generate JWT access token (15 min)
    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);

    // Generate refresh token (7 days)
    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') {
        const tokens = await migrateAdminTokens(env);
        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) : '') }; });
    } 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');
    await fireWebhook(kv, {
        type: 'auth.login',
        username: loginUsername,
        detail: 'Logged in successfully'
    });

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