为了添加用户认证功能,支持 OAuth 2,并允许用户使用 Google 和 GitHub 账户登录,我们需要进行以下步骤:

  1. 安装必要的依赖:
bun add @elysiajs/oauth @elysiajs/cookie @elysiajs/jwt
  1. 在 Google Cloud Console 和 GitHub Developer Settings 中创建 OAuth 应用,并获取客户端 ID 和密钥。

  2. 更新 src/index.ts 文件以添加认证功能:

import { Elysia } from 'elysia';
import { html } from '@elysiajs/html';
import { staticPlugin } from '@elysiajs/static';
import { oauth, OAuth2RequestError } from '@elysiajs/oauth';
import { cookie } from '@elysiajs/cookie';
import { jwt } from '@elysiajs/jwt';
import { renderToString } from 'react-dom/server';
import { Blog } from './components/Blog';
import { db } from './db';
import { posts, users } from './db/schema';
import { eq } from 'drizzle-orm';
import { marked } from 'marked';
 
const app = new Elysia()
  .use(html())
  .use(staticPlugin())
  .use(cookie())
  .use(jwt({
    name: 'jwt',
    secret: process.env.JWT_SECRET!
  }))
  .use(oauth({
    providers: {
      google: {
        clientID: process.env.GOOGLE_CLIENT_ID!,
        clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
        redirectURI: 'http://localhost:3000/auth/google/callback',
        scope: ['profile', 'email']
      },
      github: {
        clientID: process.env.GITHUB_CLIENT_ID!,
        clientSecret: process.env.GITHUB_CLIENT_SECRET!,
        redirectURI: 'http://localhost:3000/auth/github/callback',
        scope: ['user:email']
      }
    }
  }))
  .derive(({ cookie, jwt, setCookie }) => ({
    getUser: async () => {
      const token = cookie.auth;
      if (!token) return null;
      try {
        const payload = await jwt.verify(token);
        return payload;
      } catch {
        return null;
      }
    },
    setUser: (user: any) => {
      const token = jwt.sign(user);
      setCookie('auth', token, {
        httpOnly: true,
        maxAge: 7 * 86400, // 7 days
      });
    }
  }))
  .get('/', async ({ html, getUser }) => {
    const user = await getUser();
    const allPosts = await db.select().from(posts).all();
 
    const blogHtml = renderToString(Blog({ posts: allPosts, user }));
 
    return html(`
      <!DOCTYPE html>
      <html lang="en">
        <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>My Blog</title>
          <link rel="stylesheet" href="/public/styles.css">
          <script src="https://unpkg.com/htmx.org@1.9.0"></script>
          <script src="https://unpkg.com/marked@4.0.16/marked.min.js"></script>
        </head>
        <body>
          <div id="root">${blogHtml}</div>
        </body>
      </html>
    `);
  })
  .get('/login', ({ html }) => {
    return html(`
      <h1>Login</h1>
      <a href="/auth/google">Login with Google</a>
      <br>
      <a href="/auth/github">Login with GitHub</a>
    `);
  })
  .get('/auth/:provider', async ({ params }) => {
    if (params.provider === 'google') {
      return await app.oauth.google.redirect();
    } else if (params.provider === 'github') {
      return await app.oauth.github.redirect();
    }
  })
  .get('/auth/:provider/callback', async ({ params, oauth, set, setUser }) => {
    try {
      let profile;
      if (params.provider === 'google') {
        const { userInfo } = await app.oauth.google.validate();
        profile = {
          provider: 'google',
          id: userInfo.sub,
          email: userInfo.email,
          name: userInfo.name
        };
      } else if (params.provider === 'github') {
        const { userInfo } = await app.oauth.github.validate();
        profile = {
          provider: 'github',
          id: userInfo.id,
          email: userInfo.email,
          name: userInfo.name
        };
      } else {
        set.status = 400;
        return 'Invalid provider';
      }
 
      let user = await db.select().from(users).where(eq(users.providerId, profile.id)).get();
      if (!user) {
        user = await db.insert(users).values({
          providerId: profile.id,
          provider: profile.provider,
          email: profile.email,
          name: profile.name
        }).returning().get();
      }
 
      setUser(user);
      set.redirect = '/';
    } catch (e) {
      if (e instanceof OAuth2RequestError) {
        set.status = 400;
        return 'Invalid code';
      }
      set.status = 500;
      return 'Internal Server Error';
    }
  })
  .get('/logout', ({ setCookie, set }) => {
    setCookie('auth', '', { maxAge: 0 });
    set.redirect = '/';
  })
  .get('/api/post/:id', async ({ params, getUser }) => {
    const user = await getUser();
    if (!user) {
      return new Response('Unauthorized', { status: 401 });
    }
 
    const post = await db.select().from(posts).where(eq(posts.id, parseInt(params.id))).get();
 
    if (!post) {
      return new Response('Post not found', { status: 404 });
    }
 
    return `
      <article>
        <h2>${post.title}</h2>
        <div>${marked(post.content)}</div>
        <small>Created at: ${new Date(post.createdAt).toLocaleString()}</small>
      </article>
    `;
  })
  .post('/api/post', async ({ body, getUser }) => {
    const user = await getUser();
    if (!user) {
      return new Response('Unauthorized', { status: 401 });
    }
 
    const { title, content } = body as { title: string; content: string };
    const newPost = await db.insert(posts).values({ title, content, authorId: user.id }).returning().get();
    return `
      <article>
        <h2>${newPost.title}</h2>
        <div>${marked(newPost.content)}</div>
        <small>Created at: ${new Date(newPost.createdAt).toLocaleString()}</small>
        <button hx-get="/api/post/${newPost.id}" hx-target="closest article" hx-swap="outerHTML">
          Load Full Post
        </button>
        <button hx-delete="/api/post/${newPost.id}" hx-target="closest article" hx-swap="outerHTML">
          Delete Post
        </button>
      </article>
    `;
  })
  .put('/api/post/:id', async ({ params, body, getUser }) => {
    const user = await getUser();
    if (!user) {
      return new Response('Unauthorized', { status: 401 });
    }
 
    const { title, content } = body as { title: string; content: string };
    const updatedPost = await db.update(posts)
      .set({ title, content })
      .where(eq(posts.id, parseInt(params.id)))
      .returning()
      .get();
    return updatedPost;
  })
  .delete('/api/post/:id', async ({ params, getUser }) => {
    const user = await getUser();
    if (!user) {
      return new Response('Unauthorized', { status: 401 });
    }
 
    await db.delete(posts).where(eq(posts.id, parseInt(params.id))).run();
    return { success: true };
  })
  .listen(3000);
 
console.log(`Server is running at http://localhost:${app.server?.port}`);
  1. 更新 src/db/schema.ts 以添加用户表:
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
 
export const users = sqliteTable('users', {
  id: integer('id').primaryKey(),
  providerId: text('provider_id').notNull(),
  provider: text('provider').notNull(),
  email: text('email').notNull(),
  name: text('name').notNull(),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
 
export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey(),
  title: text('title').notNull(),
  content: text('content').notNull(),
  authorId: integer('author_id').notNull().references(() => users.id),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
  1. 更新 src/components/Blog.tsx 以显示登录状态和登出按钮:
import React, { useState } from 'react';
import Editor from './Editor';
import { marked } from 'marked';
 
interface Post {
  id: number;
  title: string;
  content: string;
  createdAt: number;
}
 
interface User {
  id: number;
  name: string;
  email: string;
}
 
interface BlogProps {
  posts: Post[];
  user: User | null;
}
 
export const Blog: React.FC<BlogProps> = ({ posts, user }) => {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    // @ts-ignore
    htmx.trigger('#new-post-form', 'submit');
  };
 
  return (
    <div>
      <h1>My Blog</h1>
      {user ? (
        <>
          <p>Welcome, {user.name}! <a href="/logout">Logout</a></p>
          <form id="new-post-form" hx-post="/api/post" hx-swap="beforeend" hx-target="#posts" onSubmit={handleSubmit}>
            <input
              type="text"
              name="title"
              placeholder="Title"
              required
              value={title}
              onChange={(e) => setTitle(e.target.value)}
            />
            <Editor onChange={setContent} />
            <input type="hidden" name="content" value={content} />
            <button type="submit">Create Post</button>
          </form>
        </>
      ) : (
        <p><a href="/login">Login to create posts</a></p>
      )}
      <div id="posts">
        {posts.map(post => (
          <article key={post.id}>
            <h2>{post.title}</h2>
            <div dangerouslySetInnerHTML={{ __html: marked(post.content) }} />
            <small>Created at: {new Date(post.createdAt).toLocaleString()}</small>
            {user && (
              <>
                <button hx-get={`/api/post/${post.id}`} hx-target="closest article" hx-swap="outerHTML">
                  Load Full Post
                </button>
                <button hx-delete={`/api/post/${post.id}`} hx-target="closest article" hx-swap="outerHTML">
                  Delete Post
                </button>
              </>
            )}
          </article>
        ))}
      </div>
    </div>
  );
};
  1. 更新环境变量:

确保在你的环境中设置了以下变量:

GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
JWT_SECRET=your_jwt_secret
  1. 运行数据库迁移:
bun run generate
bun run push

这个实现添加了以下功能:

  1. 用户可以使用 Google 或 GitHub 账户登录。
  2. 登录后,用户信息会被保存在数据库中。
  3. 用户登录状态通过 JWT 保存在 cookie 中。
  4. 只有登录用户才能创建、编辑和删除博客文章。
  5. 博客文章现在与作者关联。

这个实现还可以进一步改进,例如:

  • 添加用户个人资料页面。
  • 实现邮箱验证。
  • 添加更多的 OAuth 提供商,如 Facebook、Twitter 等。
  • 实现基于角色的访问控制(RBAC)。
  • 添加密码重置功能。
  • 实现记住我功能。
  • 添加登录尝试限制以防止暴力攻击。

记住,在实际部署时,你需要使用 HTTPS 来保护用户的敏感信息,并考虑其他安全最佳实践,如 CSRF 保护、适当的错误处理等。