gitdataai/admin/src/lib/auth.ts

345 lines
9.1 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.

/**
* 认证模块
* 支持:
* 1. 账号密码登录(环境变量 + 数据库)
* 2. OpenID Connect (OIDC)
*
* Session 存储在 RedisCookie 传输
*/
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;
}