码桶

发现社区成员的开源项目

login-verify.js6.1 KB
// POST /api/passkey/login-verify — verify WebAuthn authentication response, issue JWT
// No authentication required.

import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { SignJWT } from 'jose';
import { logAudit } from '../_audit.js';

function base64ToUint8Array(b64) {
    const binary = atob(b64);
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
    return bytes;
}

export async function onRequestPost(context) {
    const { request, env } = context;
    const kv = env.CF_DNS_KV;
    const serverPassword = env.APP_PASSWORD;

    if (!kv) {
        return new Response(JSON.stringify({ error: 'KV storage not configured.' }), {
            status: 500, headers: { 'Content-Type': 'application/json' }
        });
    }

    if (!serverPassword) {
        return new Response(JSON.stringify({ error: 'Server not configured.' }), {
            status: 500, headers: { 'Content-Type': 'application/json' }
        });
    }

    const body = await request.json();
    const url = new URL(request.url);
    const rpID = url.hostname;
    const origin = url.origin;

    // Find the challenge and associated username
    let challengeUsername = null;

    let verification;
    try {
        verification = await verifyAuthenticationResponse({
            response: body,
            expectedChallenge: async (challenge) => {
                const stored = await kv.get(`PASSKEY_CHALLENGE:${challenge}`);
                if (!stored) return false;
                const data = JSON.parse(stored);
                if (data.type !== 'authentication') return false;
                challengeUsername = data.username;
                await kv.delete(`PASSKEY_CHALLENGE:${challenge}`);
                return true;
            },
            expectedOrigin: origin,
            expectedRPID: rpID,
            credential: await (async () => {
                // We need to find the credential. The response.id tells us which credential was used.
                // But we don't know the username yet at this point in the flow...
                // We'll use a two-pass approach: first find the challenge to get the username,
                // then find the credential.
                // Actually, the expectedChallenge callback runs first, so we use body.response
                // to extract the challenge from clientDataJSON.
                // Let's decode clientDataJSON to get the challenge first.
                const clientDataB64 = body.response.clientDataJSON;
                const clientDataJson = atob(clientDataB64.replace(/-/g, '+').replace(/_/g, '/'));
                const clientData = JSON.parse(clientDataJson);
                const challenge = clientData.challenge;

                const stored = await kv.get(`PASSKEY_CHALLENGE:${challenge}`);
                if (!stored) throw new Error('Challenge not found or expired.');
                const data = JSON.parse(stored);
                challengeUsername = data.username;

                // Load user's credentials
                const credsJson = await kv.get(`PASSKEY_CREDS:${challengeUsername}`);
                const creds = credsJson ? JSON.parse(credsJson) : [];

                const matching = creds.find(c => c.credentialId === body.id);
                if (!matching) throw new Error('Credential not found.');

                return {
                    id: matching.credentialId,
                    publicKey: base64ToUint8Array(matching.publicKey),
                    counter: matching.counter,
                    transports: matching.transports || [],
                };
            })(),
        });
    } catch (err) {
        return new Response(JSON.stringify({ error: 'Authentication failed.', message: err.message }), {
            status: 401, headers: { 'Content-Type': 'application/json' }
        });
    }

    if (!verification.verified) {
        return new Response(JSON.stringify({ error: 'Passkey verification failed.' }), {
            status: 401, headers: { 'Content-Type': 'application/json' }
        });
    }

    // Update credential counter
    const credsJson = await kv.get(`PASSKEY_CREDS:${challengeUsername}`);
    const creds = credsJson ? JSON.parse(credsJson) : [];
    const credIdx = creds.findIndex(c => c.credentialId === body.id);
    if (credIdx >= 0) {
        creds[credIdx].counter = verification.authenticationInfo.newCounter;
        await kv.put(`PASSKEY_CREDS:${challengeUsername}`, JSON.stringify(creds));
    }

    // Determine role
    let role = 'user';
    if (challengeUsername === 'admin') {
        role = 'admin';
    } else {
        const userDataJson = await kv.get(`USER:${challengeUsername}`);
        if (userDataJson) {
            const userData = JSON.parse(userDataJson);
            role = userData.role || 'user';
        }
    }

    // Generate JWT access token (15 min)
    const secret = new TextEncoder().encode(serverPassword);
    const jti = crypto.randomUUID();
    const jwt = await new SignJWT({ sub: challengeUsername, role })
        .setProtectedHeader({ alg: 'HS256' })
        .setIssuedAt()
        .setExpirationTime('15m')
        .sign(secret);

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

    // Load accounts
    let accounts = [];
    if (challengeUsername === 'admin') {
        const tokensJson = await kv.get('USER_TOKENS:admin');
        if (tokensJson) {
            accounts = JSON.parse(tokensJson).map(t => ({ id: t.id, name: t.name }));
        }
    } else {
        const tokensJson = await kv.get(`USER_TOKENS:${challengeUsername}`);
        if (tokensJson) {
            accounts = JSON.parse(tokensJson).map(t => ({ id: t.id, name: t.name }));
        }
    }

    await logAudit(kv, challengeUsername, 'auth.passkey_login', 'Logged in via passkey');

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