Files
leaudit-platform-frontend/app/api/login/auth.server.ts
T

565 lines
20 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.
/**
* 认证服务核心实现
*
* 这个文件包含了用户认证、会话管理、Token 刷新等核心功能的具体实现。
*
* 主要功能:
* - 基于 Cookie 的会话管理
* - OAuth2.0 Token 自动刷新
* - 用户登录状态检查
* - 会话创建和销毁
* - 用户信息保存到数据库
*
* 技术栈:
* - Remix Session Storage (Cookie-based)
* - OAuth2.0 Token Management
* - TypeScript 类型安全
*
*/
import { createCookieSessionStorage } from "@remix-run/node";
import { postgrestGet, postgrestPost, postgrestPut } from "../postgrest-client";
import { JWTUtils, type UserInfoForJWT } from "~/utils/jwt";
import { OAUTH_CONFIG, API_BASE_URL } from "~/config/api-config";
import axios from 'axios';
/**
* 用户角色类型定义
*
* @typedef {string} UserRole
* @property {'common'} common - 普通用户,有基本的系统访问权限
* @property {'developer'} developer - 开发者/管理员,有完整的系统管理权限
*/
export type UserRole = string;
/**
* 用户信息接口,对应 sso_users 表结构
*/
export interface UserInfo {
sub: string; // IDaaS用户唯一标识
username?: string; // 显示用户名称/工号
nick_name?: string; // 用户真实姓名
nickname?: string; // OAuth返回的昵称字段
name?: string; // 用户姓名(通常映射到 nick_name)
phone_number?: string; // 手机号
email?: string; // 邮箱地址
ou_id?: string; // 所属组织单位ID
ou_name?: string; // 所属部门名称
status?: number; // 账户状态: 0=正常, 1=禁用
is_leader?: boolean; // 是否为部门负责人
area?: string; // 用户所属地区
id?: string | number; // 临时的用户id
user_id?: string | number;
user_role?: string;
// 🔑 组织信息字段(可能为null)
tenant_name?: string | null;
dep_name?: string | null;
dep_short_name?: string | null;
}
/**
* sso_users 表记录接口
*/
export interface SsoUser {
id?: string;
sub: string;
username: string;
nick_name: string;
phone_number?: string;
email?: string;
ou_id: string;
ou_name: string;
status: number;
is_leader: boolean;
area?: string;
created_at?: string;
updated_at?: string;
deleted_at?: string;
}
function compactUserInfoForSession(userInfo?: UserInfo, userRole?: string): UserInfo | undefined {
if (!userInfo) {
return undefined;
}
// Cookie Session 直接存整份 userInfo 很容易超过浏览器 4KB 限制;
// 服务端鉴权实际只依赖这几个核心字段,其余信息交给接口按需取回。
return {
user_id: userInfo.user_id,
sub: userInfo.sub,
username: userInfo.username,
nick_name: userInfo.nick_name || userInfo.nickname || userInfo.name,
ou_id: userInfo.ou_id,
ou_name: userInfo.ou_name,
is_leader: userInfo.is_leader,
area: userInfo.area,
user_role: userInfo.user_role || userRole,
};
}
/**
* 会话存储配置
*
* 使用 Remix 的 Cookie Session Storage 来管理用户会话。
* Cookie 存储方式的优势:
* - 服务器端控制,安全性高
* - 自动处理过期时间
* - 支持 HttpOnly,防止 XSS 攻击
*
* 配置说明:
* - name: Cookie 名称,用于标识会话
* - httpOnly: 防止客户端 JavaScript 访问,提高安全性
* - sameSite: CSRF 保护
* - secrets: 用于签名和加密 Cookie 内容
* - maxAge: 会话有效期,与 OAuth Token 过期时间保持一致
*/
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__lgsession", // 登录会话 Cookie 名称
httpOnly: true, // 仅服务器端可访问,防止 XSS
path: "/", // Cookie 作用域为整个应用
sameSite: "lax", // CSRF 保护,允许顶级导航
secrets: ["s3cr3t"], // TODO: 应该从环境变量读取
maxAge: 60 * 60 * 8, // 8小时,确保大于等于JWT token最大有效期(通常为6小时)
secure: false, // 开发环境中禁用 HTTPS 要求
},
});
/**
* 获取会话对象
*
* 从请求中提取 Cookie 并获取对应的会话对象。
* 这是一个底层函数,通常不直接使用,而是通过 getUserSession 使用。
*
* @param request - Remix Request 对象,包含 HTTP 请求信息
* @returns 会话对象,可以用来读取和设置会话数据
*/
export async function getSession(request: Request) {
const cookie = request.headers.get("Cookie");
return sessionStorage.getSession(cookie);
}
/**
* 获取用户登录状态(带自动Token刷新)
*
* 这是认证系统的核心函数,负责:
* 1. 检查用户是否已登录
* 2. 验证 OAuth Token 是否有效
* 3. 自动刷新即将过期的 Token
* 4. 返回用户信息和认证状态
*
* Token 刷新机制:
* - 检查 access_token 是否即将过期(距离过期时间 < 5 分钟)
* - 如果即将过期,使用 refresh_token 自动获取新的 access_token
* - 如果刷新失败,则认为用户未登录
*
* @param request - Remix Request 对象
* @returns 包含以下字段的对象:
* - isAuthenticated: 是否已认证
* - userRole: 用户角色 ('common' | 'developer')
* - accessToken: OAuth 访问令牌
* - refreshToken: OAuth 刷新令牌
* - userInfo: 用户详细信息
* - isTokenExpired: Token 是否已过期
* - refreshedSession: 如果刷新了 Token,返回更新后的会话对象
*/
/**
* 生成前端JWT
* @param userInfo 用户信息
* @param expiresIn OAuth token过期时间(秒)
* @returns JWT字符串
*/
// async function generateFrontendJWT(userInfo: UserInfo, savedUserData: SsoUser, userRole: UserRole, expiresIn: number): Promise<string> {
// const jwtUserInfo: UserInfoForJWT = {
// sub: userInfo.sub,
// user_id: savedUserData.id!,
// username: savedUserData.username,
// nick_name: savedUserData.nick_name,
// email: savedUserData.email,
// phone_number: savedUserData.phone_number,
// ou_id: savedUserData.ou_id,
// ou_name: savedUserData.ou_name,
// is_leader: savedUserData.is_leader,
// user_role: userRole
// };
// return JWTUtils.generateJWT(jwtUserInfo, expiresIn);
// }
/**
* 创建包含JWT的用户信息对象
* @param userInfo OAuth用户信息
* @param savedUserData 数据库中保存的用户数据
* @param userRole 用户角色
* @param frontendJWT 前端JWT
* @returns 完整的用户信息对象
*/
// function createUserInfoWithJWT(userInfo: UserInfo, savedUserData: SsoUser, userRole: UserRole, frontendJWT: string) {
// return {
// // 保持与callback.tsx中enhancedUserInfo相同的数据结构
// sub: userInfo.sub,
// username: savedUserData.username,
// nick_name: savedUserData.nick_name,
// phone_number: savedUserData.phone_number,
// email: savedUserData.email,
// ou_id: savedUserData.ou_id,
// ou_name: savedUserData.ou_name,
// status: savedUserData.status,
// is_leader: savedUserData.is_leader,
// // 增强字段,与OAuth登录保持一致
// user_id: savedUserData.id,
// user_role: userRole,
// frontend_jwt: frontendJWT
// };
// }
export async function getUserSession(request: Request) {
const session = await getSession(request);
const isAuthenticated = session.get("isAuthenticated") === true;
const userRole = session.get("userRole") as UserRole;
const accessToken = session.get("accessToken");
const refreshToken = session.get("refreshToken");
const tokenIssuedAt = session.get("tokenIssuedAt");
let tokenExpiresIn = session.get("tokenExpiresIn");
const storedUserInfo = session.get("userInfo");
const userInfo = storedUserInfo
? {
...storedUserInfo,
role: storedUserInfo.role || storedUserInfo.user_role || userRole,
user_role: storedUserInfo.user_role || userRole,
}
: storedUserInfo;
const frontendJWT = session.get("frontendJWT");
// 🔑 检查是否是公共路径(不需要认证的路径)
const url = new URL(request.url);
const pathname = url.pathname;
const publicPaths = ['/login', '/favicon.ico', '/callback'];
const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
let refreshedSession = null;
// let shouldRegenerateJWT = false;
// 🔑 新的统一过期检查逻辑
// 不区分 admin 和 OAuth 用户,所有用户都使用同样的过期检查
// 如果没有设置 tokenExpiresIn,给一个默认值(24小时)
if (!tokenExpiresIn) {
tokenExpiresIn = 86400; // 24小时(与后端登录接口返回的一致)
session.set("tokenExpiresIn", tokenExpiresIn);
refreshedSession = session;
}
// 🔑 简化:不再自动重新生成 JWT
// JWT 即将过期时,直接让用户重新登录
// 🔑 关键:验证 session 完整性
// 如果 isAuthenticated 为 true,但缺少关键数据(userInfo),则认为 session 无效
let finalIsAuthenticated = isAuthenticated;
if (finalIsAuthenticated) {
// 检查是否有用户信息
if (!userInfo) {
console.warn("⚠️ [getUserSession] Session 不完整:缺少 userInfo");
finalIsAuthenticated = false;
}
// 检查是否有前端 JWT
if (!frontendJWT) {
console.warn("⚠️ [getUserSession] Session 不完整:缺少 frontendJWT");
finalIsAuthenticated = false;
}
// 🔑 统一的 token 过期检查(所有用户类型)
// 使用签发时间 + 有效期 - 5分钟缓冲来判断是否需要重新登录
if (tokenIssuedAt && tokenExpiresIn) {
const now = Date.now(); // 当前时间(毫秒)
const expiresAt = tokenIssuedAt + (tokenExpiresIn * 1000); // 过期时间(毫秒)
const bufferTime = 5 * 60 * 1000; // 5分钟缓冲时间(毫秒)
// 如果当前时间 >= 过期时间 - 5分钟,则认为 token 即将过期或已过期
if (now >= expiresAt - bufferTime) {
const remainingSeconds = Math.floor((expiresAt - now) / 1000);
console.error(`❌ [getUserSession] Token 即将过期或已过期 (剩余: ${remainingSeconds} 秒)`);
finalIsAuthenticated = false;
}
} else if (!tokenIssuedAt) {
// 如果没有签发时间,认为 session 无效(可能是旧版本数据)
console.warn("⚠️ [getUserSession] Session 缺少 tokenIssuedAt,认为已过期");
finalIsAuthenticated = false;
}
}
// 🚨 统一的认证检查和重定向逻辑
if (!finalIsAuthenticated) {
// 如果是公共路径,不重定向,直接返回未认证状态
if (isPublicPath) {
// console.log("️ [getUserSession] 公共路径,允许未认证访问:", pathname);
return {
isAuthenticated: false,
userRole,
accessToken,
refreshToken,
userInfo,
refreshedSession,
frontendJWT
};
}
// 非公共路径且未认证,重定向到登录页
if (isAuthenticated) {
// Session 存在但已失效(token 过期或数据不完整)
console.error("❌ [getUserSession] Session 已失效,清除 session 并重定向到登录页");
const { redirect } = await import("@remix-run/node");
const destroyedSession = await sessionStorage.destroySession(session);
// 重定向到登录页,添加 expired=true 参数
throw redirect("/login?expired=true", {
headers: {
"Set-Cookie": destroyedSession
}
});
} else {
// Session 不存在(首次访问或已登出)
console.warn("⚠️ [getUserSession] 未登录,重定向到登录页");
const { redirect } = await import("@remix-run/node");
// 保存当前路径,登录后可以跳转回来
const redirectTo = pathname !== '/login' ? pathname : '/';
throw redirect(`/login?redirect=${encodeURIComponent(redirectTo)}`);
}
}
return {
isAuthenticated: finalIsAuthenticated,
userRole,
accessToken,
refreshToken,
userInfo,
refreshedSession, // 如果刷新了token,返回更新后的session
frontendJWT // 返回前端JWT
};
}
/**
* 创建用户登录会话(完整版)
*
* 在用户成功登录后调用此函数来创建完整的会话并设置 Cookie。
* 这个函数支持完整的OAuth2.0登录流程,包括token管理和用户信息存储。
*
* 处理流程:
* 1. 创建新的会话对象
* 2. 设置认证状态、用户角色、token信息
* 3. 保存用户信息和前端JWT
* 4. 生成签名的 Cookie
* 5. 返回重定向响应并设置 Cookie
*
* @param params - 会话创建参数
* @returns HTTP 302 重定向响应,包含设置 Cookie 的头部
*/
export async function createUserSession(params: {
isAuthenticated: boolean;
userRole: string;
redirectTo: string;
accessToken?: string;
refreshToken?: string;
tokenExpiresIn?: number;
tokenIssuedAt?: number; // 🔑 新增:支持传递签发时间
userInfo?: UserInfo;
frontendJWT?: string;
}) {
const session = await sessionStorage.getSession();
// 基础认证信息
session.set("isAuthenticated", params.isAuthenticated);
session.set("userRole", params.userRole);
// OAuth token信息
if (params.accessToken) {
session.set("accessToken", params.accessToken);
}
if (params.refreshToken) {
session.set("refreshToken", params.refreshToken);
}
// 🔑 Token 时间信息(与 accessToken 独立,支持管理员登录)
// 只要有 tokenExpiresIn 或 tokenIssuedAt,就设置这些字段
if (params.tokenExpiresIn) {
session.set("tokenExpiresIn", params.tokenExpiresIn);
}
if (params.tokenIssuedAt !== undefined) {
// 优先使用传递的 tokenIssuedAt
session.set("tokenIssuedAt", params.tokenIssuedAt);
} else if (params.accessToken || params.frontendJWT) {
// 如果没有传递 tokenIssuedAt,但有 tokenaccessToken 或 frontendJWT),则使用当前时间
session.set("tokenIssuedAt", Date.now());
}
// 用户信息和JWT
if (params.userInfo) {
session.set("userInfo", compactUserInfoForSession(params.userInfo, params.userRole));
}
if (params.frontendJWT) {
session.set("frontendJWT", params.frontendJWT);
}
// 🔑 根据 tokenExpiresIn 动态设置 Cookie 的 maxAge
// 如果有 tokenExpiresIn,使用它作为 Cookie 有效期;否则使用默认值(8小时)
const cookieMaxAge = params.tokenExpiresIn || (60 * 60 * 8); // 默认8小时
// console.log("🍪 [createUserSession] Cookie maxAge:", cookieMaxAge, "秒 (", (cookieMaxAge / 3600).toFixed(2), "小时)");
const cookie = await sessionStorage.commitSession(session, {
maxAge: cookieMaxAge // 🔑 动态设置 Cookie 有效期
});
return new Response(null, {
status: 302, // HTTP 重定向状态码
headers: {
Location: params.redirectTo, // 重定向目标 URL
"Set-Cookie": cookie, // 设置会话 Cookie
},
});
}
/**
* 创建用户登录会话(简化版)
*
* 在用户成功登录后调用此函数来创建会话并设置 Cookie。
* 这个函数通常在以下场景中使用:
* - 临时管理员登录
* - 测试用户登录
* - 其他简单认证方式成功后
*
* 处理流程:
* 1. 创建新的会话对象
* 2. 设置认证状态和用户角色
* 3. 生成签名的 Cookie
* 4. 返回重定向响应并设置 Cookie
*
* @param isAuthenticated - 是否已认证,通常为 true
* @param userRole - 用户角色,决定用户的权限级别
* @param redirectTo - 登录成功后重定向的 URL,默认为首页
* @returns HTTP 302 重定向响应,包含设置 Cookie 的头部
*/
export async function createSimpleUserSession(isAuthenticated: boolean, userRole: UserRole, redirectTo: string) {
const session = await sessionStorage.getSession();
session.set("isAuthenticated", isAuthenticated);
session.set("userRole", userRole);
const cookie = await sessionStorage.commitSession(session);
console.log("创建简化会话 - 设置Cookie:", !!cookie);
console.log("创建简化会话 - 用户角色:", userRole);
console.log("创建简化会话 - 重定向到:", redirectTo);
return new Response(null, {
status: 302, // HTTP 重定向状态码
headers: {
Location: redirectTo, // 重定向目标 URL
"Set-Cookie": cookie, // 设置会话 Cookie
},
});
}
/**
* 销毁会话(用户登出)
*
* 当用户主动登出或会话失效时调用此函数。
*
* 处理流程:
* 1. 获取当前用户的会话和访问令牌
* 2. 调用 IDaaS 单点登出接口
* 3. 销毁本地会话数据(清除所有存储的信息)
* 4. 清除客户端的会话 Cookie
* 5. 重定向到登录页面
*
* 注意事项:
* - 这个函数会同时处理本地会话和 IDaaS 的单点登出
* - 即使 IDaaS 登出失败,也会清除本地会话
* - 销毁会话后,用户需要重新登录才能访问受保护的页面
*
* @param request - Remix Request 对象,用于获取当前会话
* @returns HTTP 302 重定向响应,清除 Cookie 并跳转到登录页
*/
export async function logout(request: Request) {
const session = await getSession(request);
// 获取访问令牌和应用ID,用于调用IDaaS单点登出
const accessToken = session.get("accessToken");
const appId = OAUTH_CONFIG.appId || 'idaasoauth2';
console.log("🚪 [Logout] 开始登出流程...");
console.log("🔑 [Logout] accessToken 存在:", !!accessToken);
console.log("📱 [Logout] appId:", appId);
// 如果存在访问令牌,调用IDaaS单点登出(仅 OAuth 登录用户)
if (accessToken && appId) {
console.log("🌐 [Logout] OAuth 用户,准备调用 IDaaS 单点登出...");
try {
await callIDaaSLogout(accessToken, appId);
console.log("✅ [Logout] IDaaS单点登出成功");
} catch (error) {
console.error("❌ [Logout] IDaaS单点登出失败:");
console.error(" 错误详情:", error);
if (error instanceof Error) {
console.error(" 错误消息:", error.message);
console.error(" 错误堆栈:", error.stack);
}
// 即使IDaaS登出失败,也继续清除本地会话
}
} else {
console.log("️ [Logout] 管理员登录用户,无需调用 IDaaS 登出");
}
return new Response(null, {
status: 302, // HTTP 重定向状态码
headers: {
Location: "/login", // 重定向到登录页面
"Set-Cookie": await sessionStorage.destroySession(session), // 清除会话 Cookie
},
});
}
/**
* 调用IDaaS单点登出接口
*
* @param accessToken - 用户的访问令牌
* @param appId - 应用ID
* @returns Promise<void>
*/
async function callIDaaSLogout(accessToken: string, appId: string): Promise<void> {
const serverUrl = OAUTH_CONFIG.serverUrl || 'http://10.79.112.85';
const redirectUri = OAUTH_CONFIG.redirectUri || 'http://10.79.97.17/';
const logoutUrl = `${serverUrl}/public/sp/slo/${appId}`;
console.log("📡 [callIDaaSLogout] 准备发送登出请求:");
console.log(" 登出URL:", logoutUrl);
console.log(" 重定向URL:", redirectUri);
console.log(" accessToken:", accessToken ? `${accessToken.substring(0, 20)}...` : 'null');
const formData = new URLSearchParams();
formData.append('access_token', accessToken);
formData.append('redirect_url', encodeURIComponent(redirectUri));
try {
const response = await axios.post(logoutUrl, formData.toString(), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
console.log("✅ [callIDaaSLogout] IDaaS单点登出请求成功");
console.log(" 响应状态:", response.status);
console.log(" 响应数据:", response.data);
} catch (error) {
if (axios.isAxiosError(error)) {
console.error("❌ [callIDaaSLogout] 调用IDaaS登出接口失败:");
console.error(" HTTP状态:", error.response?.status);
console.error(" 状态文本:", error.response?.statusText);
console.error(" 响应数据:", error.response?.data);
console.error(" 请求配置:", error.config?.url, error.config?.method);
throw new Error(`IDaaS登出失败: ${error.response?.status} ${error.response?.statusText}`);
}
console.error("❌ [callIDaaSLogout] 调用IDaaS登出接口失败(非HTTP错误):", error);
throw error;
}
}