345 lines
9.1 KiB
TypeScript
345 lines
9.1 KiB
TypeScript
/**
|
||
* 认证模块
|
||
* 支持:
|
||
* 1. 账号密码登录(环境变量 + 数据库)
|
||
* 2. OpenID Connect (OIDC)
|
||
*
|
||
* Session 存储在 Redis,Cookie 传输
|
||
*/
|
||
import { v4 as uuidv4 } from "uuid";
|
||
import bcrypt from "bcrypt";
|
||
import * as jose from "jose";
|
||
import {
|
||
ADMIN_SESSION_COOKIE_NAME,
|
||
ADMIN_SESSION_TTL,
|
||
OIDC_ENABLED,
|
||
OIDC_ISSUER,
|
||
OIDC_CLIENT_ID,
|
||
OIDC_CLIENT_SECRET,
|
||
OIDC_REDIRECT_URI,
|
||
ADMIN_SUPER_USERNAME,
|
||
ADMIN_SUPER_PASSWORD,
|
||
ADMIN_SUPER_PASSWORD_HASH,
|
||
COOKIE_SECURE,
|
||
COOKIE_SAME_SITE,
|
||
} from "./env";
|
||
import { saveSession, loadSession, deleteSession, refreshSessionTtl } from "./redis";
|
||
import {
|
||
getUserByUsername,
|
||
getAdminSession,
|
||
createUser,
|
||
type AdminSession,
|
||
} from "./rbac";
|
||
import type { AdminUser } from "./rbac";
|
||
|
||
const SALT_ROUNDS = 12;
|
||
|
||
// ============ 密码验证 ============
|
||
|
||
export async function verifyPassword(
|
||
plain: string,
|
||
hash: string
|
||
): Promise<boolean> {
|
||
return bcrypt.compare(plain, hash);
|
||
}
|
||
|
||
export async function hashPassword(plain: string): Promise<string> {
|
||
return bcrypt.hash(plain, SALT_ROUNDS);
|
||
}
|
||
|
||
// ============ 环境变量超级管理员 ============
|
||
|
||
async function verifySuperAdmin(
|
||
username: string,
|
||
password: string
|
||
): Promise<AdminUser | null> {
|
||
if (
|
||
!ADMIN_SUPER_USERNAME ||
|
||
username !== ADMIN_SUPER_USERNAME
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
// 优先使用预哈希密码
|
||
if (ADMIN_SUPER_PASSWORD_HASH) {
|
||
const valid = await bcrypt.compare(password, ADMIN_SUPER_PASSWORD_HASH);
|
||
if (!valid) return null;
|
||
} else if (ADMIN_SUPER_PASSWORD) {
|
||
if (password !== ADMIN_SUPER_PASSWORD) return null;
|
||
} else {
|
||
return null;
|
||
}
|
||
|
||
// 返回虚超级管理员用户对象(ID=-1)
|
||
return {
|
||
id: -1,
|
||
username: ADMIN_SUPER_USERNAME,
|
||
password_hash: ADMIN_SUPER_PASSWORD_HASH,
|
||
is_active: true,
|
||
created_at: new Date(),
|
||
updated_at: new Date(),
|
||
} as AdminUser;
|
||
}
|
||
|
||
// ============ 登录 ============
|
||
|
||
export async function login(
|
||
username: string,
|
||
password: string
|
||
): Promise<{ sessionId: string; adminSession: AdminSession } | null> {
|
||
// 1. 尝试超级管理员
|
||
const superUser = await verifySuperAdmin(username, password);
|
||
let adminUser: AdminUser | null = superUser;
|
||
let isSuperUser = !!superUser;
|
||
|
||
// 2. 尝试数据库用户
|
||
if (!adminUser) {
|
||
const dbUser = await getUserByUsername(username);
|
||
if (!dbUser || !dbUser.is_active) return null;
|
||
|
||
const valid = await verifyPassword(password, dbUser.password_hash);
|
||
if (!valid) return null;
|
||
|
||
adminUser = dbUser;
|
||
isSuperUser = false;
|
||
}
|
||
|
||
// 3. 生成 session
|
||
const sessionId = uuidv4();
|
||
const now = new Date().toISOString();
|
||
|
||
// 获取权限信息
|
||
let adminSession: AdminSession;
|
||
if (isSuperUser) {
|
||
adminSession = {
|
||
userId: adminUser.id,
|
||
username: adminUser.username,
|
||
roles: ["super_admin"],
|
||
permissions: ["*"], // 超级管理员拥有所有权限
|
||
};
|
||
} else {
|
||
adminSession = await getAdminSession(adminUser.id, adminUser.username);
|
||
}
|
||
|
||
// 4. 保存到 Redis
|
||
const sessionState: Record<string, unknown> = {
|
||
"session:user_uid": adminUser.id,
|
||
"session:username": adminUser.username,
|
||
"session:roles": adminSession.roles,
|
||
"session:permissions": adminSession.permissions,
|
||
"session:created_at": now,
|
||
"session:last_active": now,
|
||
"session:ip_address": null,
|
||
"session:user_agent": null,
|
||
};
|
||
|
||
await saveSession(sessionId, sessionState, ADMIN_SESSION_TTL);
|
||
|
||
return { sessionId, adminSession };
|
||
}
|
||
|
||
// ============ Session 加载 ============
|
||
|
||
export async function loadAdminSession(
|
||
sessionId: string
|
||
): Promise<AdminSession | null> {
|
||
const state = await loadSession(sessionId);
|
||
if (!state) return null;
|
||
|
||
const userId = state["session:user_uid"];
|
||
const username = state["session:username"] as string;
|
||
const roles = (state["session:roles"] as string[]) || [];
|
||
const permissions = (state["session:permissions"] as string[]) || [];
|
||
|
||
if (!userId || !username) return null;
|
||
|
||
return {
|
||
userId: userId as number,
|
||
username,
|
||
roles,
|
||
permissions,
|
||
};
|
||
}
|
||
|
||
// ============ 刷新活跃时间 ============
|
||
|
||
export async function touchSession(sessionId: string): Promise<void> {
|
||
const state = await loadSession(sessionId);
|
||
if (!state) return;
|
||
state["session:last_active"] = new Date().toISOString();
|
||
const { saveSession } = await import("@/lib/redis");
|
||
await saveSession(sessionId, state, ADMIN_SESSION_TTL);
|
||
}
|
||
|
||
// ============ 登出 ============
|
||
|
||
export async function logout(sessionId: string): Promise<void> {
|
||
await deleteSession(sessionId);
|
||
}
|
||
|
||
// ============ OIDC ============
|
||
|
||
export function isOidcEnabled(): boolean {
|
||
return (
|
||
OIDC_ENABLED &&
|
||
!!OIDC_ISSUER &&
|
||
!!OIDC_CLIENT_ID &&
|
||
!!OIDC_CLIENT_SECRET
|
||
);
|
||
}
|
||
|
||
export function buildOidcAuthUrl(): string {
|
||
const params = new URLSearchParams({
|
||
client_id: OIDC_CLIENT_ID,
|
||
redirect_uri: OIDC_REDIRECT_URI,
|
||
response_type: "code",
|
||
scope: "openid profile email",
|
||
});
|
||
return `${OIDC_ISSUER}/authorize?${params.toString()}`;
|
||
}
|
||
|
||
export async function exchangeOidcCode(
|
||
code: string
|
||
): Promise<{ sessionId: string; adminSession: AdminSession } | null> {
|
||
try {
|
||
// 1. 用 code 换 token
|
||
const tokenRes = await fetch(`${OIDC_ISSUER}/token`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: new URLSearchParams({
|
||
grant_type: "authorization_code",
|
||
code,
|
||
redirect_uri: OIDC_REDIRECT_URI,
|
||
client_id: OIDC_CLIENT_ID,
|
||
client_secret: OIDC_CLIENT_SECRET,
|
||
}),
|
||
});
|
||
|
||
if (!tokenRes.ok) return null;
|
||
const tokenData = await tokenRes.json() as {
|
||
access_token?: string;
|
||
id_token?: string;
|
||
};
|
||
|
||
const idToken = tokenData.id_token || tokenData.access_token;
|
||
if (!idToken) return null;
|
||
|
||
// 2. 验证并解码 id_token
|
||
const JWKS = jose.createRemoteJWKSet(
|
||
new URL(`${OIDC_ISSUER}/.well-known/jwks.json`)
|
||
);
|
||
|
||
const { payload } = await jose.jwtVerify(idToken, JWKS, {
|
||
issuer: OIDC_ISSUER,
|
||
audience: OIDC_CLIENT_ID,
|
||
});
|
||
|
||
const email = payload.email as string;
|
||
const name = (payload.name || payload.preferred_username || email) as string;
|
||
|
||
// 3. 查找或创建 admin 用户
|
||
let user = await getUserByUsername(email);
|
||
if (!user) {
|
||
// 自动创建(首次 OIDC 登录)
|
||
const randomPassword = uuidv4();
|
||
const passwordHash = await hashPassword(randomPassword);
|
||
user = await createUser(email, passwordHash);
|
||
}
|
||
|
||
if (!user.is_active) return null;
|
||
|
||
// 4. 生成 session
|
||
const sessionId = uuidv4();
|
||
const now = new Date().toISOString();
|
||
const adminSession = await getAdminSession(user.id, user.username);
|
||
|
||
const sessionState: Record<string, unknown> = {
|
||
"session:user_uid": user.id,
|
||
"session:username": user.username,
|
||
"session:roles": adminSession.roles,
|
||
"session:permissions": adminSession.permissions,
|
||
"session:created_at": now,
|
||
"session:last_active": now,
|
||
"session:oidc_name": name,
|
||
"session:ip_address": null,
|
||
"session:user_agent": null,
|
||
};
|
||
|
||
await saveSession(sessionId, sessionState, ADMIN_SESSION_TTL);
|
||
|
||
return { sessionId, adminSession };
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ============ Cookie 工具 ============
|
||
|
||
export interface CookieOptions {
|
||
maxAge?: number;
|
||
path?: string;
|
||
domain?: string;
|
||
sameSite?: "strict" | "lax" | "none";
|
||
httpOnly?: boolean;
|
||
secure?: boolean;
|
||
}
|
||
|
||
export function buildSetCookieHeader(
|
||
value: string,
|
||
options: CookieOptions = {}
|
||
): string {
|
||
const {
|
||
maxAge = ADMIN_SESSION_TTL,
|
||
path = "/",
|
||
sameSite = COOKIE_SAME_SITE,
|
||
httpOnly = true,
|
||
secure = COOKIE_SECURE,
|
||
} = options;
|
||
|
||
let cookie = `${ADMIN_SESSION_COOKIE_NAME}=${encodeURIComponent(value)}; Path=${path}; Max-Age=${maxAge}; SameSite=${sameSite}`;
|
||
|
||
if (secure) cookie += "; Secure";
|
||
if (httpOnly) cookie += "; HttpOnly";
|
||
|
||
return cookie;
|
||
}
|
||
|
||
export function parseSessionCookie(cookieHeader: string | null): string | null {
|
||
if (!cookieHeader) return null;
|
||
const cookies = cookieHeader.split(";").map((c) => c.trim());
|
||
for (const cookie of cookies) {
|
||
const [name, ...valueParts] = cookie.split("=");
|
||
if (name === ADMIN_SESSION_COOKIE_NAME) {
|
||
return decodeURIComponent(valueParts.join("="));
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function buildClearCookieHeader(): string {
|
||
return `${ADMIN_SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; SameSite=${COOKIE_SAME_SITE}${COOKIE_SECURE ? "; Secure" : ""}; HttpOnly`;
|
||
}
|
||
|
||
// ============ 权限检查 ============
|
||
|
||
export function canAccess(
|
||
session: AdminSession | null,
|
||
requiredPermission: string
|
||
): boolean {
|
||
if (!session) return false;
|
||
if (session.permissions.includes("*")) return true;
|
||
return session.permissions.includes(requiredPermission);
|
||
}
|
||
|
||
export function isSuperAdmin(session: AdminSession | null): boolean {
|
||
return session?.roles.includes("super_admin") ?? false;
|
||
}
|
||
|
||
/** 从 NextRequest headers 提取当前登录的 admin user id(由 middleware 设置) */
|
||
export function getAdminUserId(req: Request): number | null {
|
||
const header = req.headers.get("x-admin-user-id");
|
||
if (!header) return null;
|
||
const id = parseInt(header, 10);
|
||
return isNaN(id) ? null : id;
|
||
}
|