From 91a0eb7031beba08a8b0bf013c81f224c021cdae Mon Sep 17 00:00:00 2001 From: Seefs Date: Mon, 8 Sep 2025 12:09:26 +0800 Subject: [PATCH] wip sso --- .env.example | 26 + controller/oauth.go | 200 ++++ controller/oauth_client.go | 374 +++++++ docs/oauth2-demo.html | 909 ++++++++++++++++++ docs/oauth2-demo.md | 870 +++++++++++++++++ docs/oauth2_setup.md | 258 +++++ examples/oauth2_test_client.go | 125 +++ go.mod | 23 +- go.sum | 94 +- main.go | 9 + middleware/oauth_jwt.go | 257 +++++ model/main.go | 1 + model/oauth_client.go | 183 ++++ router/api-router.go | 25 + setting/system_setting/oauth2.go | 70 ++ src/oauth/server.go | 80 ++ .../modals/oauth2/CreateOAuth2ClientModal.jsx | 318 ++++++ .../modals/oauth2/EditOAuth2ClientModal.jsx | 306 ++++++ web/src/components/settings/OAuth2Setting.jsx | 101 ++ .../Setting/OAuth2/OAuth2ClientSettings.jsx | 420 ++++++++ .../Setting/OAuth2/OAuth2ServerSettings.jsx | 351 +++++++ web/src/pages/Setting/index.jsx | 12 + 22 files changed, 5001 insertions(+), 11 deletions(-) create mode 100644 controller/oauth.go create mode 100644 controller/oauth_client.go create mode 100644 docs/oauth2-demo.html create mode 100644 docs/oauth2-demo.md create mode 100644 docs/oauth2_setup.md create mode 100644 examples/oauth2_test_client.go create mode 100644 middleware/oauth_jwt.go create mode 100644 model/oauth_client.go create mode 100644 setting/system_setting/oauth2.go create mode 100644 src/oauth/server.go create mode 100644 web/src/components/modals/oauth2/CreateOAuth2ClientModal.jsx create mode 100644 web/src/components/modals/oauth2/EditOAuth2ClientModal.jsx create mode 100644 web/src/components/settings/OAuth2Setting.jsx create mode 100644 web/src/pages/Setting/OAuth2/OAuth2ClientSettings.jsx create mode 100644 web/src/pages/Setting/OAuth2/OAuth2ServerSettings.jsx diff --git a/.env.example b/.env.example index c7851385b..f5fc43d6f 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,32 @@ # 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值 # STREAMING_TIMEOUT=300 +# OAuth2 服务器配置 +# 启用OAuth2服务器 +# OAUTH2_ENABLED=true +# OAuth2签发者标识 +# OAUTH2_ISSUER=https://your-domain.com +# 访问令牌有效期(分钟) +# OAUTH2_ACCESS_TOKEN_TTL=10 +# 刷新令牌有效期(分钟) +# OAUTH2_REFRESH_TOKEN_TTL=720 +# 允许的授权类型(逗号分隔) +# OAUTH2_ALLOWED_GRANT_TYPES=client_credentials,authorization_code,refresh_token +# 强制PKCE验证 +# OAUTH2_REQUIRE_PKCE=true +# JWT签名算法 +# JWT_SIGNING_ALGORITHM=RS256 +# JWT密钥ID +# JWT_KEY_ID=oauth2-key-1 +# JWT私钥文件路径 +# JWT_PRIVATE_KEY_FILE=/path/to/oauth2-private-key.pem +# 自动创建用户(首次OAuth2登录时) +# OAUTH2_AUTO_CREATE_USER=false +# 自动创建用户的默认角色 +# OAUTH2_DEFAULT_USER_ROLE=1 +# 自动创建用户的默认分组 +# OAUTH2_DEFAULT_USER_GROUP=default + # Gemini 识别图片 最大图片数量 # GEMINI_VISION_MAX_IMAGE_NUM=16 diff --git a/controller/oauth.go b/controller/oauth.go new file mode 100644 index 000000000..7d1bde7ea --- /dev/null +++ b/controller/oauth.go @@ -0,0 +1,200 @@ +package controller + +import ( + "encoding/json" + "net/http" + "one-api/setting/system_setting" + "one-api/src/oauth" + + "github.com/gin-gonic/gin" +) + +// GetJWKS 获取JWKS公钥集 +func GetJWKS(c *gin.Context) { + settings := system_setting.GetOAuth2Settings() + if !settings.Enabled { + c.JSON(http.StatusNotFound, gin.H{ + "error": "OAuth2 server is disabled", + }) + return + } + + jwks := oauth.GetJWKS() + if jwks == nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "JWKS not available", + }) + return + } + + // 设置CORS headers + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET") + c.Header("Access-Control-Allow-Headers", "Content-Type") + c.Header("Cache-Control", "public, max-age=3600") // 缓存1小时 + + // 返回JWKS + c.Header("Content-Type", "application/json") + + // 将JWKS转换为JSON字符串 + jsonData, err := json.Marshal(jwks) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to marshal JWKS", + }) + return + } + + c.String(http.StatusOK, string(jsonData)) +} + +// OAuthTokenEndpoint OAuth2 令牌端点 +func OAuthTokenEndpoint(c *gin.Context) { + settings := system_setting.GetOAuth2Settings() + if !settings.Enabled { + c.JSON(http.StatusNotFound, gin.H{ + "error": "unsupported_grant_type", + "error_description": "OAuth2 server is disabled", + }) + return + } + + // 只允许POST请求 + if c.Request.Method != "POST" { + c.JSON(http.StatusMethodNotAllowed, gin.H{ + "error": "invalid_request", + "error_description": "Only POST method is allowed", + }) + return + } + + // 只允许application/x-www-form-urlencoded内容类型 + contentType := c.GetHeader("Content-Type") + if contentType != "application/x-www-form-urlencoded" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "Content-Type must be application/x-www-form-urlencoded", + }) + return + } + + // 委托给OAuth2服务器处理 + oauth.HandleTokenRequest(c) +} + +// OAuthAuthorizeEndpoint OAuth2 授权端点 +func OAuthAuthorizeEndpoint(c *gin.Context) { + settings := system_setting.GetOAuth2Settings() + if !settings.Enabled { + c.JSON(http.StatusNotFound, gin.H{ + "error": "server_error", + "error_description": "OAuth2 server is disabled", + }) + return + } + + // 委托给OAuth2服务器处理 + oauth.HandleAuthorizeRequest(c) +} + +// OAuthServerInfo 获取OAuth2服务器信息 +func OAuthServerInfo(c *gin.Context) { + settings := system_setting.GetOAuth2Settings() + if !settings.Enabled { + c.JSON(http.StatusNotFound, gin.H{ + "error": "OAuth2 server is disabled", + }) + return + } + + // 返回OAuth2服务器的基本信息(类似OpenID Connect Discovery) + c.JSON(http.StatusOK, gin.H{ + "issuer": settings.Issuer, + "authorization_endpoint": settings.Issuer + "/oauth/authorize", + "token_endpoint": settings.Issuer + "/oauth/token", + "jwks_uri": settings.Issuer + "/.well-known/jwks.json", + "grant_types_supported": settings.AllowedGrantTypes, + "response_types_supported": []string{"code"}, + "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"}, + "code_challenge_methods_supported": []string{"S256"}, + "scopes_supported": []string{ + "api:read", + "api:write", + "admin", + }, + }) +} + +// OAuthIntrospect 令牌内省端点(RFC 7662) +func OAuthIntrospect(c *gin.Context) { + settings := system_setting.GetOAuth2Settings() + if !settings.Enabled { + c.JSON(http.StatusNotFound, gin.H{ + "error": "OAuth2 server is disabled", + }) + return + } + + // 只允许POST请求 + if c.Request.Method != "POST" { + c.JSON(http.StatusMethodNotAllowed, gin.H{ + "error": "invalid_request", + "error_description": "Only POST method is allowed", + }) + return + } + + token := c.PostForm("token") + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "active": false, + }) + return + } + + // TODO: 实现令牌内省逻辑 + // 1. 验证调用者的认证信息 + // 2. 解析和验证JWT令牌 + // 3. 返回令牌的元信息 + + c.JSON(http.StatusOK, gin.H{ + "active": false, // 临时返回,需要实现实际的内省逻辑 + }) +} + +// OAuthRevoke 令牌撤销端点(RFC 7009) +func OAuthRevoke(c *gin.Context) { + settings := system_setting.GetOAuth2Settings() + if !settings.Enabled { + c.JSON(http.StatusNotFound, gin.H{ + "error": "OAuth2 server is disabled", + }) + return + } + + // 只允许POST请求 + if c.Request.Method != "POST" { + c.JSON(http.StatusMethodNotAllowed, gin.H{ + "error": "invalid_request", + "error_description": "Only POST method is allowed", + }) + return + } + + token := c.PostForm("token") + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "Missing token parameter", + }) + return + } + + // TODO: 实现令牌撤销逻辑 + // 1. 验证调用者的认证信息 + // 2. 撤销指定的令牌(加入黑名单或从存储中删除) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + }) +} diff --git a/controller/oauth_client.go b/controller/oauth_client.go new file mode 100644 index 000000000..17efb5f42 --- /dev/null +++ b/controller/oauth_client.go @@ -0,0 +1,374 @@ +package controller + +import ( + "net/http" + "one-api/common" + "one-api/model" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/thanhpk/randstr" +) + +// CreateOAuthClientRequest 创建OAuth客户端请求 +type CreateOAuthClientRequest struct { + Name string `json:"name" binding:"required"` + ClientType string `json:"client_type" binding:"required,oneof=confidential public"` + GrantTypes []string `json:"grant_types" binding:"required"` + RedirectURIs []string `json:"redirect_uris"` + Scopes []string `json:"scopes" binding:"required"` + Description string `json:"description"` + RequirePKCE bool `json:"require_pkce"` +} + +// UpdateOAuthClientRequest 更新OAuth客户端请求 +type UpdateOAuthClientRequest struct { + ID string `json:"id" binding:"required"` + Name string `json:"name" binding:"required"` + ClientType string `json:"client_type" binding:"required,oneof=confidential public"` + GrantTypes []string `json:"grant_types" binding:"required"` + RedirectURIs []string `json:"redirect_uris"` + Scopes []string `json:"scopes" binding:"required"` + Description string `json:"description"` + RequirePKCE bool `json:"require_pkce"` + Status int `json:"status" binding:"required,oneof=1 2"` +} + +// GetAllOAuthClients 获取所有OAuth客户端 +func GetAllOAuthClients(c *gin.Context) { + page, _ := strconv.Atoi(c.Query("page")) + if page < 1 { + page = 1 + } + perPage, _ := strconv.Atoi(c.Query("per_page")) + if perPage < 1 || perPage > 100 { + perPage = 20 + } + + startIdx := (page - 1) * perPage + clients, err := model.GetAllOAuthClients(startIdx, perPage) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // 清理敏感信息 + for _, client := range clients { + client.Secret = maskSecret(client.Secret) + } + + total, _ := model.CountOAuthClients() + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": clients, + "total": total, + "page": page, + "per_page": perPage, + }) +} + +// SearchOAuthClients 搜索OAuth客户端 +func SearchOAuthClients(c *gin.Context) { + keyword := c.Query("keyword") + if keyword == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "关键词不能为空", + }) + return + } + + clients, err := model.SearchOAuthClients(keyword) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // 清理敏感信息 + for _, client := range clients { + client.Secret = maskSecret(client.Secret) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": clients, + }) +} + +// GetOAuthClient 获取单个OAuth客户端 +func GetOAuthClient(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "ID不能为空", + }) + return + } + + client, err := model.GetOAuthClientByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "客户端不存在", + }) + return + } + + // 清理敏感信息 + client.Secret = maskSecret(client.Secret) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": client, + }) +} + +// CreateOAuthClient 创建OAuth客户端 +func CreateOAuthClient(c *gin.Context) { + var req CreateOAuthClientRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "请求参数错误: " + err.Error(), + }) + return + } + + // 验证授权类型 + validGrantTypes := []string{"client_credentials", "authorization_code", "refresh_token"} + for _, grantType := range req.GrantTypes { + if !contains(validGrantTypes, grantType) { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的授权类型: " + grantType, + }) + return + } + } + + // 如果包含authorization_code,则必须提供redirect_uris + if contains(req.GrantTypes, "authorization_code") && len(req.RedirectURIs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "授权码模式需要提供重定向URI", + }) + return + } + + // 生成客户端ID和密钥 + clientID := generateClientID() + clientSecret := "" + if req.ClientType == "confidential" { + clientSecret = generateClientSecret() + } + + // 获取创建者ID + createdBy := c.GetInt("id") + + // 创建客户端 + client := &model.OAuthClient{ + ID: clientID, + Secret: clientSecret, + Name: req.Name, + ClientType: req.ClientType, + RequirePKCE: req.RequirePKCE, + Status: common.UserStatusEnabled, + CreatedBy: createdBy, + Description: req.Description, + } + + client.SetGrantTypes(req.GrantTypes) + client.SetRedirectURIs(req.RedirectURIs) + client.SetScopes(req.Scopes) + + err := model.CreateOAuthClient(client) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "创建客户端失败: " + err.Error(), + }) + return + } + + // 返回结果(包含完整的客户端密钥,仅此一次) + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "message": "客户端创建成功", + "client_id": client.ID, + "client_secret": client.Secret, // 仅在创建时返回完整密钥 + "data": client, + }) +} + +// UpdateOAuthClient 更新OAuth客户端 +func UpdateOAuthClient(c *gin.Context) { + var req UpdateOAuthClientRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "请求参数错误: " + err.Error(), + }) + return + } + + // 获取现有客户端 + client, err := model.GetOAuthClientByID(req.ID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "客户端不存在", + }) + return + } + + // 验证授权类型 + validGrantTypes := []string{"client_credentials", "authorization_code", "refresh_token"} + for _, grantType := range req.GrantTypes { + if !contains(validGrantTypes, grantType) { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的授权类型: " + grantType, + }) + return + } + } + + // 更新客户端信息 + client.Name = req.Name + client.ClientType = req.ClientType + client.RequirePKCE = req.RequirePKCE + client.Status = req.Status + client.Description = req.Description + client.SetGrantTypes(req.GrantTypes) + client.SetRedirectURIs(req.RedirectURIs) + client.SetScopes(req.Scopes) + + err = model.UpdateOAuthClient(client) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "更新客户端失败: " + err.Error(), + }) + return + } + + // 清理敏感信息 + client.Secret = maskSecret(client.Secret) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "客户端更新成功", + "data": client, + }) +} + +// DeleteOAuthClient 删除OAuth客户端 +func DeleteOAuthClient(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "ID不能为空", + }) + return + } + + err := model.DeleteOAuthClient(id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "删除客户端失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "客户端删除成功", + }) +} + +// RegenerateOAuthClientSecret 重新生成客户端密钥 +func RegenerateOAuthClientSecret(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "ID不能为空", + }) + return + } + + client, err := model.GetOAuthClientByID(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "客户端不存在", + }) + return + } + + // 只有机密客户端才能重新生成密钥 + if client.ClientType != "confidential" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "只有机密客户端才能重新生成密钥", + }) + return + } + + // 生成新密钥 + client.Secret = generateClientSecret() + + err = model.UpdateOAuthClient(client) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "重新生成密钥失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "客户端密钥重新生成成功", + "client_secret": client.Secret, // 返回新生成的密钥 + }) +} + +// generateClientID 生成客户端ID +func generateClientID() string { + return "client_" + randstr.String(16) +} + +// generateClientSecret 生成客户端密钥 +func generateClientSecret() string { + return randstr.String(32) +} + +// maskSecret 掩码密钥显示 +func maskSecret(secret string) string { + if len(secret) <= 6 { + return strings.Repeat("*", len(secret)) + } + return secret[:3] + strings.Repeat("*", len(secret)-6) + secret[len(secret)-3:] +} + +// contains 检查字符串切片是否包含指定值 +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/docs/oauth2-demo.html b/docs/oauth2-demo.html new file mode 100644 index 000000000..594c2ba15 --- /dev/null +++ b/docs/oauth2-demo.html @@ -0,0 +1,909 @@ + + + + + + OAuth2 自动登录 Demo + + + +

OAuth2 服务器自动登录 Demo

+

这个演示展示了如何使用OAuth2实现自动登录功能。

+ + +
+

配置

+
+ + + + + + + + + + + + + + +
+
+ + + +
+
+ + +
+

登录状态

+
未登录
+ + +
+

选择登录方式:

+ + + +
+ + + +
+ + +
+

令牌信息

+
+ + +
+
+
+ + +
+

操作日志

+ +
+
+ + + + \ No newline at end of file diff --git a/docs/oauth2-demo.md b/docs/oauth2-demo.md new file mode 100644 index 000000000..41751af00 --- /dev/null +++ b/docs/oauth2-demo.md @@ -0,0 +1,870 @@ +# OAuth2服务端使用Demo - 自动登录流程 + +本文档演示如何使用new-api的OAuth2服务器实现自动登录功能,包括两种授权模式的完整流程。 + +## 📋 准备工作 + +### 1. 启用OAuth2服务器 +在管理后台 -> 设置 -> OAuth2 & SSO 中: +``` +启用OAuth2服务器: 开启 +签发者标识(Issuer): https://your-domain.com +访问令牌有效期: 60分钟 +刷新令牌有效期: 24小时 +JWT签名算法: RS256 +允许的授权类型: client_credentials, authorization_code +``` + +### 2. 创建OAuth2客户端 +在OAuth2客户端管理中创建应用: +``` +客户端名称: My App +客户端类型: 机密客户端 (Confidential) +授权类型: Client Credentials, Authorization Code +权限范围: api:read, api:write +重定向URI: https://your-app.com/callback +``` + +创建成功后会获得: +- Client ID: `your_client_id` +- Client Secret: `your_client_secret` (仅显示一次) + +## 🔐 方式一:客户端凭证流程 (Client Credentials) + +适用于**服务器到服务器**的API调用,无需用户交互。 + +### 获取访问令牌 + +```bash +curl -X POST https://your-domain.com/api/oauth/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials" \ + -d "client_id=your_client_id" \ + -d "client_secret=your_client_secret" \ + -d "scope=api:read api:write" +``` + +**响应示例:** +```json +{ + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "api:read api:write" +} +``` + +### 使用访问令牌调用API + +```bash +curl -X GET https://your-domain.com/api/user/self \ + -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +## 👤 方式二:授权码流程 (Authorization Code + PKCE) + +适用于**用户登录**场景,支持自动登录功能。 + +### Step 1: 生成PKCE参数 + +```javascript +// 生成随机code_verifier +function generateCodeVerifier() { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return btoa(String.fromCharCode.apply(null, array)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +// 生成code_challenge +async function generateCodeChallenge(verifier) { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const digest = await crypto.subtle.digest('SHA-256', data); + return btoa(String.fromCharCode.apply(null, new Uint8Array(digest))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} +``` + +### Step 2: 重定向用户到授权页面 + +```javascript +const codeVerifier = generateCodeVerifier(); +const codeChallenge = await generateCodeChallenge(codeVerifier); + +// 保存code_verifier到本地存储 +localStorage.setItem('oauth_code_verifier', codeVerifier); + +// 构建授权URL +const authUrl = new URL('https://your-domain.com/api/oauth/authorize'); +authUrl.searchParams.set('response_type', 'code'); +authUrl.searchParams.set('client_id', 'your_client_id'); +authUrl.searchParams.set('redirect_uri', 'https://your-app.com/callback'); +authUrl.searchParams.set('scope', 'api:read api:write'); +authUrl.searchParams.set('state', 'random_state_value'); +authUrl.searchParams.set('code_challenge', codeChallenge); +authUrl.searchParams.set('code_challenge_method', 'S256'); + +// 重定向到授权页面 +window.location.href = authUrl.toString(); +``` + +### Step 3: 处理授权回调 + +用户授权后会跳转到`https://your-app.com/callback?code=xxx&state=xxx` + +```javascript +// 在callback页面处理授权码 +const urlParams = new URLSearchParams(window.location.search); +const code = urlParams.get('code'); +const state = urlParams.get('state'); +const codeVerifier = localStorage.getItem('oauth_code_verifier'); + +if (code && codeVerifier) { + // 交换访问令牌 + const tokenResponse = await fetch('https://your-domain.com/api/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: 'your_client_id', + client_secret: 'your_client_secret', + code: code, + redirect_uri: 'https://your-app.com/callback', + code_verifier: codeVerifier + }) + }); + + const tokens = await tokenResponse.json(); + + // 解析JWT令牌获取用户信息 + const userInfo = parseJWTToken(tokens.access_token); + console.log('用户信息:', userInfo); + + // 保存令牌和用户信息 + localStorage.setItem('access_token', tokens.access_token); + localStorage.setItem('refresh_token', tokens.refresh_token); + localStorage.setItem('user_info', JSON.stringify(userInfo)); + + // 清理临时数据 + localStorage.removeItem('oauth_code_verifier'); + + // 跳转到应用首页 + window.location.href = '/dashboard'; +} +``` + +### Step 4: JWT令牌解析和用户信息获取 + +授权码流程返回的`access_token`是一个JWT令牌,包含用户信息: + +```javascript +// JWT令牌解析函数 +function parseJWTToken(token) { + try { + // JWT格式: header.payload.signature + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid JWT token format'); + } + + // 解码payload部分 + const payload = JSON.parse(atob(parts[1])); + + // 提取用户信息 + return { + userId: payload.sub, // 用户ID + username: payload.preferred_username || payload.sub, + email: payload.email, // 用户邮箱 + name: payload.name, // 用户姓名 + roles: payload.scope?.split(' ') || [], // 权限范围 + groups: payload.groups || [], // 用户组 + exp: payload.exp, // 过期时间 + iat: payload.iat, // 签发时间 + iss: payload.iss, // 签发者 + aud: payload.aud // 受众 + }; + } catch (error) { + console.error('Failed to parse JWT token:', error); + return null; + } +} + +// JWT令牌验证函数 +function validateJWTToken(token) { + const userInfo = parseJWTToken(token); + if (!userInfo) return false; + + // 检查令牌是否过期 + const now = Math.floor(Date.now() / 1000); + if (userInfo.exp && now >= userInfo.exp) { + console.log('JWT token has expired'); + return false; + } + + return true; +} + +// 获取用户信息示例 +async function getUserInfoFromToken() { + const token = localStorage.getItem('access_token'); + if (!token) return null; + + if (!validateJWTToken(token)) { + // 令牌无效或过期,尝试刷新 + const newToken = await refreshToken(); + if (newToken) { + return parseJWTToken(newToken); + } + return null; + } + + return parseJWTToken(token); +} +``` + +**JWT令牌示例内容:** +```json +{ + "sub": "user123", // 用户唯一标识 + "preferred_username": "john_doe", // 用户名 + "email": "john@example.com", // 邮箱 + "name": "John Doe", // 真实姓名 + "scope": "api:read api:write", // 权限范围 + "groups": ["users", "developers"], // 用户组 + "iss": "https://your-domain.com", // 签发者 + "aud": "your_client_id", // 受众 + "exp": 1609459200, // 过期时间戳 + "iat": 1609455600, // 签发时间戳 + "jti": "token-unique-id" // 令牌唯一ID +} +``` + +## 👤 自动创建用户登录流程 + +### 用户信息收集和自动创建 + +当启用了`AutoCreateUser`选项时,用户首次通过OAuth2授权后会自动创建账户: + +```javascript +// 用户信息收集表单 +function showUserInfoForm(jwtUserInfo) { + const formHTML = ` +
+

完善用户信息

+

系统将为您自动创建账户,请填写或确认以下信息:

+ +
+
+ + + 用于登录的用户名 +
+ +
+ + + 在界面上显示的名称 +
+ +
+ + + 用于接收通知和找回密码 +
+ +
+ + + OAuth2自动创建的用户组 +
+ +
+

从OAuth2提供商获取的信息:

+
+${JSON.stringify(jwtUserInfo, null, 2)}
+          
+
+ + + +
+
+ `; + + document.body.innerHTML = formHTML; + + // 绑定表单提交事件 + document.getElementById('userRegistrationForm').addEventListener('submit', handleUserRegistration); +} + +// 处理用户注册 +async function handleUserRegistration(event) { + event.preventDefault(); + + const formData = { + username: document.getElementById('username').value.trim(), + displayName: document.getElementById('displayName').value.trim(), + email: document.getElementById('email').value.trim(), + group: document.getElementById('group').value, + oauth2Provider: 'oauth2', + oauth2UserId: parseJWTToken(localStorage.getItem('access_token')).userId + }; + + try { + // 调用自动创建用户API + const response = await fetch('https://your-domain.com/api/oauth/auto_create_user', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + }, + body: JSON.stringify(formData) + }); + + const result = await response.json(); + + if (result.success) { + // 用户创建成功,跳转到主界面 + localStorage.setItem('user_created', 'true'); + window.location.href = '/dashboard'; + } else { + alert('创建用户失败: ' + result.message); + } + } catch (error) { + console.error('用户创建失败:', error); + alert('创建用户时发生错误,请重试'); + } +} + +// 取消注册 +function cancelRegistration() { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + window.location.href = '/'; +} +``` + +### 完整的自动登录流程 + +```javascript +// 改进的自动登录初始化 +async function initAutoLogin() { + try { + // 1. 检查是否有有效的访问令牌 + const accessToken = localStorage.getItem('access_token'); + if (!accessToken || !validateJWTToken(accessToken)) { + // 没有有效令牌,开始OAuth2授权流程 + startOAuth2Authorization(); + return; + } + + // 2. 解析JWT令牌获取用户信息 + const jwtUserInfo = parseJWTToken(accessToken); + console.log('JWT用户信息:', jwtUserInfo); + + // 3. 检查用户是否已存在于系统中 + const userExists = await checkUserExists(jwtUserInfo.userId); + + if (!userExists && !localStorage.getItem('user_created')) { + // 4. 用户不存在且未创建,显示用户信息收集表单 + showUserInfoForm(jwtUserInfo); + return; + } + + // 5. 用户已存在或已创建,直接登录 + const apiUserInfo = await oauth2Client.callAPI('/api/user/self'); + console.log('API用户信息:', apiUserInfo); + + // 6. 显示主界面 + showDashboard(jwtUserInfo, apiUserInfo); + + } catch (error) { + console.error('自动登录失败:', error); + // 清理令牌并重新开始授权流程 + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('user_created'); + startOAuth2Authorization(); + } +} + +// 检查用户是否存在 +async function checkUserExists(userId) { + try { + const response = await fetch(`https://your-domain.com/api/oauth/user_exists/${userId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + + const result = await response.json(); + return result.exists; + } catch (error) { + console.error('检查用户存在性失败:', error); + return false; + } +} + +// 开始OAuth2授权流程 +function startOAuth2Authorization() { + const oauth2Client = new OAuth2Client({ + clientId: 'your_client_id', + clientSecret: 'your_client_secret', + serverUrl: 'https://your-domain.com', + redirectUri: window.location.origin + '/callback', + scopes: 'api:read api:write' + }); + + oauth2Client.startAuthorizationCodeFlow(); +} +``` + +### 服务器端自动创建用户API + +需要在服务器端实现相应的API端点: + +```go +// 用户存在性检查 +func CheckUserExists(c *gin.Context) { + oauthUserId := c.Param("oauth_user_id") + + var user model.User + err := model.DB.Where("oauth2_user_id = ?", oauthUserId).First(&user).Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusOK, gin.H{ + "exists": false, + }) + } else if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Database error", + }) + } else { + c.JSON(http.StatusOK, gin.H{ + "exists": true, + "user_id": user.Id, + }) + } +} + +// 自动创建用户 +func AutoCreateUser(c *gin.Context) { + settings := system_setting.GetOAuth2Settings() + if !settings.AutoCreateUser { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "自动创建用户功能未启用", + }) + return + } + + var req struct { + Username string `json:"username" binding:"required"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Group string `json:"group"` + OAuth2UserId string `json:"oauth2UserId" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的请求参数", + }) + return + } + + // 检查用户是否已存在 + var existingUser model.User + err := model.DB.Where("username = ? OR oauth2_user_id = ?", req.Username, req.OAuth2UserId).First(&existingUser).Error + if err == nil { + c.JSON(http.StatusConflict, gin.H{ + "success": false, + "message": "用户已存在", + }) + return + } + + // 创建新用户 + user := model.User{ + Username: req.Username, + DisplayName: req.DisplayName, + Email: req.Email, + Group: settings.DefaultUserGroup, + Role: settings.DefaultUserRole, + Status: 1, + Password: common.GenerateRandomString(32), // 随机密码,用户通过OAuth2登录 + OAuth2UserId: req.OAuth2UserId, + } + + if req.DisplayName == "" { + user.DisplayName = req.Username + } + + if user.Group == "" { + user.Group = "oauth2" + } + + err = user.Insert(0) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "创建用户失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "用户创建成功", + "user_id": user.Id, + }) +} +``` + +## 🔄 自动登录实现 + +### 令牌刷新机制 + +```javascript +async function refreshToken() { + const refreshToken = localStorage.getItem('refresh_token'); + + if (!refreshToken) { + // 重新授权 + redirectToAuth(); + return; + } + + try { + const response = await fetch('https://your-domain.com/api/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: 'your_client_id', + client_secret: 'your_client_secret', + refresh_token: refreshToken + }) + }); + + const tokens = await response.json(); + + if (tokens.access_token) { + localStorage.setItem('access_token', tokens.access_token); + if (tokens.refresh_token) { + localStorage.setItem('refresh_token', tokens.refresh_token); + } + return tokens.access_token; + } + } catch (error) { + // 刷新失败,重新授权 + redirectToAuth(); + } +} +``` + +### 自动认证拦截器 + +```javascript +class OAuth2Client { + constructor(clientId, clientSecret, baseURL) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.baseURL = baseURL; + } + + // 自动处理认证的请求方法 + async request(url, options = {}) { + let accessToken = localStorage.getItem('access_token'); + + // 检查令牌是否即将过期 + if (this.isTokenExpiringSoon(accessToken)) { + accessToken = await this.refreshToken(); + } + + // 添加认证头 + const headers = { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...options.headers + }; + + try { + const response = await fetch(`${this.baseURL}${url}`, { + ...options, + headers + }); + + // 如果401,尝试刷新令牌 + if (response.status === 401) { + accessToken = await this.refreshToken(); + headers['Authorization'] = `Bearer ${accessToken}`; + + // 重试请求 + return fetch(`${this.baseURL}${url}`, { + ...options, + headers + }); + } + + return response; + } catch (error) { + console.error('Request failed:', error); + throw error; + } + } + + // 检查令牌是否即将过期 + isTokenExpiringSoon(token) { + if (!token) return true; + + try { + const parts = token.split('.'); + if (parts.length !== 3) return true; + + const payload = JSON.parse(atob(parts[1])); + const exp = payload.exp * 1000; // 转换为毫秒 + const now = Date.now(); + return exp - now < 5 * 60 * 1000; // 5分钟内过期 + } catch (error) { + console.error('Token validation failed:', error); + return true; + } + } + + // 获取当前用户信息 + getCurrentUser() { + const token = localStorage.getItem('access_token'); + if (!token || !this.validateJWTToken(token)) { + return null; + } + + return this.parseJWTToken(token); + } + + // 解析JWT令牌 + parseJWTToken(token) { + try { + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid JWT token format'); + } + + const payload = JSON.parse(atob(parts[1])); + + return { + userId: payload.sub, + username: payload.preferred_username || payload.sub, + email: payload.email, + name: payload.name, + roles: payload.scope?.split(' ') || [], + groups: payload.groups || [], + exp: payload.exp, + iat: payload.iat, + iss: payload.iss, + aud: payload.aud + }; + } catch (error) { + console.error('Failed to parse JWT token:', error); + return null; + } + } + + // 验证JWT令牌 + validateJWTToken(token) { + const userInfo = this.parseJWTToken(token); + if (!userInfo) return false; + + const now = Math.floor(Date.now() / 1000); + if (userInfo.exp && now >= userInfo.exp) { + return false; + } + + return true; + } + + // 获取用户信息 + async getUserInfo() { + const response = await this.request('/api/user/self'); + return response.json(); + } + + // 调用API示例 + async callAPI(endpoint, data = null) { + const options = data ? { + method: 'POST', + body: JSON.stringify(data) + } : { method: 'GET' }; + + const response = await this.request(endpoint, options); + return response.json(); + } +} +``` + +### 使用示例 + +```javascript +// 初始化OAuth2客户端 +const oauth2Client = new OAuth2Client( + 'your_client_id', + 'your_client_secret', + 'https://your-domain.com' +); + +// 应用启动时自动检查登录状态 +async function initApp() { + try { + // 尝试获取用户信息(会自动处理令牌刷新) + const userInfo = await oauth2Client.getUserInfo(); + console.log('User logged in:', userInfo); + + // 显示用户界面 + showDashboard(userInfo); + } catch (error) { + // 用户未登录,重定向到授权页面 + redirectToAuth(); + } +} + +// 页面加载时初始化 +document.addEventListener('DOMContentLoaded', initApp); +``` + +## 🛡️ 安全最佳实践 + +### 1. HTTPS 必需 +``` +生产环境必须使用HTTPS +重定向URI必须使用https://(本地开发可用http://localhost) +``` + +### 2. 状态参数验证 +```javascript +// 发起授权时 +const state = crypto.randomUUID(); +localStorage.setItem('oauth_state', state); + +// 回调时验证 +const returnedState = urlParams.get('state'); +const savedState = localStorage.getItem('oauth_state'); +if (returnedState !== savedState) { + throw new Error('State mismatch - possible CSRF attack'); +} +``` + +### 3. 令牌安全存储 +```javascript +// 使用HttpOnly Cookie(推荐) +// 或加密存储在localStorage +function secureStorage() { + return { + setItem: (key, value) => { + const encrypted = encrypt(value); // 使用加密 + localStorage.setItem(key, encrypted); + }, + getItem: (key) => { + const encrypted = localStorage.getItem(key); + return encrypted ? decrypt(encrypted) : null; + } + }; +} +``` + +## 📚 完整示例项目 + +创建一个完整的单页应用示例: + +```html + + + + OAuth2 Demo + + +
+

请登录

+ +
+ + + + + + +``` + +## 🔍 调试和测试 + +### 验证JWT令牌 +访问 [jwt.io](https://jwt.io) 解析令牌内容: +``` +Header: {"alg":"RS256","typ":"JWT","kid":"oauth2-key-1"} +Payload: {"sub":"user_id","aud":"your_client_id","exp":1234567890} +``` + +### 查看服务器信息 +```bash +curl https://your-domain.com/.well-known/oauth-authorization-server +``` + +### 获取JWKS公钥 +```bash +curl https://your-domain.com/.well-known/jwks.json +``` + +--- + +这个demo涵盖了OAuth2服务器的完整使用流程,实现了真正的自动登录功能。用户只需要第一次授权,之后应用会自动处理令牌刷新和API认证。 \ No newline at end of file diff --git a/docs/oauth2_setup.md b/docs/oauth2_setup.md new file mode 100644 index 000000000..5cb3dd294 --- /dev/null +++ b/docs/oauth2_setup.md @@ -0,0 +1,258 @@ +# OAuth2 服务器设置指南 + +## 概述 + +该 OAuth2 服务器实现基于 RFC 6749 标准,支持以下特性: + +- **授权类型**: Client Credentials, Authorization Code + PKCE, Refresh Token +- **JWT 访问令牌**: 使用 RS256 签名 +- **JWKS 端点**: 公钥自动发布和轮换 +- **兼容性**: 与现有认证系统完全兼容 + +## 配置 + +### 1. 环境变量配置 + +在 `.env` 文件中添加以下配置: + +```env +# OAuth2 基础配置 +OAUTH2_ENABLED=true +OAUTH2_ISSUER=https://your-domain.com +OAUTH2_ACCESS_TOKEN_TTL=10 +OAUTH2_REFRESH_TOKEN_TTL=720 + +# JWT 签名配置 +JWT_SIGNING_ALGORITHM=RS256 +JWT_KEY_ID=oauth2-key-1 +JWT_PRIVATE_KEY_FILE=/path/to/private-key.pem + +# 授权类型(逗号分隔) +OAUTH2_ALLOWED_GRANT_TYPES=client_credentials,authorization_code,refresh_token + +# 强制 PKCE +OAUTH2_REQUIRE_PKCE=true + +# 自动创建用户 +OAUTH2_AUTO_CREATE_USER=false +OAUTH2_DEFAULT_USER_ROLE=1 +OAUTH2_DEFAULT_USER_GROUP=default +``` + +### 2. 数据库迁移 + +重启应用程序将自动创建 `oauth_clients` 表。 + +### 3. 创建第一个 OAuth2 客户端 + +通过管理员界面或 API 创建客户端: + +```bash +curl -X POST http://localhost:8080/api/oauth_clients \ + -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "测试服务", + "client_type": "confidential", + "grant_types": ["client_credentials"], + "scopes": ["api:read", "api:write"], + "description": "用于服务对服务认证的测试客户端" + }' +``` + +## OAuth2 端点 + +### 标准端点 + +- **令牌端点**: `POST /api/oauth/token` +- **授权端点**: `GET /api/oauth/authorize` +- **JWKS 端点**: `GET /.well-known/jwks.json` +- **服务器信息**: `GET /.well-known/oauth-authorization-server` + +### 管理端点 + +- **令牌内省**: `POST /api/oauth/introspect` (需要管理员权限) +- **令牌撤销**: `POST /api/oauth/revoke` + +### 客户端管理端点 + +- **列出客户端**: `GET /api/oauth_clients` +- **创建客户端**: `POST /api/oauth_clients` +- **更新客户端**: `PUT /api/oauth_clients` +- **删除客户端**: `DELETE /api/oauth_clients/{id}` +- **重新生成密钥**: `POST /api/oauth_clients/{id}/regenerate_secret` + +## 使用示例 + +### 1. Client Credentials 流程 + +```go +package main + +import ( + "context" + "golang.org/x/oauth2/clientcredentials" +) + +func main() { + cfg := clientcredentials.Config{ + ClientID: "your_client_id", + ClientSecret: "your_client_secret", + TokenURL: "https://your-domain.com/api/oauth/token", + Scopes: []string{"api:read"}, + } + + client := cfg.Client(context.Background()) + resp, _ := client.Get("https://your-domain.com/api/protected") + // 处理响应... +} +``` + +### 2. Authorization Code + PKCE 流程 + +```go +package main + +import ( + "context" + "golang.org/x/oauth2" +) + +func main() { + conf := oauth2.Config{ + ClientID: "your_web_client_id", + ClientSecret: "your_web_client_secret", + RedirectURL: "https://your-app.com/callback", + Scopes: []string{"api:read"}, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://your-domain.com/api/oauth/authorize", + TokenURL: "https://your-domain.com/api/oauth/token", + }, + } + + // 生成 PKCE 参数 + verifier := oauth2.GenerateVerifier() + + // 构建授权 URL + url := conf.AuthCodeURL("state", oauth2.S256ChallengeOption(verifier)) + + // 用户授权后,使用授权码交换令牌 + token, _ := conf.Exchange(context.Background(), code, oauth2.VerifierOption(verifier)) + + // 使用令牌调用 API + client := conf.Client(context.Background(), token) + resp, _ := client.Get("https://your-domain.com/api/protected") +} +``` + +### 3. cURL 示例 + +```bash +# 获取访问令牌 +curl -X POST https://your-domain.com/api/oauth/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -u "client_id:client_secret" \ + -d "grant_type=client_credentials&scope=api:read" + +# 使用访问令牌调用 API +curl -H "Authorization: Bearer ACCESS_TOKEN" \ + https://your-domain.com/api/status +``` + +## 安全建议 + +### 1. 密钥管理 + +- 使用强随机密钥生成器 +- 定期轮换 RSA 密钥对 +- 将私钥存储在安全位置 +- 考虑使用 HSM 或密钥管理服务 + +### 2. 网络安全 + +- 强制使用 HTTPS +- 配置适当的 CORS 策略 +- 实现速率限制 +- 启用请求日志和监控 + +### 3. 客户端管理 + +- 定期审查客户端列表 +- 撤销不再使用的客户端 +- 监控客户端使用情况 +- 为不同用途创建不同的客户端 + +### 4. Scope 和权限 + +- 实施最小权限原则 +- 定期审查 scope 定义 +- 为敏感操作创建特殊 scope +- 实现细粒度的权限控制 + +## 故障排除 + +### 常见问题 + +1. **"OAuth2 server is disabled"** + - 确保 `OAUTH2_ENABLED=true` + - 检查配置文件是否正确加载 + +2. **"invalid_client"** + - 验证 client_id 和 client_secret + - 确保客户端状态为启用 + +3. **"invalid_grant"** + - 检查授权类型是否被允许 + - 验证 PKCE 参数(如果启用) + +4. **"invalid_scope"** + - 确保请求的 scope 在客户端配置中 + - 检查 scope 格式(空格分隔) + +### 调试 + +启用详细日志: + +```env +GIN_MODE=debug +LOG_LEVEL=debug +``` + +检查 JWKS 端点: + +```bash +curl https://your-domain.com/.well-known/jwks.json +``` + +验证令牌: + +```bash +# 可以使用 https://jwt.io 解码和验证 JWT 令牌 +``` + +## 生产部署 + +### 1. 负载均衡 + +- OAuth2 服务器是无状态的,支持水平扩展 +- 确保所有实例使用相同的私钥 +- 使用 Redis 作为令牌存储 + +### 2. 监控 + +- 监控令牌签发速率 +- 跟踪客户端使用情况 +- 设置异常告警 + +### 3. 备份 + +- 备份私钥文件 +- 备份客户端配置数据 +- 制定灾难恢复计划 + +### 4. 性能优化 + +- 启用 JWKS 缓存 +- 使用连接池 +- 优化数据库查询 +- 考虑使用 CDN 分发 JWKS \ No newline at end of file diff --git a/examples/oauth2_test_client.go b/examples/oauth2_test_client.go new file mode 100644 index 000000000..30a6ac233 --- /dev/null +++ b/examples/oauth2_test_client.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +func main() { + // 测试 Client Credentials 流程 + testClientCredentials() + + // 测试 Authorization Code + PKCE 流程(需要浏览器交互) + // testAuthorizationCode() +} + +// testClientCredentials 测试服务对服务认证 +func testClientCredentials() { + fmt.Println("=== Testing Client Credentials Flow ===") + + cfg := clientcredentials.Config{ + ClientID: "client_demo123456789", // 需要先创建客户端 + ClientSecret: "demo_secret_32_chars_long_123456", + TokenURL: "http://127.0.0.1:8080/api/oauth/token", + Scopes: []string{"api:read", "api:write"}, + EndpointParams: map[string][]string{ + "audience": {"api://new-api"}, + }, + } + + // 创建HTTP客户端 + httpClient := cfg.Client(context.Background()) + + // 调用受保护的API + resp, err := httpClient.Get("http://127.0.0.1:8080/api/status") + if err != nil { + log.Printf("Request failed: %v", err) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read response: %v", err) + return + } + + fmt.Printf("Status: %s\n", resp.Status) + fmt.Printf("Response: %s\n", string(body)) +} + +// testAuthorizationCode 测试授权码流程 +func testAuthorizationCode() { + fmt.Println("=== Testing Authorization Code + PKCE Flow ===") + + conf := oauth2.Config{ + ClientID: "client_web123456789", // Web客户端 + ClientSecret: "web_secret_32_chars_long_123456", + RedirectURL: "http://localhost:9999/callback", + Scopes: []string{"api:read", "api:write"}, + Endpoint: oauth2.Endpoint{ + AuthURL: "http://127.0.0.1:8080/api/oauth/authorize", + TokenURL: "http://127.0.0.1:8080/api/oauth/token", + }, + } + + // 生成PKCE参数 + codeVerifier := oauth2.GenerateVerifier() + + // 构建授权URL + url := conf.AuthCodeURL( + "random-state-string", + oauth2.S256ChallengeOption(codeVerifier), + oauth2.SetAuthURLParam("audience", "api://new-api"), + ) + + fmt.Printf("Visit this URL to authorize:\n%s\n\n", url) + fmt.Printf("After authorization, you'll get a code. Use it to exchange for tokens.\n") + + // 在实际应用中,这里需要启动一个HTTP服务器来接收回调 + // 或者手动输入从回调URL中获取的授权码 + + fmt.Print("Enter the authorization code: ") + var code string + fmt.Scanln(&code) + + if code != "" { + // 交换令牌 + token, err := conf.Exchange( + context.Background(), + code, + oauth2.VerifierOption(codeVerifier), + ) + if err != nil { + log.Printf("Token exchange failed: %v", err) + return + } + + fmt.Printf("Access Token: %s\n", token.AccessToken) + fmt.Printf("Token Type: %s\n", token.TokenType) + fmt.Printf("Expires In: %v\n", token.Expiry) + + // 使用令牌调用API + client := conf.Client(context.Background(), token) + resp, err := client.Get("http://127.0.0.1:8080/api/status") + if err != nil { + log.Printf("API request failed: %v", err) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read response: %v", err) + return + } + + fmt.Printf("API Response: %s\n", string(body)) + } +} diff --git a/go.mod b/go.mod index 501d966d5..505abcdec 100644 --- a/go.mod +++ b/go.mod @@ -11,20 +11,24 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.17.11 github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 github.com/aws/smithy-go v1.22.5 - github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b + github.com/bytedance/gopkg v0.0.0-20221122125632-68358b8ecec6 github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/gzip v0.0.6 github.com/gin-contrib/sessions v0.0.5 github.com/gin-contrib/static v0.0.1 github.com/gin-gonic/gin v1.9.1 github.com/glebarez/sqlite v1.9.0 + github.com/go-oauth2/gin-server v1.1.0 + github.com/go-oauth2/oauth2/v4 v4.5.4 github.com/go-playground/validator/v10 v10.20.0 github.com/go-redis/redis/v8 v8.11.5 github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.0 github.com/jinzhu/copier v0.4.0 github.com/joho/godotenv v1.5.1 + github.com/lestrrat-go/jwx/v2 v2.1.6 github.com/pkg/errors v0.9.1 github.com/pquerna/otp v1.5.0 github.com/samber/lo v1.39.0 @@ -38,6 +42,7 @@ require ( golang.org/x/crypto v0.35.0 golang.org/x/image v0.23.0 golang.org/x/net v0.35.0 + golang.org/x/oauth2 v0.30.0 golang.org/x/sync v0.11.0 gorm.io/driver/mysql v1.4.3 gorm.io/driver/postgres v1.5.2 @@ -55,6 +60,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -65,7 +71,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.3 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect @@ -79,14 +85,25 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/tidwall/btree v0.0.0-20191029221954-400434d76274 // indirect + github.com/tidwall/buntdb v1.1.2 // indirect + github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e // indirect + github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -94,7 +111,7 @@ require ( github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.22.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 189d09de4..65131fff6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A= github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs= @@ -23,8 +25,8 @@ github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b h1:LTGVFpNmNHhj0vhOlfgWueFJ32eK9blaIlHR2ciXOT0= -github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b/go.mod h1:2ZlV9BaUH4+NXIBF0aMdKKAnHTzqH+iMU4KUjAbL23Q= +github.com/bytedance/gopkg v0.0.0-20221122125632-68358b8ecec6 h1:FCLDGi1EmB7JzjVVYNZiqc/zAJj2BQ5M0lfkVOxbfs8= +github.com/bytedance/gopkg v0.0.0-20221122125632-68358b8ecec6/go.mod h1:5FoAH5xUHHCMDvQPy1rnj8moqLkLHFaDVBjHhcFwEi0= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= @@ -39,16 +41,22 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8= +github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= @@ -67,6 +75,10 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs= github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw= +github.com/go-oauth2/gin-server v1.1.0 h1:+7AyIfrcKaThZxxABRYECysxAfTccgpFdAqY1enuzBk= +github.com/go-oauth2/gin-server v1.1.0/go.mod h1:f08F3l5/Pbayb4pjnv5PpUdQLFejgGfHrTjA6IZb0eM= +github.com/go-oauth2/oauth2/v4 v4.5.4 h1:YjI0tmGW8oxVhn9QSBIxlr641QugWrJY5UWa6XmLcW0= +github.com/go-oauth2/oauth2/v4 v4.5.4/go.mod h1:BXiOY+QZtZy2ewbsGk2B5P8TWmtz/Rf7ES5ZttQFxfQ= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= @@ -90,20 +102,26 @@ github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 h1:l5lAOZEym3oK3SQ2HBHWsJUfbNBiTXJDeW2QDxw9AQ0= +github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= @@ -112,6 +130,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= +github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -132,6 +152,10 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U= +github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= @@ -148,6 +172,18 @@ github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgx github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -160,6 +196,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs= +github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= @@ -184,10 +222,18 @@ github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUA github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0= +github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -200,21 +246,35 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw= github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo= github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o= github.com/thanhpk/randstr v1.0.6/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U= +github.com/tidwall/btree v0.0.0-20191029221954-400434d76274 h1:G6Z6HvJuPjG6XfNGi/feOATzeJrfgTNJY+rGrHbA04E= +github.com/tidwall/btree v0.0.0-20191029221954-400434d76274/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8= +github.com/tidwall/buntdb v1.1.2 h1:noCrqQXL9EKMtcdwJcmuVKSEjqu1ua99RHHgbLTEHRo= +github.com/tidwall/buntdb v1.1.2/go.mod h1:xAzi36Hir4FarpSHyfuZ6JzPJdjRZ8QlLZSntE2mqlI= +github.com/tidwall/gjson v1.3.4/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb h1:5NSYaAdrnblKByzd7XByQEJVT8+9v0W/tIY0Oo4OwrE= +github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M= +github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e h1:+NL1GDIUOKxVfbp2KoJQD9cTQ6dyP2co9q4yzmT9FZo= +github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 h1:Otn9S136ELckZ3KKDyCkxapfufrqDqwmGjcHfAyXRrE= +github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ= github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g= github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= @@ -229,8 +289,24 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.34.0 h1:d3AAQJ2DRcxJYHm7OXNXtXt2as1vMDfxeIcFvhmGGm4= +github.com/valyala/fasthttp v1.34.0/go.mod h1:epZA5N+7pY6ZaEKRmstzOuYJx9HI8DI1oaCGZpdH4h0= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= @@ -247,6 +323,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= @@ -257,12 +335,12 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/main.go b/main.go index cc2288a61..b11e3a4b8 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "one-api/router" "one-api/service" "one-api/setting/ratio_setting" + "one-api/src/oauth" "os" "strconv" @@ -203,5 +204,13 @@ func InitResources() error { if err != nil { return err } + + // Initialize OAuth2 server + err = oauth.InitOAuthServer() + if err != nil { + common.SysLog("Warning: Failed to initialize OAuth2 server: " + err.Error()) + // OAuth2 失败不应该阻止系统启动 + } + return nil } diff --git a/middleware/oauth_jwt.go b/middleware/oauth_jwt.go new file mode 100644 index 000000000..3e8fe0c69 --- /dev/null +++ b/middleware/oauth_jwt.go @@ -0,0 +1,257 @@ +package middleware + +import ( + "crypto/rsa" + "fmt" + "net/http" + "one-api/common" + "one-api/model" + "one-api/setting/system_setting" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// OAuthJWTAuth OAuth2 JWT认证中间件 +func OAuthJWTAuth() gin.HandlerFunc { + return func(c *gin.Context) { + // 检查OAuth2是否启用 + settings := system_setting.GetOAuth2Settings() + if !settings.Enabled { + c.Next() + return + } + + // 获取Authorization header + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.Next() // 没有Authorization header,继续到下一个中间件 + return + } + + // 检查是否为Bearer token + if !strings.HasPrefix(authHeader, "Bearer ") { + c.Next() // 不是Bearer token,继续到下一个中间件 + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == "" { + abortWithOAuthError(c, "invalid_token", "Missing token") + return + } + + // 验证JWT token + claims, err := validateOAuthJWT(tokenString) + if err != nil { + abortWithOAuthError(c, "invalid_token", err.Error()) + return + } + + // 验证token的有效性 + if err := validateOAuthClaims(claims); err != nil { + abortWithOAuthError(c, "invalid_token", err.Error()) + return + } + + // 设置上下文信息 + setOAuthContext(c, claims) + c.Next() + } +} + +// validateOAuthJWT 验证OAuth2 JWT令牌 +func validateOAuthJWT(tokenString string) (jwt.MapClaims, error) { + // 解析JWT而不验证签名(先获取header中的kid) + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + // 检查签名方法 + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + // 获取kid + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, fmt.Errorf("missing kid in token header") + } + + // 根据kid获取公钥 + publicKey, err := getPublicKeyByKid(kid) + if err != nil { + return nil, fmt.Errorf("failed to get public key: %w", err) + } + + return publicKey, nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("invalid token claims") + } + + return claims, nil +} + +// getPublicKeyByKid 根据kid获取公钥 +func getPublicKeyByKid(kid string) (*rsa.PublicKey, error) { + // 这里需要从JWKS获取公钥 + // 在实际实现中,你可能需要从OAuth server获取JWKS + // 这里先实现一个简单版本 + + // TODO: 实现JWKS缓存和刷新机制 + settings := system_setting.GetOAuth2Settings() + if settings.JWTKeyID == kid { + // 从OAuth server模块获取公钥 + // 这需要在OAuth server初始化后才能使用 + return nil, fmt.Errorf("JWKS functionality not yet implemented") + } + + return nil, fmt.Errorf("unknown kid: %s", kid) +} + +// validateOAuthClaims 验证OAuth2 claims +func validateOAuthClaims(claims jwt.MapClaims) error { + settings := system_setting.GetOAuth2Settings() + + // 验证issuer + if iss, ok := claims["iss"].(string); ok { + if iss != settings.Issuer { + return fmt.Errorf("invalid issuer") + } + } else { + return fmt.Errorf("missing issuer claim") + } + + // 验证audience + // if aud, ok := claims["aud"].(string); ok { + // // TODO: 验证audience + // } + + // 验证客户端ID + if clientID, ok := claims["client_id"].(string); ok { + // 验证客户端是否存在且有效 + client, err := model.GetOAuthClientByID(clientID) + if err != nil { + return fmt.Errorf("invalid client") + } + if client.Status != common.UserStatusEnabled { + return fmt.Errorf("client disabled") + } + } else { + return fmt.Errorf("missing client_id claim") + } + + return nil +} + +// setOAuthContext 设置OAuth上下文信息 +func setOAuthContext(c *gin.Context, claims jwt.MapClaims) { + c.Set("oauth_claims", claims) + c.Set("oauth_authenticated", true) + + // 提取基本信息 + if clientID, ok := claims["client_id"].(string); ok { + c.Set("oauth_client_id", clientID) + } + + if scope, ok := claims["scope"].(string); ok { + c.Set("oauth_scope", scope) + } + + if sub, ok := claims["sub"].(string); ok { + c.Set("oauth_subject", sub) + } + + // 对于client_credentials流程,subject就是client_id + // 对于authorization_code流程,subject是用户ID + if grantType, ok := claims["grant_type"].(string); ok { + c.Set("oauth_grant_type", grantType) + } +} + +// abortWithOAuthError 返回OAuth错误响应 +func abortWithOAuthError(c *gin.Context, errorCode, description string) { + c.Header("WWW-Authenticate", fmt.Sprintf(`Bearer error="%s", error_description="%s"`, errorCode, description)) + c.JSON(http.StatusUnauthorized, gin.H{ + "error": errorCode, + "error_description": description, + }) + c.Abort() +} + +// RequireOAuthScope OAuth2 scope验证中间件 +func RequireOAuthScope(requiredScope string) gin.HandlerFunc { + return func(c *gin.Context) { + // 检查是否通过OAuth认证 + if !c.GetBool("oauth_authenticated") { + abortWithOAuthError(c, "insufficient_scope", "OAuth2 authentication required") + return + } + + // 获取token的scope + scope, exists := c.Get("oauth_scope") + if !exists { + abortWithOAuthError(c, "insufficient_scope", "No scope in token") + return + } + + scopeStr, ok := scope.(string) + if !ok { + abortWithOAuthError(c, "insufficient_scope", "Invalid scope format") + return + } + + // 检查是否包含所需的scope + scopes := strings.Split(scopeStr, " ") + for _, s := range scopes { + if strings.TrimSpace(s) == requiredScope { + c.Next() + return + } + } + + abortWithOAuthError(c, "insufficient_scope", fmt.Sprintf("Required scope: %s", requiredScope)) + } +} + +// OptionalOAuthAuth 可选的OAuth认证中间件(不会阻止请求) +func OptionalOAuthAuth() gin.HandlerFunc { + return func(c *gin.Context) { + // 尝试OAuth认证,但不会阻止请求 + authHeader := c.GetHeader("Authorization") + if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if claims, err := validateOAuthJWT(tokenString); err == nil { + if validateOAuthClaims(claims) == nil { + setOAuthContext(c, claims) + } + } + } + c.Next() + } +} + +// GetOAuthClaims 获取OAuth claims +func GetOAuthClaims(c *gin.Context) (jwt.MapClaims, bool) { + claims, exists := c.Get("oauth_claims") + if !exists { + return nil, false + } + + mapClaims, ok := claims.(jwt.MapClaims) + return mapClaims, ok +} + +// IsOAuthAuthenticated 检查是否通过OAuth认证 +func IsOAuthAuthenticated(c *gin.Context) bool { + return c.GetBool("oauth_authenticated") +} diff --git a/model/main.go b/model/main.go index 1a38d371b..bffbca249 100644 --- a/model/main.go +++ b/model/main.go @@ -265,6 +265,7 @@ func migrateDB() error { &Setup{}, &TwoFA{}, &TwoFABackupCode{}, + &OAuthClient{}, ) if err != nil { return err diff --git a/model/oauth_client.go b/model/oauth_client.go new file mode 100644 index 000000000..a73ce8639 --- /dev/null +++ b/model/oauth_client.go @@ -0,0 +1,183 @@ +package model + +import ( + "encoding/json" + "one-api/common" + "strings" + "time" + + "gorm.io/gorm" +) + +// OAuthClient OAuth2 客户端模型 +type OAuthClient struct { + ID string `json:"id" gorm:"type:varchar(64);primaryKey"` + Secret string `json:"secret" gorm:"type:varchar(128);not null"` + Name string `json:"name" gorm:"type:varchar(255);not null"` + Domain string `json:"domain" gorm:"type:varchar(255)"` // 允许的重定向域名 + RedirectURIs string `json:"redirect_uris" gorm:"type:text"` // JSON array of redirect URIs + GrantTypes string `json:"grant_types" gorm:"type:varchar(255);default:'client_credentials'"` + Scopes string `json:"scopes" gorm:"type:varchar(255);default:'api:read'"` + RequirePKCE bool `json:"require_pkce" gorm:"default:true"` + Status int `json:"status" gorm:"type:int;default:1"` // 1: enabled, 2: disabled + CreatedBy int `json:"created_by" gorm:"type:int;not null"` // 创建者用户ID + CreatedTime int64 `json:"created_time" gorm:"bigint"` + LastUsedTime int64 `json:"last_used_time" gorm:"bigint;default:0"` + TokenCount int `json:"token_count" gorm:"type:int;default:0"` // 已签发的token数量 + Description string `json:"description" gorm:"type:text"` + ClientType string `json:"client_type" gorm:"type:varchar(32);default:'confidential'"` // confidential, public + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +// GetRedirectURIs 获取重定向URI列表 +func (c *OAuthClient) GetRedirectURIs() []string { + if c.RedirectURIs == "" { + return []string{} + } + var uris []string + err := json.Unmarshal([]byte(c.RedirectURIs), &uris) + if err != nil { + common.SysLog("failed to unmarshal redirect URIs: " + err.Error()) + return []string{} + } + return uris +} + +// SetRedirectURIs 设置重定向URI列表 +func (c *OAuthClient) SetRedirectURIs(uris []string) { + data, err := json.Marshal(uris) + if err != nil { + common.SysLog("failed to marshal redirect URIs: " + err.Error()) + return + } + c.RedirectURIs = string(data) +} + +// GetGrantTypes 获取允许的授权类型列表 +func (c *OAuthClient) GetGrantTypes() []string { + if c.GrantTypes == "" { + return []string{"client_credentials"} + } + return strings.Split(c.GrantTypes, ",") +} + +// SetGrantTypes 设置允许的授权类型列表 +func (c *OAuthClient) SetGrantTypes(types []string) { + c.GrantTypes = strings.Join(types, ",") +} + +// GetScopes 获取允许的scope列表 +func (c *OAuthClient) GetScopes() []string { + if c.Scopes == "" { + return []string{"api:read"} + } + return strings.Split(c.Scopes, ",") +} + +// SetScopes 设置允许的scope列表 +func (c *OAuthClient) SetScopes(scopes []string) { + c.Scopes = strings.Join(scopes, ",") +} + +// ValidateRedirectURI 验证重定向URI是否有效 +func (c *OAuthClient) ValidateRedirectURI(uri string) bool { + allowedURIs := c.GetRedirectURIs() + for _, allowedURI := range allowedURIs { + if allowedURI == uri { + return true + } + } + return false +} + +// ValidateGrantType 验证授权类型是否被允许 +func (c *OAuthClient) ValidateGrantType(grantType string) bool { + allowedTypes := c.GetGrantTypes() + for _, allowedType := range allowedTypes { + if allowedType == grantType { + return true + } + } + return false +} + +// ValidateScope 验证scope是否被允许 +func (c *OAuthClient) ValidateScope(scope string) bool { + allowedScopes := c.GetScopes() + requestedScopes := strings.Split(scope, " ") + + for _, requestedScope := range requestedScopes { + requestedScope = strings.TrimSpace(requestedScope) + if requestedScope == "" { + continue + } + found := false + for _, allowedScope := range allowedScopes { + if allowedScope == requestedScope { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// BeforeCreate GORM hook - 在创建前设置时间 +func (c *OAuthClient) BeforeCreate(tx *gorm.DB) (err error) { + c.CreatedTime = time.Now().Unix() + return +} + +// UpdateLastUsedTime 更新最后使用时间 +func (c *OAuthClient) UpdateLastUsedTime() error { + c.LastUsedTime = time.Now().Unix() + c.TokenCount++ + return DB.Model(c).Select("last_used_time", "token_count").Updates(c).Error +} + +// GetOAuthClientByID 根据ID获取OAuth客户端 +func GetOAuthClientByID(id string) (*OAuthClient, error) { + var client OAuthClient + err := DB.Where("id = ? AND status = ?", id, common.UserStatusEnabled).First(&client).Error + return &client, err +} + +// GetAllOAuthClients 获取所有OAuth客户端 +func GetAllOAuthClients(startIdx int, num int) ([]*OAuthClient, error) { + var clients []*OAuthClient + err := DB.Order("created_time desc").Limit(num).Offset(startIdx).Find(&clients).Error + return clients, err +} + +// SearchOAuthClients 搜索OAuth客户端 +func SearchOAuthClients(keyword string) ([]*OAuthClient, error) { + var clients []*OAuthClient + err := DB.Where("name LIKE ? OR id LIKE ? OR description LIKE ?", + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%").Find(&clients).Error + return clients, err +} + +// CreateOAuthClient 创建OAuth客户端 +func CreateOAuthClient(client *OAuthClient) error { + return DB.Create(client).Error +} + +// UpdateOAuthClient 更新OAuth客户端 +func UpdateOAuthClient(client *OAuthClient) error { + return DB.Save(client).Error +} + +// DeleteOAuthClient 删除OAuth客户端 +func DeleteOAuthClient(id string) error { + return DB.Where("id = ?", id).Delete(&OAuthClient{}).Error +} + +// CountOAuthClients 统计OAuth客户端数量 +func CountOAuthClients() (int64, error) { + var count int64 + err := DB.Model(&OAuthClient{}).Count(&count).Error + return count, err +} diff --git a/router/api-router.go b/router/api-router.go index 773857385..5d8e95042 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -31,6 +31,19 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/oauth/oidc", middleware.CriticalRateLimit(), controller.OidcAuth) apiRouter.GET("/oauth/linuxdo", middleware.CriticalRateLimit(), controller.LinuxdoOAuth) apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode) + + // OAuth2 Server endpoints + apiRouter.GET("/.well-known/jwks.json", controller.GetJWKS) + apiRouter.GET("/.well-known/oauth-authorization-server", controller.OAuthServerInfo) + apiRouter.POST("/oauth/token", middleware.CriticalRateLimit(), controller.OAuthTokenEndpoint) + apiRouter.GET("/oauth/authorize", controller.OAuthAuthorizeEndpoint) + apiRouter.POST("/oauth/introspect", middleware.AdminAuth(), controller.OAuthIntrospect) + apiRouter.POST("/oauth/revoke", middleware.CriticalRateLimit(), controller.OAuthRevoke) + + // OAuth2 管理API (前端使用) + apiRouter.GET("/oauth/jwks", controller.GetJWKS) + apiRouter.GET("/oauth/server-info", controller.OAuthServerInfo) + apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth) apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind) apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind) @@ -234,5 +247,17 @@ func SetApiRouter(router *gin.Engine) { modelsRoute.PUT("/", controller.UpdateModelMeta) modelsRoute.DELETE("/:id", controller.DeleteModelMeta) } + + oauthClientsRoute := apiRouter.Group("/oauth_clients") + oauthClientsRoute.Use(middleware.AdminAuth()) + { + oauthClientsRoute.GET("/", controller.GetAllOAuthClients) + oauthClientsRoute.GET("/search", controller.SearchOAuthClients) + oauthClientsRoute.GET("/:id", controller.GetOAuthClient) + oauthClientsRoute.POST("/", controller.CreateOAuthClient) + oauthClientsRoute.PUT("/", controller.UpdateOAuthClient) + oauthClientsRoute.DELETE("/:id", controller.DeleteOAuthClient) + oauthClientsRoute.POST("/:id/regenerate_secret", controller.RegenerateOAuthClientSecret) + } } } diff --git a/setting/system_setting/oauth2.go b/setting/system_setting/oauth2.go new file mode 100644 index 000000000..078fe69e9 --- /dev/null +++ b/setting/system_setting/oauth2.go @@ -0,0 +1,70 @@ +package system_setting + +import "one-api/setting/config" + +type OAuth2Settings struct { + Enabled bool `json:"enabled"` + Issuer string `json:"issuer"` + AccessTokenTTL int `json:"access_token_ttl"` // in minutes + RefreshTokenTTL int `json:"refresh_token_ttl"` // in minutes + AllowedGrantTypes []string `json:"allowed_grant_types"` // client_credentials, authorization_code, refresh_token + RequirePKCE bool `json:"require_pkce"` // force PKCE for authorization code flow + JWTSigningAlgorithm string `json:"jwt_signing_algorithm"` + JWTKeyID string `json:"jwt_key_id"` + JWTPrivateKeyFile string `json:"jwt_private_key_file"` + AutoCreateUser bool `json:"auto_create_user"` // auto create user on first OAuth2 login + DefaultUserRole int `json:"default_user_role"` // default role for auto-created users + DefaultUserGroup string `json:"default_user_group"` // default group for auto-created users + ScopeMappings map[string][]string `json:"scope_mappings"` // scope to permissions mapping +} + +// 默认配置 +var defaultOAuth2Settings = OAuth2Settings{ + Enabled: false, + AccessTokenTTL: 10, // 10 minutes + RefreshTokenTTL: 720, // 12 hours + AllowedGrantTypes: []string{"client_credentials", "authorization_code", "refresh_token"}, + RequirePKCE: true, + JWTSigningAlgorithm: "RS256", + JWTKeyID: "oauth2-key-1", + AutoCreateUser: false, + DefaultUserRole: 1, // common user + DefaultUserGroup: "default", + ScopeMappings: map[string][]string{ + "api:read": {"read"}, + "api:write": {"write"}, + "admin": {"admin"}, + }, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("oauth2", &defaultOAuth2Settings) +} + +func GetOAuth2Settings() *OAuth2Settings { + return &defaultOAuth2Settings +} + +// UpdateOAuth2Settings 更新OAuth2配置 +func UpdateOAuth2Settings(settings OAuth2Settings) { + defaultOAuth2Settings = settings +} + +// ValidateGrantType 验证授权类型是否被允许 +func (s *OAuth2Settings) ValidateGrantType(grantType string) bool { + for _, allowedType := range s.AllowedGrantTypes { + if allowedType == grantType { + return true + } + } + return false +} + +// GetScopePermissions 获取scope对应的权限 +func (s *OAuth2Settings) GetScopePermissions(scope string) []string { + if perms, exists := s.ScopeMappings[scope]; exists { + return perms + } + return []string{} +} diff --git a/src/oauth/server.go b/src/oauth/server.go new file mode 100644 index 000000000..65f53b121 --- /dev/null +++ b/src/oauth/server.go @@ -0,0 +1,80 @@ +package oauth + +import ( + "crypto/rand" + "crypto/rsa" + "fmt" + "one-api/common" + "one-api/setting/system_setting" + + "github.com/gin-gonic/gin" + "github.com/lestrrat-go/jwx/v2/jwk" +) + +var ( + simplePrivateKey *rsa.PrivateKey + simpleJWKSSet jwk.Set +) + +// InitOAuthServer 简化版OAuth2服务器初始化 +func InitOAuthServer() error { + settings := system_setting.GetOAuth2Settings() + if !settings.Enabled { + common.SysLog("OAuth2 server is disabled") + return nil + } + + // 生成RSA私钥(简化版本) + var err error + simplePrivateKey, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return fmt.Errorf("failed to generate RSA key: %w", err) + } + + // 创建JWKS + simpleJWKSSet, err = createSimpleJWKS(simplePrivateKey, settings.JWTKeyID) + if err != nil { + return fmt.Errorf("failed to create JWKS: %w", err) + } + + common.SysLog("OAuth2 server initialized successfully (simple mode)") + return nil +} + +// createSimpleJWKS 创建简单的JWKS +func createSimpleJWKS(privateKey *rsa.PrivateKey, keyID string) (jwk.Set, error) { + pubJWK, err := jwk.FromRaw(&privateKey.PublicKey) + if err != nil { + return nil, err + } + + _ = pubJWK.Set(jwk.KeyIDKey, keyID) + _ = pubJWK.Set(jwk.AlgorithmKey, "RS256") + _ = pubJWK.Set(jwk.KeyUsageKey, "sig") + + jwks := jwk.NewSet() + _ = jwks.AddKey(pubJWK) + + return jwks, nil +} + +// GetJWKS 获取JWKS(简化版本) +func GetJWKS() jwk.Set { + return simpleJWKSSet +} + +// HandleTokenRequest 简化的令牌处理(临时实现) +func HandleTokenRequest(c *gin.Context) { + c.JSON(501, map[string]string{ + "error": "not_implemented", + "error_description": "OAuth2 token endpoint not fully implemented yet", + }) +} + +// HandleAuthorizeRequest 简化的授权处理(临时实现) +func HandleAuthorizeRequest(c *gin.Context) { + c.JSON(501, map[string]string{ + "error": "not_implemented", + "error_description": "OAuth2 authorize endpoint not fully implemented yet", + }) +} diff --git a/web/src/components/modals/oauth2/CreateOAuth2ClientModal.jsx b/web/src/components/modals/oauth2/CreateOAuth2ClientModal.jsx new file mode 100644 index 000000000..2d13e840c --- /dev/null +++ b/web/src/components/modals/oauth2/CreateOAuth2ClientModal.jsx @@ -0,0 +1,318 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState } from 'react'; +import { + Modal, + Form, + Input, + Select, + TextArea, + Switch, + Space, + Typography, + Divider, + Tag, + Button, +} from '@douyinfe/semi-ui'; +import { IconPlus, IconDelete } from '@douyinfe/semi-icons'; +import { API, showError, showSuccess, showInfo } from '../../../helpers'; + +const { Text, Paragraph } = Typography; +const { Option } = Select; + +const CreateOAuth2ClientModal = ({ visible, onCancel, onSuccess }) => { + const [formApi, setFormApi] = useState(null); + const [loading, setLoading] = useState(false); + const [redirectUris, setRedirectUris] = useState(['']); + const [clientType, setClientType] = useState('confidential'); + const [grantTypes, setGrantTypes] = useState(['client_credentials']); + + // 处理提交 + const handleSubmit = async (values) => { + setLoading(true); + try { + // 过滤空的重定向URI + const validRedirectUris = redirectUris.filter(uri => uri.trim()); + + const payload = { + ...values, + client_type: clientType, + grant_types: grantTypes, + redirect_uris: validRedirectUris, + }; + + const res = await API.post('/api/oauth_clients/', payload); + const { success, message, client_id, client_secret } = res.data; + + if (success) { + showSuccess('OAuth2客户端创建成功'); + + // 显示客户端信息 + Modal.info({ + title: '客户端创建成功', + content: ( +
+ 请妥善保存以下信息: +
+
+ 客户端ID: +
+ + {client_id} + +
+ {client_secret && ( +
+ 客户端密钥(仅此一次显示): +
+ + {client_secret} + +
+ )} +
+ + {client_secret + ? '客户端密钥仅显示一次,请立即复制保存。' + : '公开客户端无需密钥。' + } + +
+ ), + width: 600, + onOk: () => { + resetForm(); + onSuccess(); + } + }); + } else { + showError(message); + } + } catch (error) { + showError('创建OAuth2客户端失败'); + } finally { + setLoading(false); + } + }; + + // 重置表单 + const resetForm = () => { + if (formApi) { + formApi.reset(); + } + setClientType('confidential'); + setGrantTypes(['client_credentials']); + setRedirectUris(['']); + }; + + // 处理取消 + const handleCancel = () => { + resetForm(); + onCancel(); + }; + + // 添加重定向URI + const addRedirectUri = () => { + setRedirectUris([...redirectUris, '']); + }; + + // 删除重定向URI + const removeRedirectUri = (index) => { + setRedirectUris(redirectUris.filter((_, i) => i !== index)); + }; + + // 更新重定向URI + const updateRedirectUri = (index, value) => { + const newUris = [...redirectUris]; + newUris[index] = value; + setRedirectUris(newUris); + }; + + // 授权类型变化处理 + const handleGrantTypesChange = (values) => { + setGrantTypes(values); + // 如果包含authorization_code但没有重定向URI,则添加一个 + if (values.includes('authorization_code') && redirectUris.length === 1 && !redirectUris[0]) { + setRedirectUris(['']); + } + }; + + return ( + formApi?.submit()} + okText="创建" + cancelText="取消" + confirmLoading={loading} + width={600} + style={{ top: 50 }} + > +
setFormApi(api)} + onSubmit={handleSubmit} + labelPosition="top" + > + {/* 基本信息 */} + + + + + {/* 客户端类型 */} +
+ 客户端类型 + + 选择适合您应用程序的客户端类型。 + +
+
setClientType('confidential')} + style={{ + flex: 1, + padding: '12px', + border: `2px solid ${clientType === 'confidential' ? '#3370ff' : '#e4e6e9'}`, + borderRadius: '6px', + cursor: 'pointer', + background: clientType === 'confidential' ? '#f0f5ff' : '#fff' + }} + > + 机密客户端(Confidential) + + 用于服务器端应用,可以安全地存储客户端密钥 + +
+
setClientType('public')} + style={{ + flex: 1, + padding: '12px', + border: `2px solid ${clientType === 'public' ? '#3370ff' : '#e4e6e9'}`, + borderRadius: '6px', + cursor: 'pointer', + background: clientType === 'public' ? '#f0f5ff' : '#fff' + }} + > + 公开客户端(Public) + + 用于移动应用或单页应用,无法安全存储密钥 + +
+
+
+ + {/* 授权类型 */} + + + + + + + {/* Scope */} + + + + + + + {/* PKCE设置 */} + + + PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。 + + + {/* 重定向URI */} + {grantTypes.includes('authorization_code') && ( + <> + 重定向URI配置 +
+ 重定向URI + + 用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP)。 + + + + {redirectUris.map((uri, index) => ( + + updateRedirectUri(index, value)} + style={{ flex: 1 }} + /> + {redirectUris.length > 1 && ( + +
+ + )} + +
+ ); +}; + +export default CreateOAuth2ClientModal; \ No newline at end of file diff --git a/web/src/components/modals/oauth2/EditOAuth2ClientModal.jsx b/web/src/components/modals/oauth2/EditOAuth2ClientModal.jsx new file mode 100644 index 000000000..39729bba9 --- /dev/null +++ b/web/src/components/modals/oauth2/EditOAuth2ClientModal.jsx @@ -0,0 +1,306 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect } from 'react'; +import { + Modal, + Form, + Input, + Select, + TextArea, + Switch, + Space, + Typography, + Divider, + Button, +} from '@douyinfe/semi-ui'; +import { IconPlus, IconDelete } from '@douyinfe/semi-icons'; +import { API, showError, showSuccess } from '../../../helpers'; + +const { Text, Paragraph } = Typography; +const { Option } = Select; + +const EditOAuth2ClientModal = ({ visible, client, onCancel, onSuccess }) => { + const [formApi, setFormApi] = useState(null); + const [loading, setLoading] = useState(false); + const [redirectUris, setRedirectUris] = useState(['']); + const [grantTypes, setGrantTypes] = useState(['client_credentials']); + + // 初始化表单数据 + useEffect(() => { + if (client && visible) { + // 解析授权类型 + let parsedGrantTypes = []; + if (typeof client.grant_types === 'string') { + parsedGrantTypes = client.grant_types.split(','); + } else if (Array.isArray(client.grant_types)) { + parsedGrantTypes = client.grant_types; + } + + // 解析Scope + let parsedScopes = []; + if (typeof client.scopes === 'string') { + parsedScopes = client.scopes.split(','); + } else if (Array.isArray(client.scopes)) { + parsedScopes = client.scopes; + } + + // 解析重定向URI + let parsedRedirectUris = ['']; + if (client.redirect_uris) { + try { + const parsed = typeof client.redirect_uris === 'string' + ? JSON.parse(client.redirect_uris) + : client.redirect_uris; + if (Array.isArray(parsed) && parsed.length > 0) { + parsedRedirectUris = parsed; + } + } catch (e) { + console.warn('Failed to parse redirect URIs:', e); + } + } + + setGrantTypes(parsedGrantTypes); + setRedirectUris(parsedRedirectUris); + + // 设置表单值 + const formValues = { + id: client.id, + name: client.name, + description: client.description, + client_type: client.client_type, + grant_types: parsedGrantTypes, + scopes: parsedScopes, + require_pkce: client.require_pkce, + status: client.status, + }; + if (formApi) { + formApi.setValues(formValues); + } + } + }, [client, visible, formApi]); + + // 处理提交 + const handleSubmit = async (values) => { + setLoading(true); + try { + // 过滤空的重定向URI + const validRedirectUris = redirectUris.filter(uri => uri.trim()); + + const payload = { + ...values, + grant_types: grantTypes, + redirect_uris: validRedirectUris, + }; + + const res = await API.put('/api/oauth_clients/', payload); + const { success, message } = res.data; + + if (success) { + showSuccess('OAuth2客户端更新成功'); + onSuccess(); + } else { + showError(message); + } + } catch (error) { + showError('更新OAuth2客户端失败'); + } finally { + setLoading(false); + } + }; + + // 添加重定向URI + const addRedirectUri = () => { + setRedirectUris([...redirectUris, '']); + }; + + // 删除重定向URI + const removeRedirectUri = (index) => { + setRedirectUris(redirectUris.filter((_, i) => i !== index)); + }; + + // 更新重定向URI + const updateRedirectUri = (index, value) => { + const newUris = [...redirectUris]; + newUris[index] = value; + setRedirectUris(newUris); + }; + + // 授权类型变化处理 + const handleGrantTypesChange = (values) => { + setGrantTypes(values); + // 如果包含authorization_code但没有重定向URI,则添加一个 + if (values.includes('authorization_code') && redirectUris.length === 1 && !redirectUris[0]) { + setRedirectUris(['']); + } + }; + + if (!client) return null; + + return ( + formApi?.submit()} + okText="保存" + cancelText="取消" + confirmLoading={loading} + width={600} + style={{ top: 50 }} + > +
setFormApi(api)} + onSubmit={handleSubmit} + labelPosition="top" + > + {/* 客户端ID(只读) */} + + + {/* 基本信息 */} + + + + + {/* 客户端类型(只读) */} + + + + + + + 客户端类型创建后不可更改。 + + + {/* 授权类型 */} + + + + + + + {/* Scope */} + + + + + + + {/* PKCE设置 */} + + + PKCE(Proof Key for Code Exchange)可提高授权码流程的安全性。 + + + {/* 状态 */} + + + + + + {/* 重定向URI */} + {grantTypes.includes('authorization_code') && ( + <> + 重定向URI配置 +
+ 重定向URI + + 用于授权码流程,用户授权后将重定向到这些URI。必须使用HTTPS(本地开发可使用HTTP)。 + + + + {redirectUris.map((uri, index) => ( + + updateRedirectUri(index, value)} + style={{ flex: 1 }} + /> + {redirectUris.length > 1 && ( + +
+ + )} + +
+ ); +}; + +export default EditOAuth2ClientModal; \ No newline at end of file diff --git a/web/src/components/settings/OAuth2Setting.jsx b/web/src/components/settings/OAuth2Setting.jsx new file mode 100644 index 000000000..7b2bac6ae --- /dev/null +++ b/web/src/components/settings/OAuth2Setting.jsx @@ -0,0 +1,101 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState } from 'react'; +import { Card, Spin } from '@douyinfe/semi-ui'; +import { API, showError, toBoolean } from '../../helpers'; +import OAuth2ServerSettings from '../../pages/Setting/OAuth2/OAuth2ServerSettings'; +import OAuth2ClientSettings from '../../pages/Setting/OAuth2/OAuth2ClientSettings'; + +const OAuth2Setting = () => { + const [inputs, setInputs] = useState({ + 'oauth2.enabled': false, + 'oauth2.issuer': '', + 'oauth2.access_token_ttl': 10, + 'oauth2.refresh_token_ttl': 720, + 'oauth2.jwt_signing_algorithm': 'RS256', + 'oauth2.jwt_key_id': 'oauth2-key-1', + 'oauth2.jwt_private_key_file': '', + 'oauth2.allowed_grant_types': ['client_credentials', 'authorization_code'], + 'oauth2.require_pkce': true, + 'oauth2.auto_create_user': false, + 'oauth2.default_user_role': 1, + 'oauth2.default_user_group': 'default', + }); + const [loading, setLoading] = useState(false); + + const getOptions = async () => { + setLoading(true); + try { + const res = await API.get('/api/option/'); + const { success, message, data } = res.data; + if (success) { + let newInputs = {}; + data.forEach((item) => { + if (Object.keys(inputs).includes(item.key)) { + if (item.key === 'oauth2.allowed_grant_types') { + try { + newInputs[item.key] = JSON.parse(item.value || '["client_credentials","authorization_code"]'); + } catch { + newInputs[item.key] = ['client_credentials', 'authorization_code']; + } + } else if (typeof inputs[item.key] === 'boolean') { + newInputs[item.key] = toBoolean(item.value); + } else if (typeof inputs[item.key] === 'number') { + newInputs[item.key] = parseInt(item.value) || inputs[item.key]; + } else { + newInputs[item.key] = item.value; + } + } + }); + setInputs({...inputs, ...newInputs}); + } else { + showError(message); + } + } catch (error) { + showError('获取OAuth2设置失败'); + } finally { + setLoading(false); + } + }; + + const refresh = () => { + getOptions(); + }; + + useEffect(() => { + getOptions(); + }, []); + + return ( +
+ + +
+ ); +}; + +export default OAuth2Setting; \ No newline at end of file diff --git a/web/src/pages/Setting/OAuth2/OAuth2ClientSettings.jsx b/web/src/pages/Setting/OAuth2/OAuth2ClientSettings.jsx new file mode 100644 index 000000000..4afc6478e --- /dev/null +++ b/web/src/pages/Setting/OAuth2/OAuth2ClientSettings.jsx @@ -0,0 +1,420 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState } from 'react'; +import { + Card, + Table, + Button, + Space, + Tag, + Typography, + Input, + Popconfirm, + Modal, + Form, + Banner, + Row, + Col +} from '@douyinfe/semi-ui'; +import { IconSearch, IconPlus } from '@douyinfe/semi-icons'; +import { API, showError, showSuccess, showInfo } from '../../../helpers'; +import CreateOAuth2ClientModal from '../../../components/modals/oauth2/CreateOAuth2ClientModal'; +import EditOAuth2ClientModal from '../../../components/modals/oauth2/EditOAuth2ClientModal'; +import { useTranslation } from 'react-i18next'; + +const { Text, Title } = Typography; + +export default function OAuth2ClientSettings() { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [clients, setClients] = useState([]); + const [filteredClients, setFilteredClients] = useState([]); + const [searchKeyword, setSearchKeyword] = useState(''); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [editingClient, setEditingClient] = useState(null); + const [showSecretModal, setShowSecretModal] = useState(false); + const [currentSecret, setCurrentSecret] = useState(''); + + // 加载客户端列表 + const loadClients = async () => { + setLoading(true); + try { + const res = await API.get('/api/oauth_clients/'); + if (res.data.success) { + setClients(res.data.data || []); + setFilteredClients(res.data.data || []); + } else { + showError(res.data.message); + } + } catch (error) { + showError('加载OAuth2客户端失败'); + } finally { + setLoading(false); + } + }; + + // 搜索过滤 + const handleSearch = (value) => { + setSearchKeyword(value); + if (!value) { + setFilteredClients(clients); + } else { + const filtered = clients.filter(client => + client.name?.toLowerCase().includes(value.toLowerCase()) || + client.id?.toLowerCase().includes(value.toLowerCase()) || + client.description?.toLowerCase().includes(value.toLowerCase()) + ); + setFilteredClients(filtered); + } + }; + + // 删除客户端 + const handleDelete = async (client) => { + try { + const res = await API.delete(`/api/oauth_clients/${client.id}`); + if (res.data.success) { + showSuccess('删除成功'); + loadClients(); + } else { + showError(res.data.message); + } + } catch (error) { + showError('删除失败'); + } + }; + + // 重新生成密钥 + const handleRegenerateSecret = async (client) => { + try { + const res = await API.post(`/api/oauth_clients/${client.id}/regenerate_secret`); + if (res.data.success) { + setCurrentSecret(res.data.client_secret); + setShowSecretModal(true); + loadClients(); + } else { + showError(res.data.message); + } + } catch (error) { + showError('重新生成密钥失败'); + } + }; + + // 表格列定义 + const columns = [ + { + title: '客户端名称', + dataIndex: 'name', + key: 'name', + render: (text, record) => ( +
+ {text} +
+ {record.id} +
+ ), + }, + { + title: '类型', + dataIndex: 'client_type', + key: 'client_type', + render: (text) => ( + + {text === 'confidential' ? '机密客户端' : '公开客户端'} + + ), + }, + { + title: '授权类型', + dataIndex: 'grant_types', + key: 'grant_types', + render: (grantTypes) => { + const types = typeof grantTypes === 'string' ? grantTypes.split(',') : (grantTypes || []); + return ( +
+ {types.map(type => ( + + {type === 'client_credentials' ? '客户端凭证' : + type === 'authorization_code' ? '授权码' : + type === 'refresh_token' ? '刷新令牌' : type} + + ))} +
+ ); + }, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + render: (status) => ( + + {status === 1 ? '启用' : '禁用'} + + ), + }, + { + title: '创建时间', + dataIndex: 'created_time', + key: 'created_time', + render: (time) => new Date(time * 1000).toLocaleString(), + }, + { + title: '操作', + key: 'action', + render: (_, record) => ( + + + {record.client_type === 'confidential' && ( + + )} + handleDelete(record)} + okText="确定" + cancelText="取消" + > + + + + ), + }, + ]; + + useEffect(() => { + loadClients(); + }, []); + + return ( +
+ + + + + + + } + placeholder="搜索客户端名称、ID或描述" + value={searchKeyword} + onChange={handleSearch} + showClear + /> + + + + + + + + `第 ${range[0]}-${range[1]} 条,共 ${total} 条`, + pageSize: 10, + }} + empty={ +
+ 暂无OAuth2客户端 +
+ +
+ } + /> + + {/* 快速操作 */} +
+ 快速操作 +
+
+ + + + + +
+ + + + {/* 创建客户端模态框 */} + setShowCreateModal(false)} + onSuccess={() => { + setShowCreateModal(false); + loadClients(); + }} + /> + + {/* 编辑客户端模态框 */} + { + setShowEditModal(false); + setEditingClient(null); + }} + onSuccess={() => { + setShowEditModal(false); + setEditingClient(null); + loadClients(); + }} + /> + + {/* 密钥显示模态框 */} + setShowSecretModal(false)} + onOk={() => setShowSecretModal(false)} + cancelText="" + okText="我已复制保存" + width={600} + > +
+ 新的客户端密钥如下,请立即复制保存。关闭此窗口后将无法再次查看。 +
+ {currentSecret} +
+
+
+ + ); +} \ No newline at end of file diff --git a/web/src/pages/Setting/OAuth2/OAuth2ServerSettings.jsx b/web/src/pages/Setting/OAuth2/OAuth2ServerSettings.jsx new file mode 100644 index 000000000..c10e2be97 --- /dev/null +++ b/web/src/pages/Setting/OAuth2/OAuth2ServerSettings.jsx @@ -0,0 +1,351 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState, useRef } from 'react'; +import { Banner, Button, Col, Form, Row, Card } from '@douyinfe/semi-ui'; +import { + compareObjects, + API, + showError, + showSuccess, + showWarning, +} from '../../../helpers'; +import { useTranslation } from 'react-i18next'; + +export default function OAuth2ServerSettings(props) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [inputs, setInputs] = useState({ + 'oauth2.enabled': false, + 'oauth2.issuer': '', + 'oauth2.access_token_ttl': 10, + 'oauth2.refresh_token_ttl': 720, + 'oauth2.jwt_signing_algorithm': 'RS256', + 'oauth2.jwt_key_id': 'oauth2-key-1', + 'oauth2.jwt_private_key_file': '', + 'oauth2.allowed_grant_types': ['client_credentials', 'authorization_code'], + 'oauth2.require_pkce': true, + 'oauth2.auto_create_user': false, + 'oauth2.default_user_role': 1, + 'oauth2.default_user_group': 'default', + }); + const refForm = useRef(); + const [inputsRow, setInputsRow] = useState(inputs); + + function handleFieldChange(fieldName) { + return (value) => { + setInputs((inputs) => ({ ...inputs, [fieldName]: value })); + }; + } + + function onSubmit() { + const updateArray = compareObjects(inputs, inputsRow); + if (!updateArray.length) return showWarning(t('你似乎并没有修改什么')); + const requestQueue = updateArray.map((item) => { + let value = ''; + if (typeof inputs[item.key] === 'boolean') { + value = String(inputs[item.key]); + } else if (Array.isArray(inputs[item.key])) { + value = JSON.stringify(inputs[item.key]); + } else { + value = inputs[item.key]; + } + return API.put('/api/option/', { + key: item.key, + value, + }); + }); + setLoading(true); + Promise.all(requestQueue) + .then((res) => { + if (requestQueue.length === 1) { + if (res.includes(undefined)) return; + } else if (requestQueue.length > 1) { + if (res.includes(undefined)) + return showError(t('部分保存失败,请重试')); + } + showSuccess(t('保存成功')); + if (props && props.refresh) { + props.refresh(); + } + }) + .catch(() => { + showError(t('保存失败,请重试')); + }) + .finally(() => { + setLoading(false); + }); + } + + // 测试OAuth2连接 + const testOAuth2 = async () => { + try { + const res = await API.get('/api/oauth/server-info'); + if (res.data.success) { + showSuccess('OAuth2服务器运行正常'); + } else { + showError('OAuth2服务器测试失败: ' + res.data.message); + } + } catch (error) { + showError('OAuth2服务器连接测试失败'); + } + }; + + useEffect(() => { + if (props && props.options) { + const currentInputs = {}; + for (let key in props.options) { + if (Object.keys(inputs).includes(key)) { + if (key === 'oauth2.allowed_grant_types') { + try { + currentInputs[key] = JSON.parse(props.options[key] || '["client_credentials","authorization_code"]'); + } catch { + currentInputs[key] = ['client_credentials', 'authorization_code']; + } + } else if (typeof inputs[key] === 'boolean') { + currentInputs[key] = props.options[key] === 'true'; + } else if (typeof inputs[key] === 'number') { + currentInputs[key] = parseInt(props.options[key]) || inputs[key]; + } else { + currentInputs[key] = props.options[key]; + } + } + } + setInputs({...inputs, ...currentInputs}); + setInputsRow(structuredClone({...inputs, ...currentInputs})); + if (refForm.current) { + refForm.current.setValues({...inputs, ...currentInputs}); + } + } + }, [props]); + + return ( +
+ +
(refForm.current = formAPI)} + > + + +

• OAuth2服务器提供标准的API认证和授权功能

+

• 支持Client Credentials、Authorization Code + PKCE等标准流程

+

• 更改配置后需要重启服务才能生效

+

• 生产环境务必配置HTTPS和安全的JWT签名密钥

+
+ } + style={{ marginBottom: 15 }} + /> + +
+ + + + + + + + + + + + + + +
(refForm.current = formAPI)} + > + + +
+ + + + + + + + + + + + + RS256 (RSA with SHA-256) + HS256 (HMAC with SHA-256) + + + + + + + + + + + + +
(refForm.current = formAPI)} + > + + +
+ + Client Credentials(客户端凭证) + Authorization Code(授权码) + Refresh Token(刷新令牌) + + + + + + + + + + + + +
(refForm.current = formAPI)} + > + + +
+ + + + + 普通用户 + 管理员 + 超级管理员 + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/web/src/pages/Setting/index.jsx b/web/src/pages/Setting/index.jsx index 255ec683e..fe7a60921 100644 --- a/web/src/pages/Setting/index.jsx +++ b/web/src/pages/Setting/index.jsx @@ -32,6 +32,7 @@ import { MessageSquare, Palette, CreditCard, + Shield, } from 'lucide-react'; import SystemSetting from '../../components/settings/SystemSetting'; @@ -45,6 +46,7 @@ import RatioSetting from '../../components/settings/RatioSetting'; import ChatsSetting from '../../components/settings/ChatsSetting'; import DrawingSetting from '../../components/settings/DrawingSetting'; import PaymentSetting from '../../components/settings/PaymentSetting'; +import OAuth2Setting from '../../components/settings/OAuth2Setting'; const Setting = () => { const { t } = useTranslation(); @@ -134,6 +136,16 @@ const Setting = () => { content: , itemKey: 'models', }); + panes.push({ + tab: ( + + + {t('OAuth2 & SSO')} + + ), + content: , + itemKey: 'oauth2', + }); panes.push({ tab: (