geo/frontend/lib/auth.ts

137 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { api } from "@/lib/api";
/** 尝试使用 refresh token 获取新的 access token */
async function refreshAccessToken(token: Record<string, unknown>) {
try {
const backendUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
const res = await fetch(`${backendUrl}/api/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: token.refreshToken }),
});
if (!res.ok) {
throw new Error(`Refresh failed: ${res.status}`);
}
const data = await res.json();
return {
...token,
accessToken: data.access_token,
refreshToken: data.refresh_token, // 滑动过期:更新为新 refresh token
// 新 access token 有效期 1 小时
expires_at: Date.now() + 60 * 60 * 1000,
error: undefined,
};
} catch (error) {
// 刷新失败:记录错误,标记让前端展示重新登录
console.error("[Auth] Token refresh failed:", error);
try {
const Sentry = await import("@sentry/nextjs");
Sentry.captureException(error);
} catch {
// Sentry 未安装或未配置,静默忽略
}
return {
...token,
error: "RefreshAccessTokenError",
};
}
}
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "邮箱", type: "email" },
password: { label: "密码", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
try {
// 在 Docker 容器内localhost 指向容器自身,需要使用服务名 backend
const backendUrl = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL?.replace("localhost", "backend") || "http://backend:8000";
const res = await fetch(`${backendUrl}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: credentials.email,
password: credentials.password,
}),
});
if (!res.ok) {
return null;
}
const data = await res.json();
if (data.access_token) {
const user = {
id: data.user?.id || credentials.email,
name: data.user?.name,
email: data.user?.email,
accessToken: data.access_token,
refreshToken: data.refresh_token,
is_admin: data.user?.is_admin || false,
};
return user;
}
return null;
} catch (error) {
console.error("[Auth] Login failed:", error);
try {
const Sentry = await import("@sentry/nextjs");
Sentry.captureException(error);
} catch {
// Sentry 未安装或未配置,静默忽略
}
return null;
}
},
}),
],
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, user }) {
// 初次登录:将用户信息写入 token
if (user) {
token.sub = user.id;
token.accessToken = user.accessToken;
token.refreshToken = (user as unknown as Record<string, unknown>).refreshToken as string;
token.id = user.id;
token.is_admin = (user as unknown as Record<string, unknown>).is_admin as boolean;
// access token 有效期 1 小时
token.expires_at = Date.now() + 60 * 60 * 1000;
return token;
}
// 后续请求:检查 access token 是否即将过期(提前 5 分钟刷新)
const expiresAt = token.expires_at as number | undefined;
if (expiresAt && expiresAt < Date.now() + 5 * 60 * 1000) {
return refreshAccessToken(token as Record<string, unknown>);
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
session.refreshToken = token.refreshToken as string;
// 将 refresh 失败错误传递给前端,以便触发重新登录
session.error = token.error as string | undefined;
if (session.user) {
session.user.id = token.id as string;
session.user.is_admin = token.is_admin as boolean;
}
return session;
},
},
pages: {
signIn: "/login",
},
};