367 lines
9.9 KiB
TypeScript
367 lines
9.9 KiB
TypeScript
import { compare } from "bcrypt-ts";
|
|
import NextAuth, { type DefaultSession } from "next-auth";
|
|
import type { DefaultJWT } from "next-auth/jwt";
|
|
import Credentials from "next-auth/providers/credentials";
|
|
import Google from "next-auth/providers/google";
|
|
import { headers } from "next/headers";
|
|
import { DUMMY_PASSWORD } from "@/lib/constants";
|
|
import { createGuestUser, getAuthSettings, getUser } from "@/lib/db/queries";
|
|
import { buildAuthConfig } from "./auth.config";
|
|
import { db } from "@/lib/db";
|
|
import { loginToken, user as userTable } from "@/lib/db/schema";
|
|
import { eq, and, gt } from "drizzle-orm";
|
|
import {
|
|
establishSingleActiveSession,
|
|
getValidatedSessionUser,
|
|
isManagedSessionUserType,
|
|
requiresSingleActiveSession,
|
|
revokeSingleActiveSession,
|
|
} from "@/lib/auth/session-control";
|
|
|
|
export type UserType = "guest" | "regular" | "admin" | "agent_admin";
|
|
|
|
declare module "next-auth" {
|
|
interface Session extends DefaultSession {
|
|
user: {
|
|
id: string;
|
|
type: UserType;
|
|
name?: string | null;
|
|
sessionKey?: string;
|
|
} & DefaultSession["user"];
|
|
}
|
|
|
|
interface User {
|
|
id?: string;
|
|
email?: string | null;
|
|
name?: string | null;
|
|
type?: UserType;
|
|
}
|
|
}
|
|
|
|
declare module "next-auth/jwt" {
|
|
interface JWT extends DefaultJWT {
|
|
id: string;
|
|
type: UserType;
|
|
name?: string | null;
|
|
sessionKey?: string;
|
|
}
|
|
}
|
|
|
|
async function getOrCreateGoogleUser(email: string, name?: string | null) {
|
|
const existingUsers = await getUser(email);
|
|
if (existingUsers.length > 0) {
|
|
return existingUsers[0];
|
|
}
|
|
|
|
const [createdUser] = await db
|
|
.insert(userTable)
|
|
.values({
|
|
email,
|
|
password: "",
|
|
name: (name || email.split("@")[0] || "").slice(0, 100),
|
|
role: "regular",
|
|
isActive: true,
|
|
})
|
|
.returning();
|
|
|
|
return createdUser || null;
|
|
}
|
|
|
|
function createBaseProviders(googleClientId?: string, googleClientSecret?: string) {
|
|
const providers: any[] = [
|
|
Credentials({
|
|
credentials: {},
|
|
async authorize(credentials: any) {
|
|
// 1. DingTalk Token 登录逻辑
|
|
if (credentials.token) {
|
|
const { token } = credentials;
|
|
|
|
const now = new Date();
|
|
const [validToken] = await db
|
|
.select()
|
|
.from(loginToken)
|
|
.where(and(eq(loginToken.token, token), gt(loginToken.expiresAt, now)))
|
|
.limit(1);
|
|
|
|
if (!validToken) {
|
|
return null;
|
|
}
|
|
|
|
const users = await getUser(validToken.email);
|
|
|
|
if (users.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const user = users[0];
|
|
|
|
// 检查用户是否被激活
|
|
if (!user.isActive) {
|
|
return null;
|
|
}
|
|
|
|
await db.delete(loginToken).where(eq(loginToken.id, validToken.id));
|
|
|
|
const userType: UserType = (user.role as UserType) || "regular";
|
|
return { id: user.id, email: user.email, name: user.name, type: userType };
|
|
}
|
|
|
|
// 2. 邮箱密码登录逻辑
|
|
const email = String(credentials?.email || "").trim().toLowerCase();
|
|
const password = String(credentials?.password || "");
|
|
|
|
if (!email || !password) {
|
|
await compare(password || "", DUMMY_PASSWORD);
|
|
return null;
|
|
}
|
|
|
|
const users = await getUser(email);
|
|
|
|
if (users.length === 0) {
|
|
await compare(password, DUMMY_PASSWORD);
|
|
return null;
|
|
}
|
|
|
|
const [user] = users;
|
|
|
|
if (!user.password) {
|
|
await compare(password, DUMMY_PASSWORD);
|
|
return null;
|
|
}
|
|
|
|
const passwordsMatch = await compare(password, user.password);
|
|
|
|
if (!passwordsMatch) {
|
|
return null;
|
|
}
|
|
|
|
// 检查用户是否被激活
|
|
if (!user.isActive) {
|
|
return null;
|
|
}
|
|
|
|
// 从数据库读取用户角色,默认为 regular
|
|
const userType: UserType = (user.role as UserType) || "regular";
|
|
|
|
return { id: user.id, email: user.email, name: user.name, type: userType };
|
|
},
|
|
}),
|
|
Credentials({
|
|
id: "guest",
|
|
credentials: {},
|
|
async authorize() {
|
|
const [guestUser] = await createGuestUser();
|
|
return { ...guestUser, type: "guest" };
|
|
},
|
|
}),
|
|
];
|
|
|
|
if (googleClientId && googleClientSecret) {
|
|
providers.push(
|
|
Google({
|
|
clientId: googleClientId,
|
|
clientSecret: googleClientSecret,
|
|
})
|
|
);
|
|
}
|
|
|
|
return providers;
|
|
}
|
|
|
|
function normalizeBaseUrl(input?: string | null) {
|
|
if (!input) return "";
|
|
|
|
try {
|
|
const parsed = new URL(input);
|
|
return `${parsed.protocol}//${parsed.host}`;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
async function resolveRuntimeAuthUrl(settingsAuthUrl?: string | null) {
|
|
const fromEnv =
|
|
normalizeBaseUrl(process.env.AUTH_URL) ||
|
|
normalizeBaseUrl(process.env.NEXTAUTH_URL);
|
|
if (fromEnv) return fromEnv;
|
|
|
|
const fromSettings = normalizeBaseUrl(settingsAuthUrl);
|
|
if (fromSettings) return fromSettings;
|
|
|
|
const requestHeaders = await headers();
|
|
const forwardedHost = requestHeaders.get("x-forwarded-host");
|
|
const host = requestHeaders.get("host");
|
|
const resolvedHost = forwardedHost || host || "";
|
|
if (!resolvedHost) return "";
|
|
|
|
const forwardedProto = requestHeaders.get("x-forwarded-proto");
|
|
const protocol = forwardedProto || "https";
|
|
return `${protocol}://${resolvedHost}`;
|
|
}
|
|
|
|
const nextAuth = NextAuth(async () => {
|
|
const runtimeAuthSettings = await getAuthSettings().catch(() => null as any);
|
|
const resolvedAuthUrl =
|
|
(await resolveRuntimeAuthUrl(runtimeAuthSettings?.authUrl)) || undefined;
|
|
const googleClientId =
|
|
runtimeAuthSettings?.googleClientId || process.env.GOOGLE_CLIENT_ID || "";
|
|
const googleClientSecret =
|
|
runtimeAuthSettings?.googleClientSecret || process.env.GOOGLE_CLIENT_SECRET || "";
|
|
|
|
return {
|
|
...buildAuthConfig(resolvedAuthUrl),
|
|
providers: createBaseProviders(googleClientId, googleClientSecret),
|
|
callbacks: {
|
|
async signIn({ account, user, profile }) {
|
|
if (account?.provider !== "google") {
|
|
return true;
|
|
}
|
|
|
|
const authSettings = await getAuthSettings();
|
|
if (!authSettings.isGoogleEnabled) {
|
|
return false;
|
|
}
|
|
|
|
const email = (user?.email || (profile as any)?.email || "").trim().toLowerCase();
|
|
if (!email) {
|
|
return false;
|
|
}
|
|
|
|
const dbUser = await getOrCreateGoogleUser(
|
|
email,
|
|
user?.name || (profile as any)?.name || null
|
|
);
|
|
|
|
return Boolean(dbUser?.isActive);
|
|
},
|
|
async jwt({ token, user, account, profile }) {
|
|
let resolvedUserId =
|
|
typeof token.id === "string" && token.id.length > 0 ? token.id : undefined;
|
|
let resolvedUserType =
|
|
typeof token.type === "string" && isManagedSessionUserType(token.type)
|
|
? token.type
|
|
: undefined;
|
|
|
|
if (user?.id && user?.type) {
|
|
token.id = user.id as string;
|
|
token.type = user.type;
|
|
token.name = user.name;
|
|
resolvedUserId = token.id;
|
|
resolvedUserType = token.type;
|
|
}
|
|
|
|
if (account?.provider === "google") {
|
|
const email = String(
|
|
user?.email || (profile as any)?.email || token.email || ""
|
|
)
|
|
.trim()
|
|
.toLowerCase();
|
|
|
|
if (email) {
|
|
const dbUser = await getOrCreateGoogleUser(
|
|
email,
|
|
user?.name || (profile as any)?.name || null
|
|
);
|
|
|
|
if (dbUser) {
|
|
token.id = dbUser.id;
|
|
token.type = (dbUser.role as UserType) || "regular";
|
|
token.name = dbUser.name;
|
|
token.email = dbUser.email;
|
|
resolvedUserId = dbUser.id;
|
|
resolvedUserType = (dbUser.role as UserType) || "regular";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (account && resolvedUserId && requiresSingleActiveSession(resolvedUserType)) {
|
|
const { sessionKey } = await establishSingleActiveSession({
|
|
userId: resolvedUserId,
|
|
});
|
|
token.sessionKey = sessionKey;
|
|
} else if (!requiresSingleActiveSession(resolvedUserType)) {
|
|
delete token.sessionKey;
|
|
}
|
|
|
|
return token;
|
|
},
|
|
session({ session, token }) {
|
|
if (session.user) {
|
|
session.user.id = token.id;
|
|
session.user.type = token.type || "regular";
|
|
session.user.name = token.name as string;
|
|
session.user.sessionKey =
|
|
typeof token.sessionKey === "string" ? token.sessionKey : undefined;
|
|
}
|
|
|
|
return session;
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
const {
|
|
handlers: { GET, POST },
|
|
auth: rawAuth,
|
|
signIn,
|
|
signOut: rawSignOut,
|
|
} = nextAuth;
|
|
|
|
export { GET, POST, signIn };
|
|
|
|
export async function auth() {
|
|
const session = await rawAuth();
|
|
if (!session?.user?.id) {
|
|
return session;
|
|
}
|
|
|
|
if (!requiresSingleActiveSession(session.user.type)) {
|
|
return session;
|
|
}
|
|
|
|
const sessionKey = session.user.sessionKey;
|
|
if (!sessionKey) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const validatedUser = await getValidatedSessionUser({
|
|
userId: session.user.id,
|
|
sessionKey,
|
|
});
|
|
|
|
if (!validatedUser) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...session,
|
|
user: {
|
|
...session.user,
|
|
email: validatedUser.email,
|
|
name: validatedUser.name ?? session.user.name,
|
|
type: validatedUser.role,
|
|
sessionKey,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error("[Auth] Failed to validate active session:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function signOut(options?: Parameters<typeof rawSignOut>[0]) {
|
|
const session = await auth().catch(() => null);
|
|
|
|
if (session?.user?.id && requiresSingleActiveSession(session.user.type)) {
|
|
await revokeSingleActiveSession({
|
|
userId: session.user.id,
|
|
sessionKey: session.user.sessionKey,
|
|
}).catch((error) => {
|
|
console.error("[Auth] Failed to revoke active session during sign out:", error);
|
|
});
|
|
}
|
|
|
|
return rawSignOut(options);
|
|
}
|