chore: 完成项目多维度优化与功能迭代

本次提交包含多项改进:
1. 新增Vitest测试配置与相关测试用例
2. 完善Deep Link监听与OAuth回调支持
3. 修复并优化多个React组件的依赖与性能
4. 更新后端API域名与端点地址
5. 优化KV存储服务实现与类型定义
6. 调整用户信息展示与权限相关逻辑
7. 重构部分业务逻辑,使用useCallback优化性能
8. 更新依赖包与Cargo.lock依赖版本
This commit is contained in:
Yukino_fox
2026-07-05 10:28:36 +08:00
parent 6f197babf6
commit 21c1183b1d
32 changed files with 1889 additions and 1280 deletions
+303 -192
View File
@@ -1,181 +1,203 @@
/**
* SECTL OAuth 2.0 认证服务
* 基于 SECTL-One-Stop 项目的 OAuth API 实现
* 基于 SECTL-One-Stop SDK 的 OAuth API 实现
* 支持 PKCE (Proof Key for Code Exchange) 流程
*/
import { Client, Account } from "appwrite"
import CryptoJS from "crypto-js"
// SECTL API 配置
export const SECTL_CONFIG = {
baseUrl: "https://appwrite.sectl.top",
authUrl: "https://sectl.top",
platformId: "", // 需要在设置中配置
callbackUrl: "http://localhost:5173/auth/callback",
baseUrl: "https://appwrite.sectl.cn",
authUrl: "https://sectl.cn",
platformId: "",
callbackUrl: "secscore://oauth",
callbackPort: 5173,
}
// Token 数据类型
// Token 数据类型 (与 SDK TokenData 对齐)
export interface TokenData {
access_token: string
refresh_token: string
token_type: string
expires_in: number
user_id: string
refresh_token?: string
token_type?: string
expires_in?: number
scope?: string
user_id?: string
}
// 用户信息类型
// Token 验证结果 (与 SDK TokenIntrospection 对齐)
export interface TokenIntrospection {
active: boolean
scope?: string
user_id?: string
client_id?: string
exp?: number
iat?: number
}
// 用户信息类型 (与 SDK UserInfoData 对齐)
export interface UserInfo {
user_id: string
email: string
name: string
github_username?: string
permission: string
role: string
user_id?: string
id?: string
email?: string
name?: string
avatar?: string
avatar_url?: string
background_url?: string
bio: string
tags: string[]
platform_id?: string
login_time?: string
created_at?: string
updated_at?: string
email_verified?: boolean
status?: string
metadata?: Record<string, unknown>
}
// PKCE 相关工具函数
function generateCodeVerifier(): string {
async function generateCodeVerifier(): Promise<string> {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return base64URLEncode(array)
}
function generateCodeChallenge(verifier: string): string {
const sha256 = CryptoJS.SHA256(verifier)
return base64URLEncode(CryptoJS.enc.Base64.parse(sha256.toString(CryptoJS.enc.Base64)))
async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest("SHA-256", data)
return base64URLEncode(new Uint8Array(digest))
}
function base64URLEncode(buffer: Uint8Array | CryptoJS.lib.WordArray): string {
let base64: string
if (buffer instanceof Uint8Array) {
base64 = btoa(String.fromCharCode(...buffer))
} else {
base64 = buffer.toString(CryptoJS.enc.Base64)
function base64URLEncode(buffer: Uint8Array): string {
let binary = ""
for (let i = 0; i < buffer.length; i++) {
binary += String.fromCharCode(buffer[i])
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function extractUserIdFromJwt(accessToken: string): string | null {
try {
const parts = accessToken.split(".")
if (parts.length !== 3) return null
let payload = parts[1]
const padding = 4 - (payload.length % 4)
if (padding !== 4) payload += "=".repeat(padding)
const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/"))
const claims = JSON.parse(decoded)
return claims.user_id || null
} catch {
return null
}
}
function extractPlatformIdFromJwt(accessToken: string): string | null {
try {
const parts = accessToken.split(".")
if (parts.length !== 3) return null
let payload = parts[1]
const padding = 4 - (payload.length % 4)
if (padding !== 4) payload += "=".repeat(padding)
const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/"))
const claims = JSON.parse(decoded)
return claims.platform_id || null
} catch {
return null
}
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
class SectlAuthService {
private client: Client
private tokenData: TokenData | null = null
private codeVerifier: string = ""
private accessToken: string | null = null
private refreshToken: string | null = null
private tokenExpiresAt: number | null = null
private userId: string | null = null
private codeVerifier: string | null = null
constructor() {
this.client = new Client()
new Account(this.client)
this.loadToken()
}
/**
* 初始化客户端配置
*/
initialize(platformId: string, callbackUrl?: string) {
SECTL_CONFIG.platformId = platformId
if (callbackUrl) {
SECTL_CONFIG.callbackUrl = callbackUrl
}
this.client.setEndpoint(SECTL_CONFIG.baseUrl).setProject("sectl-cloud") // 项目 ID
return this
}
/**
* 生成授权 URL
*/
getAuthorizationUrl(scope: string[] = ["user:read"]): string {
this.codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(this.codeVerifier)
async getAuthorizationUrl(scope?: string[]): Promise<string> {
const state = this.generateRandomState()
this.codeVerifier = await generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(this.codeVerifier)
// 保存 code_verifier 到 localStorage,以便 deep link 回调时使用
localStorage.setItem("sectl_code_verifier", this.codeVerifier)
const params = new URLSearchParams({
client_id: SECTL_CONFIG.platformId,
redirect_uri: SECTL_CONFIG.callbackUrl,
response_type: "code",
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
scope: scope.join(" "),
})
if (scope && scope.length > 0) {
params.set("scope", scope.join(" "))
}
return `${SECTL_CONFIG.authUrl}/oauth/authorize?${params.toString()}`
}
/**
* 执行完整的 OAuth 授权流程
*/
async authorize(scope: string[] = ["user:read"]): Promise<TokenData> {
// 生成 PKCE 参数
this.codeVerifier = generateCodeVerifier()
// codeChallenge 在 getAuthorizationUrl 中生成
async authorize(scope?: string[]): Promise<TokenData> {
this.codeVerifier = await generateCodeVerifier()
// 构建授权 URL
const authUrl = this.getAuthorizationUrl(scope)
// 在新窗口中打开授权页面
const authUrl = await this.getAuthorizationUrl(scope)
const authWindow = window.open(authUrl, "_blank", "width=600,height=700")
// 等待回调
return new Promise((resolve, reject) => {
// 监听授权回调
const checkCallback = async () => {
try {
// 检查是否有授权码(通过 postMessage 或 URL 参数)
window.addEventListener("message", async (event) => {
if (event.data.type === "oauth-callback" && event.data.code) {
try {
const token = await this.exchangeCodeForToken(event.data.code, scope)
authWindow?.close()
resolve(token)
} catch (error) {
reject(error)
}
}
})
// 轮询检查窗口关闭
const checkInterval = setInterval(() => {
if (authWindow?.closed) {
clearInterval(checkInterval)
reject(new Error("授权窗口已关闭"))
}
}, 1000)
} catch (error) {
reject(error)
window.addEventListener("message", async (event) => {
if (event.data.type === "oauth-callback" && event.data.code) {
try {
const token = await this.exchangeCodeForToken(event.data.code, scope)
authWindow?.close()
resolve(token)
} catch (error) {
reject(error)
}
}
}
})
checkCallback()
const checkInterval = setInterval(() => {
if (authWindow?.closed) {
clearInterval(checkInterval)
reject(new Error("授权窗口已关闭"))
}
}, 1000)
})
}
/**
* 使用授权码换取 Token
*/
async exchangeCodeForToken(code: string, scope: string[] = ["user:read"]): Promise<TokenData> {
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token`
async exchangeCodeForToken(code: string, scope?: string[]): Promise<TokenData> {
if (!this.codeVerifier) {
throw new Error("缺少 code_verifier,请先调用 getAuthorizationUrl()")
}
const payload = {
const deviceUuid = this.generateDeviceUuid()
const payload: Record<string, unknown> = {
grant_type: "authorization_code",
code,
client_id: SECTL_CONFIG.platformId,
redirect_uri: SECTL_CONFIG.callbackUrl,
code_verifier: this.codeVerifier,
scope: scope.join(" "),
device_uuid: deviceUuid,
ip_address: "127.0.0.1",
}
if (scope && scope.length > 0) {
payload.scope = scope.join(" ")
}
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token`
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
@@ -185,9 +207,55 @@ class SectlAuthService {
}
const data: TokenData = await response.json()
this.tokenData = data
this.saveToken()
this.saveToken(data)
return data
} catch (error) {
console.error("Token 交换失败:", error)
throw error
}
}
// 用于 Deep Link 回调的 code 交换
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async exchangeCode(code: string, _state: string): Promise<TokenData> {
// 从 localStorage 恢复 code_verifier
const savedVerifier = localStorage.getItem("sectl_code_verifier")
if (savedVerifier) {
this.codeVerifier = savedVerifier
}
if (!this.codeVerifier) {
throw new Error("缺少 code_verifier,请重新发起登录")
}
const deviceUuid = this.generateDeviceUuid()
const payload = {
grant_type: "authorization_code",
code,
client_id: SECTL_CONFIG.platformId,
redirect_uri: SECTL_CONFIG.callbackUrl,
code_verifier: this.codeVerifier,
device_uuid: deviceUuid,
ip_address: "127.0.0.1",
}
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token`
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error_description || "Token 交换失败")
}
const data: TokenData = await response.json()
this.saveToken(data)
return data
} catch (error) {
console.error("Token 交换失败:", error)
@@ -195,11 +263,8 @@ class SectlAuthService {
}
}
/**
* 获取用户信息
*/
async getUserInfo(): Promise<UserInfo> {
if (!this.tokenData?.access_token) {
if (!this.accessToken) {
throw new Error("未授权,请先登录")
}
@@ -207,9 +272,7 @@ class SectlAuthService {
try {
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${this.tokenData.access_token}`,
},
headers: { Authorization: `Bearer ${this.accessToken}` },
})
if (!response.ok) {
@@ -224,12 +287,8 @@ class SectlAuthService {
}
}
/**
* 验证 Token
*/
async introspectToken(token?: string): Promise<{ active: boolean; user_id?: string }> {
const tokenToCheck = token || this.tokenData?.access_token
async introspectToken(token?: string): Promise<TokenIntrospection> {
const tokenToCheck = token || this.accessToken
if (!tokenToCheck) {
return { active: false }
}
@@ -239,9 +298,7 @@ class SectlAuthService {
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: tokenToCheck,
client_id: SECTL_CONFIG.platformId,
@@ -253,34 +310,32 @@ class SectlAuthService {
}
return await response.json()
} catch (error) {
console.error("Token 验证失败:", error)
} catch {
return { active: false }
}
}
/**
* 刷新 Token
*/
async refreshAccessToken(): Promise<TokenData> {
if (!this.tokenData?.refresh_token) {
throw new Error("没有刷新令牌")
if (!this.refreshToken) {
throw new Error("没有 refresh_token,无法刷新")
}
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token`
const deviceUuid = this.generateDeviceUuid()
const payload = {
grant_type: "refresh_token",
refresh_token: this.tokenData.refresh_token,
refresh_token: this.refreshToken,
client_id: SECTL_CONFIG.platformId,
device_uuid: deviceUuid,
ip_address: "127.0.0.1",
}
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/refresh`
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
@@ -290,9 +345,7 @@ class SectlAuthService {
}
const data: TokenData = await response.json()
this.tokenData = data
this.saveToken()
this.saveToken(data)
return data
} catch (error) {
console.error("Token 刷新失败:", error)
@@ -300,89 +353,147 @@ class SectlAuthService {
}
}
/**
* 登出(撤销 Token
*/
async logout(): Promise<void> {
if (!this.tokenData?.access_token) {
return
}
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/logout`
try {
await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${this.tokenData.access_token}`,
},
})
if (this.accessToken) {
await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ client_id: SECTL_CONFIG.platformId }),
})
}
} catch (error) {
console.error("登出失败:", error)
} finally {
this.tokenData = null
this.accessToken = null
this.refreshToken = null
this.tokenExpiresAt = null
this.userId = null
this.clearToken()
}
}
/**
* 获取当前 Token
*/
getToken(): TokenData | null {
return this.tokenData
}
/**
* 获取 Access Token
*/
getAccessToken(): string | null {
return this.tokenData?.access_token || null
}
/**
* 检查是否已授权
*/
isAuthenticated(): boolean {
return !!this.tokenData?.access_token
}
/**
* 保存 Token 到本地存储
*/
private saveToken(): void {
if (this.tokenData) {
localStorage.setItem("sectl_token", JSON.stringify(this.tokenData))
localStorage.setItem("sectl_code_verifier", this.codeVerifier)
private saveToken(tokenData: TokenData): void {
const rawAccessToken = tokenData.access_token
if (rawAccessToken.includes("|")) {
const parts = rawAccessToken.split("|")
this.accessToken = parts[0]
this.refreshToken = tokenData.refresh_token || parts[1] || null
} else {
this.accessToken = rawAccessToken
this.refreshToken = tokenData.refresh_token || null
}
this.userId = tokenData.user_id || extractUserIdFromJwt(this.accessToken)
const jwtPlatformId = extractPlatformIdFromJwt(this.accessToken)
if (jwtPlatformId && !SECTL_CONFIG.platformId) {
SECTL_CONFIG.platformId = jwtPlatformId
}
if (tokenData.expires_in) {
this.tokenExpiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in
}
const storableData: TokenData = {
access_token: rawAccessToken,
refresh_token: tokenData.refresh_token,
token_type: tokenData.token_type,
expires_in: tokenData.expires_in,
scope: tokenData.scope,
user_id: this.userId || undefined,
}
localStorage.setItem("sectl_token", JSON.stringify(storableData))
// 登录成功后清除 code_verifier
localStorage.removeItem("sectl_code_verifier")
}
/**
* 从本地存储加载 Token
*/
private loadToken(): void {
try {
const tokenStr = localStorage.getItem("sectl_token")
const codeVerifier = localStorage.getItem("sectl_code_verifier")
if (tokenStr) {
this.tokenData = JSON.parse(tokenStr)
this.codeVerifier = codeVerifier || ""
const tokenData: TokenData = JSON.parse(tokenStr)
const rawAccessToken = tokenData.access_token
if (rawAccessToken.includes("|")) {
const parts = rawAccessToken.split("|")
this.accessToken = parts[0]
this.refreshToken = tokenData.refresh_token || parts[1] || null
} else {
this.accessToken = rawAccessToken
this.refreshToken = tokenData.refresh_token || null
}
this.userId = tokenData.user_id || extractUserIdFromJwt(this.accessToken)
if (tokenData.expires_in) {
this.tokenExpiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in
}
}
} catch (error) {
console.error("加载 Token 失败:", error)
}
}
/**
* 清除 Token
*/
private clearToken(): void {
localStorage.removeItem("sectl_token")
localStorage.removeItem("sectl_code_verifier")
this.tokenData = null
this.codeVerifier = ""
this.accessToken = null
this.refreshToken = null
this.tokenExpiresAt = null
this.userId = null
this.codeVerifier = null
}
getToken(): TokenData | null {
if (!this.accessToken) return null
return {
access_token: this.accessToken,
refresh_token: this.refreshToken || undefined,
user_id: this.userId || undefined,
}
}
getAccessToken(): string | null {
return this.accessToken
}
getUserId(): string | null {
return this.userId
}
isAuthenticated(): boolean {
return !!this.accessToken
}
isTokenExpired(): boolean {
if (!this.tokenExpiresAt) return false
return Date.now() / 1000 >= this.tokenExpiresAt
}
private generateRandomState(): string {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return base64URLEncode(array)
}
private generateDeviceUuid(): string {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID()
}
const array = new Uint8Array(16)
crypto.getRandomValues(array)
array[6] = (array[6] & 0x0f) | 0x40
array[8] = (array[8] & 0x3f) | 0x80
const hex = Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("")
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
}
// 导出单例
export const sectlAuth = new SectlAuthService()