基于 cloudflare hono github 的登录系统
技术栈:
cloudflare worker
D1数据库
hono框架
github auth
本地项目地址:
/Users/qy/Documents/git_rep/cloudflare_project/2025-03-05-hono-github-login-db/hono-auth-test-app-D1
github remote repo:
https://github.com/qyzhizi/hono-auth-test-app-D1
另一个参考:
/Users/qy/Documents/git_rep/cloudflare_project/2025-02-22-github-clone-authjs-cloudflare/Authjs-cloudflare
next.js 不适合部署到 cloudflare,感觉总是会遇到各种兼容性的问题, 所以我切换到 hono, 框架更简洁,使用更简单
使用 hono 可以使用 包 hono@latest , 创建一个 cloudflare 的工程:
pnpm create hono@latest hono-auth-test-app
过程中 选择 cloudflare-workers 框架
安装依赖:
pnpm i
运行:
pnpm dev
实际上启动的是: wrangler dev
测试:
curl -X GET http://localhost:8787/
编辑:wrangler.jsonc 添加: nodejs_compat
在本文中,我们将包含依赖于 Node.js API 的包。
如果你在默认状态下使用它,你会生气,所以启用 nodejs_compat 标志。
具体原因参考:https://developers.cloudflare.com/workers/runtime-apis/nodejs/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "hono-auth-test-app",
"main": "src/index.ts",
"compatibility_date": "2025-03-05",
"compatibility_flags": [
"nodejs_compat"
]
}
DB环境构筑
博文中使用的是 本地 docker 部署的 pg, 这里我是用的是 cloudflare 平台的 D1 数据库,其实也是 sqlite 数据库
在本地调试时也可以通过 wrangler 虚拟出 worker 运行时与D1 数据库
本地创建D1 数据库
pnpm add drizzle-orm
pnpm add -D drizzle-kit
创建 src/infrastructure/db/schema.ts
import {
sqliteTable,
integer,
text,
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm"; // 正确导入 sql
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
email: text("email").notNull().unique(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const userAuths = sqliteTable("user_auths", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
providerType: integer("provider_type").notNull(),
providerUserId: text("provider_user_id").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const tables = {
users,
userAuths,
};
drizzle.config.ts
的設定
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle/migrations",
schema: "./src/infrastructure/db/schema.ts",
dialect: "sqlite",
driver: "d1-http",
});
本地创建数据库迁移:
pnpm drizzle-kit generate --config=drizzle-dev.config.ts
├── [ 56] README.md
├── [ 96] drizzle
│ └── [ 128] migrations
│ ├── [ 734] 0000_awesome_wither.sql
│ └── [ 128] meta
│ ├── [3.4K] 0000_snapshot.json
│ └── [ 204] _journal.json
├── [ 206] drizzle-dev.config.ts
配置 D1 数据库
"d1_databases": [
{
"binding": "DB",
"database_name": "hono_db1",
"database_id": "df0c8a98-2a17-4977-b9d9-34d795e90fea",
"migrations_dir": "drizzle/migrations"
}
]
binding: 绑定类型
database_name: 数据库名字
database_id 是远程 cloudflare D1 数据库的 id, 目前应该可以先不管
migrations_dir 是数据库迁移数据所在的目录
创建 D1 数据库:
npx wrangler d1 migrations apply hono_db1 --local
如果要创建 cloudflare 远程数据库可以使用:
npx wrangler d1 migrations apply hono_db1 --remote
hono_db1 是 数据库的名字
检查D1 数据库是否已经创建:
npx wrangler d1 execute hono_db1 --command="select * from user_auths;" --local
如果里面有数据,就是下面这个样子的,如果没有数据,那么会显示为空,但不会报错,目前是还没有数据的
⛅️ wrangler 3.112.0 (update available 3.114.0)
---------------------------------------------------------
🌀 Executing on local database hono_db1 (df0c8a98-2a17-4977-b9d9-34d795e90fea) from .wrangler/state/v3/d1:
🌀 To execute on your remote database, add a --remote flag to your wrangler command.
🚣 1 command executed successfully.
┌────┬─────────┬───────────────┬──────────────────┬─────────────────────┬─────────────────────┐
│ id │ user_id │ provider_type │ provider_user_id │ created_at │ updated_at │
├────┼─────────┼───────────────┼──────────────────┼─────────────────────┼─────────────────────┤
│ 1 │ 1 │ 0 │ 25292861 │ 2025-03-08 09:45:42 │ 2025-03-08 09:45:42 │
└────┴─────────┴───────────────┴──────────────────┴─────────────────────┴─────────────────────┘
创建远程数据库,如果后面要部署到远程,需要这一步:
如果未登录 cloudflare,需要登录先
npx wrangler d1 migrations apply hono_db1 --remote
.dev.vars 用于处理本地环境中的 secret 信息
因此可以这样添加:
ACCESS_TOKEN_SECRET='access-token-secret'
REFRESH_TOKEN_SECRET='refresh-token-secret'
ACCESS_TOKEN_EXPIRY="300"
REFRESH_TOKEN_EXPIRY="604800"
GITHUB_ID=Ov23l******C
GITHUB_SECRET=5f1*****a
这里的 GITHUB_ID GITHUB_SECRET 后面会提到,是 github auth 认证所创建的应用信息
ACCESS_TOKEN_SECRET REFRESH_TOKEN_SECRET 是用于生成 jwt 所需的 密钥
可以使用下面的命令生成 32位的随机密码:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
创建文件:src/types/provider.ts
export const Provider = {
GitHub: 0,
Google: 1,
};
export type ProviderType = (typeof Provider)[keyof typeof Provider];
src/infrastructure/user.ts
这个文件是的 findOrCreateUser() 函数是按照认证成功
时调用而实现的。
在中间件中调用
从表中通过电子邮件地址获取用户信息
用户信息存在且未关联认证信息时,向 user_auths 表添加认证信息并返回用户信息
用户信息存在且与认证信息关联时返回用户信息
上述情况不适用时,请创建新条目
import { tables, userAuths, users } from "./db/schema";
import { findFirstUserAuth } from "./userAuth";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";
import type { Context } from "hono";
import { ProviderType } from "../types/provider";
export interface User {
id: number;
name: string;
email: string;
}
export const findOrCreateUser = async (
c: Context,
name: string,
email: string,
providerType: ProviderType,
providerUserId: string,
): Promise<User> => {
const db = drizzle(c.env.DB, { schema: tables });
let user = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.email, email),
});
if (user) {
const userAuth = await findFirstUserAuth(
c,
user.id,
providerType,
providerUserId,
);
if (!userAuth) {
await db.insert(userAuths).values({
userId: user.id,
providerType,
providerUserId,
});
}
return user;
}
const newUser = await db.insert(users).values({ name, email }).returning();
try {
if (newUser.length > 0) {
await db.insert(userAuths).values({
userId: newUser[0].id,
providerType,
providerUserId,
});
return newUser[0];
}
} catch (error) {
console.error("Failed to insert into userAuths, rolling back user insertion", error);
await db.delete(users).where(eq(users.id, newUser[0].id));
}
throw new Error("User creation failed");
};
export const findManyUsers = async (c: Context): Promise<User[]> => {
const db = drizzle(c.env.DB, { schema: tables });
return await db.query.users.findMany();
};
src/infrastructure/userAuth.ts
主要是一个函数:findFirstUserAuth 用于查找第一个用户凭据
注意 const db = drizzle(c.env.DB, { schema: tables });
这种数据库初始化方式,这里使用的是 drizzle ORM
import { drizzle } from "drizzle-orm/d1";
drizzle-orm
import { and, eq } from "drizzle-orm";
import { tables, userAuths } from "./db/schema";
import { drizzle } from "drizzle-orm/d1";
import type { Context } from "hono";
import { ProviderType } from "../types/provider";
export interface UserAuth {
id: number;
userId: number;
providerType: ProviderType;
providerUserId: string;
createdAt: Date;
updatedAt: Date;
}
export const findFirstUserAuth = async (
c: Context,
userId: number,
providerType: ProviderType,
providerUserId: string,
): Promise<UserAuth | undefined> => {
const db = drizzle(c.env.DB, { schema: tables });
const result = await db.query.userAuths.findFirst({
where: and(
eq(userAuths.userId, userId),
eq(userAuths.providerType, providerType),
eq(userAuths.providerUserId, providerUserId),
),
});
return result;
};
中间件实现
这里将执行令牌验证处理和 GitHub 认证的实现
实现令牌验证处理
实现验证访问令牌和刷新令牌的中间件,并保护所有endpoint。
环境变量设置
添加签名用的私钥和有效期到环境变量文件中。
.dev.vars
ACCESS_TOKEN_SECRET='access-token-secret'
REFRESH_TOKEN_SECRET='refresh-token-secret'
ACCESS_TOKEN_EXPIRY="300"
REFRESH_TOKEN_EXPIRY="604800"
以下图示进行实现
使用了 Hono 的助手来验证令牌、签名和设置 Cookie。
两者都很简单易用。
https://hono.dev/docs/helpers/jwt
https://hono.dev/docs/helpers/cookie
/auth/* 计划为每个提供商创建认证用端点,所以先跳过查看路径。
src/middleware/auth/index.ts
import type { Hono } from "hono";
import { getCookie, setCookie } from "hono/cookie";
import { createMiddleware } from "hono/factory";
import { sign, verify } from "hono/jwt";
import type { JWTPayload } from "hono/utils/jwt/types";
import { useGitHubAuth } from "./github";
export const configAuthMiddleware = (app: Hono) => {
useGitHubAuth(app);
app.use(authMiddleware); // エンドポイント全体に適用
};
export const authMiddleware = createMiddleware(async (c, next) => {
if (c.req.path.startsWith("/auth/")) {
return next();
}
let accessToken = getCookie(c, "access_token");
if (!accessToken) {
return c.text("Unauthorized: no access token", 401);
}
let accessTokenPayload: JWTPayload;
try {
accessTokenPayload = await verify(accessToken, c.env.ACCESS_TOKEN_SECRET);
} catch (err) {
const refreshToken = getCookie(c, "refresh_token");
if (!refreshToken) {
return c.text("Unauthorized: no refresh token", 401);
}
let refreshTokenPayload: JWTPayload;
try {
refreshTokenPayload = await verify(refreshToken, c.env.REFRESH_TOKEN_SECRET);
} catch (err2) {
return c.text("Unauthorized: invalid refresh token", 401);
}
accessTokenPayload = {
...refreshTokenPayload,
exp:
Math.floor(Date.now() / 1000) + parseInt(c.env.ACCESS_TOKEN_EXPIRY, 10),
};
accessToken = await sign(accessTokenPayload, c.env.ACCESS_TOKEN_SECRET);
}
setCookie(c, "access_token", accessToken, {
path: "/",
httpOnly: true,
secure: true,
sameSite: "Strict",
});
return next();
});
在 src 直下的 index.ts 中调用刚才定义的函数。
src/index.tsx
import { Hono } from "hono";
import type { FC } from 'hono/jsx'
import { configAuthMiddleware } from "./middleware/auth";
const Layout: FC = (props) => {
return (
<html>
<body>{props.children}</body>
</html>
)
}
const Top: FC<{ messages: string[] }> = (props: {
messages: string[]
}) => {
return (
<Layout>
<h1>Hello Hono!</h1>
<ul>
{props.messages.map((message) => {
return <li>{message}!!</li>
})}
</ul>
</Layout>
)
}
const app = new Hono();
configAuthMiddleware(app); // 追加
// app.get("/", (c) => {
// return c.text("Hello Hono!");
// });
app.get('/', (c) => {
const messages = ['Good Morning', 'Good Evening', 'Good Night']
return c.html(<Top messages={messages} />)
})
export default app;
启动服务器进行确认。
而不是返回 Hello Hono! ,而是返回 401 错误表示成功。
> curl -X GET http://localhost:8787/
Unauthorized: no access token
GitHub 认证的实现
以下流程进行实现。
GitHub 认证
认证成功后,使用认证信息进行用户确认或注册
返回令牌
OAuth 应用注册
在实现之前,请先进行 OAuth Apps 的注册。
打开 GitHub,从设置 > 开发者设置中添加 OAuth 应用。
本次为了在本地环境中进行验证,按照以下方式设置并添加。
如果可以添加的话,Client ID 和 Client secrets 会显示出来,然后将其添加到环境变量中。
.dev.vars
GITHUB_ID=your-github-client-id
GITHUB_SECRET=your-github-client-secret
GitHub 认证的实现将使用 Hono 的 oauth-providers 包。
https://github.com/honojs/middleware/tree/main/packages/oauth-providers
pnpm i @hono/oauth-providers
以下为 GitHub 认证用端点的实现。
当您访问 /auth/github 端点时,您会被重定向到 GitHub 身份验证页面。
认证成功后,将被重定向到回调 URL,因此可以使用认证信息进行用户搜索、注册和发行令牌。
src/middleware/auth/github.ts
import { Context, Hono } from "hono";
import { githubAuth } from "@hono/oauth-providers/github";
import { Provider } from "../../types/provider";
import { findOrCreateUser } from "../../infrastructure/user";
import { sign } from "hono/jwt";
import { setCookie } from "hono/cookie";
export const useGitHubAuth = (app: Hono) => {
app.use("/auth/github", (c: Context, next) => {
return githubAuth({
client_id: c.env.GITHUB_ID,
client_secret: c.env.GITHUB_SECRET,
scope: ["read:user", "user", "user:email"],
oauthApp: true,
})(c, next);
});
app.get("/auth/github", async (c: Context) => {
const userData = c.get("user-github");
if (!userData) {
return c.text("GitHub authentication failed", 400);
}
if (!userData.id || !userData.name || !userData.email) {
return c.text("Required information could not be retrieved", 400);
}
const user = await findOrCreateUser(
c,
userData.name,
userData.email,
Provider.GitHub,
userData.id.toString(),
);
const accessTokenPayload = {
sub: user.id.toString(),
name: user.name,
exp:
Math.floor(Date.now() / 1000) + parseInt(c.env.ACCESS_TOKEN_EXPIRY, 10),
};
const refreshTokenPayload = {
sub: user.id.toString(),
name: user.name,
exp:
Math.floor(Date.now() / 1000) +
parseInt(c.env.REFRESH_TOKEN_EXPIRY, 10),
};
const accessToken = await sign(
accessTokenPayload,
c.env.ACCESS_TOKEN_SECRET,
);
const refreshToken = await sign(
refreshTokenPayload,
c.env.REFRESH_TOKEN_SECRET,
);
setCookie(c, "access_token", accessToken, {
path: "/",
httpOnly: true,
secure: true,
sameSite: "Strict",
});
setCookie(c, "refresh_token", refreshToken, {
path: "/",
httpOnly: true,
secure: true,
sameSite: "Strict",
});
return c.redirect("/");
});
};
为使 GitHub 认证的端点可用,将之前实现的函数添加到 auth/index.ts 的 configAuthMiddleware() 函数中。
import { useGitHubAuth } from "./github";
export const configAuthMiddleware = (app: Hono) => {
useGitHubAuth(app);
// useGoogleAuth(app);
app.use(authMiddleware);
};
动作确认
GitHub 认证用端点访问时会跳转到 GitHub 认证页面,因此允许。
允许后将被重定向到路由
确认 Cookie 中已设置令牌。
由于重定向时没有执行令牌验证,因此需要重新调用端点。(需调查..)
401 而不是显示 Hello Hono!🥳
获取用户信息
最后,我尝试获取数据库中注册的用户信息。
为 src/index.ts 添加新的端点。
import { findManyUsers } from "./infrastructure/user";
app.get("/users", async (c) => {
const users = await findManyUsers(c);
return c.json({ users: users });
});
调用/users 获取用户信息成功🙌
结束
最近想尝试的技术终于有机会接触到了。非常满意。
Hono 拥有丰富的第三方中间件和嵌入式中间件,还有简单易用的助手,感觉开发体验非常好😸
以上即为全部。
本篇文章如有任何参考价值,不胜荣幸。
感谢您阅读至此!