WebAuthentication(React)Skill web-auth

React 应用程序的身份验证模式,包括登录流程、OAuth、JWT 处理、会话管理和受保护的路由。

前端开发 0 次安装 2 次浏览 更新于 3/3/2026

Web 认证(React)

核心模式

JWT 令牌存储

选项和权衡:

存储 XSS 安全 CSRF 安全 最佳用途
httpOnly 饼干 否(需要 CSRF 令牌) 令牌最安全
localStorage 简单应用,短期令牌
内存(状态) 非常短期的令牌,带刷新

基于 Cookie 的认证(推荐)

// API 客户端设置
const api = {
  async login(email: string, password: string) {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include', // 重要:发送/接收饼干
      body: JSON.stringify({ email, password }),
    });
    return response.json();
  },

  async logout() {
    await fetch('/api/auth/logout', {
      method: 'POST',
      credentials: 'include',
    });
  },

  async getUser() {
    const response = await fetch('/api/auth/me', {
      credentials: 'include',
    });
    if (!response.ok) throw new Error('未认证');
    return response.json();
  },
};

认证上下文模式

import { createContext, useContext, useEffect, useState, ReactNode } from 'react';

interface User {
  id: string;
  email: string;
  name: string;
}

interface AuthState {
  user: User | null;
  isLoading: boolean;
  isAuthenticated: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  refresh: () => Promise<void>;
}

const AuthContext = createContext<AuthState | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // 挂载时检查认证状态
    checkAuth();
  }, []);

  async function checkAuth() {
    try {
      const userData = await api.getUser();
      setUser(userData);
    } catch {
      setUser(null);
    } finally {
      setIsLoading(false);
    }
  }

  async function login(email: string, password: string) {
    const { user } = await api.login(email, password);
    setUser(user);
  }

  async function logout() {
    await api.logout();
    setUser(null);
  }

  async function refresh() {
    await checkAuth();
  }

  return (
    <AuthContext.Provider
      value={{
        user,
        isLoading,
        isAuthenticated: !!user,
        login,
        logout,
        refresh,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth 必须在 AuthProvider 内部使用');
  }
  return context;
}

OAuth 模式

Google OAuth(Web)

// 使用 Google 身份服务
import { useEffect } from 'react';

declare global {
  interface Window {
    google: any;
  }
}

function GoogleLoginButton() {
  useEffect(() => {
    // 加载 Google 身份服务脚本
    const script = document.createElement('script');
    script.src = 'https://accounts.google.com/gsi/client';
    script.async = true;
    document.body.appendChild(script);

    script.onload = () => {
      window.google.accounts.id.initialize({
        client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
        callback: handleGoogleResponse,
      });

      window.google.accounts.id.renderButton(
        document.getElementById('google-signin'),
        { theme: 'outline', size: 'large' }
      );
    };

    return () => {
      document.body.removeChild(script);
    };
  }, []);

  async function handleGoogleResponse(response: { credential: string }) {
    // 将令牌发送到后端进行验证
    const result = await fetch('/api/auth/google', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify({ token: response.credential }),
    });

    if (result.ok) {
      window.location.href = '/dashboard';
    }
  }

  return <div id="google-signin" />;
}

NextAuth.js(推荐用于 Next.js)

// pages/api/auth/[...nextauth].ts 或 app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import CredentialsProvider from 'next-auth/providers/credentials';

export const authOptions = {
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    CredentialsProvider({
      name: 'Credentials',
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        // 验证凭据是否与数据库匹配
        const user = await verifyCredentials(
          credentials?.email,
          credentials?.password
        );
        return user ?? null;
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      session.user.id = token.id;
      return session;
    },
  },
};

export default NextAuth(authOptions);
// 组件中的使用
import { useSession, signIn, signOut } from 'next-auth/react';

function AuthButton() {
  const { data: session, status } = useSession;

  if (status === 'loading') {
    return <Spinner />;
  }

  if (session) {
    return (
      <div>
        <span>以 {session.user?.email} 身份登录</span>
        <button onClick={() => signOut()}>登出</button>
      </div>
    );
  }

  return (
    <div>
      <button onClick={() => signIn('google')}>用 Google 登录</button>
      <button onClick={() => signIn('credentials')}>登录</button>
    </div>
  );
}

受保护的路由

React Router

import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/auth';

function ProtectedRoute() {
  const { isAuthenticated, isLoading } = useAuth();
  const location = useLocation();

  if (isLoading) {
    return <LoadingScreen />;
  }

  if (!isAuthenticated) {
    // 重定向到登录,保留预期目的地
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return <Outlet />;
}

// 路由中的使用
<Routes>
  <Route path="/login" element={<LoginPage />} />
  <Route element={<ProtectedRoute />}>
    <Route path="/dashboard" element={<Dashboard />} />
    <Route path="/settings" element={<Settings />} />
  </Route>
</Routes>

Next.js App Router

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token');

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*'],
};
// app/dashboard/layout.tsx
import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getServerSession(authOptions);

  if (!session) {
    redirect('/login');
  }

  return <>{children}</>;
}

令牌刷新模式

// 带有自动令牌刷新的 API 客户端
class ApiClient {
  private refreshPromise: Promise<void> | null = null;

  async fetch(url: string, options: RequestInit = {}) {
    const response = await fetch(url, {
      ...options,
      credentials: 'include',
    });

    if (response.status === 401) {
      // 令牌过期,尝试刷新
      await this.refreshToken();
      // 重试原始请求
      return fetch(url, { ...options, credentials: 'include' });
    }

    return response;
  }

  private async refreshToken() {
    // 消除重复的刷新请求
    if (this.refreshPromise) {
      return this.refreshPromise;
    }

    this.refreshPromise = fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include',
    }).then(async (response) => {
      if (!response.ok) {
        // 刷新失败,用户登出
        window.location.href = '/login';
        throw new Error('会话过期');
      }
    }).finally(() => {
      this.refreshPromise = null;
    });

    return this.refreshPromise;
  }
}

export const api = new ApiClient();

表单处理

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const loginSchema = z.object({
  email: z.string().email('无效的电子邮件'),
  password: z.string().min(8, '密码至少为 8 个字符'),
});

type LoginForm = z.infer<typeof loginSchema>;

function LoginPage() {
  const { login } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm<LoginForm>({
    resolver: zodResolver(loginSchema),
  });

  async function onSubmit(data: LoginForm) {
    try {
      await login(data.email, data.password);
      // 重定向到预期页面或仪表板
      const from = location.state?.from?.pathname || '/dashboard';
      navigate(from, { replace: true });
    } catch (error) {
      setError('root', {
        message: '无效的电子邮件或密码',
      });
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">电子邮件</label>
        <input {...register('email')} type="email" id="email" />
        {errors.email && <span>{errors.email.message}</span>}
      </div>

      <div>
        <label htmlFor="password">密码</label>
        <input {...register('password')} type="password" id="password" />
        {errors.password && <span>{errors.password.message}</span>}
      </div>

      {errors.root && <div className="error">{errors.root.message}</div>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '正在登录...' : '登录'}
      </button>
    </form>
  );
}

安全最佳实践

CSRF 保护

// 服务器:在响应中包含 CSRF 令牌
// 客户端:随请求发送
async function fetchWithCSRF(url: string, options: RequestInit = {}) {
  const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');

  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'X-CSRF-Token': csrfToken || '',
    },
    credentials: 'include',
  });
}

安全头(服务器)

// Next.js next.config.js
const securityHeaders = [
  {
    key: 'X-Content-Type-Options',
    value: 'nosniff',
  },
  {
    key: 'X-Frame-Options',
    value: 'DENY',
  },
  {
    key: 'X-XSS-Protection',
    value: '1; mode=block',
  },
  {
    key: 'Strict-Transport-Security',
    value: 'max-age=31536000; includeSubDomains',
  },
];

module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: securityHeaders,
      },
    ];
  },
};

常见问题

问题 解决方案
饼干未发送 添加 credentials: 'include' 到 fetch
饼干的 CORS 错误 服务器必须设置 Access-Control-Allow-Credentials: true
刷新时会话丢失 验证饼干设置(域,路径,安全)
OAuth 重定向失败 检查提供商控制台中的重定向 URI
localStorage 中的令牌被盗 移动到 httpOnly 饼干

后端集成(FastAPI)

# 参考:FastAPI 认证端点
from fastapi import FastAPI, Response, Depends, HTTPException
from fastapi.security import HTTPBearer

@app.post("/api/auth/login")
async def login(credentials: LoginRequest, response: Response):
    user = await verify_user(credentials.email, credentials.password)
    if not user:
        raise HTTPException(status_code=401, detail="无效的凭据")

    token = create_jwt_token(user.id)
    response.set_cookie(
        key="auth-token",
        value=token,
        httponly=True,
        secure=True,  # 仅 HTTPS
        samesite="lax",
        max_age=60 * 60 * 24 * 7,  # 7 天
    )
    return {"user": user}

@app.post("/api/auth/logout")
async def logout(response: Response):
    response.delete_cookie("auth-token")
    return {"success": True}