优化登录逻辑的实现,将认证请求和token验证的处理分成两个逻辑文件。新增交叉评查任务列表的页面(尚未对接真实数据)。
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
// import { API_BASE_URL } from '../config/api-config';
|
||||
|
||||
// 交叉评查任务状态枚举
|
||||
export enum CrossCheckingTaskStatus {
|
||||
PENDING = 'pending',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
COMPLETED = 'completed'
|
||||
}
|
||||
|
||||
// 交叉评查任务类型枚举
|
||||
export enum CrossCheckingTaskType {
|
||||
CITY = 'city',
|
||||
COUNTY = 'county'
|
||||
}
|
||||
|
||||
// 案卷类型枚举
|
||||
export enum CrossCheckingDocType {
|
||||
PENALTY = 'penalty', // 行政处罚
|
||||
PERMIT = 'permit' // 行政许可
|
||||
}
|
||||
|
||||
// 交叉评查任务接口
|
||||
export interface CrossCheckingTask {
|
||||
id: number;
|
||||
sequence: number;
|
||||
taskName: string;
|
||||
startDate: string;
|
||||
taskType: CrossCheckingTaskType;
|
||||
docType: CrossCheckingDocType; // 案卷类型
|
||||
evaluationRegion: string;
|
||||
progress: number;
|
||||
status: CrossCheckingTaskStatus;
|
||||
score: number;
|
||||
operation: string;
|
||||
}
|
||||
|
||||
// API响应格式
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
// 任务列表查询参数
|
||||
export interface TaskListParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
taskType?: string;
|
||||
docType?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
// 任务列表响应数据
|
||||
export interface TaskListResponse {
|
||||
tasks: CrossCheckingTask[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟数据 - 临时使用
|
||||
*/
|
||||
const mockTasks: CrossCheckingTask[] = [
|
||||
{
|
||||
id: 1,
|
||||
sequence: 1,
|
||||
taskName: '2024年度交叉评查',
|
||||
startDate: '2024-12-23',
|
||||
taskType: CrossCheckingTaskType.CITY,
|
||||
docType: CrossCheckingDocType.PENALTY,
|
||||
evaluationRegion: '梅州市、揭阳市、潮州市、云浮市',
|
||||
progress: 0,
|
||||
status: CrossCheckingTaskStatus.PENDING,
|
||||
score: 0,
|
||||
operation: '去评查'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sequence: 2,
|
||||
taskName: '2024年第4季度交叉评查',
|
||||
startDate: '2024-12-05',
|
||||
taskType: CrossCheckingTaskType.COUNTY,
|
||||
docType: CrossCheckingDocType.PERMIT,
|
||||
evaluationRegion: '梅江区、梅县区、平远县、蕉岭县、大埔县、丰顺县、五华县',
|
||||
progress: 72,
|
||||
status: CrossCheckingTaskStatus.IN_PROGRESS,
|
||||
score: 0,
|
||||
operation: '进行中'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
sequence: 3,
|
||||
taskName: '2024年第3季度交叉评查',
|
||||
startDate: '2024-9-23',
|
||||
taskType: CrossCheckingTaskType.COUNTY,
|
||||
docType: CrossCheckingDocType.PERMIT,
|
||||
evaluationRegion: '梅江区、梅县区、平远县、蕉岭县、大埔县、丰顺县、五华县',
|
||||
progress: 100,
|
||||
status: CrossCheckingTaskStatus.COMPLETED,
|
||||
score: 95,
|
||||
operation: '查看结果'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
sequence: 4,
|
||||
taskName: '2024年中交叉评查',
|
||||
startDate: '2024-6-23',
|
||||
taskType: CrossCheckingTaskType.CITY,
|
||||
docType: CrossCheckingDocType.PENALTY,
|
||||
evaluationRegion: '梅州市、揭阳市、潮州市、云浮市',
|
||||
progress: 100,
|
||||
status: CrossCheckingTaskStatus.COMPLETED,
|
||||
score: 85,
|
||||
operation: '查看结果'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
sequence: 5,
|
||||
taskName: '2024年第2季度交叉评查',
|
||||
startDate: '2024-3-23',
|
||||
taskType: CrossCheckingTaskType.COUNTY,
|
||||
docType: CrossCheckingDocType.PENALTY,
|
||||
evaluationRegion: '梅江区、梅县区、平远县、蕉岭县、大埔县、丰顺县、五华县',
|
||||
progress: 100,
|
||||
status: CrossCheckingTaskStatus.COMPLETED,
|
||||
score: 92,
|
||||
operation: '查看结果'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取交叉评查任务列表
|
||||
* @param params 查询参数
|
||||
* @returns 任务列表响应
|
||||
*/
|
||||
export async function getCrossCheckingTasks(params: TaskListParams = {}): Promise<ApiResponse<TaskListResponse>> {
|
||||
try {
|
||||
// 模拟API延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
taskType,
|
||||
docType,
|
||||
status,
|
||||
keyword,
|
||||
dateFrom,
|
||||
dateTo
|
||||
} = params;
|
||||
|
||||
// 筛选数据
|
||||
let filteredTasks = [...mockTasks];
|
||||
|
||||
// 按任务类型筛选
|
||||
if (taskType && taskType !== 'all') {
|
||||
filteredTasks = filteredTasks.filter(task => task.taskType === taskType);
|
||||
}
|
||||
|
||||
// 按案卷类型筛选
|
||||
if (docType && docType !== 'all') {
|
||||
filteredTasks = filteredTasks.filter(task => task.docType === docType);
|
||||
}
|
||||
|
||||
// 按状态筛选
|
||||
if (status && status !== 'all') {
|
||||
filteredTasks = filteredTasks.filter(task => task.status === status);
|
||||
}
|
||||
|
||||
// 按关键词搜索
|
||||
if (keyword) {
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
filteredTasks = filteredTasks.filter(task =>
|
||||
task.taskName.toLowerCase().includes(lowerKeyword) ||
|
||||
task.evaluationRegion.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
}
|
||||
|
||||
// 按日期范围筛选
|
||||
if (dateFrom || dateTo) {
|
||||
filteredTasks = filteredTasks.filter(task => {
|
||||
const taskDate = new Date(task.startDate);
|
||||
if (dateFrom && new Date(dateFrom) > taskDate) return false;
|
||||
if (dateTo && new Date(dateTo) < taskDate) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const totalCount = filteredTasks.length;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedTasks = filteredTasks.slice(startIndex, endIndex);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
tasks: paginatedTasks,
|
||||
totalCount,
|
||||
currentPage: page,
|
||||
pageSize,
|
||||
totalPages
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取交叉评查任务列表失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取任务列表失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的交叉评查任务
|
||||
* @param taskData 任务数据
|
||||
* @returns 创建结果
|
||||
*/
|
||||
export async function createCrossCheckingTask(taskData: Omit<CrossCheckingTask, 'id' | 'sequence' | 'progress' | 'score'>): Promise<ApiResponse<CrossCheckingTask>> {
|
||||
try {
|
||||
// 模拟API延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
const newTask: CrossCheckingTask = {
|
||||
...taskData,
|
||||
id: Math.max(...mockTasks.map(t => t.id)) + 1,
|
||||
sequence: mockTasks.length + 1,
|
||||
progress: 0,
|
||||
score: 0
|
||||
};
|
||||
|
||||
// 添加到模拟数据
|
||||
mockTasks.unshift(newTask);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: newTask,
|
||||
message: '创建任务成功'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('创建交叉评查任务失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '创建任务失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除交叉评查任务
|
||||
* @param taskId 任务ID
|
||||
* @returns 删除结果
|
||||
*/
|
||||
export async function deleteCrossCheckingTask(taskId: number): Promise<ApiResponse<boolean>> {
|
||||
try {
|
||||
// 模拟API延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const taskIndex = mockTasks.findIndex(task => task.id === taskId);
|
||||
if (taskIndex === -1) {
|
||||
return {
|
||||
success: false,
|
||||
error: '任务不存在'
|
||||
};
|
||||
}
|
||||
|
||||
// 从模拟数据中删除
|
||||
mockTasks.splice(taskIndex, 1);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: true,
|
||||
message: '删除任务成功'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('删除交叉评查任务失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '删除任务失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详情
|
||||
* @param taskId 任务ID
|
||||
* @returns 任务详情
|
||||
*/
|
||||
export async function getCrossCheckingTaskDetail(taskId: number): Promise<ApiResponse<CrossCheckingTask>> {
|
||||
try {
|
||||
// 模拟API延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const task = mockTasks.find(t => t.id === taskId);
|
||||
if (!task) {
|
||||
return {
|
||||
success: false,
|
||||
error: '任务不存在'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: task
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取任务详情失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取任务详情失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计数据
|
||||
* @returns 统计数据
|
||||
*/
|
||||
export async function getCrossCheckingStats(): Promise<ApiResponse<{
|
||||
totalTasks: number;
|
||||
pendingTasks: number;
|
||||
inProgressTasks: number;
|
||||
completedTasks: number;
|
||||
}>> {
|
||||
try {
|
||||
const totalTasks = mockTasks.length;
|
||||
const pendingTasks = mockTasks.filter(t => t.status === CrossCheckingTaskStatus.PENDING).length;
|
||||
const inProgressTasks = mockTasks.filter(t => t.status === CrossCheckingTaskStatus.IN_PROGRESS).length;
|
||||
const completedTasks = mockTasks.filter(t => t.status === CrossCheckingTaskStatus.COMPLETED).length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalTasks,
|
||||
pendingTasks,
|
||||
inProgressTasks,
|
||||
completedTasks
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '获取统计数据失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
# 认证模块 (Authentication Module)
|
||||
|
||||
这个文件夹包含了整个应用的用户认证和会话管理功能。
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
app/api/login/
|
||||
├── README.md # 本文档
|
||||
├── auth.server.ts # 🔐 认证核心实现
|
||||
├── auth-exports.server.ts # 📤 认证函数导出入口
|
||||
├── oauth-client.ts # 🌐 OAuth2.0 客户端
|
||||
├── token-manager.server.ts # 🎫 Token 管理器
|
||||
└── OAuth2.0认证协议集成指南.md # 📖 OAuth2.0 集成文档
|
||||
```
|
||||
|
||||
## 🔧 核心文件说明
|
||||
|
||||
### `auth.server.ts` - 认证核心实现
|
||||
包含所有认证相关的核心功能:
|
||||
- **会话管理**: 基于 Cookie 的安全会话存储
|
||||
- **用户认证**: 检查用户登录状态
|
||||
- **Token 刷新**: 自动刷新过期的 OAuth Token
|
||||
- **登录/登出**: 创建和销毁用户会话
|
||||
|
||||
主要函数:
|
||||
- `getUserSession()` - 获取用户会话(带 Token 刷新)
|
||||
- `createUserSession()` - 创建登录会话
|
||||
- `logout()` - 销毁会话并登出
|
||||
- `getSession()` - 获取原始会话对象
|
||||
|
||||
### `auth-exports.server.ts` - 导出入口
|
||||
这个文件解决了 Remix + Vite 架构中的一个重要问题:
|
||||
|
||||
**问题**: 同构文件(.tsx)不能直接导入 .server.ts 文件
|
||||
**原因**: 防止服务器端敏感代码被打包到客户端
|
||||
**解决**: 通过这个 .server.ts 文件重新导出认证函数
|
||||
|
||||
```typescript
|
||||
// ✅ 正确的导入方式
|
||||
import { getUserSession } from "~/api/login/auth-exports.server";
|
||||
|
||||
// ❌ 错误的导入方式(会导致构建错误)
|
||||
import { getUserSession } from "~/api/login/auth.server";
|
||||
```
|
||||
|
||||
### `oauth-client.ts` - OAuth2.0 客户端
|
||||
实现 OAuth2.0 认证协议的客户端功能:
|
||||
- 生成授权 URL
|
||||
- 获取访问令牌
|
||||
- 获取用户信息
|
||||
- 状态值生成和验证
|
||||
|
||||
### `token-manager.server.ts` - Token 管理
|
||||
负责 OAuth Token 的生命周期管理:
|
||||
- Token 有效期检查
|
||||
- 自动刷新即将过期的 Token
|
||||
- Token 错误处理
|
||||
|
||||
## 🚀 使用指南
|
||||
|
||||
### 在路由中检查用户认证
|
||||
|
||||
```typescript
|
||||
// app/routes/some-route.tsx
|
||||
import { getUserSession } from "~/api/login/auth-exports.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const { isAuthenticated, userRole } = await getUserSession(request);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (userRole !== 'developer') {
|
||||
throw new Response("权限不足", { status: 403 });
|
||||
}
|
||||
|
||||
return json({ userRole });
|
||||
}
|
||||
```
|
||||
|
||||
### 处理用户登出
|
||||
|
||||
```typescript
|
||||
// app/routes/some-route.tsx
|
||||
import { logout } from "~/api/login/auth-exports.server";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData();
|
||||
|
||||
if (formData.get("intent") === "logout") {
|
||||
return logout(request);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### 创建登录会话
|
||||
|
||||
```typescript
|
||||
// app/routes/callback.tsx
|
||||
import { createUserSession } from "~/api/login/auth-exports.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// OAuth 认证成功后...
|
||||
|
||||
// 根据用户信息判断角色
|
||||
const userRole = userInfo.username === "admin" ? "developer" : "common";
|
||||
|
||||
return createUserSession(true, userRole, "/dashboard");
|
||||
}
|
||||
```
|
||||
|
||||
## 🛡️ 安全特性
|
||||
|
||||
### Cookie 安全配置
|
||||
```typescript
|
||||
cookie: {
|
||||
httpOnly: true, // 防止 XSS 攻击
|
||||
sameSite: "lax", // CSRF 保护
|
||||
secure: false, // 开发环境设为 false,生产环境应为 true
|
||||
maxAge: 7200, // 2小时过期
|
||||
}
|
||||
```
|
||||
|
||||
### Token 自动刷新
|
||||
- 检查 Token 是否即将过期(5分钟内)
|
||||
- 自动使用 refresh_token 获取新的 access_token
|
||||
- 刷新失败时自动登出用户
|
||||
|
||||
### 用户角色权限
|
||||
- `common`: 普通用户,基本功能访问权限
|
||||
- `developer`: 开发者/管理员,完整系统管理权限
|
||||
|
||||
## 🔄 OAuth2.0 登录流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant App as 应用
|
||||
participant IDaaS as IDaaS平台
|
||||
|
||||
User->>App: 访问受保护页面
|
||||
App->>User: 重定向到 /login
|
||||
User->>App: 点击"统一身份认证登录"
|
||||
App->>IDaaS: 重定向到 IDaaS 登录页
|
||||
User->>IDaaS: 完成登录
|
||||
IDaaS->>App: 回调并返回 code
|
||||
App->>IDaaS: 使用 code 获取 access_token
|
||||
IDaaS->>App: 返回 access_token
|
||||
App->>IDaaS: 使用 access_token 获取用户信息
|
||||
IDaaS->>App: 返回用户信息
|
||||
App->>App: 创建用户会话
|
||||
App->>User: 重定向到目标页面
|
||||
```
|
||||
|
||||
## 🚨 注意事项
|
||||
|
||||
### 开发环境配置
|
||||
1. 确保 OAuth 配置正确(`app/config/api-config.ts`)
|
||||
2. IDaaS 平台中配置正确的回调地址
|
||||
3. 检查 Cookie 的 `secure` 设置(开发环境为 `false`)
|
||||
|
||||
### 生产环境部署
|
||||
1. 使用环境变量管理敏感信息(secrets)
|
||||
2. 启用 HTTPS 并设置 `secure: true`
|
||||
3. 配置正确的域名和回调地址
|
||||
4. 监控 Token 刷新日志
|
||||
|
||||
### 调试技巧
|
||||
- 查看浏览器 Application 标签页中的 Cookies
|
||||
- 检查服务器控制台的认证相关日志
|
||||
- 使用 Remix Dev Tools 查看 loader 数据
|
||||
|
||||
## 📖 相关文档
|
||||
|
||||
- [OAuth2.0认证协议集成指南](./OAuth2.0认证协议集成指南.md)
|
||||
- [Remix Session Storage 文档](https://remix.run/docs/en/main/utils/sessions)
|
||||
- [Cookie Security Best Practices](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies)
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 认证服务核心实现
|
||||
*
|
||||
* 这个文件包含了用户认证、会话管理、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
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* OAuth2.0客户端类
|
||||
* 用于处理IDaaS OAuth2.0认证流程
|
||||
* 如果需要添加新的Token相关功能:
|
||||
* 1. 优先考虑在 `TokenManager` 中添加
|
||||
* 2. 如果需要新的网络请求,在 `OAuthClient` 中添加
|
||||
*/
|
||||
|
||||
interface OAuthConfig {
|
||||
serverUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
appId: string;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
jti: string;
|
||||
}
|
||||
|
||||
interface UserInfoResponse {
|
||||
success: boolean;
|
||||
code: string;
|
||||
message: string | null;
|
||||
requestId: string;
|
||||
data: {
|
||||
sub: string;
|
||||
ou_id: string;
|
||||
nickname: string;
|
||||
phone_number: string;
|
||||
ou_name: string;
|
||||
email: string;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class OAuthClient {
|
||||
private config: OAuthConfig;
|
||||
|
||||
constructor(config: OAuthConfig) {
|
||||
this.config = {
|
||||
...config,
|
||||
serverUrl: config.serverUrl.replace(/\/$/, '') // 移除末尾斜杠
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成授权URL
|
||||
* @param state 状态值,建议包含随机字符串和_idp后缀
|
||||
* @returns 授权URL
|
||||
*/
|
||||
getAuthorizeUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
scope: 'read',
|
||||
client_id: this.config.clientId,
|
||||
redirect_uri: this.config.redirectUri,
|
||||
state: state
|
||||
});
|
||||
|
||||
return `${this.config.serverUrl}/oauth/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取访问令牌
|
||||
* @param code 授权码
|
||||
* @returns 访问令牌响应
|
||||
*/
|
||||
async getAccessToken(code: string): Promise<TokenResponse | null> {
|
||||
const url = `${this.config.serverUrl}/oauth/token`;
|
||||
const data = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
redirect_uri: this.config.redirectUri
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: data
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('获取访问令牌失败:', errorData);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json() as TokenResponse;
|
||||
} catch (error) {
|
||||
console.error('获取访问令牌网络错误:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
* @param accessToken 访问令牌
|
||||
* @returns 用户信息响应
|
||||
*/
|
||||
async getUserInfo(accessToken: string): Promise<UserInfoResponse | null> {
|
||||
const url = `${this.config.serverUrl}/api/bff/v1.2/oauth2/userinfo`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('获取用户信息失败:', response.status, response.statusText);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json() as UserInfoResponse;
|
||||
} catch (error) {
|
||||
console.error('获取用户信息网络错误:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
* @param refreshToken 刷新令牌
|
||||
* @returns 新的访问令牌响应
|
||||
*/
|
||||
async refreshAccessToken(refreshToken: string): Promise<TokenResponse | null> {
|
||||
const url = `${this.config.serverUrl}/oauth/token`;
|
||||
const data = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: data
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('刷新访问令牌失败:', errorData);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json() as TokenResponse;
|
||||
} catch (error) {
|
||||
console.error('刷新访问令牌网络错误:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单点登出
|
||||
* @param accessToken 访问令牌
|
||||
* @param redirectUrl 登出后重定向URL
|
||||
* @returns 登出是否成功
|
||||
*/
|
||||
async logout(accessToken: string, redirectUrl: string): Promise<boolean> {
|
||||
const url = `${this.config.serverUrl}/public/sp/slo/${this.config.appId}`;
|
||||
const data = new URLSearchParams({
|
||||
access_token: accessToken,
|
||||
redirect_url: redirectUrl
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: data
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机状态值
|
||||
* @returns 状态值字符串
|
||||
*/
|
||||
generateState(): string {
|
||||
const randomStr = Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
return `${randomStr}_idp`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2.0工具函数
|
||||
*/
|
||||
export const oauthUtils = {
|
||||
/**
|
||||
* 从URL中提取查询参数
|
||||
* @param url URL字符串
|
||||
* @returns 查询参数对象
|
||||
*/
|
||||
getQueryParams(url: string): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
const urlObj = new URL(url);
|
||||
|
||||
for (const [key, value] of urlObj.searchParams) {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
return params;
|
||||
},
|
||||
|
||||
/**
|
||||
* 验证状态值
|
||||
* @param state 返回的状态值
|
||||
* @param expectedState 期望的状态值
|
||||
* @returns 是否匹配
|
||||
*/
|
||||
validateState(state: string, expectedState: string): boolean {
|
||||
return state === expectedState;
|
||||
},
|
||||
|
||||
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Token管理服务
|
||||
* 负责处理OAuth访问令牌的刷新和管理
|
||||
* 如果需要添加新的Token相关功能:
|
||||
* 1. 优先考虑在 `TokenManager` 中添加
|
||||
* 2. 如果需要新的网络请求,在 `OAuthClient` 中添加
|
||||
*/
|
||||
import { OAuthClient } from "./oauth-client";
|
||||
import { OAUTH_CONFIG } from "~/config/api-config";
|
||||
|
||||
interface TokenInfo {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
tokenIssuedAt: number;
|
||||
tokenExpiresIn: number;
|
||||
}
|
||||
|
||||
interface RefreshResult {
|
||||
success: boolean;
|
||||
newTokenInfo?: TokenInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token管理服务
|
||||
* 负责处理OAuth访问令牌的刷新和管理
|
||||
*/
|
||||
export class TokenManager {
|
||||
private oauthClient: OAuthClient;
|
||||
|
||||
constructor() {
|
||||
this.oauthClient = new OAuthClient(OAUTH_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查token是否过期
|
||||
*/
|
||||
isTokenExpired(tokenInfo: TokenInfo): boolean {
|
||||
const now = Date.now();
|
||||
const expiresAt = tokenInfo.tokenIssuedAt + (tokenInfo.tokenExpiresIn * 1000);
|
||||
return now >= expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查token是否需要刷新(提前5分钟)
|
||||
*/
|
||||
shouldRefreshToken(tokenInfo: TokenInfo, refreshThresholdMinutes: number = 5): boolean {
|
||||
const now = Date.now();
|
||||
const expiresAt = tokenInfo.tokenIssuedAt + (tokenInfo.tokenExpiresIn * 1000);
|
||||
const refreshThreshold = refreshThresholdMinutes * 60 * 1000;
|
||||
return now >= (expiresAt - refreshThreshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取token剩余有效时间(秒)
|
||||
*/
|
||||
getTokenRemainingTime(tokenInfo: TokenInfo): number {
|
||||
const now = Date.now();
|
||||
const expiresAt = tokenInfo.tokenIssuedAt + (tokenInfo.tokenExpiresIn * 1000);
|
||||
return Math.floor((expiresAt - now) / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
*/
|
||||
async refreshToken(refreshToken: string): Promise<RefreshResult> {
|
||||
try {
|
||||
console.log("开始刷新访问令牌...");
|
||||
|
||||
const newTokenResponse = await this.oauthClient.refreshAccessToken(refreshToken);
|
||||
|
||||
if (!newTokenResponse) {
|
||||
console.error("刷新令牌失败:服务器返回空响应");
|
||||
return {
|
||||
success: false,
|
||||
error: "服务器返回空响应"
|
||||
};
|
||||
}
|
||||
|
||||
const newTokenInfo: TokenInfo = {
|
||||
accessToken: newTokenResponse.access_token,
|
||||
refreshToken: newTokenResponse.refresh_token,
|
||||
tokenIssuedAt: Date.now(),
|
||||
tokenExpiresIn: newTokenResponse.expires_in
|
||||
};
|
||||
|
||||
console.log(`令牌刷新成功,新令牌有效期: ${newTokenResponse.expires_in}秒`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newTokenInfo
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error("刷新令牌时发生错误:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "未知错误"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并自动刷新token(如果需要)
|
||||
*/
|
||||
async checkAndRefreshToken(tokenInfo: TokenInfo): Promise<RefreshResult> {
|
||||
// 如果token已过期,必须刷新
|
||||
if (this.isTokenExpired(tokenInfo)) {
|
||||
console.log("Token已过期,尝试刷新...");
|
||||
return this.refreshToken(tokenInfo.refreshToken);
|
||||
}
|
||||
|
||||
// 如果token即将过期,主动刷新
|
||||
if (this.shouldRefreshToken(tokenInfo)) {
|
||||
const remainingTime = this.getTokenRemainingTime(tokenInfo);
|
||||
console.log(`Token将在${remainingTime}秒后过期,主动刷新...`);
|
||||
return this.refreshToken(tokenInfo.refreshToken);
|
||||
}
|
||||
|
||||
// Token仍然有效,无需刷新
|
||||
return {
|
||||
success: true,
|
||||
newTokenInfo: tokenInfo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化token到期时间
|
||||
*/
|
||||
formatTokenExpiry(tokenInfo: TokenInfo): string {
|
||||
const expiresAt = new Date(tokenInfo.tokenIssuedAt + (tokenInfo.tokenExpiresIn * 1000));
|
||||
return expiresAt.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取token状态信息(用于调试)
|
||||
*/
|
||||
getTokenStatus(tokenInfo: TokenInfo): {
|
||||
isExpired: boolean;
|
||||
shouldRefresh: boolean;
|
||||
remainingTime: number;
|
||||
expiryTime: string;
|
||||
} {
|
||||
return {
|
||||
isExpired: this.isTokenExpired(tokenInfo),
|
||||
shouldRefresh: this.shouldRefreshToken(tokenInfo),
|
||||
remainingTime: this.getTokenRemainingTime(tokenInfo),
|
||||
expiryTime: this.formatTokenExpiry(tokenInfo)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const tokenManager = new TokenManager();
|
||||
Reference in New Issue
Block a user