my.woshicai.tech
· 后端开发

使用 GitHub OAuth 实现私密博客:Cloudflare Pages + KV 实战指南

详细讲解如何在 Astro 项目中实现基于 GitHub OAuth 的私密博客功能,包括认证流程、权限控制、白名单机制,以及 Cloudflare KV 存储的实战经验

GitHub OAuth Cloudflare Astro 认证系统 私密博客

使用 GitHub OAuth 实现私密博客:Cloudflare Pages + KV 实战指南

最近在个人博客中实现了私密博客功能:公开的技术文章所有人可见,私密的生活记录仅授权用户(家人朋友)可见。本文详细讲解使用 GitHub OAuth + Cloudflare KV 的完整实现方案。

需求背景

为什么需要私密博客?

  • 技术博客:公开分享,建立个人品牌
  • 生活记录:只想给家人朋友看,不想让陌生人看到
  • 隐私保护:部分思考不适合公开

核心需求

  1. 完全隐藏:未登录用户看不到私密文章的存在
  2. 简单登录:无需密码,用 GitHub 账号一键登录
  3. 权限控制:只有白名单用户可访问私密内容
  4. 多用户支持:博主、家人、朋友各自有自己的私密文章

技术架构

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   用户      │────▶│  GitHub     │────▶│  Cloudflare │
│  浏览器     │◀────│  OAuth      │◀────│  Functions  │
└─────────────┘     └─────────────┘     └──────┬──────┘
       │                                       │
       │         ┌─────────────┐              │
       └────────▶│  Astro      │◀─────────────┘
                 │  SSR        │
                 └──────┬──────┘

                 ┌──────┴──────┐
                 │  Cloudflare │
                 │  KV         │
                 └─────────────┘

为什么选择 Cloudflare?

  • Cloudflare Pages: 免费托管,原生支持 Astro SSR
  • Cloudflare KV: 键值存储,免费额度充足(10GB 存储)
  • 边缘计算: Functions 全球部署,低延迟
  • 成本: 全免费方案,适合个人项目

实现步骤

1. 创建 GitHub OAuth App

  1. 访问 https://github.com/settings/developers
  2. 点击 “New OAuth App”
  3. 填写信息:
    • Application name: 你的博客名称
    • Homepage URL: https://your-domain.com
    • Authorization callback URL: https://your-domain.com/api/auth/github/callback
  4. 获取 Client IDClient 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>

安全考虑

// 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