feat: impl accounts and channels

This commit is contained in:
2026-09-06 18:17:42 +08:00
parent aa9999a940
commit 1ccee1414c
70 changed files with 9879 additions and 31 deletions
+43
View File
@@ -0,0 +1,43 @@
import type { NextAuthConfig } from "next-auth";
export const authConfig = {
pages: {
signIn: "/login",
error: "/login",
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const pathname = nextUrl.pathname;
const publicPaths = ["/login", "/register", "/api/auth"];
const isPublic = publicPaths.some(
(path) => pathname === path || pathname.startsWith("/api/auth/")
);
// 已登录用户在登录/注册页时重定向到首页
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
return Response.redirect(new URL("/", nextUrl));
}
// 访问受保护页面必须已登录
if (!isLoggedIn && !isPublic) {
return false;
}
return true;
},
jwt({ token, user }) {
if (user?.id) {
token.id = user.id;
}
return token;
},
session({ session, token }) {
if (session.user && token.id) {
session.user.id = token.id as string;
}
return session;
},
},
providers: [],
} satisfies NextAuthConfig;
+147
View File
@@ -0,0 +1,147 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { users, userAccounts } from "@/lib/db/schema";
import { verifyPassword } from "./password";
import { authConfig } from "./config";
import type { Provider } from "next-auth/providers";
// 动态构建 Providers 列表
const providers: Provider[] = [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const email = String(credentials.email).toLowerCase().trim();
const password = String(credentials.password);
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user || !user.passwordHash || !user.isActive) {
return null;
}
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
return null;
}
return {
id: user.id,
name: user.name,
email: user.email,
image: user.avatar,
};
},
}),
];
// 如果配置了 OIDC,且 AUTH_OIDC_ENABLED 为 true,则动态注册通用 OIDC 提供商
const isOidcEnabled =
process.env.AUTH_OIDC_ENABLED === "true" &&
Boolean(process.env.AUTH_OIDC_ISSUER) &&
Boolean(process.env.AUTH_OIDC_CLIENT_ID);
if (isOidcEnabled) {
providers.push({
id: "oidc",
name: process.env.AUTH_OIDC_NAME || "SSO 统一身份认证",
// Laysense currently returns nonce: "" even when no nonce was requested,
// causing Auth.js to reject the ID Token. Keep the standard OIDC flow here
// and leave it disabled in .env.local until the identity provider is fixed.
type: "oidc",
issuer: process.env.AUTH_OIDC_ISSUER!.replace(/\/$/, ""),
clientId: process.env.AUTH_OIDC_CLIENT_ID,
clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET,
// Laysense requires an OAuth state parameter to correlate the callback.
checks: ["state"],
authorization: {
params: {
scope: process.env.AUTH_OIDC_SCOPES || "openid profile email",
},
},
});
}
export const { handlers, signIn, signOut, auth } = NextAuth({
...authConfig,
providers,
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 天
},
callbacks: {
...authConfig.callbacks,
async signIn({ user, account }) {
if (!account || account.type === "credentials") {
return true;
}
// 处理 OIDC / OAuth 登录与本地用户的关联或新建
const email = user.email?.toLowerCase().trim();
if (!email) {
return false;
}
// 1. 查询用户是否已存在
let [existingUser] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!existingUser) {
// 创建新用户
const [newUser] = await db
.insert(users)
.values({
name: user.name || email.split("@")[0],
email: email,
avatar: user.image || null,
isActive: true,
})
.returning();
existingUser = newUser;
}
user.id = existingUser.id;
// 2. 查询是否已记录该 provider 的账户绑定
const [existingAccount] = await db
.select()
.from(userAccounts)
.where(
eq(userAccounts.providerAccountId, account.providerAccountId)
)
.limit(1);
if (!existingAccount) {
await db.insert(userAccounts).values({
userId: existingUser.id,
provider: account.provider,
providerAccountId: account.providerAccountId,
refreshToken: account.refresh_token,
accessToken: account.access_token,
expiresAt: account.expires_at ? new Date(account.expires_at * 1000) : null,
tokenType: account.token_type,
scope: account.scope,
idToken: account.id_token,
});
}
return true;
},
},
});
+21
View File
@@ -0,0 +1,21 @@
import { hash, verify } from "@node-rs/argon2";
// 遵循 OWASP 密码哈希安全推荐配置
const ARGON2_OPTIONS = {
memoryCost: 19456, // 19 MiB
timeCost: 2,
outputLen: 32,
parallelism: 1,
};
export async function hashPassword(password: string): Promise<string> {
return await hash(password, ARGON2_OPTIONS);
}
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
try {
return await verify(passwordHash, password);
} catch {
return false;
}
}