refactor: repartition server-side and client-side code

This commit is contained in:
LIlGG
2025-10-11 18:26:07 +08:00
parent 7acc4949fb
commit e9b573a276
309 changed files with 631 additions and 962 deletions

427
app/.server/service/auth.ts Normal file
View File

@@ -0,0 +1,427 @@
import type { IdTokenClaims } from '@logto/node';
import { type LogtoContext, makeLogtoRemix } from '@logto/remix';
import type { LoaderFunctionArgs } from '@remix-run/node';
import { createCookieSessionStorage, redirect } from '@remix-run/node';
import type { LogtoUser } from '~/types/logto';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('auth.server');
/**
* 认证相关类型定义
*/
interface LogtoConfig {
endpoint: string;
appId: string;
appSecret: string;
baseUrl: string;
scopes?: string[];
}
interface MockUser extends Pick<LogtoContext, 'isAuthenticated' | 'userInfo' | 'claims'> {}
/**
* 虚拟用户接口,与 MockUser 类似但代表真实存在于 logto 的用户
*/
interface VirtualUser extends Pick<LogtoContext, 'isAuthenticated' | 'userInfo' | 'claims'> {
isVirtual: true;
}
// Logto路由配置类型
interface LogtoRoutes {
'sign-in': { path: string; redirectBackTo: string };
'sign-in-callback': { path: string; redirectBackTo: string };
'sign-out': { path: string; redirectBackTo: string };
'sign-up': { path: string; redirectBackTo: string };
}
/**
* 公共 Cookie 配置基础
*/
const baseCookieOptions = {
httpOnly: true,
path: '/',
sameSite: 'lax' as const,
secrets: [process.env.LOGTO_COOKIE_SECRET || 's3cr3t'],
};
/**
* 创建认证 session 存储
*/
const sessionStorage = createCookieSessionStorage({
cookie: {
...baseCookieOptions,
name: 'logto_session',
maxAge: 60 * 60 * 24 * 30, // 30 天过期
},
});
/**
* 创建虚拟用户 session 存储
*/
const virtualUserStorage = createCookieSessionStorage({
cookie: {
...baseCookieOptions,
name: 'virtual_user',
maxAge: 60 * 60 * 24 * 30,
},
});
/**
* 创建认证错误信息 session 存储
*/
const errorSessionStorage = createCookieSessionStorage({
cookie: {
...baseCookieOptions,
name: 'auth_error',
maxAge: 60, // 1分钟后过期错误信息不需要长期保存
},
});
/**
* 创建 Logto 配置
*/
const config: LogtoConfig = {
endpoint: process.env.LOGTO_ENDPOINT || '',
appId: process.env.LOGTO_APP_ID || '',
appSecret: process.env.LOGTO_APP_SECRET || '',
baseUrl: process.env.LOGTO_BASE_URL || 'http://localhost:5173',
scopes: ['email', 'profile'],
};
// 创建原始 Logto 实例(私有,不直接导出)
const originalLogto = makeLogtoRemix(config, { sessionStorage });
export function shouldEnforceAuth(): boolean {
return process.env.LOGTO_ENABLE === 'true';
}
function getMockDevUser(): MockUser {
return {
isAuthenticated: true,
userInfo: {
iss: 'https://mock.issuer.com',
sub: 'mock-user-id',
aud: 'mock-audience',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
name: 'Mock User',
username: 'user',
email: 'mock@example.com',
},
};
}
/**
* 设置认证错误信息到会话中
*/
export async function setAuthError(errorMessage: string): Promise<string> {
const session = await errorSessionStorage.getSession();
session.set('authError', errorMessage);
return errorSessionStorage.commitSession(session);
}
/**
* 获取并清除认证错误信息
*/
export async function getAuthError(
request: Request,
): Promise<{ errorMessage?: string; headers: { 'Set-Cookie': string } }> {
const session = await errorSessionStorage.getSession(request.headers.get('Cookie'));
const errorMessage = session.get('authError') as string | undefined;
// 清除错误信息
return {
errorMessage,
headers: {
'Set-Cookie': await errorSessionStorage.destroySession(session),
},
};
}
/**
* 增强版的 Logto 对象,添加错误处理和开发环境跳过
*/
export const logto = {
...originalLogto,
/**
* 增强版的 handleAuthRoutes 方法
*/
handleAuthRoutes: (routes: LogtoRoutes) => {
const originalHandler = originalLogto.handleAuthRoutes(routes);
return async (args: LoaderFunctionArgs) => {
try {
// 特殊处理退出登录路由
const { request } = args;
const url = new URL(request.url);
const path = url.pathname;
// 如果是退出登录路由,检查是否是虚拟用户
if (path === routes['sign-out'].path) {
const virtualUser = await getVirtualUser(request);
if (virtualUser?.isVirtual) {
logger.info('[Auth] 虚拟用户退出登录');
const clearCookie = await clearVirtualUser();
return redirect(routes['sign-out'].redirectBackTo, {
headers: {
'Set-Cookie': clearCookie,
},
});
}
}
return await originalHandler(args);
} catch (error) {
logger.error('[Auth] 认证服务错误:', error);
return handleAuthError(error, args);
}
};
},
getContext: originalLogto.getContext,
};
/**
* 处理认证过程中的错误
*/
async function handleAuthError(error: unknown, args: LoaderFunctionArgs) {
// 判断错误类型
let errorMessage = '认证服务暂时不可用,请稍后再试';
// 处理网络错误(认证服务器不可用)
if (
error instanceof Error &&
(error.message.includes('fetch failed') ||
error.message.includes('network') ||
error.message.includes('ECONNREFUSED') ||
error.message.includes('timeout'))
) {
errorMessage = '无法连接到认证服务器,请检查网络连接';
}
// 其他类型的错误
else if (error instanceof Error) {
errorMessage = '登录服务出现异常,请稍后再试';
}
// 确定重定向URL
const redirectUrl = determineRedirectUrl(args.request);
// 生成带有错误信息的Cookie
const cookie = await setAuthError(errorMessage);
// 重定向回原始页面同时携带错误会话Cookie
return redirect(redirectUrl, {
headers: {
'Set-Cookie': cookie,
},
});
}
/**
* 根据请求确定最合适的重定向URL
*/
function determineRedirectUrl(request: Request): string {
// 默认重定向到首页
const defaultUrl = '/';
try {
const url = new URL(request.url);
const redirectTo = url.searchParams.get('redirectTo');
if (redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')) {
// 确保只重定向到应用内部URL
return redirectTo;
}
// 如果有referer头也可以考虑使用它
const referer = request.headers.get('referer');
if (referer) {
try {
const refererUrl = new URL(referer);
// 确保是同一域名下的URL
if (refererUrl.hostname === url.hostname) {
return refererUrl.pathname + refererUrl.search;
}
} catch {
// 忽略无效的referer
}
}
} catch {
// 如果URL解析失败使用默认的根路径
}
return defaultUrl;
}
/**
* 设置虚拟用户信息到 cookie
* @param userInfo 用户信息
* @returns cookie 字符串,可用于 HTTP 响应头
*/
export async function setVirtualUser(userInfo: LogtoUser): Promise<string> {
const session = await virtualUserStorage.getSession();
const virtualUserInfo = {
id: userInfo.id,
iss: process.env.LOGTO_ENDPOINT || 'https://auth.upage.io',
sub: userInfo.id,
aud: process.env.LOGTO_APP_ID || 'virtual-app',
// 30天后过期
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30,
iat: Math.floor(Date.now() / 1000),
name: userInfo.name || null,
email: userInfo.primaryEmail || null,
phone_number: userInfo.primaryPhone || null,
username: userInfo.username || null,
picture: userInfo.avatar || null,
} as IdTokenClaims;
session.set('userInfo', virtualUserInfo);
session.set('isAuthenticated', true);
return virtualUserStorage.commitSession(session);
}
/**
* 获取虚拟用户信息
* @param request 请求对象
* @returns 虚拟用户信息,如果不存在则返回 null
*/
export async function getVirtualUser(request: Request): Promise<VirtualUser | null> {
const cookieHeader = request.headers.get('Cookie');
const session = await virtualUserStorage.getSession(cookieHeader);
const isAuthenticated = session.get('isAuthenticated');
const userInfo = session.get('userInfo') as IdTokenClaims;
if (!isAuthenticated || !userInfo) {
return null;
}
// 验证用户信息是否有效
const now = Math.floor(Date.now() / 1000);
if (userInfo.exp && userInfo.exp < now) {
// 用户信息已过期,清除 session
await clearVirtualUser();
return null;
}
// 获取上次验证时间
const lastVerified = session.get('lastVerified') || 0;
const verifyInterval = 60 * 60 * 1000;
// 如果距离上次验证时间不足验证间隔,则跳过 Logto 验证
if (now * 1000 - lastVerified < verifyInterval) {
return {
isAuthenticated: true,
userInfo,
isVirtual: true,
};
}
try {
// 更新验证时间
session.set('lastVerified', Date.now());
await virtualUserStorage.commitSession(session);
return {
isAuthenticated: true,
userInfo,
isVirtual: true,
};
} catch (error) {
logger.error('[Auth] 验证虚拟用户失败:', error);
// 如果验证过程中出现错误,仍然返回用户信息
return {
isAuthenticated: true,
userInfo,
isVirtual: true,
};
}
}
/**
* 清除虚拟用户信息
* @returns cookie 字符串,用于清除虚拟用户 cookie
*/
export async function clearVirtualUser(): Promise<string> {
const session = await virtualUserStorage.getSession();
return virtualUserStorage.destroySession(session);
}
/**
* 检查用户是否已认证,如果未认证,则重定向到登录页面
*/
export async function requireUser(request: Request) {
const context = await getUser(request);
if (!context.isAuthenticated) {
return redirect('/api/auth/sign-in');
}
return context;
}
/**
* 获取当前用户信息
* 按优先级依次检查:
* 1. 开发环境模拟用户
* 2. 虚拟用户
* 3. Logto 认证用户
*/
export async function getUser(request: Request) {
// 首先检查是否为开发环境
if (!shouldEnforceAuth()) {
return getMockDevUser();
}
// 检查是否存在虚拟用户
const virtualUser = await getVirtualUser(request);
if (virtualUser) {
return virtualUser;
}
// 继续原有的 logto 认证流程
return await logto.getContext({
fetchUserInfo: true,
getAccessToken: true,
})(request);
}
/**
* 通用权限验证中间件
* 用于API和页面路由的权限验证
*
* 返回json错误或重定向到登录页面
*/
export async function requireAuth(request: Request, options: { isApi?: boolean; redirectTo?: string } = {}) {
const { isApi = false, redirectTo = '/api/auth/sign-in' } = options;
const context = await getUser(request);
if (!context.isAuthenticated) {
if (isApi) {
// API路由返回JSON错误
return new Response(
JSON.stringify({
error: 'Unauthorized',
message: '请先登录',
code: 401,
}),
{
status: 401,
headers: {
'Content-Type': 'application/json',
},
},
);
}
// 页面路由重定向到登录页面
return redirect(redirectTo);
}
return context;
}