文章
next js 与fastapi交互demo
.env
BACKEND_BASE_URL=http://localhost:8000
src/config/default.tsx
export const BACKEND_BASE_URL=process.env.BACKEND_BASE_URL;
export const BACKEND_LOGIN_URL = `${BACKEND_BASE_URL}/auth/login`;
export const BACKEND_REFRESH_TOKEN_URL = `${BACKEND_BASE_URL}/auth/refresh`;
export const ACCESS_TOKEN_NAME = 'access_token';
export const REFRESH_TOKEN_NAME = 'refresh_token';
src/lib/auth.ts
import {cookies} from 'next/headers';
import {ACCESS_TOKEN_NAME, REFRESH_TOKEN_NAME} from "@/config/default";
// 存储 Tokens 到 Cookie
export async function setAuthTokens({accessToken, refreshToken}: {
accessToken: string; refreshToken: string;
}) {
(await cookies()).set(ACCESS_TOKEN_NAME, accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 15, // 15分钟
});
(await cookies()).set(REFRESH_TOKEN_NAME, refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // 7天
});
}
// 获取 Tokens
export async function getAuthTokens() {
return {
accessToken: (await cookies()).get(ACCESS_TOKEN_NAME)?.value,
refreshToken: (await cookies()).get(REFRESH_TOKEN_NAME)?.value,
};
}
// 清除Token
export async function clearAuthTokens() {
(await cookies()).delete(ACCESS_TOKEN_NAME);
(await cookies()).delete(REFRESH_TOKEN_NAME);
}
src/lib/fetchWithAuth.ts
import {jwtDecode} from "jwt-decode";
import {cookies} from "next/headers";
import {redirect} from "next/navigation";
import {clearAuthTokens, getAuthTokens} from "@/lib/auth";
import {ACCESS_TOKEN_NAME, BACKEND_REFRESH_TOKEN_URL} from "@/config/default";
/**
* 封装带自动 Token 管理的 fetch 请求
* @param url 请求地址
* @param options 请求配置(包含 method、body、headers 等)
* @returns 响应数据
*/
export async function fetchWithAuth(
url: string,
options: RequestInit = {}
): Promise<Response> {
// 1、从cookie获取token
const {accessToken, refreshToken} = await getAuthTokens();
// 2、检查token是否存在
if (!accessToken || !refreshToken) {
console.error("未找到Token,跳转登录页");
redirect("/login");
}
// 3、检查access token是否过期
let validToken = accessToken;
try {
const {exp} = jwtDecode(accessToken);
if (exp * 1000 < Date.now()) {
// Token过期 自动刷新
console.log("Access Token 已过期,尝试自动刷新...")
const refreshResponse = await fetch(BACKEND_REFRESH_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${refreshToken}`
},
body: JSON.stringify({})
});
if (!refreshResponse.ok) {
throw new Error("刷新Token失败");
}
const {access_token: newAccessToken} = await refreshResponse.json();
validToken = newAccessToken;
// 更新cookie
(await cookies()).set(ACCESS_TOKEN_NAME, newAccessToken, {
httpOnly: true,
maxAge: 60 * 15
});
}
} catch (error) {
console.error("Token 验证/刷新失败:",error);
await clearAuthTokens();
}
// 4、发起请求 携带有效Token
const headers = {
"Content-Type": "application/json",
"Authorization": `Bearer ${validToken}`,
...options.headers,
};
return fetch(url, {
...options,
headers,
// 如果未指定 method,默认 POST(可根据需求调整)
method: options.method || "POST"
})
}
src/middleware.ts
import {NextResponse, NextRequest} from "next/server";
import {jwtDecode} from "jwt-decode";
import {ACCESS_TOKEN_NAME, BACKEND_REFRESH_TOKEN_URL, REFRESH_TOKEN_NAME} from '@/config/default'
export async function middleware(request: NextRequest) {
const {pathname} = request.nextUrl;
const accessToken = request.cookies.get(ACCESS_TOKEN_NAME)?.value;
const refreshToken = request.cookies.get(REFRESH_TOKEN_NAME)?.value;
console.log("middleware:", request.url);
// 1、放行公开路由(无需认证)
const publicPaths = ["/login", "/api/login"];
if (publicPaths.includes(pathname)) {
return NextResponse.next();
}
// 2、无token跳转登录
if (!accessToken || !refreshToken) {
const loginUrl = new URL("/login", request.url);
// 如果不是登陆页面,添加next参数
if (pathname !== "/login") {
loginUrl.searchParams.set("next", pathname)
}
return NextResponse.redirect(loginUrl);
}
// 3、检查access token是否过期
try {
const {exp} = jwtDecode(accessToken);
const isExpired = exp * 1000 < Date.now();
// Token 有效 继续访问
if (!isExpired) {
return NextResponse.next();
}
// Token过期 尝试刷新
const refreshResponse = await fetch(BACKEND_REFRESH_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${refreshToken}`
},
body: JSON.stringify({})
});
if (refreshResponse.ok) {
const {access_token: newAccessToken} = await refreshResponse.json();
// 更新cookie并继续请求
const response = NextResponse.next();
response.cookies.set(ACCESS_TOKEN_NAME, newAccessToken, {
httpOnly: true,
maxAge: 60 * 15
});
return response;
}
} catch (error) {
console.log("Token 验证失败", error);
}
// 4、刷新失效或token无效 清除cookie并跳转登录
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete(ACCESS_TOKEN_NAME);
response.cookies.delete(REFRESH_TOKEN_NAME);
return response;
}
// 配置中间件生效的路由(可选)
export const config = {
matcher: ['/((?!_next/static|favicon.ico).*)'], // 匹配所有路由,排除静态文件
};
src/app/api/login/route.tsx
"use server"
import {BACKEND_LOGIN_URL} from '@/config/default'
import {NextRequest, NextResponse} from 'next/server'
import {setAuthTokens} from "@/lib/auth";
export async function POST(request: NextRequest) {
const requestData = await request.json()
const jsonData = JSON.stringify(requestData)
const requestOptions = {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: jsonData
}
const response = await fetch(BACKEND_LOGIN_URL, requestOptions)
const responseData = await response.json()
if (response.ok) {
// console.log("login/route.tsx:responseData", responseData)
const {access_token: accessToken, refresh_token: refreshToken, username} = responseData
await setAuthTokens({accessToken, refreshToken});
return NextResponse.json({"loggedIn": true, "username": username}, {status: 200})
}
return NextResponse.json({"loggedIn": false, ...responseData}, {status: 400})
}
src/app/api/logout/route.tsx
import {clearAuthTokens} from "@/lib/auth";
import {NextRequest, NextResponse} from "next/server";
export async function POST(request: NextRequest) {
await clearAuthTokens()
return NextResponse.json({}, {status: 200})
}
src/app/login/page.tsx
"use client"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import React from "react";
import {useRouter, useSearchParams} from "next/navigation";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
const LOGIN_URL = "/api/login/"
const isValidNextUrl = (url: string | null): boolean => {
if (!url) {
return false;
}
try {
// 确保是相对路径或同源路径
const parsedUrl = new URL(url, window.location.origin);
return parsedUrl.origin === window.location.origin;
} catch {
return false;
}
}
export default function Page() {
const router = useRouter();
const searchParams = useSearchParams();
const nextUrl = searchParams.get("next");
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
// console.log(event, event.target)
const formData = new FormData(event.currentTarget)
const objectFromForm = Object.fromEntries(formData)
const jsonData = JSON.stringify(objectFromForm)
const requestOptions = {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: jsonData
}
const response = await fetch(LOGIN_URL, requestOptions)
if (response.ok) {
console.log("logged in")
const safeNextUrl = isValidNextUrl(nextUrl) ? nextUrl : "/"
router.push(safeNextUrl)
} else {
console.log(await response.json())
}
}
return (
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm">
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>Login to your account</CardTitle>
<CardDescription>
Enter your email below to login to your account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Username</Label>
<Input
id="username"
name="username"
type="text"
placeholder="Your username"
required
/>
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<a
href="#"
className="hidden"
>
Forgot your password?
</a>
</div>
<Input id="password" name="password" type="password" required/>
</div>
<div className="flex flex-col gap-3">
<Button type="submit" className="w-full">
Login
</Button>
</div>
</div>
<div className="mt-4 text-center text-sm">
Don't have an account?{" "}
<a href="#" className="underline underline-offset-4">
Sign up
</a>
</div>
</form>
</CardContent>
</Card>
</div>
</div>
</div>
)
}
src/app/logout/page.tsx
"use client"
import React from "react";
import { MouseEvent } from 'react';
import {useRouter} from "next/navigation";
const LOGOUT_URL = "/api/logout/"
export default function Page() {
const router = useRouter();
async function handleClick(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault()
const requestOptions = {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: ""
}
await fetch(LOGOUT_URL, requestOptions)
console.log("logged out")
router.push("/login")
}
return <div className="h-[95vh]">
<div className='max-w-md mx-auto py-5'>
<h1>Are you sure you want to logout?</h1>
<button className='bg-red-500 text-white hover:bg-red-300 px-3 py-2' onClick={handleClick}>Yes, logout
</button>
</div>
</div>
}
src/app/articles/page.tsx
import {fetchWithAuth} from "@/lib/fetchWithAuth";
import {BACKEND_BASE_URL} from "@/config/default";
const ARTICLES_API_URL = `${BACKEND_BASE_URL}/articles`;
export default async function Page() {
const res = await fetchWithAuth(ARTICLES_API_URL, {method: "GET"});
const data = await res.json();
return (
<main className="max-w-md mx-auto py-5">
{JSON.stringify(data)}
</main>
);
}