mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat(oauth): 实现 OAuth 回调服务器与完整令牌管理功能
- 新增 OAuth 回调服务器模块,支持本地 HTTP 服务器处理授权回调 - 添加令牌撤销和令牌内省 API 端点 - 重构 OAuth 授权流程,增加 state 参数防止 CSRF 攻击 - 改进前端 OAuth 登录组件,适配新的回调机制
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
-922
@@ -1,922 +0,0 @@
|
|||||||
# SECTL Auth API 参考
|
|
||||||
|
|
||||||
本文档详细说明 SECTL Auth 提供的所有 API 接口。
|
|
||||||
|
|
||||||
## 基础信息
|
|
||||||
|
|
||||||
- **基础 URL**: `https://sectl.top`
|
|
||||||
- **认证方式**: OAuth 2.0 + Bearer Token
|
|
||||||
- **数据格式**: JSON
|
|
||||||
|
|
||||||
## 接口概览
|
|
||||||
|
|
||||||
| 接口 | 方法 | 说明 | 对应页面 |
|
|
||||||
| ----------------------- | ---- | ------------------- | ------------------------ |
|
|
||||||
| `/oauth/authorize` | GET | 获取授权码 | OAuthAuthorizeView.vue |
|
|
||||||
| `/api/oauth/token` | POST | 换取/刷新令牌 | AuthCallbackView.vue |
|
|
||||||
| `/api/oauth/userinfo` | GET | 获取用户信息 | PlatformUsersView.vue |
|
|
||||||
| `/api/oauth/config` | POST | 配置 Token 过期时间 | PlatformSettingsView.vue |
|
|
||||||
| `/api/oauth/revoke` | POST | 撤销令牌 | SecurityView.vue |
|
|
||||||
| `/api/oauth/introspect` | POST | 验证令牌 | - |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OAuth 接口
|
|
||||||
|
|
||||||
### 1. 获取授权码
|
|
||||||
|
|
||||||
引导用户跳转到授权页面。
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /oauth/authorize
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求参数:**
|
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
| ------------- | ------ | ---- | ---------------- |
|
|
||||||
| client_id | string | ✅ | 平台 ID |
|
|
||||||
| redirect_uri | string | ✅ | 回调 URL |
|
|
||||||
| response_type | string | ✅ | 固定值 `code` |
|
|
||||||
| state | string | ❌ | 防 CSRF 状态参数 |
|
|
||||||
|
|
||||||
**示例请求:**
|
|
||||||
|
|
||||||
```
|
|
||||||
https://sectl.top/oauth/authorize?client_id=pf_AHmIhAiptyLFxk8x&redirect_uri=http://localhost:5000/callback&response_type=code
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应:**
|
|
||||||
|
|
||||||
- 重定向到 `redirect_uri?code={authorization_code}&state={state}`
|
|
||||||
|
|
||||||
**错误响应:**
|
|
||||||
|
|
||||||
- 重定向到 `redirect_uri?error={error_code}&error_description={description}`
|
|
||||||
|
|
||||||
**错误码:**
|
|
||||||
|
|
||||||
| 错误码 | 说明 |
|
|
||||||
| ------------------------- | ------------------ |
|
|
||||||
| invalid_request | 请求参数缺失或无效 |
|
|
||||||
| unauthorized_client | 客户端未授权 |
|
|
||||||
| access_denied | 用户拒绝授权 |
|
|
||||||
| unsupported_response_type | 不支持的响应类型 |
|
|
||||||
| invalid_scope | 无效的权限范围 |
|
|
||||||
| server_error | 服务器内部错误 |
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) - 授权页面
|
|
||||||
- [LoginView.vue](../../src/views/LoginView.vue) - 登录页面
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 获取访问令牌
|
|
||||||
|
|
||||||
使用授权码换取访问令牌。
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/oauth/token
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Content-Type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
| ------------- | ------ | ---- | ------------------------------------------------ |
|
|
||||||
| grant_type | string | ✅ | `authorization_code` 或 `refresh_token` |
|
|
||||||
| code | string | 条件 | 授权码(grant_type=authorization_code 时必填) |
|
|
||||||
| refresh_token | string | 条件 | 刷新令牌(grant_type=refresh_token 时必填) |
|
|
||||||
| client_id | string | ✅ | 平台 ID |
|
|
||||||
| client_secret | string | ✅ | 平台密钥 |
|
|
||||||
| redirect_uri | string | 条件 | 回调地址(grant_type=authorization_code 时必填) |
|
|
||||||
|
|
||||||
**授权码模式示例:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"code": "NjliNDIyOTYwMDE4znLUNeWM6J8ye0MC",
|
|
||||||
"client_id": "pf_AHmIhAiptyLFxk8x",
|
|
||||||
"client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1",
|
|
||||||
"redirect_uri": "http://localhost:5000/callback"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**刷新令牌模式示例:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"refresh_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
||||||
"client_id": "pf_AHmIhAiptyLFxk8x",
|
|
||||||
"client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
||||||
"refresh_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": 3600
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应字段:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
| ------------- | ------ | ----------------------- |
|
|
||||||
| access_token | string | 访问令牌(JWT) |
|
|
||||||
| refresh_token | string | 刷新令牌 |
|
|
||||||
| token_type | string | 令牌类型,固定为 Bearer |
|
|
||||||
| expires_in | number | 过期时间(秒) |
|
|
||||||
|
|
||||||
**错误响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "invalid_grant",
|
|
||||||
"error_description": "Authorization code has expired"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误码:**
|
|
||||||
|
|
||||||
| 错误码 | HTTP 状态 | 说明 |
|
|
||||||
| ---------------------- | --------- | ------------------------ |
|
|
||||||
| invalid_request | 400 | 请求格式错误 |
|
|
||||||
| invalid_client | 401 | 客户端认证失败 |
|
|
||||||
| invalid_grant | 400 | 授权码无效或过期 |
|
|
||||||
| unauthorized_client | 400 | 客户端无权使用此授权类型 |
|
|
||||||
| unsupported_grant_type | 400 | 不支持的授权类型 |
|
|
||||||
| invalid_scope | 400 | 无效的权限范围 |
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue) - 回调处理
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 获取用户信息
|
|
||||||
|
|
||||||
使用访问令牌获取用户信息。
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/oauth/userinfo
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer {access_token}
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "69bd422960018cf4d0e5",
|
|
||||||
"email": "user@example.com",
|
|
||||||
"name": "张三",
|
|
||||||
"github_username": "zhangsan",
|
|
||||||
"permission": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应字段:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
| --------------- | ------ | --------------------- |
|
|
||||||
| user_id | string | 用户唯一标识 |
|
|
||||||
| email | string | 用户邮箱 |
|
|
||||||
| name | string | 用户名称 |
|
|
||||||
| github_username | string | GitHub 用户名(如有) |
|
|
||||||
| permission | number | 用户权限等级 |
|
|
||||||
|
|
||||||
**错误响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "invalid_token",
|
|
||||||
"error_description": "The access token is invalid or has expired"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [PlatformUsersView.vue](../../src/views/dashboard/manage/PlatformUsersView.vue) - 平台用户列表
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 配置 Token 过期时间
|
|
||||||
|
|
||||||
配置平台的 access_token 过期时间(需要管理员权限)。
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/oauth/config
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Content-Type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
| ------------ | ------ | ---- | ------------------------------------------- |
|
|
||||||
| platform_id | string | ✅ | 平台 ID |
|
|
||||||
| expires_in | number | ✅ | 过期时间(秒),-1 表示永不过期,最小 60 秒 |
|
|
||||||
| admin_secret | string | ✅ | 管理员密钥 |
|
|
||||||
|
|
||||||
**请求示例:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"platform_id": "pf_AHmIhAiptyLFxk8x",
|
|
||||||
"expires_in": 86400,
|
|
||||||
"admin_secret": "your-admin-secret"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Token expiration time updated",
|
|
||||||
"expires_in": 86400
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "unauthorized",
|
|
||||||
"error_description": "Invalid admin secret"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) - 平台设置
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 撤销令牌
|
|
||||||
|
|
||||||
撤销指定的访问令牌或刷新令牌。
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/oauth/revoke
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Content-Type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
| --------------- | ------ | ---- | --------------------------------- |
|
|
||||||
| token | string | ✅ | 要撤销的令牌 |
|
|
||||||
| token_type_hint | string | ❌ | `access_token` 或 `refresh_token` |
|
|
||||||
| client_id | string | ✅ | 平台 ID |
|
|
||||||
| client_secret | string | ✅ | 平台密钥 |
|
|
||||||
|
|
||||||
**请求示例:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
|
||||||
"token_type_hint": "access_token",
|
|
||||||
"client_id": "pf_AHmIhAiptyLFxk8x",
|
|
||||||
"client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Token revoked successfully"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue) - 安全设置
|
|
||||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. 验证令牌
|
|
||||||
|
|
||||||
验证访问令牌的有效性和获取令牌信息。
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/oauth/introspect
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Content-Type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
| ------------- | ------ | ---- | ------------ |
|
|
||||||
| token | string | ✅ | 要验证的令牌 |
|
|
||||||
| client_id | string | ✅ | 平台 ID |
|
|
||||||
| client_secret | string | ✅ | 平台密钥 |
|
|
||||||
|
|
||||||
**请求示例:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
|
||||||
"client_id": "pf_AHmIhAiptyLFxk8x",
|
|
||||||
"client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**活跃令牌响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"active": true,
|
|
||||||
"user_id": "69bd422960018cf4d0e5",
|
|
||||||
"client_id": "pf_AHmIhAiptyLFxk8x",
|
|
||||||
"exp": 1704783600,
|
|
||||||
"iat": 1704780000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**非活跃令牌响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"active": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应字段:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
| --------- | ------- | ------------------------ |
|
|
||||||
| active | boolean | 令牌是否有效 |
|
|
||||||
| user_id | string | 用户 ID(仅活跃令牌) |
|
|
||||||
| client_id | string | 平台 ID(仅活跃令牌) |
|
|
||||||
| exp | number | 过期时间戳(仅活跃令牌) |
|
|
||||||
| iat | number | 签发时间戳(仅活跃令牌) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 平台管理 API
|
|
||||||
|
|
||||||
以下 API 用于管理平台信息,需要通过 Appwrite SDK 调用。
|
|
||||||
|
|
||||||
### 获取平台列表
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 使用 Appwrite SDK
|
|
||||||
const result = await databases.listDocuments({
|
|
||||||
databaseId: "69bd89d8000304c37368",
|
|
||||||
collectionId: "platforms",
|
|
||||||
queries: [Query.orderDesc("$createdAt"), Query.limit(100)],
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)
|
|
||||||
|
|
||||||
### 获取平台详情
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const platform = await databases.getDocument({
|
|
||||||
databaseId: "69bd89d8000304c37368",
|
|
||||||
collectionId: "platforms",
|
|
||||||
documentId: "platform-document-id",
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [PlatformDetailView.vue](../../src/views/dashboard/detail/PlatformDetailView.vue)
|
|
||||||
|
|
||||||
### 更新平台信息
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
await databases.updateDocument({
|
|
||||||
databaseId: "69bd89d8000304c37368",
|
|
||||||
collectionId: "platforms",
|
|
||||||
documentId: "platform-document-id",
|
|
||||||
data: {
|
|
||||||
callback_url: "http://new-callback.com",
|
|
||||||
token_expires_in: 7200,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue)
|
|
||||||
|
|
||||||
### 申请创建平台
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
await databases.createDocument({
|
|
||||||
databaseId: "69bd89d8000304c37368",
|
|
||||||
collectionId: "platform_applications",
|
|
||||||
documentId: ID.unique(),
|
|
||||||
data: {
|
|
||||||
name: "我的应用",
|
|
||||||
description: "应用描述",
|
|
||||||
callback_url: "http://localhost:5000/callback",
|
|
||||||
owner_id: currentUser.$id,
|
|
||||||
status: "pending",
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [PlatformApplyView.vue](../../src/views/dashboard/form/PlatformApplyView.vue)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 用户管理 API
|
|
||||||
|
|
||||||
### 获取用户授权的平台列表
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const result = await databases.listDocuments({
|
|
||||||
databaseId: "69bd89d8000304c37368",
|
|
||||||
collectionId: "oauth_tokens",
|
|
||||||
queries: [Query.equal("user_id", currentUser.$id), Query.equal("revoked", false)],
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue)
|
|
||||||
|
|
||||||
### 获取平台授权用户列表
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const result = await databases.listDocuments({
|
|
||||||
databaseId: "69bd89d8000304c37368",
|
|
||||||
collectionId: "oauth_tokens",
|
|
||||||
queries: [Query.equal("platform_id", "pf_xxx"), Query.equal("revoked", false)],
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [PlatformUsersView.vue](../../src/views/dashboard/manage/PlatformUsersView.vue)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 云存储 API
|
|
||||||
|
|
||||||
云存储 API 提供文件上传、下载、管理等功能。需要通过 OAuth access_token 进行认证。
|
|
||||||
|
|
||||||
### 接口概览
|
|
||||||
|
|
||||||
| 接口 | 方法 | 说明 | SDK 方法 |
|
|
||||||
| ------------------------------- | ------ | ------------------------ | ------------------------------- |
|
|
||||||
| `/api/cloud/upload` | POST | 上传文件 | `uploadFile()` |
|
|
||||||
| `/api/cloud/files/{file_id}` | GET | 获取文件信息 | `getFile()` |
|
|
||||||
| `/api/cloud/download/{file_id}` | GET | 获取下载链接 | `downloadFile()` |
|
|
||||||
| `/api/cloud/view/{file_id}` | GET | 查看文件内容 | `viewFile()` |
|
|
||||||
| `/api/cloud/preview/{file_id}` | GET | 预览文件(支持图片处理) | `previewFile()` |
|
|
||||||
| `/api/cloud/info` | GET | 获取存储信息 | `getStorageInfo()` |
|
|
||||||
| `/api/cloud/files` | GET | 列出/搜索文件 | `listFiles()` / `searchFiles()` |
|
|
||||||
| `/api/cloud/files/{file_id}` | PUT | 更新文件(重命名) | `updateFile()` |
|
|
||||||
| `/api/cloud/files/{file_id}` | DELETE | 删除文件 | `deleteFile()` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1. 上传文件
|
|
||||||
|
|
||||||
上传文件到云存储,系统会自动验证文件格式和检查存储容量。
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/cloud/upload
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer {access_token}
|
|
||||||
Content-Type: multipart/form-data
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求参数:**
|
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
| ----------- | ------ | ---- | ------------ |
|
|
||||||
| file | File | ✅ | 要上传的文件 |
|
|
||||||
| platform_id | string | ✅ | 平台 ID |
|
|
||||||
|
|
||||||
**请求示例(JavaScript):**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append("file", fileInput.files[0])
|
|
||||||
formData.append("platform_id", "pf_AHmIhAiptyLFxk8x")
|
|
||||||
|
|
||||||
const response = await fetch("https://sectl.top/api/cloud/upload", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: "Bearer eyJhbGciOiJIUzI1NiIs...",
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"file_id": "cf_xxxxxxxx",
|
|
||||||
"filename": "example.jpg",
|
|
||||||
"size": 1024567,
|
|
||||||
"url": "https://cloud.appwrite.io/v1/storage/buckets/69c869010007b752a9b1/files/xxx/view",
|
|
||||||
"storage": {
|
|
||||||
"used": "50MB",
|
|
||||||
"limit": "100MB",
|
|
||||||
"available": "50MB",
|
|
||||||
"percentage": 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误响应 - 格式不允许:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "invalid_format",
|
|
||||||
"error_description": "File format 'exe' is not allowed",
|
|
||||||
"allowed_formats": ["jpg", "png", "pdf"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误响应 - 超出容量:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "storage_exceeded",
|
|
||||||
"error_description": "Upload would exceed storage limit",
|
|
||||||
"storage": {
|
|
||||||
"used": "95MB",
|
|
||||||
"limit": "100MB",
|
|
||||||
"available": "5MB",
|
|
||||||
"requested": "10MB"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [CloudServiceView.vue](../../src/views/dashboard/CloudServiceView.vue) - 云服务总览
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 下载文件
|
|
||||||
|
|
||||||
获取文件的下载链接。
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/cloud/download/{file_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer {access_token}
|
|
||||||
```
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
| ------- | ------ | ---- | ------- |
|
|
||||||
| file_id | string | ✅ | 文件 ID |
|
|
||||||
|
|
||||||
**成功响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"file_id": "cf_xxxxxxxx",
|
|
||||||
"filename": "example.jpg",
|
|
||||||
"download_url": "https://cloud.appwrite.io/v1/storage/buckets/69c869010007b752a9b1/files/xxx/download",
|
|
||||||
"expires_at": "2025-01-01T01:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 获取存储信息
|
|
||||||
|
|
||||||
获取用户在该平台的存储使用情况和配置信息。
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/cloud/info?platform_id={platform_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer {access_token}
|
|
||||||
```
|
|
||||||
|
|
||||||
**查询参数:**
|
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
| ----------- | ------ | ---- | ------- |
|
|
||||||
| platform_id | string | ✅ | 平台 ID |
|
|
||||||
|
|
||||||
**成功响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"platform_id": "pf_xxx",
|
|
||||||
"tier": "free",
|
|
||||||
"storage": {
|
|
||||||
"used": 52428800,
|
|
||||||
"used_formatted": "50MB",
|
|
||||||
"limit": 104857600,
|
|
||||||
"limit_formatted": "100MB",
|
|
||||||
"available": 52428800,
|
|
||||||
"available_formatted": "50MB",
|
|
||||||
"percentage": 50
|
|
||||||
},
|
|
||||||
"allowed_formats": ["jpg", "png", "pdf"],
|
|
||||||
"max_file_size": 10485760
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 列出文件
|
|
||||||
|
|
||||||
获取用户在该平台的文件列表。
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /api/cloud/files?platform_id={platform_id}&limit=20&offset=0
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer {access_token}
|
|
||||||
```
|
|
||||||
|
|
||||||
**查询参数:**
|
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
| ----------- | ------ | ---- | ----------------- |
|
|
||||||
| platform_id | string | ✅ | 平台 ID |
|
|
||||||
| limit | number | ❌ | 每页数量,默认 20 |
|
|
||||||
| offset | number | ❌ | 偏移量,默认 0 |
|
|
||||||
|
|
||||||
**成功响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"file_id": "cf_xxx",
|
|
||||||
"filename": "example.jpg",
|
|
||||||
"size": 1024567,
|
|
||||||
"size_formatted": "1MB",
|
|
||||||
"created_at": "2025-01-01T00:00:00Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 10,
|
|
||||||
"limit": 20,
|
|
||||||
"offset": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 删除文件
|
|
||||||
|
|
||||||
删除指定的文件。
|
|
||||||
|
|
||||||
```http
|
|
||||||
DELETE /api/cloud/files/{file_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer {access_token}
|
|
||||||
```
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
| ------- | ------ | ---- | ------- |
|
|
||||||
| file_id | string | ✅ | 文件 ID |
|
|
||||||
|
|
||||||
**成功响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "File deleted successfully",
|
|
||||||
"storage": {
|
|
||||||
"used": "45MB",
|
|
||||||
"limit": "100MB",
|
|
||||||
"available": "55MB"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 云存储 SDK
|
|
||||||
|
|
||||||
我们提供了 JavaScript SDK 简化云存储 API 的调用。
|
|
||||||
|
|
||||||
### 安装
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script src="https://sectl.top/platform-sdk/cloud-storage.js"></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
或使用 ES6 模块:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import CloudStorage from "./cloud-storage.js"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 初始化
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const cloud = new CloudStorage({
|
|
||||||
baseUrl: "https://sectl.top",
|
|
||||||
platformId: "your_platform_id",
|
|
||||||
accessToken: "your_access_token",
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 使用示例
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 上传文件
|
|
||||||
try {
|
|
||||||
const result = await cloud.uploadFile(fileInput.files[0])
|
|
||||||
console.log("文件上传成功:", result.file_id)
|
|
||||||
console.log("存储使用情况:", result.storage.percentage + "%")
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === "invalid_format") {
|
|
||||||
console.error("不支持的文件格式:", error.data.allowed_formats)
|
|
||||||
} else if (error.code === "storage_exceeded") {
|
|
||||||
console.error("存储空间不足")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取存储信息
|
|
||||||
const info = await cloud.getStorageInfo()
|
|
||||||
console.log("已用空间:", info.storage.used_formatted)
|
|
||||||
console.log("允许格式:", info.allowed_formats)
|
|
||||||
|
|
||||||
// 列出文件
|
|
||||||
const files = await cloud.listFiles({ limit: 10 })
|
|
||||||
files.files.forEach((file) => {
|
|
||||||
console.log(file.filename, file.size_formatted)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 下载文件
|
|
||||||
const download = await cloud.downloadFile("cf_xxx")
|
|
||||||
window.location.href = download.download_url
|
|
||||||
|
|
||||||
// 删除文件
|
|
||||||
await cloud.deleteFile("cf_xxx")
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关文件:**
|
|
||||||
|
|
||||||
- [cloud-storage.js](../../platform-sdk/cloud-storage.js) - SDK 源码
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 远程退登 API
|
|
||||||
|
|
||||||
### 创建退登事件
|
|
||||||
|
|
||||||
当用户退出登录时,系统会自动创建退登事件。
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
await databases.createDocument({
|
|
||||||
databaseId: "69bd89d8000304c37368",
|
|
||||||
collectionId: "logout_events",
|
|
||||||
documentId: ID.unique(),
|
|
||||||
data: {
|
|
||||||
user_id: currentUser.$id,
|
|
||||||
platform_id: null, // null 表示全局退出
|
|
||||||
global: true,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 订阅退登事件
|
|
||||||
|
|
||||||
使用 Appwrite Realtime 订阅退登事件:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { Client } from "appwrite"
|
|
||||||
|
|
||||||
const client = new Client()
|
|
||||||
.setEndpoint("https://cloud.appwrite.io/v1")
|
|
||||||
.setProject("your-project-id")
|
|
||||||
|
|
||||||
const unsubscribe = client.subscribe(
|
|
||||||
"databases.69bd89d8000304c37368.collections.logout_events.documents",
|
|
||||||
(response) => {
|
|
||||||
const event = response.payload
|
|
||||||
if (event.user_id === currentUserId) {
|
|
||||||
// 执行本地退出
|
|
||||||
localLogout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
### HTTP 状态码
|
|
||||||
|
|
||||||
| 状态码 | 说明 |
|
|
||||||
| ------ | ------------------ |
|
|
||||||
| 200 | 请求成功 |
|
|
||||||
| 400 | 请求参数错误 |
|
|
||||||
| 401 | 未授权,认证失败 |
|
|
||||||
| 403 | 禁止访问,权限不足 |
|
|
||||||
| 404 | 资源不存在 |
|
|
||||||
| 500 | 服务器内部错误 |
|
|
||||||
|
|
||||||
### 错误响应格式
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "error_code",
|
|
||||||
"error_description": "详细的错误描述",
|
|
||||||
"error_uri": "https://docs.example.com/error/error_code"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 安全建议
|
|
||||||
|
|
||||||
1. **使用 HTTPS** - 所有 API 通信必须使用 HTTPS
|
|
||||||
2. **保护密钥** - 不要在客户端代码中暴露 client_secret
|
|
||||||
3. **验证回调地址** - 确保 redirect_uri 与注册时一致
|
|
||||||
4. **使用状态参数** - 添加 state 参数防止 CSRF 攻击
|
|
||||||
5. **安全存储令牌** - Token 应安全存储,避免泄露
|
|
||||||
6. **及时撤销** - 用户退出时及时撤销令牌
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 相关页面
|
|
||||||
|
|
||||||
- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) - OAuth 授权页面
|
|
||||||
- [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue) - 认证回调
|
|
||||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) - 平台管理
|
|
||||||
- [PlatformDetailView.vue](../../src/views/dashboard/detail/PlatformDetailView.vue) - 平台详情
|
|
||||||
- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) - 平台设置
|
|
||||||
- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue) - 安全设置
|
|
||||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表
|
|
||||||
-482
@@ -1,482 +0,0 @@
|
|||||||
# 平台接入指南
|
|
||||||
|
|
||||||
本文档介绍如何将第三方平台与 SECTL Auth 集成,实现统一认证和远程退登功能。
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
|
|
||||||
- [概述](#概述)
|
|
||||||
- [快速开始](#快速开始)
|
|
||||||
- [管理后台功能](#管理后台功能)
|
|
||||||
- [OAuth 集成](#oauth-集成)
|
|
||||||
- [远程退登](#远程退登)
|
|
||||||
- [代码示例](#代码示例)
|
|
||||||
- [常见问题](#常见问题)
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
SECTL Auth 提供以下核心功能:
|
|
||||||
|
|
||||||
1. **统一认证** - 用户使用 SECTL Auth 账号登录第三方平台
|
|
||||||
2. **远程退登** - 用户在 SECTL Auth 中心退出后,平台自动退出
|
|
||||||
3. **用户管理** - 查看已授权用户、统计信息等
|
|
||||||
|
|
||||||
### 工作流程
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐ 登录请求 ┌─────────────┐
|
|
||||||
│ 用户 │ ─────────────> │ SECTL Auth │
|
|
||||||
│ │ │ │
|
|
||||||
│ 平台A │ <───────────── │ 认证中心 │
|
|
||||||
│ │ Access Token │ │
|
|
||||||
└──────┬──────┘ └──────┬──────┘
|
|
||||||
│ │
|
|
||||||
│ 远程退登通知 │
|
|
||||||
│ <─────────────────────────── │
|
|
||||||
│ (Realtime) │
|
|
||||||
▼ ▼
|
|
||||||
自动退出登录 用户点击退出
|
|
||||||
```
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 1. 注册平台
|
|
||||||
|
|
||||||
在 SECTL Auth 平台管理页面 ([PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)) 申请接入,获取:
|
|
||||||
|
|
||||||
| 参数 | 说明 | 示例 |
|
|
||||||
| -------------- | ------------ | ------------------------------------- |
|
|
||||||
| platform_id | 平台唯一标识 | `pf_AHmIhAiptyLFxk8x` |
|
|
||||||
| platform_token | 平台认证密钥 | `sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1` |
|
|
||||||
| callback_url | 回调地址 | `http://localhost:5000/callback` |
|
|
||||||
|
|
||||||
### 2. 平台状态说明
|
|
||||||
|
|
||||||
平台在管理后台有以下状态:
|
|
||||||
|
|
||||||
| 状态 | 说明 | 可操作 |
|
|
||||||
| -------- | ------ | -------- |
|
|
||||||
| active | 运行中 | 正常授权 |
|
|
||||||
| pending | 审核中 | 等待审核 |
|
|
||||||
| disabled | 已停用 | 无法授权 |
|
|
||||||
|
|
||||||
### 3. 查看统计信息
|
|
||||||
|
|
||||||
在平台管理页面可以查看:
|
|
||||||
|
|
||||||
- **总平台数** - 已创建的平台数量
|
|
||||||
- **运行中** - 活跃平台数量
|
|
||||||
- **审核中** - 待审核申请数量
|
|
||||||
- **总用户数** - 授权用户总数
|
|
||||||
|
|
||||||
## 管理后台功能
|
|
||||||
|
|
||||||
### 平台列表 ([PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue))
|
|
||||||
|
|
||||||
平台管理页面提供以下功能:
|
|
||||||
|
|
||||||
#### 1. 平台卡片展示
|
|
||||||
|
|
||||||
每个平台显示:
|
|
||||||
|
|
||||||
- 平台名称和描述
|
|
||||||
- 状态标签(运行中/审核中/已停用)
|
|
||||||
- 创建时间
|
|
||||||
- 授权用户数
|
|
||||||
- 管理按钮
|
|
||||||
|
|
||||||
#### 2. 搜索和过滤
|
|
||||||
|
|
||||||
- 按名称搜索平台
|
|
||||||
- 按状态过滤
|
|
||||||
- 分页展示
|
|
||||||
|
|
||||||
#### 3. 快捷操作
|
|
||||||
|
|
||||||
- **新建平台** - 跳转到申请页面
|
|
||||||
- **我的申请** - 查看自己的平台申请
|
|
||||||
- **审核申请** - 审核其他用户的申请(需 >=18 权限)
|
|
||||||
|
|
||||||
### 平台详情 ([PlatformDetailView.vue](../../src/views/dashboard/detail/PlatformDetailView.vue))
|
|
||||||
|
|
||||||
查看平台详细信息:
|
|
||||||
|
|
||||||
#### 基本信息
|
|
||||||
|
|
||||||
- 平台 ID (Client ID)
|
|
||||||
- 平台密钥 (Client Secret)
|
|
||||||
- 回调地址
|
|
||||||
- 创建时间
|
|
||||||
|
|
||||||
#### 安全设置
|
|
||||||
|
|
||||||
- Token 过期时间配置
|
|
||||||
- 密钥重置
|
|
||||||
- 回调地址修改
|
|
||||||
|
|
||||||
#### 用户统计
|
|
||||||
|
|
||||||
- 授权用户列表
|
|
||||||
- 登录时间
|
|
||||||
- 最后活跃时间
|
|
||||||
|
|
||||||
### 平台申请 ([PlatformApplyView.vue](../../src/views/dashboard/form/PlatformApplyView.vue))
|
|
||||||
|
|
||||||
申请创建新平台:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 申请参数
|
|
||||||
{
|
|
||||||
name: "我的应用", // 平台名称
|
|
||||||
description: "应用描述", // 平台描述
|
|
||||||
callback_url: "http://..." // 回调地址
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
申请后状态为 `pending`,等待管理员审核。
|
|
||||||
|
|
||||||
### 申请审核 ([PlatformApplicationsView.vue](../../src/views/dashboard/manage/PlatformApplicationsView.vue))
|
|
||||||
|
|
||||||
管理员审核平台申请:
|
|
||||||
|
|
||||||
- 查看申请详情
|
|
||||||
- 批准申请(状态变为 `active`)
|
|
||||||
- 拒绝申请(状态变为 `disabled`)
|
|
||||||
|
|
||||||
## OAuth 集成
|
|
||||||
|
|
||||||
### 授权流程
|
|
||||||
|
|
||||||
#### 1. 引导用户到授权页面
|
|
||||||
|
|
||||||
```
|
|
||||||
GET https://sectl.top/oauth/authorize
|
|
||||||
```
|
|
||||||
|
|
||||||
**参数:**
|
|
||||||
|
|
||||||
| 参数名 | 必填 | 说明 |
|
|
||||||
| ------------- | ---- | ------------- |
|
|
||||||
| client_id | ✅ | 平台 ID |
|
|
||||||
| redirect_uri | ✅ | 回调地址 |
|
|
||||||
| response_type | ✅ | 固定值 `code` |
|
|
||||||
|
|
||||||
**示例:**
|
|
||||||
|
|
||||||
```
|
|
||||||
https://sectl.top/oauth/authorize?client_id=pf_xxx&redirect_uri=http://localhost:5000/callback&response_type=code
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 处理回调
|
|
||||||
|
|
||||||
用户授权后,会重定向到:
|
|
||||||
|
|
||||||
```
|
|
||||||
{callback_url}?code={authorization_code}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 换取访问令牌
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST https://sectl.top/api/oauth/token
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"code": "授权码",
|
|
||||||
"client_id": "pf_xxx",
|
|
||||||
"client_secret": "sk_xxx",
|
|
||||||
"redirect_uri": "http://localhost:5000/callback"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
|
||||||
"refresh_token": "xxx",
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": 3600
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. 获取用户信息
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET https://sectl.top/api/oauth/userinfo
|
|
||||||
Authorization: Bearer {access_token}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "69bd422960018cf4d0e5",
|
|
||||||
"email": "user@example.com",
|
|
||||||
"name": "张三",
|
|
||||||
"github_username": "zhangsan"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 远程退登
|
|
||||||
|
|
||||||
### 实现原理
|
|
||||||
|
|
||||||
1. 用户在 SECTL Auth 点击退出登录
|
|
||||||
2. 系统创建 `logout_events` 文档
|
|
||||||
3. 第三方应用通过 Appwrite Realtime 订阅该集合
|
|
||||||
4. 收到匹配的事件后执行本地退出
|
|
||||||
|
|
||||||
### 集成步骤
|
|
||||||
|
|
||||||
#### 1. 安装 Appwrite SDK
|
|
||||||
|
|
||||||
**C#**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet add package Appwrite
|
|
||||||
```
|
|
||||||
|
|
||||||
**Python**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install appwrite
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 配置 Realtime 订阅
|
|
||||||
|
|
||||||
**C# 示例:**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using Appwrite;
|
|
||||||
using Appwrite.Services;
|
|
||||||
|
|
||||||
var client = new Client()
|
|
||||||
.SetEndpoint("https://cloud.appwrite.io/v1")
|
|
||||||
.SetProject("your-project-id");
|
|
||||||
|
|
||||||
var realtime = new Realtime(client);
|
|
||||||
|
|
||||||
// 订阅 logout_events 集合
|
|
||||||
realtime.Subscribe("databases.{databaseId}.collections.logout_events.documents",
|
|
||||||
callback: (message) => {
|
|
||||||
var logoutEvent = message.Payload;
|
|
||||||
if (logoutEvent.UserId == currentUserId) {
|
|
||||||
// 执行本地退出
|
|
||||||
LocalLogout();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Python 示例:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
from appwrite.client import Client
|
|
||||||
from appwrite.services.realtime import Realtime
|
|
||||||
|
|
||||||
client = Client()
|
|
||||||
client.set_endpoint('https://cloud.appwrite.io/v1')
|
|
||||||
client.set_project('your-project-id')
|
|
||||||
|
|
||||||
realtime = Realtime(client)
|
|
||||||
|
|
||||||
def on_logout_event(message):
|
|
||||||
event = message['payload']
|
|
||||||
if event['user_id'] == current_user_id:
|
|
||||||
# 执行本地退出
|
|
||||||
local_logout()
|
|
||||||
|
|
||||||
# 订阅 logout_events 集合
|
|
||||||
realtime.subscribe(
|
|
||||||
'databases.{database_id}.collections.logout_events.documents',
|
|
||||||
callback=on_logout_event
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据结构
|
|
||||||
|
|
||||||
**logout_events 集合字段:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
| ----------- | ------- | ------------------ |
|
|
||||||
| user_id | String | 退出登录的用户ID |
|
|
||||||
| platform_id | String | 特定平台ID(可选) |
|
|
||||||
| global | Boolean | 是否全局退出 |
|
|
||||||
| created_at | String | 创建时间 |
|
|
||||||
|
|
||||||
## 代码示例
|
|
||||||
|
|
||||||
### C# 完整示例
|
|
||||||
|
|
||||||
详见 [csharp-example](./csharp-example/) 目录。
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// SectlAuthClient.cs
|
|
||||||
public class SectlAuthClient
|
|
||||||
{
|
|
||||||
private readonly string _platformId;
|
|
||||||
private readonly string _platformSecret;
|
|
||||||
private readonly string _callbackUrl;
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
|
|
||||||
public SectlAuthClient(string platformId, string platformSecret, string callbackUrl)
|
|
||||||
{
|
|
||||||
_platformId = platformId;
|
|
||||||
_platformSecret = platformSecret;
|
|
||||||
_callbackUrl = callbackUrl;
|
|
||||||
_httpClient = new HttpClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取授权 URL
|
|
||||||
public string GetAuthorizationUrl()
|
|
||||||
{
|
|
||||||
return $"https://sectl.top/oauth/authorize?" +
|
|
||||||
$"client_id={_platformId}&" +
|
|
||||||
$"redirect_uri={Uri.EscapeDataString(_callbackUrl)}&" +
|
|
||||||
$"response_type=code";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 换取访问令牌
|
|
||||||
public async Task<TokenResponse> ExchangeCodeAsync(string code)
|
|
||||||
{
|
|
||||||
var request = new
|
|
||||||
{
|
|
||||||
grant_type = "authorization_code",
|
|
||||||
code = code,
|
|
||||||
client_id = _platformId,
|
|
||||||
client_secret = _platformSecret,
|
|
||||||
redirect_uri = _callbackUrl
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await _httpClient.PostAsJsonAsync(
|
|
||||||
"https://sectl.top/api/oauth/token", request);
|
|
||||||
|
|
||||||
return await response.Content.ReadFromJsonAsync<TokenResponse>();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户信息
|
|
||||||
public async Task<UserInfo> GetUserInfoAsync(string accessToken)
|
|
||||||
{
|
|
||||||
_httpClient.DefaultRequestHeaders.Authorization =
|
|
||||||
new AuthenticationHeaderValue("Bearer", accessToken);
|
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(
|
|
||||||
"https://sectl.top/api/oauth/userinfo");
|
|
||||||
|
|
||||||
return await response.Content.ReadFromJsonAsync<UserInfo>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Python 完整示例
|
|
||||||
|
|
||||||
详见 [python-example](./python-example/) 目录。
|
|
||||||
|
|
||||||
```python
|
|
||||||
# sectl_auth/client.py
|
|
||||||
import requests
|
|
||||||
|
|
||||||
class SectlAuthClient:
|
|
||||||
BASE_URL = "https://sectl.top"
|
|
||||||
AUTH_URL = "https://sectl.top"
|
|
||||||
|
|
||||||
def __init__(self, platform_id: str, platform_secret: str, callback_url: str):
|
|
||||||
self.platform_id = platform_id
|
|
||||||
self.platform_secret = platform_secret
|
|
||||||
self.callback_url = callback_url
|
|
||||||
|
|
||||||
def get_authorization_url(self) -> str:
|
|
||||||
"""获取授权 URL"""
|
|
||||||
return (
|
|
||||||
f"{self.AUTH_URL}/oauth/authorize?"
|
|
||||||
f"client_id={self.platform_id}&"
|
|
||||||
f"redirect_uri={self.callback_url}&"
|
|
||||||
f"response_type=code"
|
|
||||||
)
|
|
||||||
|
|
||||||
def exchange_code(self, code: str) -> dict:
|
|
||||||
"""用授权码换取访问令牌"""
|
|
||||||
response = requests.post(
|
|
||||||
f"{self.BASE_URL}/api/oauth/token",
|
|
||||||
json={
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"code": code,
|
|
||||||
"client_id": self.platform_id,
|
|
||||||
"client_secret": self.platform_secret,
|
|
||||||
"redirect_uri": self.callback_url
|
|
||||||
}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
def get_user_info(self, access_token: str) -> dict:
|
|
||||||
"""获取用户信息"""
|
|
||||||
response = requests.get(
|
|
||||||
f"{self.BASE_URL}/api/oauth/userinfo",
|
|
||||||
headers={"Authorization": f"Bearer {access_token}"}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### Q: 如何获取 platform_id 和 platform_token?
|
|
||||||
|
|
||||||
A: 在 SECTL Auth 平台管理页面申请创建平台,审核通过后会获得:
|
|
||||||
|
|
||||||
- platform_id (Client ID)
|
|
||||||
- platform_token (Client Secret)
|
|
||||||
|
|
||||||
### Q: 授权码有效期多久?
|
|
||||||
|
|
||||||
A: 授权码有效期为 10 分钟,且只能使用一次。
|
|
||||||
|
|
||||||
### Q: Access Token 过期后怎么办?
|
|
||||||
|
|
||||||
A: 使用 refresh_token 换取新的 access_token:
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/oauth/token
|
|
||||||
{
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"refresh_token": "your-refresh-token",
|
|
||||||
"client_id": "your-platform-id",
|
|
||||||
"client_secret": "your-platform-secret"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Q: 如何配置 Token 过期时间?
|
|
||||||
|
|
||||||
A: 在平台设置页面 ([PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue)) 可以配置:
|
|
||||||
|
|
||||||
- access_token 过期时间(默认 3600 秒)
|
|
||||||
- 设置为 -1 表示永不过期
|
|
||||||
|
|
||||||
### Q: 远程退登不生效怎么办?
|
|
||||||
|
|
||||||
A: 检查以下几点:
|
|
||||||
|
|
||||||
1. 是否正确订阅了 `logout_events` 集合
|
|
||||||
2. 检查 user_id 是否匹配
|
|
||||||
3. 确认 Appwrite Realtime 连接正常
|
|
||||||
4. 查看浏览器控制台是否有错误
|
|
||||||
|
|
||||||
### Q: 平台状态显示"审核中"怎么办?
|
|
||||||
|
|
||||||
A: 新创建的平台需要管理员审核。审核通过后状态会变为 `active`。
|
|
||||||
|
|
||||||
## 相关页面
|
|
||||||
|
|
||||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) - 平台管理
|
|
||||||
- [PlatformDetailView.vue](../../src/views/dashboard/detail/PlatformDetailView.vue) - 平台详情
|
|
||||||
- [PlatformApplyView.vue](../../src/views/dashboard/form/PlatformApplyView.vue) - 申请平台
|
|
||||||
- [PlatformApplicationsView.vue](../../src/views/dashboard/manage/PlatformApplicationsView.vue) - 审核申请
|
|
||||||
- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) - 平台设置
|
|
||||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表
|
|
||||||
|
|
||||||
## 参考文档
|
|
||||||
|
|
||||||
- [OAuth 2.0 流程](./OAUTH_FLOW.MD)
|
|
||||||
- [API 参考](./API_REFERENCE.md)
|
|
||||||
- [故障排查](../oauth-flow/TROUBLESHOOTING.md)
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
# OAuth 2.0 授权流程文档
|
|
||||||
|
|
||||||
本文档详细说明 SECTL Auth 的 OAuth 2.0 授权流程,帮助第三方应用开发者接入统一认证服务。
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
SECTL Auth 实现了标准的 OAuth 2.0 授权码流程(Authorization Code Flow),允许第三方应用使用 SECTL Auth 账户进行用户认证。
|
|
||||||
|
|
||||||
### 流程特点
|
|
||||||
|
|
||||||
- **安全性高** - 使用授权码模式,避免 Token 暴露在浏览器端
|
|
||||||
- **标准化** - 遵循 OAuth 2.0 RFC 6749 规范
|
|
||||||
- **远程退登** - 支持通过 Realtime 实现跨平台同步退出
|
|
||||||
- **可配置** - 支持自定义 Token 过期时间
|
|
||||||
|
|
||||||
## 授权流程
|
|
||||||
|
|
||||||
### 流程图
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────┐ ┌─────────────┐
|
|
||||||
│ 用户 │ │ 第三方应用 │
|
|
||||||
└────┬────┘ └──────┬──────┘
|
|
||||||
│ │
|
|
||||||
│ 1. 访问应用 │
|
|
||||||
│───────────────────────────────────────────────>│
|
|
||||||
│ │
|
|
||||||
│ 2. 重定向到授权页面 │
|
|
||||||
│<───────────────────────────────────────────────│
|
|
||||||
│ │
|
|
||||||
│ 3. 登录并授权 (LoginView.vue / OAuthAuthorizeView.vue)
|
|
||||||
│───────────────────────────────────────────────>│
|
|
||||||
│ │
|
|
||||||
│ 4. 返回授权码 │
|
|
||||||
│<───────────────────────────────────────────────│
|
|
||||||
│ │
|
|
||||||
│ 5. 用授权码换取 Token │
|
|
||||||
│ │──────┐
|
|
||||||
│ │ │
|
|
||||||
│ 6. 返回 Access Token │<─────┘
|
|
||||||
│ │
|
|
||||||
│ 7. 使用 Token 访问用户资源 │
|
|
||||||
│<───────────────────────────────────────────────│
|
|
||||||
```
|
|
||||||
|
|
||||||
## 详细步骤
|
|
||||||
|
|
||||||
### 1. 引导用户到授权页面
|
|
||||||
|
|
||||||
当用户需要登录时,第三方应用应将用户重定向到 SECTL Auth 的授权页面。
|
|
||||||
|
|
||||||
**请求地址:**
|
|
||||||
|
|
||||||
```
|
|
||||||
GET https://sectl.top/oauth/authorize
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求参数:**
|
|
||||||
|
|
||||||
| 参数名 | 必填 | 类型 | 说明 |
|
|
||||||
| ------------- | ---- | ------ | --------------------------------- |
|
|
||||||
| client_id | ✅ | string | 平台 ID,如 `pf_AHmIhAiptyLFxk8x` |
|
|
||||||
| redirect_uri | ✅ | string | 回调地址,必须与平台设置匹配 |
|
|
||||||
| response_type | ✅ | string | 固定值 `code` |
|
|
||||||
|
|
||||||
**示例 URL:**
|
|
||||||
|
|
||||||
```
|
|
||||||
https://sectl.top/oauth/authorize?client_id=pf_AHmIhAiptyLFxk8x&redirect_uri=http://localhost:5000/callback&response_type=code
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关代码:** [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue)
|
|
||||||
|
|
||||||
### 2. 用户登录与授权
|
|
||||||
|
|
||||||
用户将被引导至 SECTL Auth 登录页面(如果未登录):
|
|
||||||
|
|
||||||
1. **登录页面** ([LoginView.vue](../../src/views/LoginView.vue))
|
|
||||||
- 用户输入邮箱密码或使用 GitHub 登录
|
|
||||||
- 支持记住我功能
|
|
||||||
- 检测 OAuth 参数,登录后自动跳转授权页
|
|
||||||
|
|
||||||
2. **授权确认页** ([OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue))
|
|
||||||
- 显示应用名称和图标
|
|
||||||
- 列出请求的权限范围
|
|
||||||
- 用户可选择"授权"或"拒绝"
|
|
||||||
|
|
||||||
**权限范围:**
|
|
||||||
|
|
||||||
- 访问用户基本信息(用户名、邮箱、头像)
|
|
||||||
- 验证用户身份
|
|
||||||
|
|
||||||
### 3. 获取授权码
|
|
||||||
|
|
||||||
用户授权后,页面将重定向到指定的回调地址:
|
|
||||||
|
|
||||||
```
|
|
||||||
{redirect_uri}?code={authorization_code}
|
|
||||||
```
|
|
||||||
|
|
||||||
**示例:**
|
|
||||||
|
|
||||||
```
|
|
||||||
http://localhost:5000/callback?code=NjliNDIyOTYwMDE4znLUNeWM6J8ye0MC
|
|
||||||
```
|
|
||||||
|
|
||||||
**授权码特性:**
|
|
||||||
|
|
||||||
- 有效期:10 分钟
|
|
||||||
- 一次性使用
|
|
||||||
- 与 `redirect_uri` 绑定
|
|
||||||
|
|
||||||
### 4. 换取访问令牌
|
|
||||||
|
|
||||||
第三方应用后端使用授权码向 SECTL Auth 换取访问令牌。
|
|
||||||
|
|
||||||
**请求地址:**
|
|
||||||
|
|
||||||
```
|
|
||||||
POST https://sectl.top/api/oauth/token
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Content-Type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"code": "NjliNDIyOTYwMDE4znLUNeWM6J8ye0MC",
|
|
||||||
"client_id": "pf_AHmIhAiptyLFxk8x",
|
|
||||||
"client_secret": "sk_UyMHuFf6oKXh8ZcHSb7lfOSgzumoOeg1",
|
|
||||||
"redirect_uri": "http://localhost:5000/callback"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**参数说明:**
|
|
||||||
|
|
||||||
| 参数名 | 必填 | 说明 |
|
|
||||||
| ------------- | ---- | --------------------------- |
|
|
||||||
| grant_type | ✅ | 固定值 `authorization_code` |
|
|
||||||
| code | ✅ | 上一步获取的授权码 |
|
|
||||||
| client_id | ✅ | 平台 ID |
|
|
||||||
| client_secret | ✅ | 平台密钥 |
|
|
||||||
| redirect_uri | ✅ | 必须与授权时一致 |
|
|
||||||
|
|
||||||
**成功响应:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
|
||||||
"refresh_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": 3600
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Token 信息:**
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
| ------------- | ----------------------------------- |
|
|
||||||
| access_token | 访问令牌,用于调用 API |
|
|
||||||
| refresh_token | 刷新令牌,用于获取新的 access_token |
|
|
||||||
| token_type | 令牌类型,固定为 Bearer |
|
|
||||||
| expires_in | 过期时间(秒) |
|
|
||||||
|
|
||||||
### 5. 使用访问令牌
|
|
||||||
|
|
||||||
获取 access_token 后,可用于调用 SECTL Auth API 获取用户信息。
|
|
||||||
|
|
||||||
**获取用户信息:**
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET https://sectl.top/api/oauth/userinfo
|
|
||||||
Authorization: Bearer {access_token}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "69bd422960018cf4d0e5",
|
|
||||||
"email": "user@example.com",
|
|
||||||
"name": "张三",
|
|
||||||
"github_username": "zhangsan"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Token 管理
|
|
||||||
|
|
||||||
### 过期时间配置
|
|
||||||
|
|
||||||
平台可配置 Token 过期时间:
|
|
||||||
|
|
||||||
| 令牌类型 | 默认有效期 | 说明 |
|
|
||||||
| ------------- | --------------- | ---------------------- |
|
|
||||||
| access_token | 3600 秒 (1小时) | 短期有效,用于访问资源 |
|
|
||||||
| refresh_token | 长期有效 | 用于刷新 access_token |
|
|
||||||
| 授权码 | 600 秒 (10分钟) | 一次性使用 |
|
|
||||||
|
|
||||||
**修改过期时间:**
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST https://sectl.top/api/oauth/config
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"expires_in": 86400,
|
|
||||||
"admin_secret": "your-admin-secret"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 刷新 Token
|
|
||||||
|
|
||||||
当 access_token 过期时,使用 refresh_token 获取新的令牌:
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST https://sectl.top/api/oauth/token
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"refresh_token": "your-refresh-token",
|
|
||||||
"client_id": "your-platform-id",
|
|
||||||
"client_secret": "your-platform-secret"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 远程退登
|
|
||||||
|
|
||||||
SECTL Auth 支持远程退登功能,当用户在 SECTL Auth 中心退出登录时,已授权的第三方应用会收到通知。
|
|
||||||
|
|
||||||
### 实现原理
|
|
||||||
|
|
||||||
1. 用户在 SECTL Auth 点击退出登录
|
|
||||||
2. 系统创建 `logout_events` 文档
|
|
||||||
3. 第三方应用通过 Appwrite Realtime 订阅该集合
|
|
||||||
4. 收到退登事件后,应用执行本地退出操作
|
|
||||||
|
|
||||||
### 集成方式
|
|
||||||
|
|
||||||
详见 [平台接入指南](../platform-integration/README.md)。
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
### 授权错误
|
|
||||||
|
|
||||||
当授权失败时,将重定向到回调地址并附带错误信息:
|
|
||||||
|
|
||||||
```
|
|
||||||
{redirect_uri}?error=access_denied&error_description=user+denied+authorization
|
|
||||||
```
|
|
||||||
|
|
||||||
**常见错误码:**
|
|
||||||
|
|
||||||
| 错误码 | 说明 |
|
|
||||||
| ------------------------- | ------------------ |
|
|
||||||
| invalid_request | 请求参数缺失或无效 |
|
|
||||||
| unauthorized_client | 客户端未授权 |
|
|
||||||
| access_denied | 用户拒绝授权 |
|
|
||||||
| unsupported_response_type | 不支持的响应类型 |
|
|
||||||
| invalid_scope | 无效的权限范围 |
|
|
||||||
| server_error | 服务器内部错误 |
|
|
||||||
|
|
||||||
### Token 错误
|
|
||||||
|
|
||||||
换取 Token 时的错误响应:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "invalid_grant",
|
|
||||||
"error_description": "Authorization code has expired"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**常见错误码:**
|
|
||||||
|
|
||||||
| 错误码 | 说明 |
|
|
||||||
| ---------------------- | ------------------------ |
|
|
||||||
| invalid_request | 请求格式错误 |
|
|
||||||
| invalid_client | 客户端认证失败 |
|
|
||||||
| invalid_grant | 授权码无效或过期 |
|
|
||||||
| unauthorized_client | 客户端无权使用此授权类型 |
|
|
||||||
| unsupported_grant_type | 不支持的授权类型 |
|
|
||||||
| invalid_scope | 无效的权限范围 |
|
|
||||||
|
|
||||||
## 安全建议
|
|
||||||
|
|
||||||
1. **使用 HTTPS** - 所有 OAuth 通信必须使用 HTTPS
|
|
||||||
2. **保护 client_secret** - 不要在客户端代码中暴露 client_secret
|
|
||||||
3. **验证 redirect_uri** - 确保回调地址与注册时一致
|
|
||||||
4. **使用状态参数** - 建议添加 `state` 参数防止 CSRF 攻击
|
|
||||||
5. **及时清理** - 使用后的授权码应立即作废
|
|
||||||
6. **安全存储** - Token 应安全存储,避免泄露
|
|
||||||
|
|
||||||
## 相关页面
|
|
||||||
|
|
||||||
- [LoginView.vue](../../src/views/LoginView.vue) - 登录页面
|
|
||||||
- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) - OAuth 授权页面
|
|
||||||
- [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue) - 认证回调处理
|
|
||||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表
|
|
||||||
|
|
||||||
## 参考文档
|
|
||||||
|
|
||||||
- [平台接入指南](../platform-integration/README.md)
|
|
||||||
- [API 参考](../platform-integration/API_REFERENCE.md)
|
|
||||||
- [故障排查](./TROUBLESHOOTING.md)
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
# OAuth 流程故障排查指南
|
|
||||||
|
|
||||||
本文档汇总了 SECTL Auth OAuth 流程中的常见问题及解决方案。
|
|
||||||
|
|
||||||
## 目录
|
|
||||||
|
|
||||||
- [授权阶段问题](#授权阶段问题)
|
|
||||||
- [Token 换取问题](#token-换取问题)
|
|
||||||
- [用户信息获取问题](#用户信息获取问题)
|
|
||||||
- [远程退登问题](#远程退登问题)
|
|
||||||
- [平台管理问题](#平台管理问题)
|
|
||||||
- [网络与 CORS 问题](#网络与-cors-问题)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 授权阶段问题
|
|
||||||
|
|
||||||
### 1. 授权页面无法打开
|
|
||||||
|
|
||||||
**现象:** 访问授权 URL 返回 404 或空白页面
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查 URL 格式是否正确
|
|
||||||
```
|
|
||||||
https://auth.sectl.top/oauth/authorize?client_id=xxx&redirect_uri=xxx&response_type=code
|
|
||||||
```
|
|
||||||
2. 确认必要参数是否齐全
|
|
||||||
- `client_id` - 平台 ID
|
|
||||||
- `redirect_uri` - 回调地址
|
|
||||||
- `response_type` - 固定值 `code`
|
|
||||||
3. 检查平台是否存在且状态为 `active`
|
|
||||||
- 登录 SECTL Auth 管理后台
|
|
||||||
- 查看 [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)
|
|
||||||
- 确认平台状态不是 `pending` 或 `disabled`
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue)
|
|
||||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 授权后没有记录到登录平台
|
|
||||||
|
|
||||||
**现象:** 用户授权后,在"登录平台"页面 ([LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue)) 看不到记录
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查浏览器控制台是否有错误
|
|
||||||
2. 检查 `user_data` 集合是否有该用户的记录
|
|
||||||
3. 检查 `user_data.login_platforms` 字段是否正确更新
|
|
||||||
4. 检查 `oauth_tokens` 集合是否有对应的令牌记录
|
|
||||||
|
|
||||||
**可能原因:**
|
|
||||||
|
|
||||||
- 用户未登录 SECTL Auth
|
|
||||||
- `user_data` 集合权限问题
|
|
||||||
- 网络请求失败
|
|
||||||
- 平台状态为 `disabled`
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue)
|
|
||||||
- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 用户已登录但授权页面仍要求登录
|
|
||||||
|
|
||||||
**现象:** 用户已登录 SECTL Auth,但授权页面仍跳转到登录页
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查登录状态是否正确保持
|
|
||||||
2. 检查浏览器 Cookie 是否被阻止
|
|
||||||
3. 检查是否跨域问题
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
|
|
||||||
- 确保浏览器允许第三方 Cookie
|
|
||||||
- 检查 `rememberMe` 选项是否勾选
|
|
||||||
- 查看 [LoginView.vue](../../src/views/LoginView.vue) 中的登录逻辑
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Token 换取问题
|
|
||||||
|
|
||||||
### 1. 换取 token 时提示 "Invalid authorization code"
|
|
||||||
|
|
||||||
**现象:** 用 code 换取 token 返回 400 错误
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "invalid_grant",
|
|
||||||
"error_description": "Invalid authorization code"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查 code 是否已过期(10分钟有效期)
|
|
||||||
2. 检查 code 是否已被使用过
|
|
||||||
3. 检查 `redirect_uri` 是否与授权时一致
|
|
||||||
4. 检查 `client_id` 和 `client_secret` 是否正确
|
|
||||||
|
|
||||||
**可能原因:**
|
|
||||||
|
|
||||||
- Code 已过期
|
|
||||||
- Code 已被使用(一次性)
|
|
||||||
- 回调地址不匹配
|
|
||||||
- 平台密钥错误
|
|
||||||
|
|
||||||
**相关代码:** [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 换取 token 时提示 "Invalid client"
|
|
||||||
|
|
||||||
**现象:** 返回 401 错误
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "invalid_client",
|
|
||||||
"error_description": "Client authentication failed"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 确认 `client_id` 正确
|
|
||||||
2. 确认 `client_secret` 正确且未泄露
|
|
||||||
3. 检查平台状态是否为 `active`
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
|
|
||||||
- 在 [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) 查看平台信息
|
|
||||||
- 如密钥泄露,在 [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) 重置密钥
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Access Token 过期后无法刷新
|
|
||||||
|
|
||||||
**现象:** 使用 refresh_token 换取新 token 失败
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查 refresh_token 是否有效
|
|
||||||
2. 检查 refresh_token 是否被撤销
|
|
||||||
3. 检查平台是否被禁用
|
|
||||||
|
|
||||||
**请求示例:**
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /api/oauth/token
|
|
||||||
{
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"refresh_token": "your-refresh-token",
|
|
||||||
"client_id": "your-platform-id",
|
|
||||||
"client_secret": "your-platform-secret"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 用户信息获取问题
|
|
||||||
|
|
||||||
### 1. 获取用户信息返回 401
|
|
||||||
|
|
||||||
**现象:** 调用 `/api/oauth/userinfo` 返回未授权错误
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查 access_token 是否过期
|
|
||||||
2. 检查 Authorization 头格式是否正确
|
|
||||||
```
|
|
||||||
Authorization: Bearer {access_token}
|
|
||||||
```
|
|
||||||
3. 检查 token 是否被撤销
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
|
|
||||||
- 使用 refresh_token 获取新的 access_token
|
|
||||||
- 重新走授权流程
|
|
||||||
|
|
||||||
**相关页面:** [PlatformUsersView.vue](../../src/views/dashboard/manage/PlatformUsersView.vue)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 用户信息字段缺失
|
|
||||||
|
|
||||||
**现象:** 返回的用户信息缺少某些字段
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查用户是否设置了该字段
|
|
||||||
2. 检查数据库中 `user_data` 集合是否有该字段
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "69bd422960018cf4d0e5",
|
|
||||||
"email": "user@example.com",
|
|
||||||
"name": "张三",
|
|
||||||
"github_username": "zhangsan",
|
|
||||||
"permission": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 远程退登问题
|
|
||||||
|
|
||||||
### 1. 远程退登不生效
|
|
||||||
|
|
||||||
**现象:** 用户在 SECTL Auth 退出后,第三方平台没有自动退出
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查是否正确订阅了 `logout_events` 集合
|
|
||||||
2. 检查 user_id 是否匹配
|
|
||||||
3. 确认 Appwrite Realtime 连接正常
|
|
||||||
4. 查看浏览器控制台是否有错误
|
|
||||||
|
|
||||||
**订阅代码示例:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
client.subscribe(
|
|
||||||
"databases.69bd89d8000304c37368.collections.logout_events.documents",
|
|
||||||
(response) => {
|
|
||||||
const event = response.payload
|
|
||||||
if (event.user_id === currentUserId) {
|
|
||||||
localLogout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue)
|
|
||||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Realtime 连接断开
|
|
||||||
|
|
||||||
**现象:** 退登事件偶尔收不到
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查网络连接稳定性
|
|
||||||
2. 检查是否有心跳机制保持连接
|
|
||||||
3. 检查浏览器是否进入休眠状态
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
|
|
||||||
- 实现重连机制
|
|
||||||
- 定期检查连接状态
|
|
||||||
- 使用 Service Worker 保持后台连接
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 平台管理问题
|
|
||||||
|
|
||||||
### 1. 平台管理的"登录用户"为空
|
|
||||||
|
|
||||||
**现象:** 在平台管理页面查看登录用户,显示为空
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查 `oauth_tokens` 集合是否有记录
|
|
||||||
2. 检查记录是否有 `user_id` 字段
|
|
||||||
3. 检查 `oauth_tokens` 集合权限
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
|
|
||||||
如果 `oauth_tokens` 没有 `user_id` 字段,需要在 Appwrite Console 中添加:
|
|
||||||
|
|
||||||
1. 进入 Database → 选择数据库
|
|
||||||
2. 找到 `oauth_tokens` 集合
|
|
||||||
3. 添加字段:`user_id`(String 类型,非必填)
|
|
||||||
|
|
||||||
**相关页面:**
|
|
||||||
|
|
||||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue)
|
|
||||||
- [PlatformUsersView.vue](../../src/views/dashboard/manage/PlatformUsersView.vue)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 平台状态显示"审核中"
|
|
||||||
|
|
||||||
**现象:** 新创建的平台无法使用,状态为 `pending`
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查是否已提交平台申请
|
|
||||||
2. 联系管理员审核
|
|
||||||
3. 在 [MyPlatformApplicationsView.vue](../../src/views/dashboard/manage/MyPlatformApplicationsView.vue) 查看申请状态
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
|
|
||||||
- 等待管理员审核
|
|
||||||
- 管理员在 [PlatformApplicationsView.vue](../../src/views/dashboard/manage/PlatformApplicationsView.vue) 中批准申请
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 无法修改平台回调地址
|
|
||||||
|
|
||||||
**现象:** 在平台设置中修改回调地址失败
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查是否有权限修改(所有者或管理员)
|
|
||||||
2. 检查回调地址格式是否正确
|
|
||||||
3. 检查数据库权限配置
|
|
||||||
|
|
||||||
**相关页面:** [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 网络与 CORS 问题
|
|
||||||
|
|
||||||
### 1. CORS 错误
|
|
||||||
|
|
||||||
**现象:** 浏览器控制台显示 CORS 错误
|
|
||||||
|
|
||||||
**错误信息:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Access to fetch at 'https://auth.sectl.top/...' from origin 'https://auth.sectl.top' has been blocked by CORS policy
|
|
||||||
```
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
|
|
||||||
已在 OAuth API 中添加 CORS 头:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
||||||
```
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 确认请求头是否正确
|
|
||||||
2. 确认是否为预检请求(OPTIONS)问题
|
|
||||||
3. 检查服务器 CORS 配置
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 授权码中的用户ID解码失败
|
|
||||||
|
|
||||||
**现象:** 换取 token 后 `user_id` 为 "anonymous"
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查授权码格式(应为 32 位:前16位 base64 用户ID + 后16位随机)
|
|
||||||
2. 检查 `oauth_codes` 集合是否有 `user_id` 字段
|
|
||||||
|
|
||||||
**代码逻辑:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 生成授权码时编码用户ID
|
|
||||||
const userIdHash = Buffer.from(userId)
|
|
||||||
.toString("base64")
|
|
||||||
.replace(/[^a-zA-Z0-9]/g, "")
|
|
||||||
.substring(0, 16)
|
|
||||||
const code = userIdHash + randomPart
|
|
||||||
|
|
||||||
// 换取 token 时解码
|
|
||||||
try {
|
|
||||||
const userIdHash = code.substring(0, 16)
|
|
||||||
const base64Str = userIdHash.replace(/_/g, "/").replace(/-/g, "+") + "=="
|
|
||||||
const decoded = Buffer.from(base64Str, "base64").toString("utf8")
|
|
||||||
tokenUserId = decoded
|
|
||||||
} catch (e) {
|
|
||||||
tokenUserId = "anonymous"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 网络超时
|
|
||||||
|
|
||||||
**现象:** 请求 OAuth API 超时
|
|
||||||
|
|
||||||
**排查步骤:**
|
|
||||||
|
|
||||||
1. 检查网络连接
|
|
||||||
2. 检查服务器状态
|
|
||||||
3. 增加请求超时时间
|
|
||||||
|
|
||||||
**解决方案:**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 增加超时时间
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
signal: AbortSignal.timeout(30000), // 30秒超时
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 调试技巧
|
|
||||||
|
|
||||||
### 1. 浏览器开发者工具
|
|
||||||
|
|
||||||
使用浏览器开发者工具排查问题:
|
|
||||||
|
|
||||||
1. **Network 面板** - 查看请求和响应
|
|
||||||
2. **Console 面板** - 查看错误日志
|
|
||||||
3. **Application 面板** - 查看 Cookie 和 LocalStorage
|
|
||||||
|
|
||||||
### 2. Appwrite Console
|
|
||||||
|
|
||||||
在 Appwrite Console 中检查:
|
|
||||||
|
|
||||||
1. **Database** - 检查集合数据和权限
|
|
||||||
2. **Auth** - 检查用户和会话
|
|
||||||
3. **Functions** - 查看函数日志
|
|
||||||
4. **Realtime** - 检查实时连接
|
|
||||||
|
|
||||||
### 3. 日志记录
|
|
||||||
|
|
||||||
在关键位置添加日志:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 授权流程日志
|
|
||||||
console.log("OAuth params:", { client_id, redirect_uri, response_type })
|
|
||||||
console.log("Authorization code:", code)
|
|
||||||
console.log("Token response:", tokenData)
|
|
||||||
|
|
||||||
// 退登事件日志
|
|
||||||
console.log("Logout event received:", event)
|
|
||||||
console.log("Current user ID:", currentUserId)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题速查表
|
|
||||||
|
|
||||||
| 问题 | 可能原因 | 解决方案 |
|
|
||||||
| :------------- | :------------------- | :---------------------- |
|
|
||||||
| 授权页面 404 | 平台不存在或状态异常 | 检查平台状态和 ID |
|
|
||||||
| Invalid code | Code 过期或已使用 | 重新授权获取新 code |
|
|
||||||
| Invalid client | 密钥错误 | 检查 client_secret |
|
|
||||||
| Token 过期 | 超过 expires_in | 使用 refresh_token 刷新 |
|
|
||||||
| 用户信息 401 | Token 无效 | 检查 Token 格式和有效期 |
|
|
||||||
| 退登不生效 | Realtime 未连接 | 检查订阅代码 |
|
|
||||||
| CORS 错误 | 跨域配置问题 | 检查服务器 CORS 头 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 获取帮助
|
|
||||||
|
|
||||||
如果以上方案无法解决问题:
|
|
||||||
|
|
||||||
1. 查看 [API 参考](../platform-integration/API_REFERENCE.md)
|
|
||||||
2. 查看 [平台接入指南](../platform-integration/README.md)
|
|
||||||
3. 提交 Issue 到项目仓库
|
|
||||||
4. 联系管理员
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 相关页面
|
|
||||||
|
|
||||||
- [OAuthAuthorizeView.vue](../../src/views/OAuthAuthorizeView.vue) - OAuth 授权页面
|
|
||||||
- [AuthCallbackView.vue](../../src/views/AuthCallbackView.vue) - 认证回调
|
|
||||||
- [LoginView.vue](../../src/views/LoginView.vue) - 登录页面
|
|
||||||
- [PlatformManagementView.vue](../../src/views/dashboard/manage/PlatformManagementView.vue) - 平台管理
|
|
||||||
- [PlatformSettingsView.vue](../../src/views/dashboard/other/PlatformSettingsView.vue) - 平台设置
|
|
||||||
- [SecurityView.vue](../../src/views/dashboard/other/SecurityView.vue) - 安全设置
|
|
||||||
- [LoginPlatformsView.vue](../../src/views/dashboard/list/LoginPlatformsView.vue) - 登录平台列表
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
+126
-10
@@ -54,6 +54,35 @@ pub struct OAuthUserInfo {
|
|||||||
pub permission: u32,
|
pub permission: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OAuthIntrospectResponse {
|
||||||
|
pub active: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub scope: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub client_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub username: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub token_type: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub exp: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub iat: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sub: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub aud: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub iss: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OAuthAuthorizationUrlResponse {
|
||||||
|
pub url: String,
|
||||||
|
pub state: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn get_iv_hex() -> String {
|
fn get_iv_hex() -> String {
|
||||||
SecurityService::generate_iv_hex()
|
SecurityService::generate_iv_hex()
|
||||||
}
|
}
|
||||||
@@ -300,13 +329,23 @@ pub async fn auth_clear_all(
|
|||||||
pub async fn oauth_get_authorization_url(
|
pub async fn oauth_get_authorization_url(
|
||||||
platform_id: String,
|
platform_id: String,
|
||||||
callback_url: String,
|
callback_url: String,
|
||||||
) -> Result<IpcResponse<String>, String> {
|
state: Option<String>,
|
||||||
|
) -> Result<IpcResponse<OAuthAuthorizationUrlResponse>, String> {
|
||||||
|
let state = state.unwrap_or_else(|| {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let random_bytes: Vec<u8> = (0..32).map(|_| rng.gen()).collect();
|
||||||
|
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &random_bytes)
|
||||||
|
});
|
||||||
|
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://sectl.top/oauth/authorize?client_id={}&redirect_uri={}&response_type=code",
|
"https://sectl.top/oauth/authorize?client_id={}&redirect_uri={}&response_type=code&state={}",
|
||||||
platform_id,
|
platform_id,
|
||||||
urlencoding::encode(&callback_url)
|
urlencoding::encode(&callback_url),
|
||||||
|
urlencoding::encode(&state)
|
||||||
);
|
);
|
||||||
Ok(IpcResponse::success(url))
|
|
||||||
|
Ok(IpcResponse::success(OAuthAuthorizationUrlResponse { url, state }))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -316,15 +355,92 @@ pub async fn oauth_exchange_code(
|
|||||||
platform_secret: String,
|
platform_secret: String,
|
||||||
callback_url: String,
|
callback_url: String,
|
||||||
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
|
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
|
||||||
|
println!("[OAuth] 换取令牌 - code: {}, platform_id: {}, callback_url: {}", code, platform_id, callback_url);
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"client_id": platform_id,
|
||||||
|
"client_secret": platform_secret,
|
||||||
|
"redirect_uri": callback_url
|
||||||
|
});
|
||||||
|
|
||||||
|
println!("[OAuth] 请求体:{:?}", payload);
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.post("https://sectl.top/api/oauth/token")
|
.post("https://sectl.top/api/oauth/token")
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
println!("[OAuth] 响应状态:{}", response.status());
|
||||||
|
|
||||||
|
let response_text = response.text().await.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||||
|
println!("[OAuth] 响应内容:{}", response_text);
|
||||||
|
|
||||||
|
let status_success = response_text.is_empty() || !response_text.contains("error");
|
||||||
|
|
||||||
|
if !status_success {
|
||||||
|
return Ok(IpcResponse::error(&response_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_response: OAuthTokenResponse = serde_json::from_str(&response_text)
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(IpcResponse::success(token_response))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn oauth_revoke_token(
|
||||||
|
token: String,
|
||||||
|
token_type_hint: Option<String>,
|
||||||
|
platform_id: String,
|
||||||
|
platform_secret: String,
|
||||||
|
) -> Result<IpcResponse<()>, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mut payload = serde_json::json!({
|
||||||
|
"token": token,
|
||||||
|
"client_id": platform_id,
|
||||||
|
"client_secret": platform_secret
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(hint) = token_type_hint {
|
||||||
|
payload["token_type_hint"] = serde_json::json!(hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post("https://sectl.top/api/oauth/revoke")
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let error_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
return Ok(IpcResponse::error(&error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(IpcResponse::success(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn oauth_introspect_token(
|
||||||
|
token: String,
|
||||||
|
platform_id: String,
|
||||||
|
platform_secret: String,
|
||||||
|
) -> Result<IpcResponse<OAuthIntrospectResponse>, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post("https://sectl.top/api/oauth/introspect")
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
"grant_type": "authorization_code",
|
"token": token,
|
||||||
"code": code,
|
|
||||||
"client_id": platform_id,
|
"client_id": platform_id,
|
||||||
"client_secret": platform_secret,
|
"client_secret": platform_secret
|
||||||
"redirect_uri": callback_url
|
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -338,12 +454,12 @@ pub async fn oauth_exchange_code(
|
|||||||
return Ok(IpcResponse::error(&error_text));
|
return Ok(IpcResponse::error(&error_text));
|
||||||
}
|
}
|
||||||
|
|
||||||
let token_response: OAuthTokenResponse = response
|
let introspect_response: OAuthIntrospectResponse = response
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
Ok(IpcResponse::success(token_response))
|
Ok(IpcResponse::success(introspect_response))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod filesystem;
|
|||||||
pub mod http_server;
|
pub mod http_server;
|
||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
pub mod oauth_server;
|
||||||
pub mod reason;
|
pub mod reason;
|
||||||
pub mod response;
|
pub mod response;
|
||||||
pub mod reward;
|
pub mod reward;
|
||||||
@@ -30,6 +31,7 @@ pub use filesystem::*;
|
|||||||
pub use http_server::*;
|
pub use http_server::*;
|
||||||
pub use log::*;
|
pub use log::*;
|
||||||
pub use mcp::*;
|
pub use mcp::*;
|
||||||
|
pub use oauth_server::*;
|
||||||
pub use reason::*;
|
pub use reason::*;
|
||||||
pub use response::*;
|
pub use response::*;
|
||||||
pub use reward::*;
|
pub use reward::*;
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
use parking_lot::RwLock;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::Emitter;
|
||||||
|
use tauri::State;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::response::IpcResponse;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OAuthServerStartResult {
|
||||||
|
pub url: String,
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OAuthCallbackResult {
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub state: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub error_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy<Arc<Mutex<Option<oneshot::Sender<()>>>>> =
|
||||||
|
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None)));
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn oauth_start_callback_server(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<OAuthServerStartResult>, String> {
|
||||||
|
let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await;
|
||||||
|
|
||||||
|
// 如果服务器已经在运行,直接返回 URL
|
||||||
|
if shutdown_tx.is_some() {
|
||||||
|
let port = 16888u16;
|
||||||
|
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
||||||
|
return Ok(IpcResponse::success(OAuthServerStartResult { url, port }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let port = 16888u16;
|
||||||
|
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
||||||
|
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
||||||
|
let url_for_spawn = url.clone();
|
||||||
|
|
||||||
|
let (tx, rx) = oneshot::channel::<()>();
|
||||||
|
*shutdown_tx = Some(tx);
|
||||||
|
drop(shutdown_tx);
|
||||||
|
|
||||||
|
let app_handle_clone = app_handle.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let app = axum::Router::new()
|
||||||
|
.route("/oauth/callback", axum::routing::get(handle_oauth_callback))
|
||||||
|
.layer(axum::extract::Extension(app_handle_clone));
|
||||||
|
|
||||||
|
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to bind OAuth callback server: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("OAuth callback server started at {}", url_for_spawn);
|
||||||
|
|
||||||
|
let server = axum::serve(listener, app);
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = server => {},
|
||||||
|
_ = rx => {
|
||||||
|
println!("OAuth callback server shutting down");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(IpcResponse::success(OAuthServerStartResult { url, port }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn oauth_stop_callback_server(
|
||||||
|
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
) -> Result<IpcResponse<()>, String> {
|
||||||
|
let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await;
|
||||||
|
|
||||||
|
if let Some(tx) = shutdown_tx.take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(IpcResponse::success(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_oauth_callback(
|
||||||
|
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||||
|
axum::extract::Extension(app_handle): axum::extract::Extension<tauri::AppHandle>,
|
||||||
|
) -> impl axum::response::IntoResponse {
|
||||||
|
let code = params.get("code").cloned();
|
||||||
|
let state = params.get("state").cloned();
|
||||||
|
let error = params.get("error").cloned();
|
||||||
|
let error_description = params.get("error_description").cloned();
|
||||||
|
|
||||||
|
println!("[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}", code, state, error);
|
||||||
|
|
||||||
|
let result = OAuthCallbackResult {
|
||||||
|
code: code.clone(),
|
||||||
|
state: state.clone(),
|
||||||
|
error: error.clone(),
|
||||||
|
error_description: error_description.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match app_handle.emit("oauth-callback", result) {
|
||||||
|
Ok(_) => println!("[OAuth Callback] Event 发送成功"),
|
||||||
|
Err(e) => println!("[OAuth Callback] Event 发送失败:{:?}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = r#"
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>OAuth 授权完成</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.success { color: #4ade80; }
|
||||||
|
.error { color: #f87171; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="success">✓ 授权成功</h1>
|
||||||
|
<p>请返回 SecScore 应用查看登录结果</p>
|
||||||
|
<p style="margin-top: 16px; font-size: 12px; opacity: 0.7;">此窗口可以关闭</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
setTimeout(() => {
|
||||||
|
window.close();
|
||||||
|
}, 3000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let error_html = r#"
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>OAuth 授权失败</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.success { color: #4ade80; }
|
||||||
|
.error { color: #f87171; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="error">✗ 授权失败</h1>
|
||||||
|
<p>请返回 SecScore 应用查看错误信息</p>
|
||||||
|
<p style="margin-top: 16px; font-size: 12px; opacity: 0.7;">此窗口可以关闭</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let response_html = if error.is_some() { error_html } else { html };
|
||||||
|
|
||||||
|
(
|
||||||
|
axum::http::StatusCode::OK,
|
||||||
|
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||||
|
response_html,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -80,6 +80,10 @@ pub fn run() {
|
|||||||
oauth_exchange_code,
|
oauth_exchange_code,
|
||||||
oauth_get_user_info,
|
oauth_get_user_info,
|
||||||
oauth_refresh_token,
|
oauth_refresh_token,
|
||||||
|
oauth_revoke_token,
|
||||||
|
oauth_introspect_token,
|
||||||
|
oauth_start_callback_server,
|
||||||
|
oauth_stop_callback_server,
|
||||||
theme_list,
|
theme_list,
|
||||||
theme_current,
|
theme_current,
|
||||||
theme_set,
|
theme_set,
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
|||||||
allowClear
|
allowClear
|
||||||
style={{ minWidth: 260 }}
|
style={{ minWidth: 260 }}
|
||||||
placeholder={t("autoScore.tagNamePlaceholder")}
|
placeholder={t("autoScore.tagNamePlaceholder")}
|
||||||
value={Array.isArray(action.value) ? action.value : action.value ? [action.value] : []}
|
value={
|
||||||
|
Array.isArray(action.value) ? action.value : action.value ? [action.value] : []
|
||||||
|
}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
options={mergedTagOptions}
|
options={mergedTagOptions}
|
||||||
onChange={(nextValue) => updateAction(action.id, { value: nextValue })}
|
onChange={(nextValue) => updateAction(action.id, { value: nextValue })}
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import {
|
|||||||
type JsonRule,
|
type JsonRule,
|
||||||
} from "@react-awesome-query-builder/antd"
|
} from "@react-awesome-query-builder/antd"
|
||||||
import type { TFunction } from "i18next"
|
import type { TFunction } from "i18next"
|
||||||
import { IntervalValueWidget, parseIntervalTriggerValue, stringifyIntervalTriggerValue } from "./IntervalValueWidget"
|
import {
|
||||||
|
IntervalValueWidget,
|
||||||
|
parseIntervalTriggerValue,
|
||||||
|
stringifyIntervalTriggerValue,
|
||||||
|
} from "./IntervalValueWidget"
|
||||||
|
|
||||||
export interface AutoScoreTrigger {
|
export interface AutoScoreTrigger {
|
||||||
event: string
|
event: string
|
||||||
@@ -76,9 +80,7 @@ const toStringValue = (value: unknown): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizeTagValues = (values: unknown[]): string[] => {
|
const normalizeTagValues = (values: unknown[]): string[] => {
|
||||||
const normalized = values
|
const normalized = values.map((value) => toStringValue(value).trim()).filter(Boolean)
|
||||||
.map((value) => toStringValue(value).trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
return Array.from(new Set(normalized))
|
return Array.from(new Set(normalized))
|
||||||
}
|
}
|
||||||
@@ -265,7 +267,10 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
|
|||||||
export const createEmptyTriggerTree = (config: Config): ImmutableTree =>
|
export const createEmptyTriggerTree = (config: Config): ImmutableTree =>
|
||||||
QbUtils.checkTree(QbUtils.loadTree(buildEmptyGroup()), config)
|
QbUtils.checkTree(QbUtils.loadTree(buildEmptyGroup()), config)
|
||||||
|
|
||||||
export const triggersToQueryTree = (config: Config, triggers: AutoScoreTrigger[]): ImmutableTree => {
|
export const triggersToQueryTree = (
|
||||||
|
config: Config,
|
||||||
|
triggers: AutoScoreTrigger[]
|
||||||
|
): ImmutableTree => {
|
||||||
const children = triggers.map(ruleFromTrigger).filter((item): item is JsonRule => Boolean(item))
|
const children = triggers.map(ruleFromTrigger).filter((item): item is JsonRule => Boolean(item))
|
||||||
const group: JsonGroup = {
|
const group: JsonGroup = {
|
||||||
...buildEmptyGroup(),
|
...buildEmptyGroup(),
|
||||||
@@ -282,7 +287,8 @@ export const queryTreeToTriggers = (tree: ImmutableTree, config: Config): AutoSc
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasUnsupportedLogicInGroup = (group: JsonGroup): boolean => {
|
const hasUnsupportedLogicInGroup = (group: JsonGroup): boolean => {
|
||||||
const conjunction = typeof group.properties?.conjunction === "string" ? group.properties.conjunction : "AND"
|
const conjunction =
|
||||||
|
typeof group.properties?.conjunction === "string" ? group.properties.conjunction : "AND"
|
||||||
const not = Boolean(group.properties?.not)
|
const not = Boolean(group.properties?.not)
|
||||||
|
|
||||||
if (conjunction !== "AND" || not) {
|
if (conjunction !== "AND" || not) {
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ export const TriggerRuleBuilder: React.FC<TriggerRuleBuilderProps> = ({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="query-builder-container"
|
className="query-builder-container"
|
||||||
style={{ overflowX: "auto", pointerEvents: canEdit ? "auto" : "none", opacity: canEdit ? 1 : 0.7 }}
|
style={{
|
||||||
|
overflowX: "auto",
|
||||||
|
pointerEvents: canEdit ? "auto" : "none",
|
||||||
|
opacity: canEdit ? 1 : 0.7,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Query
|
<Query
|
||||||
{...queryConfig}
|
{...queryConfig}
|
||||||
|
|||||||
@@ -436,7 +436,12 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ marginBottom: "24px", display: "flex", gap: "12px" }}>
|
<div style={{ marginBottom: "24px", display: "flex", gap: "12px" }}>
|
||||||
<Button type="primary" loading={saving} disabled={!canEdit} onClick={() => handleSubmit().catch(() => void 0)}>
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={saving}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onClick={() => handleSubmit().catch(() => void 0)}
|
||||||
|
>
|
||||||
{editingRuleId === null ? t("autoScore.addAutomation") : t("autoScore.updateAutomation")}
|
{editingRuleId === null ? t("autoScore.addAutomation") : t("autoScore.updateAutomation")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!canEdit || saving} onClick={resetEditor}>
|
<Button disabled={!canEdit || saving} onClick={resetEditor}>
|
||||||
|
|||||||
@@ -19,13 +19,20 @@ interface OAuthLoginProps {
|
|||||||
interface OAuthConfig {
|
interface OAuthConfig {
|
||||||
platform_id: string
|
platform_id: string
|
||||||
platform_secret: string
|
platform_secret: string
|
||||||
callback_url: string
|
}
|
||||||
|
|
||||||
|
interface OAuthCallbackResult {
|
||||||
|
code?: string
|
||||||
|
state?: string
|
||||||
|
error?: string
|
||||||
|
error_description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const deepLinkUnlistenRef = useRef<UnlistenFn | null>(null)
|
const callbackUnlistenRef = useRef<UnlistenFn | null>(null)
|
||||||
|
const expectedStateRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const getOAuthConfig = (): OAuthConfig | null => {
|
const getOAuthConfig = (): OAuthConfig | null => {
|
||||||
const api = (window as any).api
|
const api = (window as any).api
|
||||||
@@ -33,7 +40,6 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
|
|
||||||
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
|
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
|
||||||
const platformSecret = import.meta.env.VITE_OAUTH_PLATFORM_SECRET
|
const platformSecret = import.meta.env.VITE_OAUTH_PLATFORM_SECRET
|
||||||
const callbackUrl = import.meta.env.VITE_OAUTH_CALLBACK_URL || "secscore://oauth/callback"
|
|
||||||
|
|
||||||
if (!platformId || !platformSecret) {
|
if (!platformId || !platformSecret) {
|
||||||
return null
|
return null
|
||||||
@@ -42,32 +48,44 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
return {
|
return {
|
||||||
platform_id: platformId,
|
platform_id: platformId,
|
||||||
platform_secret: platformSecret,
|
platform_secret: platformSecret,
|
||||||
callback_url: callbackUrl,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeepLink = async (url: string) => {
|
const handleOAuthCallback = async (result: OAuthCallbackResult) => {
|
||||||
|
console.log("[OAuth] 收到回调:", result)
|
||||||
const config = getOAuthConfig()
|
const config = getOAuthConfig()
|
||||||
if (!config) return
|
if (!config) {
|
||||||
|
console.error("[OAuth] 配置不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url)
|
if (result.error) {
|
||||||
const code = urlObj.searchParams.get("code")
|
console.error("[OAuth] 错误:", result.error, result.error_description)
|
||||||
const error = urlObj.searchParams.get("error")
|
message.error(result.error_description || result.error || "授权失败")
|
||||||
|
|
||||||
if (error) {
|
|
||||||
message.error(decodeURIComponent(error))
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code) {
|
if (result.code) {
|
||||||
|
console.log("[OAuth] 授权码:", result.code)
|
||||||
|
console.log("[OAuth] State:", result.state, "期望:", expectedStateRef.current)
|
||||||
|
|
||||||
|
// 验证 state 防止 CSRF
|
||||||
|
if (expectedStateRef.current && result.state !== expectedStateRef.current) {
|
||||||
|
message.error("安全验证失败:state 不匹配")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const api = (window as any).api
|
const api = (window as any).api
|
||||||
|
const callbackUrl = "http://127.0.0.1:16888/oauth/callback"
|
||||||
|
|
||||||
const tokenRes = await api.oauthExchangeCode(
|
const tokenRes = await api.oauthExchangeCode(
|
||||||
code,
|
result.code,
|
||||||
config.platform_id,
|
config.platform_id,
|
||||||
config.platform_secret,
|
config.platform_secret,
|
||||||
config.callback_url
|
callbackUrl
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!tokenRes.success) {
|
if (!tokenRes.success) {
|
||||||
@@ -84,6 +102,8 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await api.oauthStopCallbackServer()
|
||||||
|
expectedStateRef.current = null
|
||||||
onSuccess(userRes.data)
|
onSuccess(userRes.data)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -95,28 +115,29 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const setupDeepLink = async () => {
|
const setupListener = async () => {
|
||||||
try {
|
try {
|
||||||
const unlisten = await listen<string>("deep-link://new-url", (event) => {
|
const unlisten = await listen<OAuthCallbackResult>("oauth-callback", async (event) => {
|
||||||
|
console.log("[OAuth] Event listener 收到 payload:", event.payload)
|
||||||
if (event.payload) {
|
if (event.payload) {
|
||||||
handleDeepLink(event.payload)
|
await handleOAuthCallback(event.payload)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
deepLinkUnlistenRef.current = unlisten
|
callbackUnlistenRef.current = unlisten
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to setup deep link listener:", error)
|
console.error("Failed to setup OAuth callback listener:", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupDeepLink()
|
setupListener()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (deepLinkUnlistenRef.current) {
|
if (callbackUnlistenRef.current) {
|
||||||
deepLinkUnlistenRef.current()
|
callbackUnlistenRef.current()
|
||||||
deepLinkUnlistenRef.current = null
|
callbackUnlistenRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, []) // handleOAuthCallback 在依赖数组外,使用 ref 存储
|
||||||
|
|
||||||
const handleOAuthLogin = async () => {
|
const handleOAuthLogin = async () => {
|
||||||
const config = getOAuthConfig()
|
const config = getOAuthConfig()
|
||||||
@@ -129,7 +150,23 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const api = (window as any).api
|
const api = (window as any).api
|
||||||
const urlRes = await api.oauthGetAuthorizationUrl(config.platform_id, config.callback_url)
|
|
||||||
|
const serverRes = await api.oauthStartCallbackServer()
|
||||||
|
if (!serverRes.success) {
|
||||||
|
message.error(serverRes.message || "启动回调服务器失败")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackUrl = serverRes.data.url
|
||||||
|
|
||||||
|
// 生成随机 state 防止 CSRF
|
||||||
|
const state = generateRandomState()
|
||||||
|
console.log("[OAuth] 生成 state:", state)
|
||||||
|
expectedStateRef.current = state
|
||||||
|
console.log("[OAuth] state 已设置:", expectedStateRef.current)
|
||||||
|
|
||||||
|
const urlRes = await api.oauthGetAuthorizationUrl(config.platform_id, callbackUrl, state)
|
||||||
|
|
||||||
if (!urlRes.success) {
|
if (!urlRes.success) {
|
||||||
message.error(urlRes.message || "获取授权链接失败")
|
message.error(urlRes.message || "获取授权链接失败")
|
||||||
@@ -137,13 +174,23 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await open(urlRes.data)
|
await open(urlRes.data.url)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.message || "登录失败")
|
message.error(error.message || "登录失败")
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成随机 state 字符串
|
||||||
|
const generateRandomState = (): string => {
|
||||||
|
const array = new Uint8Array(32)
|
||||||
|
window.crypto.getRandomValues(array)
|
||||||
|
return btoa(String.fromCharCode(...array))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={t("auth.oauthLogin", "SECTL Auth 登录")}
|
title={t("auth.oauthLogin", "SECTL Auth 登录")}
|
||||||
@@ -159,13 +206,13 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
<div>{t("auth.oauthLoggingIn", "正在登录...")}</div>
|
<div>{t("auth.oauthLoggingIn", "正在登录...")}</div>
|
||||||
<div style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
<div style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
||||||
请在浏览器中完成授权
|
请在浏览器中完成授权,授权后会自动返回
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||||
<div style={{ color: "var(--ss-text-secondary)" }}>
|
<div style={{ color: "var(--ss-text-secondary)" }}>
|
||||||
{t("auth.oauthHint", "使用 SECTL Auth 账号登录,享受统一认证和远程退登功能")}
|
{t("auth.oauthHint", "使用 SECTL Auth 账号登录,享受统一认证和远程退登功能")}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ const mergeFontOptions = (options: FontOption[]): FontOption[] => {
|
|||||||
|
|
||||||
const findFontOption = (options: FontOption[], value?: string): FontOption | undefined => {
|
const findFontOption = (options: FontOption[], value?: string): FontOption | undefined => {
|
||||||
if (!value) return options.find((item) => item.value === "system") || options[0]
|
if (!value) return options.find((item) => item.value === "system") || options[0]
|
||||||
return options.find((item) => item.value === value) || options.find((item) => item.value === "system")
|
return (
|
||||||
|
options.find((item) => item.value === value) || options.find((item) => item.value === "system")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyFontFamily = (fontFamily: string) => {
|
const applyFontFamily = (fontFamily: string) => {
|
||||||
|
|||||||
+68
-5
@@ -286,7 +286,9 @@ const api = {
|
|||||||
actions: autoScoreAction[]
|
actions: autoScoreAction[]
|
||||||
}): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
}): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||||
invoke("auto_score_update_rule", { rule }),
|
invoke("auto_score_update_rule", { rule }),
|
||||||
autoScoreDeleteRule: (ruleId: number): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
autoScoreDeleteRule: (
|
||||||
|
ruleId: number
|
||||||
|
): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||||
invoke("auto_score_delete_rule", { ruleId }),
|
invoke("auto_score_delete_rule", { ruleId }),
|
||||||
autoScoreToggleRule: (params: {
|
autoScoreToggleRule: (params: {
|
||||||
ruleId: number
|
ruleId: number
|
||||||
@@ -300,7 +302,9 @@ const api = {
|
|||||||
}
|
}
|
||||||
message?: string
|
message?: string
|
||||||
}> => invoke("auto_score_get_status"),
|
}> => invoke("auto_score_get_status"),
|
||||||
autoScoreSortRules: (ruleIds: number[]): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
autoScoreSortRules: (
|
||||||
|
ruleIds: number[]
|
||||||
|
): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||||
invoke("auto_score_sort_rules", { ruleIds }),
|
invoke("auto_score_sort_rules", { ruleIds }),
|
||||||
|
|
||||||
// Settings & Sync
|
// Settings & Sync
|
||||||
@@ -353,9 +357,16 @@ const api = {
|
|||||||
// OAuth
|
// OAuth
|
||||||
oauthGetAuthorizationUrl: (
|
oauthGetAuthorizationUrl: (
|
||||||
platformId: string,
|
platformId: string,
|
||||||
callbackUrl: string
|
callbackUrl: string,
|
||||||
): Promise<{ success: boolean; data: string; message?: string }> =>
|
state?: string
|
||||||
invoke("oauth_get_authorization_url", { platformId, callbackUrl }),
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data: {
|
||||||
|
url: string
|
||||||
|
state: string
|
||||||
|
}
|
||||||
|
message?: string
|
||||||
|
}> => invoke("oauth_get_authorization_url", { platformId, callbackUrl, state }),
|
||||||
oauthExchangeCode: (
|
oauthExchangeCode: (
|
||||||
code: string,
|
code: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
@@ -398,6 +409,58 @@ const api = {
|
|||||||
}
|
}
|
||||||
message?: string
|
message?: string
|
||||||
}> => invoke("oauth_refresh_token", { refreshToken, platformId, platformSecret }),
|
}> => invoke("oauth_refresh_token", { refreshToken, platformId, platformSecret }),
|
||||||
|
oauthRevokeToken: (
|
||||||
|
token: string,
|
||||||
|
tokenTypeHint: string | null,
|
||||||
|
platformId: string,
|
||||||
|
platformSecret: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
}> =>
|
||||||
|
invoke("oauth_revoke_token", {
|
||||||
|
token,
|
||||||
|
tokenTypeHint,
|
||||||
|
platformId,
|
||||||
|
platformSecret,
|
||||||
|
}),
|
||||||
|
oauthIntrospectToken: (
|
||||||
|
token: string,
|
||||||
|
platformId: string,
|
||||||
|
platformSecret: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data: {
|
||||||
|
active: boolean
|
||||||
|
scope?: string
|
||||||
|
client_id?: string
|
||||||
|
username?: string
|
||||||
|
token_type?: string
|
||||||
|
exp?: number
|
||||||
|
iat?: number
|
||||||
|
sub?: string
|
||||||
|
aud?: string
|
||||||
|
iss?: string
|
||||||
|
}
|
||||||
|
message?: string
|
||||||
|
}> =>
|
||||||
|
invoke("oauth_introspect_token", {
|
||||||
|
token,
|
||||||
|
platformId,
|
||||||
|
platformSecret,
|
||||||
|
}),
|
||||||
|
oauthStartCallbackServer: (): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data: {
|
||||||
|
url: string
|
||||||
|
port: number
|
||||||
|
}
|
||||||
|
message?: string
|
||||||
|
}> => invoke("oauth_start_callback_server"),
|
||||||
|
oauthStopCallbackServer: (): Promise<{
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
}> => invoke("oauth_stop_callback_server"),
|
||||||
|
|
||||||
// Data import/export
|
// Data import/export
|
||||||
exportDataJson: (): Promise<{ success: boolean; data: string }> => invoke("data_export_json"),
|
exportDataJson: (): Promise<{ success: boolean; data: string }> => invoke("data_export_json"),
|
||||||
|
|||||||
Reference in New Issue
Block a user