前端鉴权体系深度解析
鉴权(Authentication)就像酒店的入住系统:你出示身份证(凭证),前台验证身份(鉴权),给你房卡(Token),之后你用房卡进出房间(访问资源)。
没有鉴权,互联网将是一片混乱
四大鉴权方案深度拆解
发送凭证到服务器
签名Header+Payload
客户端存储Token
Authorization: Bearer xxx
无需查库,本地验签
签名有效,返回数据
JWT 交互式解析器动手试试
Header(头部)
声明 Token 的类型和签名算法。固定为 JWT,常用 HS256 或 RS256。
{
"alg": "HS256", // ← 签名算法
"typ": "JWT" // ← Token 类型
}Payload(载荷)
存放用户信息和声明。注意:仅 Base64 编码,未加密,切勿存放敏感数据!
{
"sub": "user-123", // ← 用户ID(Subject)
"name": "张三", // ← 用户名
"role": "admin", // ← 角色
"iat": 1719312000, // ← 签发时间
"exp": 1719398400 // ← 过期时间
}Signature(签名)
使用密钥对 Header 和 Payload 进行签名。用于验证数据完整性,防篡改。
HMACSHA256(
base64UrlEncode(header) +
"." +
base64UrlEncode(payload),
secret // ← 只有服务端知道
)服务端:生成与验证 JWT
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET; // ← 256位随机密钥
const ACCESS_EXPIRES = '15m'; // ← 短期访问令牌
const REFRESH_EXPIRES = '7d'; // ← 长期刷新令牌
interface TokenPayload {
sub: string; // 用户ID
role: string; // 用户角色
}
// 生成 Token 对
export function generateTokens(user: TokenPayload) {
const accessToken = jwt.sign(
{
sub: user.sub,
role: user.role,
type: 'access'
},
SECRET,
{ expiresIn: ACCESS_EXPIRES } // ← 15分钟过期
);
const refreshToken = jwt.sign(
{
sub: user.sub,
type: 'refresh'
},
SECRET,
{ expiresIn: REFRESH_EXPIRES } // ← 7天过期
);
return { accessToken, refreshToken }; // ← 返回双 Token
}
// 验证 Token
export function verifyToken(token: string): TokenPayload {
try {
const decoded = jwt.verify(token, SECRET) as any;
if (decoded.type !== 'access') { // ← 检查 Token 类型
throw new Error('Invalid token type');
}
return decoded;
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new Error('Token expired'); // ← Token 已过期
}
throw new Error('Invalid token'); // ← Token 无效
}
}客户端:存储与自动刷新
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// Token 存储策略
const tokenStorage = {
// Access Token 存内存(最安全,防 XSS)
_accessToken: null as string | null,
getAccessToken() {
return this._accessToken;
},
setAccessToken(token: string) {
this._accessToken = token;
},
// Refresh Token 存 httpOnly Cookie(服务端设置)
// 或 localStorage(次优选择)
};
// 带自动刷新的请求封装
export async function fetchWithAuth(
url: string,
options: RequestInit = {}
) {
let token = tokenStorage.getAccessToken();
const response = await fetch(`${API_URL}${url}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`, // ← 携带 Token
},
});
// Token 过期,尝试刷新
if (response.status === 401) {
const newToken = await refreshAccessToken();
if (newToken) {
tokenStorage.setAccessToken(newToken);
// 用新 Token 重试请求
return fetch(`${API_URL}${url}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${newToken}`,
},
});
}
}
return response;
}
// 刷新 Access Token
async function refreshAccessToken(): Promise<string | null> {
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include', // ← 携带 Cookie(refresh token)
});
if (response.ok) {
const { accessToken } = await response.json();
return accessToken;
}
// 刷新失败,跳转登录
window.location.href = '/login';
return null;
}- 🚫永远不要在 Payload 存密码或敏感信息:JWT 仅是 Base64 编码,任何人可以解码查看内容
- 🚫不要用
none算法:某些库允许{ alg: 'none' }跳过验证,务必在服务端白名单允许的算法 - 🚫Access Token 生命周期要短:推荐 15-30 分钟,配合 Refresh Token 实现无感刷新
- 🚫不要存 localStorage(高安全场景):易受 XSS 攻击,推荐存内存变量 + Refresh Token 存 httpOnly Cookie
Session vs JWT 全维度对比
Next.js 全栈鉴权实现
登录页面组件
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { signIn } from 'next-auth/react';
export default function LoginPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
// 账号密码登录
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
setError('');
const formData = new FormData(e.currentTarget);
const result = await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirect: false, // ← 不自动跳转
});
if (result?.error) {
setError('邮箱或密码错误'); // ← 显示错误
setIsLoading(false);
} else {
router.push('/dashboard'); // ← 跳转到仪表盘
router.refresh(); // ← 刷新服务端组件
}
}
// 第三方登录
async function handleOAuthLogin(provider: string) {
setIsLoading(true);
await signIn(provider, {
callbackUrl: '/dashboard' // ← 登录成功后跳转
});
}
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-full max-w-md p-8 bg-white rounded-2xl
border-2 border-[var(--foreground)]
shadow-[8px_8px_0px_0px_var(--foreground)]">
<h1 className="text-3xl font-['Outfit'] font-extrabold mb-8">
登录
</h1>
{error && (
<div className="mb-4 p-3 bg-red-50 border-2 border-red-300
rounded-xl text-sm text-red-700">
{error}
</div>
)}
{/* 第三方登录按钮 */}
<div className="space-y-3 mb-6">
<button
onClick={() => handleOAuthLogin('github')}
disabled={isLoading}
className="w-full py-3 bg-[var(--foreground)] text-white
rounded-xl font-bold border-2 border-[var(--foreground)]
shadow-[4px_4px_0px_0px_var(--border)]
hover:shadow-[2px_2px_0px_0px_var(--border)]
hover:translate-x-[2px] hover:translate-y-[2px]
transition-all flex items-center justify-center gap-2"
>
<GitHubIcon /> 使用 GitHub 登录
</button>
<button
onClick={() => handleOAuthLogin('google')}
disabled={isLoading}
className="w-full py-3 bg-white text-[var(--foreground)]
rounded-xl font-bold border-2 border-[var(--foreground)]
shadow-[4px_4px_0px_0px_var(--foreground)]
hover:shadow-[2px_2px_0px_0px_var(--foreground)]
hover:translate-x-[2px] hover:translate-y-[2px]
transition-all flex items-center justify-center gap-2"
>
<GoogleIcon /> 使用 Google 登录
</button>
</div>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t-2 border-[var(--border)]" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-4 text-sm text-[var(--foreground)]/50">
或使用邮箱登录
</span>
</div>
</div>
{/* 邮箱密码表单 */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-bold mb-2">邮箱</label>
<input
name="email"
type="email"
required
className="w-full px-4 py-3 border-2 border-[var(--foreground)]
rounded-xl focus:outline-none focus:border-[var(--accent)]
shadow-[2px_2px_0px_0px_var(--border)]"
/>
</div>
<div>
<label className="block text-sm font-bold mb-2">密码</label>
<input
name="password"
type="password"
required
className="w-full px-4 py-3 border-2 border-[var(--foreground)]
rounded-xl focus:outline-none focus:border-[var(--accent)]
shadow-[2px_2px_0px_0px_var(--border)]"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-[var(--accent)] text-white
rounded-xl font-bold border-2 border-[var(--foreground)]
shadow-[4px_4px_0px_0px_var(--foreground)]
hover:shadow-[2px_2px_0px_0px_var(--foreground)]
hover:translate-x-[2px] hover:translate-y-[2px]
transition-all"
>
{isLoading ? '登录中...' : '登录'}
</button>
</form>
</div>
</div>
);
}自定义鉴权 Hook
'use client';
import { useSession, signOut } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
interface AuthUser {
id: string;
name: string;
email: string;
role: 'user' | 'admin' | 'moderator';
image?: string;
}
export function useAuth(options?: {
required?: boolean; // ← 是否强制要求登录
redirectTo?: string; // ← 未登录时跳转地址
allowedRoles?: string[]; // ← 允许的角色
}) {
const { data: session, status } = useSession();
const router = useRouter();
const isLoading = status === 'loading';
const isAuthenticated = status === 'authenticated';
const user = session?.user as AuthUser | undefined;
// 检查角色权限
const hasRequiredRole = options?.allowedRoles
? options.allowedRoles.includes(user?.role ?? '')
: true;
// 未登录时重定向
useEffect(() => {
if (isLoading) return;
if (options?.required && !isAuthenticated) {
router.push(options.redirectTo ?? '/login');
}
if (isAuthenticated && !hasRequiredRole) {
router.push('/unauthorized'); // ← 无权限页面
}
}, [isLoading, isAuthenticated, hasRequiredRole]);
// 登出
const logout = async () => {
await signOut({
redirect: true,
callbackUrl: '/login'
});
};
// 获取当前用户ID(用于 SWR/React Query 的 key)
const userId = user?.id;
return {
user, // ← 用户信息
isLoading, // ← 加载状态
isAuthenticated, // ← 是否已登录
hasRequiredRole, // ← 是否有权限
logout, // ← 登出函数
userId, // ← 用户ID
};
}
// 使用示例
function DashboardPage() {
const { user, isLoading, logout } = useAuth({
required: true, // ← 强制登录
redirectTo: '/login',
});
if (isLoading) return <LoadingSpinner />;
return (
<div>
<h1>Welcome, {user?.name}</h1>
<button onClick={logout}>Logout</button>
</div>
);
}
// 管理员页面
function AdminPage() {
const { user, hasRequiredRole } = useAuth({
required: true,
allowedRoles: ['admin'], // ← 只允许管理员
});
if (!hasRequiredRole) {
return <AccessDenied />;
}
return <AdminPanel />;
}Next.js 中间件路由保护
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
// 需要登录的路由
const protectedRoutes = ['/dashboard', '/profile', '/settings'];
// 需要管理员权限的路由
const adminRoutes = ['/admin'];
// 公开路由(已登录用户不应访问)
const authRoutes = ['/login', '/register'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 获取 JWT Token(无需触发完整的 session 流程)
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
});
const isAuthenticated = !!token;
const isAdmin = token?.role === 'admin';
// 检查是否为受保护路由
const isProtectedRoute = protectedRoutes.some(route =>
pathname.startsWith(route)
);
// 检查是否为管理员路由
const isAdminRoute = adminRoutes.some(route =>
pathname.startsWith(route)
);
// 检查是否为认证路由(登录/注册)
const isAuthRoute = authRoutes.some(route =>
pathname.startsWith(route)
);
// 未登录用户访问受保护路由 → 重定向到登录页
if (isProtectedRoute && !isAuthenticated) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname); // ← 记录来源页
return NextResponse.redirect(loginUrl);
}
// 非管理员访问管理员路由 → 重定向到无权限页
if (isAdminRoute && !isAdmin) {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
// 已登录用户访问登录页 → 重定向到仪表盘
if (isAuthRoute && isAuthenticated) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// 添加安全头
const response = NextResponse.next();
response.headers.set(
'X-Frame-Options', 'DENY'
);
response.headers.set(
'X-Content-Type-Options', 'nosniff'
);
response.headers.set(
'Referrer-Policy', 'origin-when-cross-origin'
);
return response;
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};生产级安全防护清单
XSS 防护
跨站脚本攻击
DOMPurify 过滤用户输入的 HTMLContent-Security-Policy 响应头httpOnly: trueCSRF 防护
跨站请求伪造
sameSite: 'lax' 或 'strict'Origin 和 Referer 头Token 无感刷新
双 Token 架构
Access Token 过期时,自动用 Refresh Token 获取新的 Access Token,用户无感知。
强制 HTTPS
所有认证接口必须使用 HTTPS,防止中间人攻击截获 Token。
密钥管理
JWT Secret 至少 256 位,使用环境变量存储,定期轮换。推荐使用 RS256 非对称加密。
登录限流
限制登录尝试次数(如 5次/15分钟),防止暴力破解。使用 Redis 记录尝试次数。
审计日志
记录所有认证事件:登录、登出、Token 刷新、权限变更。便于安全审计和异常检测。
Cookie 安全配置速查
// 生产环境 Cookie 完整配置
const secureCookieOptions = {
name: '__Host-auth-token', // ← 使用 __Host- 前缀(最严格)
value: token,
httpOnly: true, // ← JS 无法读取,防 XSS
secure: true, // ← 仅 HTTPS 传输
sameSite: 'lax', // ← 防 CSRF(strict 最严格)
maxAge: 60 * 60 * 24 * 7, // ← 7天过期
path: '/', // ← 全站有效
// domain: '.example.com', // ← __Host- 前缀不能设置 domain
};
// __Host- 前缀的规则(浏览器强制执行):
// 1. 必须设置 secure: true
// 2. 必须设置 path: '/'
// 3. 不能设置 domain
// 4. 这些规则由浏览器强制执行,非 JS 可绕过
// __Secure- 前缀(较宽松):
// 1. 必须设置 secure: true
// 2. 可以设置 domain
// 设置 Cookie
response.cookies.set(secureCookieOptions);
// 读取 Cookie(服务端)
const token = request.cookies.get('__Host-auth-token')?.value;
// 删除 Cookie
response.cookies.delete('__Host-auth-token');SSO 单点登录
SSO 认证流程
SSO 客户端集成
const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL;
const APP_ID = process.env.NEXT_PUBLIC_APP_ID;
// SSO 客户端 SDK
export const sso = {
// 1. 跳转到 SSO 登录页
login(callbackUrl?: string) {
const redirectUri = encodeURIComponent(
callbackUrl ?? window.location.href
);
window.location.href =
`${SSO_URL}/authorize?` +
`client_id=${APP_ID}&` +
`redirect_uri=${redirectUri}&` +
`response_type=code&` +
`scope=openid profile email`;
},
// 2. 处理 SSO 回调(在回调页使用)
async handleCallback(code: string) {
const response = await fetch(`${SSO_URL}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code, // ← 授权码
client_id: APP_ID,
client_secret: process.env.SSO_CLIENT_SECRET,
redirect_uri: `${window.location.origin}/callback`,
}),
});
const { access_token, id_token } = await response.json();
// access_token: 用于访问 API
// id_token: 包含用户身份信息(JWT 格式)
return { access_token, id_token };
},
// 3. 检查 SSO 登录状态
async checkSession(): Promise<boolean> {
try {
const response = await fetch(`${SSO_URL}/session/check`, {
credentials: 'include', // ← 携带 SSO Cookie
});
const { active } = await response.json();
return active;
} catch {
return false;
}
},
// 4. SSO 登出(登出所有应用)
logout() {
window.location.href =
`${SSO_URL}/logout?` +
`client_id=${APP_ID}&` +
`post_logout_redirect_uri=` +
encodeURIComponent(window.location.origin);
},
};
// 回调页组件
export default async function SSOCallbackPage({
searchParams,
}: {
searchParams: { code?: string; error?: string };
}) {
if (searchParams.error) {
return <div>登录失败: {searchParams.error}</div>;
}
if (searchParams.code) {
const tokens = await sso.handleCallback(searchParams.code);
// 存储 token 并跳转...
redirect('/dashboard');
}
return <div>处理中...</div>;
}SSO 实现方案对比
Central Authentication Service
耶鲁大学开发的开源 SSO 协议,基于票据验证。广泛用于教育和政府机构。
Security Assertion Markup Language
基于 XML 的企业级 SSO 标准。支持细粒度权限断言,适合大型企业。
OpenID Connect
基于 OAuth 2.0 的现代身份认证层。使用 JWT,是当前最流行的 SSO 方案。
鉴权方案选型速查表
Session 方案
RedishttpOnly Cookie滑动 30minCookie 自带httpOnly 防护JWT 方案
内存变量httpOnly Cookie15 分钟RS256Authorization HeaderOAuth 2.0 方案
Auth Code + PKCE按应用类型Bearer最小权限原则NextAuth.js选型决策树
多个内部系统需要统一登录 → SSO (OIDC)
使用 Keycloak / Auth0 / 自建 SSO 服务
单页应用或移动端 → JWT + Refresh Token
Access Token 存内存,Refresh Token 存 httpOnly Cookie
服务端渲染、高安全要求 → Session + Cookie
使用 Redis 存储,天然 CSRF 防护
接入 GitHub/Google/微信登录 → OAuth 2.0
推荐使用 NextAuth.js / Lucia Auth 简化集成