Waku 集成

将 Better Auth 集成到 Waku。

Better Auth 可以轻松地与 Waku 集成。在开始之前,请确保您已经配置了 Better Auth 实例。如果还没有,请查看 安装指南。

创建 auth 实例

在您的应用程序中创建一个名为 auth.ts 的文件。导入 Better Auth 并创建您的实例。

确保使用变量名 auth 或作为 default 导出实例。

src/auth.ts
import { betterAuth } from "better-auth"

export const auth = betterAuth({
    database: {
        provider: "postgres", //根据您的数据库提供者修改
        url: process.env.DATABASE_URL, // 数据库路径或连接字符串
    }
})

创建 API 路由

我们需要将处理器挂载到 API 路由。为 Waku 的文件系统路由在 src/pages/api/auth 创建一个目录。在 src/pages/api/auth 目录中创建一个捕获所有路由文件 [...route].ts,并添加以下代码:

src/pages/_api/api/auth/[...route].ts
import { auth } from "../../../../auth" // 根据需要调整路径

export const GET = async (request: Request): Promise<Response> => {
  return auth.handler(request)
}

export const POST = async (request: Request): Promise<Response> => {
  return auth.handler(request)
}

您可以在 better-auth 配置中更改路径,但建议保持为 src/pages/_api/api/auth/[...route].ts

创建客户端

创建客户端实例。这里我们在 lib/ 目录下创建 auth-client.ts 文件。

src/lib/auth-client.ts
import { createAuthClient } from "better-auth/react" // 确保从 better-auth/react 导入

export const authClient = createAuthClient({
    // 可在这里传入客户端配置
})

export type Session = typeof authClient.$Infer.Session // 可以从 authClient 推断 Typescript 类型

创建客户端后,您可以用它来注册、登录和执行其他操作。 部分操作是响应式的。客户端使用 nano-store 存储状态,并在状态变化时重新渲染组件。

客户端还使用 better-fetch 来发起请求,您可以将 fetch 配置传给客户端。

RSC 和服务器操作

从 auth 实例导出的 api 对象包含了所有可在服务器端执行的操作。Better Auth 内部创建的每个端点都可以作为函数被调用,包括插件端点。

示例:在服务器操作中获取 Session

server.ts
"use server" // Waku 当前仅支持文件级 "use server"

import { auth } from "./auth"
import { unstable_getHeaders as getHeaders } from "waku/server"

export const someAuthenticatedAction = async () => {
  "use server"
  const headers = getHeaders()
  const session = await auth.api.getSession({
      headers,
  })
};

示例:在 RSC 中获取 Session

import { auth } from "../auth"
import { unstable_getHeaders as getHeaders } from "waku/server"

export async function ServerComponent() {
  const headers = getHeaders()
  const session = await auth.api.getSession({
      headers,
  })
  if(!session) {
      return <div>未认证</div>
  }
  return (
      <div>
          <h1>欢迎 {session.user.name}</h1>
      </div>
  )
}
响应开始流式传输后运行的 RSC 无法设置 Cookie。Cookie 缓存 直到通过客户端的服务器操作或路由处理器与服务器互动后才会刷新。

当您在服务器操作中调用需要设置 Cookie 的函数(如 signInEmail 或 signUpEmail)时,Cookie 不会被设置。

我们可以创建一个插件,与中间件配合以设置 Cookie。

auth.ts
import { betterAuth } from "better-auth";
import { createAuthMiddleware } from "better-auth/api";
import { unstable_getContextData as getContextData } from "waku/server"

export const auth = betterAuth({
    //...您的配置
    plugins: [wakuCookies()] // 确保这是数组中的最后一个插件
})

function wakuCookies() {
  return {
    id: "waku-cookies",
    hooks: {
      after: [
        {
          matcher(ctx) {
            return true;
          },
          handler: createAuthMiddleware(async (ctx) => {
            const returned = ctx.context.responseHeaders;
            if ("_flag" in ctx && ctx._flag === "router") {
              return;
            }
            if (returned instanceof Headers) {
              const setCookieHeader = returned?.get("set-cookie");
              if (!setCookieHeader) return;
              const contextData = getContextData();
              contextData.betterAuthSetCookie = setCookieHeader;
            }
          }),
        },
      ],
    },
  } satisfies BetterAuthPlugin;
}

下面展示了用于将 contextData.betterAuthSetCookie 添加到响应的中间件。 现在,当您调用设置 Cookie 的函数时,它们会被自动设置。

"use server";
import { auth } from "../auth"

const signIn = async () => {
    await auth.api.signInEmail({
        body: {
            email: "[email protected]",
            password: "password",
        }
    })
}

中间件

在 Waku 中间件中,建议只检查会话 Cookie 是否存在以处理重定向。这避免了通过 API 或数据库调用阻塞请求。

您可以使用 Better Auth 的 getSessionCookie 辅助函数:

getSessionCookie() 函数不会自动引用在 auth.ts 中指定的 auth 配置。因此,如果您自定义了 Cookie 名称或前缀,需要确保 getSessionCookie() 中的配置与 auth.ts 中定义的配置匹配。

src/middleware/auth.ts
import type { MiddlewareHandler } from "hono"
import { getSession } from "../auth"
import { getSessionCookie } from "better-auth/cookies"
import type { MiddlewareHandler } from "hono";
import { unstable_getContextData as getContextData } from "waku/server";

const authMiddleware: () => MiddlewareHandler = () => {
  return async (c, next) => {
    const reqUrl = new URL(c.req.url);
    const sessionCookie = getSessionCookie(c.req.raw);
    // 这不是安全的!
    // 这是建议的乐观重定向用户的方法
    // 推荐在每个页面/路由中处理认证检查
    if (
      !sessionCookie &&
      reqUrl.pathname !== "/" &&
      !reqUrl.pathname.startsWith("/api")
    ) {
      if (!reqUrl.pathname.endsWith(".txt")) {
        // 当前 RSC 请求以 .txt 结尾,且不处理重定向响应
        // 需要在 React Flight 流里编码重定向
        // Waku 中有部分功能可在服务器组件中实现
        // 但不能在中间件中实现。
        return c.redirect("/", 302);
      }
    }

    // TODO 可根据 c.req.url 优化,避免每次请求都执行
    // 或跳过此处启动 Promise,只在服务器组件与函数调用时执行
    getSession();
    await next();
    const contextData = getContextData();
    const betterAuthSetCookie = contextData.betterAuthSetCookie as
      | string
      | undefined;
    if (betterAuthSetCookie) {
      c.header("set-cookie", betterAuthSetCookie, { append: true });
    }
  };
};

export default authMiddleware;

安全警告: getSessionCookie 函数只检查 会话 Cookie 是否存在;它不会对其进行验证。仅依赖此检查来保证安全是危险的,因为任何人都可以手动创建 Cookie 来绕过它。对于任何受保护的操作或页面,您都必须在服务器端验证会话。

如果您有自定义 Cookie 名称或前缀,可以将其传递给 getSessionCookie 函数。

const sessionCookie = getSessionCookie(request, {
    cookieName: "my_session_cookie",
    cookiePrefix: "my_prefix"
})

或者,您也可以使用 getCookieCache 辅助函数从 Cookie 缓存获取会话对象。

import { getCookieCache } from "better-auth/cookies"

const authMiddleware: () => MiddlewareHandler = () => {
    return async (c, next) => {
        const reqUrl = new URL(c.req.url);
        const session = await getCookieCache(c.req.raw)
        if (!session && reqUrl.pathname !== "/") {
            if (!reqUrl.pathname.endsWith(".txt")) {
                ctx.res.status = 302
                ctx.res.headers = {
                    Location: new URL("/", reqUrl).toString(),
                }
            }
        }
    }
    await next();
  }
}

export default authMiddleware;

如果您将中间件文件放在 ./src/middleware,它会被 Waku 默认服务器适配器自动加载。

如何在每个页面/路由处理中进行认证检查

本示例中,我们在服务器组件中使用 auth.api.getSession 函数获取会话对象, 然后检查会话是否有效。如果无效,则重定向用户到登录页。 Waku 提供了 getContext 获取请求头,和 getContextData() 用于每次请求存储数据。我们可以利用它避免同一请求中多次获取会话。

auth.ts
import { unstable_getContext as getContext, unstable_getContextData as getContextData } from "waku/server";

// 以上用于创建服务器端 auth 配置的代码
// export const auth = ...

export function getSession(): Promise<Session | null> {
  const contextData = getContextData();
  const ctx = getContext();
  const existingSessionPromise = contextData.sessionPromise as
    | Promise<Session | null>
    | undefined;
  if (existingSessionPromise) {
    return existingSessionPromise;
  }
  const sessionPromise = auth.api.getSession({
    headers: new Headers(ctx.req.headers),
  });
  contextData.sessionPromise = sessionPromise;
  return sessionPromise;
}
src/pages/dashboard.tsx
import { getSession } from "../auth";
import { unstable_redirect as redirect } from 'waku/router/server';

export default async function DashboardPage() {
    const session = await getSession()

    if (!session) {
        redirect("/sign-in")
    }

    return (
        <div>
            <h1>欢迎 {session.user.name}</h1>
        </div>
    )
}

示例用法

注册

src/components/signup.tsx
"use client"

import { useState } from "react"
import { authClient } from "../lib/auth-client"

export default function SignUp() {
  const [email, setEmail] = useState("")
  const [name, setName] = useState("")
  const [password, setPassword] = useState("")

  const signUp = async () => {
    await authClient.signUp.email(
      {
        email,
        password,
        name,
      },
      {
        onRequest: (ctx) => {
          // 显示加载状态
        },
        onSuccess: (ctx) => {
          // 重定向到首页
        },
        onError: (ctx) => {
          alert(ctx.error)
        },
      },
    )
  }

  return (
    <div>
      <h2>
        注册
      </h2>
      <form
        onSubmit={signUp}
      >
        <input
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="姓名"
        />
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="邮箱"
        />
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          placeholder="密码"
        />
        <button
          type="submit"
        >
          注册
        </button>
      </form>
    </div>
  )
}

登录

src/components/signin.tsx
"use client"

import { useState } from "react"
import { authClient } from "../lib/auth-client"

export default function SignIn() {
  const [email, setEmail] = useState("")
  const [password, setPassword] = useState("")

  const signIn = async () => {
    await authClient.signIn.email(
      {
        email,
        password,
      },
      {
        onRequest: (ctx) => {
          // 显示加载状态
        },
        onSuccess: (ctx) => {
          // 重定向到首页
        },
        onError: (ctx) => {
          alert(ctx.error)
        },
      },
    )
  }

  return (
    <div>
      <h2>
        登录
      </h2>
      <form onSubmit={signIn}>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
        <button
          type="submit"
        >
          登录
        </button>
      </form>
    </div>
  )
}