升级内部原型从 HTTP Basic Auth 到完整登录流程,使用签名 Cookie 持久化会话,配合中间件保护路由。
TL;DR:用签名会话 cookie 换掉了 HTTP Basic Auth,实现了一套全栈登录流程。这次的改动包括新的 API 路由、一个轻量 auth 库和保护路由的 middleware,全部用 TypeScript 写就跑在 Next.js 13 上。
我们的 greenview 应用在早期原型阶段用 HTTP Basic Auth 快速锁住了 UI。这个方案有两个大问题:
每次请求都会携带凭证——浏览器自动附上 Authorization header,导致 base64 编码的用户名/密码暴露在网络中(即便走 HTTPS,也不是什么好做法)。
没有会话状态——存不了用户相关的数据(比如偏好设置),因为每个请求都是无状态的。
症状体现在控制台上:刷新受保护页面时不断出现 401 响应,网络标签页里每个 API 调用都带着 Authorization: Basic … header。我需要一个正经的登录流程,能在服务端持久化会话,同时让客户端知道用户何时通过了身份验证。
我的第一反应是保留现有的 Basic Auth middleware,直接加一个"登录"页面收集凭证,然后调用同样的受保护接口。我写了一个 POST /api/login,用 src/lib/auth.ts 里硬编码的值校验凭证,然后返回一个带 token 的 JSON 对象。客户端把 token 存进 localStorage,在每次请求时把它加到自定义的 X-Auth-Token header 里。
CORS 的头疼——加上自定义 header 之后浏览器会发一个预检 OPTIONS 请求,而我们的 Next.js edge middleware 没处理它,导致 404。
Token 泄露——存在 localStorage 里的话,页面上的任意脚本都能拿到,增加了 XSS 风险。
Middleware 不匹配——现有的 Basic Auth middleware 只认 Authorization header,所以新的 token 根本没人验证。
经历了几次令人抓狂的调试(一直在 middleware 日志里看到 "Missing Authorization header"),我决定废弃这个混合方案,全面转向 cookie。
写了一个轻量库处理 cookie 命名、token 生成和凭证校验。文件完全自包含,除了 Node crypto API 没有任何外部依赖。
// src/lib/auth.ts
export const AUTH_COOKIE_NAME = "gv_session";
/**
* Returns the static credentials configured for the demo.
* In a real app you'd query a DB.
*/
export async function getConfiguredCredentials() {
// Hard‑coded for now, but you can load from env vars.
return { username: "admin", password: "s3cr3t" };
}
/**
* Simple SHA‑256 hash of "username:password" → hex string.
* Used as the session token; not production‑grade crypto.
*/
async function sha256Hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Generates a deterministic token from credentials.
* In production you'd use a random UUID + signed JWT.
*/
export async function computeSessionToken(username: string, password: string) {
return await sha256Hex(`${username}:${password}`);
}
/**
* Validates the incoming cookie token against the expected hash.
*/
export async function getExpectedToken() {
const { username, password } = await getConfiguredCredentials();
return computeSessionToken(username, password);
}
这个路由接收 JSON body { username, password },校验通过后设置一个 HTTP-only cookie。
// src/app/api/login/route.ts
import { NextResponse } from "next/server";
import {
AUTH_COOKIE_NAME,
computeSessionToken,
getConfiguredCredentials,
} from "@/lib/auth";
export async function POST(req: Request) {
try {
const { username, password } = await req.json();
const { username: validUser, password: validPass } =
await getConfiguredCredentials();
if (username !== validUser || password !== validPass) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const token = await computeSessionToken(username, password);
const res = NextResponse.json({ ok: true });
// Secure flag will be false in dev because we run on localhost.
res.cookies.set(AUTH_COOKIE_NAME, token, {
httpOnly: true,
path: "/",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 1 week
secure: process.env.NODE_ENV === "production",
});
return res;
} catch (err) {
console.error("Login error:", err);
return NextResponse.json({ error: "Bad request" }, { status: 400 });
}
}
通过设置 maxAge: 0 清除 cookie。
// src/app/api/logout/route.ts
import { NextResponse } from "next/server";
import { AUTH_COOKIE_NAME } from "@/lib/auth";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(AUTH_COOKIE_NAME, "", {
httpOnly: true,
path: "/",
maxAge: 0,
sameSite: "lax",
});
return res;
}
一个客户端组件,向 /api/login 提交凭证,成功后重定向。
// src/app/login/page.tsx
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2, Lock } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const search = useSearchParams();
const redirectTo = search.get("next") ?? "/";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (res.ok && data.ok) {
router.replace(redirectTo);
} else {
setError(data.error ?? "Login failed");
}
setLoading(false);
};
return (
<div className="max-w-md mx-auto mt-20 p-6 border rounded">
<h1 className="text-2xl mb-4 flex items-center">
<Lock className="mr-2" /> Sign in
</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full p-2 border mb-3"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full p-2 border mb-3"
/>
{error && <p className="text-red-600 mb-2">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full p-2 bg-blue-600 text-white rounded flex justify-center"
>
{loading ? <Loader2 className="animate-spin" /> : "Login"}
</button>
</form>
</div>
);
}
这是我的 Build in Public 系列的一部分——分享在墨西哥卡波圣卢卡斯从零构建 SaaS 项目的真实过程。
仓库:zaerohell/greenview · 2026-08-19
#playadev #buildinpublic