/** * 认证服务核心实现 * * 这个文件包含了用户认证、会话管理、Token 刷新等核心功能的具体实现。 * * 主要功能: * - 基于 Cookie 的会话管理 * - OAuth2.0 Token 自动刷新 * - 用户登录状态检查 * - 会话创建和销毁 * * 技术栈: * - Remix Session Storage (Cookie-based) * - OAuth2.0 Token Management * - TypeScript 类型安全 * */ import { createCookieSessionStorage } from "@remix-run/node"; import { tokenManager } from "./token-manager.server"; /** * 用户角色类型定义 * * @typedef {string} UserRole * @property {'common'} common - 普通用户,有基本的系统访问权限 * @property {'developer'} developer - 开发者/管理员,有完整的系统管理权限 */ export type UserRole = 'common' | 'developer'; /** * 会话存储配置 * * 使用 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 * 2, // 2小时,与 OAuth Token 同步 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,返回更新后的会话对象 */ export async function getUserSession(request: Request) { const session = await getSession(request); const isAuthenticated = session.get("isAuthenticated") === true; const userRole = session.get("userRole") || 'common' as UserRole; let accessToken = session.get("accessToken"); const refreshToken = session.get("refreshToken"); let tokenIssuedAt = session.get("tokenIssuedAt"); let tokenExpiresIn = session.get("tokenExpiresIn"); const userInfo = session.get("userInfo"); let isTokenExpired = false; let refreshedSession = null; // 如果有token信息,检查是否需要刷新 if (accessToken && refreshToken && tokenIssuedAt && tokenExpiresIn) { try { const tokenInfo = { accessToken, refreshToken, tokenIssuedAt, tokenExpiresIn }; // 检查并自动刷新token const refreshResult = await tokenManager.checkAndRefreshToken(tokenInfo); if (refreshResult.success && refreshResult.newTokenInfo) { const newToken = refreshResult.newTokenInfo; // 如果token被刷新了,更新session if (newToken.accessToken !== accessToken) { console.log("Token已刷新,更新session"); session.set("accessToken", newToken.accessToken); session.set("refreshToken", newToken.refreshToken); session.set("tokenIssuedAt", newToken.tokenIssuedAt); session.set("tokenExpiresIn", newToken.tokenExpiresIn); // 更新本地变量 accessToken = newToken.accessToken; tokenIssuedAt = newToken.tokenIssuedAt; tokenExpiresIn = newToken.tokenExpiresIn; refreshedSession = session; } isTokenExpired = false; } else { console.error("Token刷新失败:", refreshResult.error); isTokenExpired = true; } } catch (error) { console.error("Token验证过程中出错:", error); isTokenExpired = true; } } return { isAuthenticated: isAuthenticated && !isTokenExpired, userRole, accessToken, refreshToken, userInfo, isTokenExpired, refreshedSession // 如果刷新了token,返回更新后的session }; } /** * 创建用户登录会话 * * 在用户成功登录后调用此函数来创建会话并设置 Cookie。 * 这个函数通常在以下场景中使用: * - OAuth2.0 登录成功后 * - 临时管理员登录 * - 其他认证方式成功后 * * 处理流程: * 1. 创建新的会话对象 * 2. 设置认证状态和用户角色 * 3. 生成签名的 Cookie * 4. 返回重定向响应并设置 Cookie * * @param isAuthenticated - 是否已认证,通常为 true * @param userRole - 用户角色,决定用户的权限级别 * @param redirectTo - 登录成功后重定向的 URL,默认为首页 * @returns HTTP 302 重定向响应,包含设置 Cookie 的头部 */ export async function createUserSession(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. 销毁会话数据(清除所有存储的信息) * 3. 清除客户端的会话 Cookie * 4. 重定向到登录页面 * * 注意事项: * - 这个函数只处理本地会话,不会调用 IDaaS 的单点登出 * - 如果需要全局登出,应该额外调用 IDaaS 的 SLO 接口 * - 销毁会话后,用户需要重新登录才能访问受保护的页面 * * @param request - Remix Request 对象,用于获取当前会话 * @returns HTTP 302 重定向响应,清除 Cookie 并跳转到登录页 */ export async function logout(request: Request) { const session = await getSession(request); return new Response(null, { status: 302, // HTTP 重定向状态码 headers: { Location: "/login", // 重定向到登录页面 "Set-Cookie": await sessionStorage.destroySession(session), // 清除会话 Cookie }, }); }