feat: 引入 Cloudflare Access JWT 安全验证机制
This commit is contained in:
1 parent
b565f9bcda
commit
964efa2cad
3 files changed
+182
-31
No files matched your search
+49
-31
@@ -1,41 +1,59 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
|
||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
|
import { verifyAccessJwt } from '$lib/server/access';
|
||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
function unauthorized(): Response {
|
||||||
|
return new Response('Unauthorized - Access credential verification failed', {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
// 拦截 /admin 路由
|
if (!event.url.pathname.startsWith('/admin')) {
|
||||||
if (event.url.pathname.startsWith('/admin')) {
|
return resolve(event);
|
||||||
// 优先尝试读取常规 Header (旧版或回源时存在)
|
|
||||||
let userEmail = event.request.headers.get('cf-access-authenticated-user-email');
|
|
||||||
|
|
||||||
// 针对 Cloudflare Pages,从 JWT Assertion 中解析邮箱
|
|
||||||
const jwt = event.request.headers.get('cf-access-jwt-assertion');
|
|
||||||
if (!userEmail && jwt) {
|
|
||||||
try {
|
|
||||||
const payloadBase64Url = jwt.split('.')[1];
|
|
||||||
// Base64URL 转 Base64
|
|
||||||
const base64 = payloadBase64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
// atob 在 Cloudflare Workers/Pages 中原生支持
|
|
||||||
const payloadString = atob(base64);
|
|
||||||
const payload = JSON.parse(payloadString);
|
|
||||||
if (payload && payload.email) {
|
|
||||||
userEmail = payload.email;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse CF Access JWT', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 本地开发环境通过 $app/environment 的 dev 变量来判断
|
if (dev) {
|
||||||
const isLocalDev = dev;
|
|
||||||
|
|
||||||
if (!userEmail && !isLocalDev) {
|
|
||||||
return new Response('Unauthorized', { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将用户信息存入 locals,供后续使用
|
|
||||||
event.locals.user = {
|
event.locals.user = {
|
||||||
email: userEmail || 'dev@local.host'
|
email: event.request.headers.get('cf-access-authenticated-user-email') || 'dev@local.host'
|
||||||
};
|
};
|
||||||
|
return resolve(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = event.request.headers.get('cf-access-jwt-assertion');
|
||||||
|
const teamDomain = event.platform?.env.CF_ACCESS_TEAM_DOMAIN;
|
||||||
|
const audience = event.platform?.env.CF_ACCESS_AUD;
|
||||||
|
|
||||||
|
if (!token || !teamDomain || !audience) {
|
||||||
|
console.error(
|
||||||
|
JSON.stringify({
|
||||||
|
message: 'Cloudflare Access configuration or JWT is missing',
|
||||||
|
path: event.url.pathname,
|
||||||
|
hasToken: Boolean(token),
|
||||||
|
hasTeamDomain: Boolean(teamDomain),
|
||||||
|
hasAudience: Boolean(audience)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
event.locals.user = {
|
||||||
|
email: await verifyAccessJwt(token, teamDomain, audience)
|
||||||
|
};
|
||||||
|
} catch (cause) {
|
||||||
|
console.error(
|
||||||
|
JSON.stringify({
|
||||||
|
message: 'Cloudflare Access JWT verification failed',
|
||||||
|
path: event.url.pathname,
|
||||||
|
error: cause instanceof Error ? cause.message : String(cause)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolve(event);
|
return resolve(event);
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from 'jose';
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { normalizeAccessTeamDomain, verifyAccessJwt } from './access';
|
||||||
|
|
||||||
|
const issuer = 'https://example.cloudflareaccess.com';
|
||||||
|
const audience = 'test-access-audience';
|
||||||
|
let privateKey: CryptoKey;
|
||||||
|
let keySet: ReturnType<typeof createLocalJWKSet>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const keyPair = await generateKeyPair('RS256');
|
||||||
|
privateKey = keyPair.privateKey;
|
||||||
|
const publicJwk = await exportJWK(keyPair.publicKey);
|
||||||
|
publicJwk.kid = 'test-key';
|
||||||
|
publicJwk.alg = 'RS256';
|
||||||
|
publicJwk.use = 'sig';
|
||||||
|
keySet = createLocalJWKSet({ keys: [publicJwk] });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function signToken(overrides: Record<string, unknown> = {}): Promise<string> {
|
||||||
|
return new SignJWT({ email: 'admin@example.com', ...overrides })
|
||||||
|
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||||||
|
.setIssuer(issuer)
|
||||||
|
.setAudience(audience)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('5m')
|
||||||
|
.sign(privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('normalizeAccessTeamDomain', () => {
|
||||||
|
it('normalizes a valid Cloudflare Access team hostname', () => {
|
||||||
|
expect(normalizeAccessTeamDomain('example.cloudflareaccess.com')).toBe(issuer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'http://example.cloudflareaccess.com',
|
||||||
|
'https://example.com',
|
||||||
|
'https://example.cloudflareaccess.com/path',
|
||||||
|
'https://example.cloudflareaccess.com.evil.example'
|
||||||
|
])('rejects an invalid team domain: %s', (value) => {
|
||||||
|
expect(() => normalizeAccessTeamDomain(value)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyAccessJwt', () => {
|
||||||
|
it('returns the verified email claim', async () => {
|
||||||
|
await expect(verifyAccessJwt(await signToken(), issuer, audience, keySet)).resolves.toBe(
|
||||||
|
'admin@example.com'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsigned and malformed tokens', async () => {
|
||||||
|
const unsignedPayload = btoa(JSON.stringify({ email: 'attacker@example.com' }));
|
||||||
|
await expect(
|
||||||
|
verifyAccessJwt(`x.${unsignedPayload}.x`, issuer, audience, keySet)
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token for another audience', async () => {
|
||||||
|
await expect(
|
||||||
|
verifyAccessJwt(await signToken(), issuer, 'another-audience', keySet)
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a non-empty email claim', async () => {
|
||||||
|
await expect(
|
||||||
|
verifyAccessJwt(await signToken({ email: '' }), issuer, audience, keySet)
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from 'jose';
|
||||||
|
|
||||||
|
const ACCESS_HOST_SUFFIX = '.cloudflareaccess.com';
|
||||||
|
const remoteKeySets = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
||||||
|
|
||||||
|
export function normalizeAccessTeamDomain(value: string): string {
|
||||||
|
const raw = value.trim();
|
||||||
|
if (!raw) throw new Error('Cloudflare Access team domain is empty');
|
||||||
|
|
||||||
|
const url = new URL(raw.includes('://') ? raw : `https://${raw}`);
|
||||||
|
if (
|
||||||
|
url.protocol !== 'https:' ||
|
||||||
|
url.username ||
|
||||||
|
url.password ||
|
||||||
|
url.port ||
|
||||||
|
(url.pathname !== '/' && url.pathname !== '') ||
|
||||||
|
url.search ||
|
||||||
|
url.hash ||
|
||||||
|
!url.hostname.endsWith(ACCESS_HOST_SUFFIX)
|
||||||
|
) {
|
||||||
|
throw new Error('Cloudflare Access team domain must be an HTTPS cloudflareaccess.com origin');
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemoteKeySet(issuer: string): ReturnType<typeof createRemoteJWKSet> {
|
||||||
|
let keySet = remoteKeySets.get(issuer);
|
||||||
|
if (!keySet) {
|
||||||
|
keySet = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`), {
|
||||||
|
timeoutDuration: 5_000,
|
||||||
|
cooldownDuration: 30_000,
|
||||||
|
cacheMaxAge: 10 * 60_000
|
||||||
|
});
|
||||||
|
remoteKeySets.set(issuer, keySet);
|
||||||
|
}
|
||||||
|
return keySet;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAccessJwt(
|
||||||
|
token: string,
|
||||||
|
teamDomain: string,
|
||||||
|
audience: string,
|
||||||
|
keySet?: JWTVerifyGetKey
|
||||||
|
): Promise<string> {
|
||||||
|
const issuer = normalizeAccessTeamDomain(teamDomain);
|
||||||
|
const expectedAudience = audience.trim();
|
||||||
|
if (!expectedAudience) throw new Error('Cloudflare Access audience is empty');
|
||||||
|
|
||||||
|
const { payload } = await jwtVerify(token, keySet ?? getRemoteKeySet(issuer), {
|
||||||
|
algorithms: ['RS256'],
|
||||||
|
issuer,
|
||||||
|
audience: expectedAudience,
|
||||||
|
clockTolerance: 5,
|
||||||
|
requiredClaims: ['iss', 'aud', 'exp', 'email']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof payload.email !== 'string' || !payload.email.trim()) {
|
||||||
|
throw new Error('Cloudflare Access JWT email claim is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.email.trim();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user