详细记录了将 Next.js 仪表盘从 HTTP Basic Auth 迁移到签名 Cookie Session 认证的全过程,解决跨域 API 调用和退出登录难题。
我移除了不安全的 HTTP Basic Auth 流程,引入了一套基于 cookie 的会话机制,使用了 Next.js 13 API 路由、一个轻量级的 crypto 辅助模块以及中间件。新方案将签名令牌存储在 pc_session cookie 中,每次请求时验证,并允许 UI 处理重定向而无需暴露凭证。
我们的 pcview 仪表板仅靠 next.config.js 中配置的 HTTP Basic Auth 保护。这在快速演示时确实够用,但一旦需要以下功能就不行了:
Authorization 头,导致 401 错误。/app/(dashboard) 路由,而让公开的落地页保持开放。症状表现为:React 组件在首次页面加载后尝试获取库存数据时,出现一连串 401 响应。控制台显示:
GET https://api.pcview.dev/api/displays/devices 401
Unauthorized
我的第一个尝试是保留 HTTP Basic Auth,直接在客户端用 fetch(..., { headers: { Authorization: ... } }) 转发头。这失败了,因为浏览器会阻止跨域请求上的 Authorization 头,除非服务器明确允许,而且我们也不想把原始用户名/密码暴露在客户端 bundle 中。
我还尝试过使用 NextAuth.js,但这个项目的体量以及我们只需要一个静态凭证(配置在 .env 中)的实际情况,让这个完整的库显得杀鸡用牛刀了。我需要的是轻量、完全可控、并与现有文件路由兼容的方案。
我创建了一个小型工具模块,实现以下功能:
PCVIEW_USER、PCVIEW_PASS)读取静态凭证。username:password 的 SHA-256 哈希作为期望令牌。// src/lib/auth.ts
export const AUTH_COOKIE_NAME = "pc_session";
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("");
}
// Pull static credentials from env (fallback to dev defaults)
export async function getConfiguredCredentials() {
const user = process.env.PCVIEW_USER ?? "admin";
const pass = process.env.PCVIEW_PASS ?? "admin123";
return { user, pass };
}
// Expected token = sha256("user:pass")
export async function getExpectedToken() {
const { user, pass } = await getConfiguredCredentials();
return sha256Hex(`${user}:${pass}`);
}
// Session token = base64(sha256("user:pass:nonce:ts"))
export async function computeSessionToken() {
const { user, pass } = await getConfiguredCredentials();
const nonce = crypto.randomUUID();
const ts = Date.now();
const raw = `${user}:${pass}:${nonce}:${ts}`;
const hash = await sha256Hex(raw);
return btoa(`${hash}:${nonce}:${ts}`);
}
该路由接收 JSON 请求体 { username, password },对照静态凭证进行验证,然后设置签名 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) {
const { username, password } = await req.json();
const { user, pass } = await getConfiguredCredentials();
if (username !== user || password !== pass) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const token = await computeSessionToken();
const response = NextResponse.json({ ok: true });
response.cookies.set(AUTH_COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24, // 1 day
});
return response;
}
只需清除 cookie。
// src/app/api/logout/route.ts
import { NextResponse } from "next/server";
import { AUTH_COOKIE_NAME } from "@/lib/auth";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set(AUTH_COOKIE_NAME, "", {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 0,
});
return response;
}
一个客户端组件,提交凭证、处理加载/错误状态,成功后重定向到仪表板。
// 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 redirect = search.get("next") ?? "/app";
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);
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Login failed");
}
router.replace(redirect);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="flex h-screen items-center justify-center">
<form onSubmit={handleSubmit} className="max-w-sm space-y-4">
<h2 className="text-2xl font-bold flex items-center">
<Lock className="mr-2" /> Sign in to pcview
</h2>
{error && <p className="text-red-600">{error}</p>}
<input
required
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full p-2 border rounded"
/>
<input
required
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full p-2 border rounded"
/>
<button
type="submit"
disabled={loading}
className="w-full p-2 bg-black text-white rounded flex items-center justify-center"
>
{loading ? <Loader2 className="animate-spin" /> : "Sign in"}
</button>
</form>
</div>
);
}
本文是我 Build in Public 系列的一部分——分享从墨西哥卡波圣卢卡斯(Cabo San Lucas)构建 SaaS 项目的真实过程。
仓库:zaerohell/pcview · 2026-08-19
#playadev #buildinpublic