· 后端开发
使用 GitHub OAuth 实现私密博客:Cloudflare Pages + KV 实战指南
详细讲解如何在 Astro 项目中实现基于 GitHub OAuth 的私密博客功能,包括认证流程、权限控制、白名单机制,以及 Cloudflare KV 存储的实战经验
GitHub OAuth Cloudflare Astro 认证系统 私密博客
使用 GitHub OAuth 实现私密博客:Cloudflare Pages + KV 实战指南
最近在个人博客中实现了私密博客功能:公开的技术文章所有人可见,私密的生活记录仅授权用户(家人朋友)可见。本文详细讲解使用 GitHub OAuth + Cloudflare KV 的完整实现方案。
需求背景
为什么需要私密博客?
- 技术博客:公开分享,建立个人品牌
- 生活记录:只想给家人朋友看,不想让陌生人看到
- 隐私保护:部分思考不适合公开
核心需求
- 完全隐藏:未登录用户看不到私密文章的存在
- 简单登录:无需密码,用 GitHub 账号一键登录
- 权限控制:只有白名单用户可访问私密内容
- 多用户支持:博主、家人、朋友各自有自己的私密文章
技术架构
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 用户 │────▶│ GitHub │────▶│ Cloudflare │
│ 浏览器 │◀────│ OAuth │◀────│ Functions │
└─────────────┘ └─────────────┘ └──────┬──────┘
│ │
│ ┌─────────────┐ │
└────────▶│ Astro │◀─────────────┘
│ SSR │
└──────┬──────┘
│
┌──────┴──────┐
│ Cloudflare │
│ KV │
└─────────────┘
为什么选择 Cloudflare?
- Cloudflare Pages: 免费托管,原生支持 Astro SSR
- Cloudflare KV: 键值存储,免费额度充足(10GB 存储)
- 边缘计算: Functions 全球部署,低延迟
- 成本: 全免费方案,适合个人项目
实现步骤
1. 创建 GitHub OAuth App
- 访问 https://github.com/settings/developers
- 点击 “New OAuth App”
- 填写信息:
- Application name: 你的博客名称
- Homepage URL:
https://your-domain.com - Authorization callback URL:
https://your-domain.com/api/auth/github/callback
- 获取 Client ID 和 Client Secret
2. 配置 Cloudflare 环境
创建 KV 命名空间:
# 使用 wrangler CLI
npx wrangler kv:namespace create "WHITELIST"
npx wrangler kv:namespace create "SESSIONS"
在 Cloudflare Pages 后台添加环境变量:
GITHUB_CLIENT_ID=your_client_id
GITHUB_CLIENT_SECRET=your_client_secret
JWT_SECRET=your_random_secret_key
BASE_URL=https://your-domain.com
3. 扩展博客 Schema
修改 src/content/config.ts 添加可见性字段:
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
publishDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
category: z.string().default('未分类'),
// 新增字段
visibility: z.enum(['public', 'private']).default('public'),
blogType: z.enum(['tech', 'life']).default('tech'),
author: z.string().optional(), // 私密文章必填
}),
});
export const collections = { blog };
文章内容示例:
---
title: 周末去公园野餐
description: 和家人一起享受美好时光
publishDate: 2025-02-11
visibility: private # 私密文章
blogType: life # 生活类型
author: github_username # 作者 GitHub 用户名
tags: ['生活', '家庭']
---
今天天气很好,和家人一起去公园野餐...
4. 实现认证流程
4.1 登录入口
src/pages/api/auth/github/login.ts:
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ redirect }) => {
const clientId = import.meta.env.GITHUB_CLIENT_ID;
const baseUrl = import.meta.env.BASE_URL;
// 生成随机 state 防止 CSRF
const state = crypto.randomUUID();
// GitHub OAuth URL
const githubAuthUrl = new URL('https://github.com/login/oauth/authorize');
githubAuthUrl.searchParams.set('client_id', clientId);
githubAuthUrl.searchParams.set('redirect_uri', `${baseUrl}/api/auth/github/callback`);
githubAuthUrl.searchParams.set('scope', 'read:user');
githubAuthUrl.searchParams.set('state', state);
// 将 state 存入 cookie(用于后续验证)
const headers = new Headers();
headers.append('Set-Cookie', `oauth_state=${state}; HttpOnly; Secure; SameSite=Strict; Max-Age=600`);
headers.append('Location', githubAuthUrl.toString());
return new Response(null, { status: 302, headers });
};
4.2 回调处理
src/pages/api/auth/github/callback.ts:
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ request, redirect, cookies }) => {
const url = new URL(request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
// 验证 state 防止 CSRF
const savedState = cookies.get('oauth_state')?.value;
if (!savedState || savedState !== state) {
return redirect('/login?error=invalid_state');
}
try {
// 用 code 换取 access_token
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'Your-Blog',
},
body: JSON.stringify({
client_id: import.meta.env.GITHUB_CLIENT_ID,
client_secret: import.meta.env.GITHUB_CLIENT_SECRET,
code,
}),
});
const { access_token } = await tokenRes.json();
// 获取用户信息
const userRes = await fetch('https://api.github.com/user', {
headers: {
'Authorization': `Bearer ${access_token}`,
'User-Agent': 'Your-Blog',
},
});
const githubUser = await userRes.json();
// 检查白名单
const { WHITELIST } = (request as any).env;
const whitelistUser = await WHITELIST.get(githubUser.login);
// 白名单为空时,第一个用户自动加入
const allUsers = await WHITELIST.list();
if (allUsers.keys.length === 0) {
await WHITELIST.put(githubUser.login, JSON.stringify({
githubId: githubUser.id,
githubLogin: githubUser.login,
avatarUrl: githubUser.avatar_url,
name: githubUser.name || githubUser.login,
addedAt: new Date().toISOString(),
}));
} else if (!whitelistUser) {
return redirect('/login?error=not_whitelisted');
}
// 生成 JWT
const jwt = await generateJWT({
githubId: githubUser.id,
githubLogin: githubUser.login,
avatarUrl: githubUser.avatar_url,
name: githubUser.name || githubUser.login,
});
// 设置 cookie
const headers = new Headers();
headers.append('Set-Cookie', `token=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=604800`);
headers.append('Set-Cookie', `oauth_state=; HttpOnly; Secure; SameSite=Strict; Max-Age=0`); // 清除 state
headers.append('Location', '/');
return new Response(null, { status: 302, headers });
} catch (error) {
console.error('OAuth error:', error);
return redirect('/login?error=auth_failed');
}
};
4.3 JWT 处理
src/lib/jwt.ts:
import { SignJWT, jwtVerify } from 'jose';
const JWT_SECRET = new TextEncoder().encode(import.meta.env.JWT_SECRET);
export interface JWTPayload {
githubId: string;
githubLogin: string;
avatarUrl: string;
name: string;
}
export async function generateJWT(payload: JWTPayload): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(JWT_SECRET);
}
export async function verifyJWT(token: string): Promise<JWTPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as JWTPayload;
} catch {
return null;
}
}
5. 权限控制中间件
src/middleware.ts:
import { defineMiddleware } from 'astro:middleware';
import { verifyJWT } from './lib/jwt';
export const onRequest = defineMiddleware(async ({ request, cookies, locals }, next) => {
const token = cookies.get('token')?.value;
if (token) {
const user = await verifyJWT(token);
if (user) {
locals.user = user;
}
}
return next();
});
6. 博客列表权限过滤
src/pages/blog/index.astro:
---
import Layout from '../../layouts/Layout.astro';
import { getCollection } from 'astro:content';
// 获取当前用户
const user = Astro.locals.user;
// 获取所有文章
const allPosts = await getCollection('blog');
// 过滤可见文章
const visiblePosts = allPosts.filter(post => {
// 公开文章:所有人可见
if (post.data.visibility === 'public') {
return true;
}
// 私密文章:仅作者可见
if (post.data.visibility === 'private') {
return user && post.data.author === user.githubLogin;
}
return false;
});
// 按日期排序
const sortedPosts = visiblePosts.sort((a, b) =>
b.data.publishDate.getTime() - a.data.publishDate.getTime()
);
---
<Layout title="博客">
<div class="container">
<h1>博客</h1>
<p>共 {sortedPosts.length} 篇文章</p>
<div class="space-y-6">
{sortedPosts.map(post => (
<article class="border-b pb-6">
<div class="flex items-center gap-2 text-sm text-gray-500">
<time>{post.data.publishDate.toLocaleDateString('zh-CN')}</time>
{post.data.visibility === 'private' && (
<span class="text-amber-600">🔒 私密</span>
)}
</div>
<h2>
<a href={`/blog/${post.slug}`} class="text-xl font-semibold hover:text-blue-500">
{post.data.title}
</a>
</h2>
<p class="text-gray-600">{post.data.description}</p>
</article>
))}
</div>
</div>
</Layout>
7. 博客详情权限验证
src/pages/blog/[slug].astro:
---
import Layout from '../../layouts/Layout.astro';
import { getCollection } from 'astro:content';
const { slug } = Astro.params;
const user = Astro.locals.user;
// 获取文章
const allPosts = await getCollection('blog');
const post = allPosts.find(p => p.slug === slug);
// 文章不存在
if (!post) {
return Astro.redirect('/404');
}
// 私密文章权限检查
if (post.data.visibility === 'private') {
if (!user) {
return Astro.redirect('/login');
}
if (post.data.author !== user.githubLogin) {
return Astro.redirect('/blog');
}
}
const { Content } = await post.render();
---
<Layout title={post.data.title}>
<article class="container max-w-3xl">
<header class="mb-8">
{post.data.visibility === 'private' && (
<span class="inline-block px-2 py-1 text-xs bg-amber-100 text-amber-800 rounded mb-4">
🔒 私密文章
</span>
)}
<h1 class="text-4xl font-bold">{post.data.title}</h1>
<time class="text-gray-500">{post.data.publishDate.toLocaleDateString('zh-CN')}</time>
</header>
<div class="prose max-w-none">
<Content />
</div>
</article>
</Layout>
安全考虑
1. Cookie 安全设置
// HttpOnly: 防止 XSS 窃取
document.cookie = 'token=xxx; HttpOnly; Secure; SameSite=Strict';
2. CSRF 防护
使用 state 参数验证 OAuth 流程:
// 登录时生成 state
const state = crypto.randomUUID();
// 回调时验证
if (savedState !== receivedState) {
throw new Error('Invalid state');
}
3. 白名单机制
即使 OAuth 登录成功,也必须在白名单中才能访问:
const whitelistUser = await WHITELIST.get(githubUser.login);
if (!whitelistUser) {
return redirect('/login?error=not_whitelisted');
}
踩坑记录
1. GitHub API 403 错误
GitHub API 要求必须包含 User-Agent 头:
// ❌ 错误
fetch('https://api.github.com/user', {
headers: { 'Authorization': `Bearer ${token}` }
});
// ✅ 正确
fetch('https://api.github.com/user', {
headers: {
'Authorization': `Bearer ${token}`,
'User-Agent': 'Your-Blog-Name' // 必须!
}
});
2. KV 绑定问题
Cloudflare Pages Functions 通过 request.env 访问 KV:
// ❌ 错误:process.env 在 Cloudflare 中不可用
const { WHITELIST } = process.env;
// ✅ 正确
const { WHITELIST } = (request as any).env;
3. JWT 库选择
使用 jose 而不是 jsonwebtoken,因为前者支持 Cloudflare Workers:
# ✅ 推荐
bun add jose
# ❌ 不推荐(依赖 Node.js crypto)
bun add jsonwebtoken
用户体验优化
1. 登录页面
<!-- src/pages/login.astro -->
<Layout title="登录">
<div class="container py-12 flex justify-center">
<div class="w-full max-w-md">
<h1 class="text-3xl font-bold text-center mb-8">登录</h1>
{error === 'not_whitelisted' && (
<div class="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded">
<p class="text-yellow-800 text-sm">
您还未被授权访问此网站。请联系管理员添加到白名单。
</p>
</div>
)}
<div class="bg-white rounded-lg shadow-lg p-8">
<a href="/api/auth/github/login"
class="flex items-center justify-center gap-3 w-full py-3 px-4 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<!-- GitHub 图标 -->
</svg>
<span class="font-semibold">使用 GitHub 登录</span>
</a>
</div>
</div>
</div>
</Layout>
2. 导航栏用户菜单
<!-- 在 Layout.astro 中 -->
<nav>
{user ? (
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<img src={user.avatarUrl} alt={user.name} class="w-8 h-8 rounded-full" />
<span class="text-sm">{user.name}</span>
</div>
<form action="/api/auth/logout" method="post">
<button type="submit" class="text-sm text-gray-600 hover:text-blue-500">
登出
</button>
</form>
</div>
) : (
<a href="/api/auth/github/login" class="flex items-center gap-2 px-4 py-2 bg-gray-900 text-white rounded-lg">
<svg><!-- GitHub 图标 --></svg>
<span>GitHub 登录</span>
</a>
)}
</nav>
总结
使用 GitHub OAuth + Cloudflare 实现私密博客的优势:
| 特性 | 方案 |
|---|---|
| 认证方式 | GitHub OAuth(无需密码) |
| 后端服务 | Cloudflare Functions(免费) |
| 数据存储 | Cloudflare KV(免费额度充足) |
| 托管平台 | Cloudflare Pages(原生 SSR 支持) |
| 成本 | 全免费 |
核心文件结构:
src/
├── pages/
│ ├── login.astro # 登录页面
│ ├── api/auth/github/
│ │ ├── login.ts # OAuth 入口
│ │ └── callback.ts # OAuth 回调
│ └── blog/
│ ├── index.astro # 列表(权限过滤)
│ └── [slug].astro # 详情(权限验证)
├── lib/
│ └── jwt.ts # JWT 处理
└── middleware.ts # 认证中间件
参考资源
私密博客功能已上线,体验地址:my.woshicai.tech